@xui/node-graph 2.0.0-alpha.11
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 +201 -0
- package/README.md +217 -0
- package/fesm2022/xui-node-graph.mjs +2975 -0
- package/fesm2022/xui-node-graph.mjs.map +1 -0
- package/package.json +48 -0
- package/types/xui-node-graph.d.ts +1172 -0
|
@@ -0,0 +1,2975 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { signal, computed, untracked, Injectable, inject, InjectionToken, input, booleanAttribute, ViewEncapsulation, ChangeDetectionStrategy, Component, ElementRef, numberAttribute, DestroyRef, viewChild, afterRenderEffect, model, output, contentChildren, Directive } from '@angular/core';
|
|
3
|
+
import { xui } from '@xui/core';
|
|
4
|
+
import { cva } from 'class-variance-authority';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Composite key for the port registry. A port id is only unique within its node.
|
|
8
|
+
*
|
|
9
|
+
* Length-prefixed so the pair can never be ambiguous: a node called `a` with a
|
|
10
|
+
* port called `b:c` and a node called `a:b` with a port called `c` must not
|
|
11
|
+
* collide, which any plain separator would allow.
|
|
12
|
+
*/
|
|
13
|
+
function xuiGraphPortKey(nodeId, portId) {
|
|
14
|
+
return `${nodeId.length}:${nodeId}:${portId}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reactive state shared by a graph and everything inside it.
|
|
19
|
+
*
|
|
20
|
+
* Provided by {@link XuiNodeGraph}, so every node, port and edge in one canvas
|
|
21
|
+
* sees the same instance and two graphs on a page never interfere.
|
|
22
|
+
*/
|
|
23
|
+
class XuiNodeGraphStore {
|
|
24
|
+
settingsSource = null;
|
|
25
|
+
listeners = null;
|
|
26
|
+
surface = null;
|
|
27
|
+
/** Node top-left positions captured at the start of a drag, keyed by node id. */
|
|
28
|
+
dragOrigins = new Map();
|
|
29
|
+
dragMoved = false;
|
|
30
|
+
/**
|
|
31
|
+
* The drag in flight, including every group frame as it stood when the drag
|
|
32
|
+
* began. Reactive, because those frozen frames are what the groups *render*
|
|
33
|
+
* while a node is being dragged, not merely what the drop is judged against.
|
|
34
|
+
*
|
|
35
|
+
* Both uses come from the same problem: a frame is sized from its members, so
|
|
36
|
+
* a live frame stretches to follow the very node being dragged out of it and
|
|
37
|
+
* then snaps back on release. Pinning the frame for the duration makes the
|
|
38
|
+
* node visibly leave a box that stays put — and makes the box you can see the
|
|
39
|
+
* box that decides where the node lands.
|
|
40
|
+
*/
|
|
41
|
+
drag = signal(null, /* @ts-ignore */
|
|
42
|
+
...(ngDevMode ? [{ debugName: "drag" }] : /* istanbul ignore next */ []));
|
|
43
|
+
/**
|
|
44
|
+
* The graph binds its own `viewport` model and `edges` input here, so those
|
|
45
|
+
* stay the single source of truth and the store never has to mirror them
|
|
46
|
+
* through an effect that could fight the host for ownership.
|
|
47
|
+
*/
|
|
48
|
+
viewportSource = null;
|
|
49
|
+
edgesSource = null;
|
|
50
|
+
ownViewport = signal({ x: 0, y: 0, zoom: 1 }, /* @ts-ignore */
|
|
51
|
+
...(ngDevMode ? [{ debugName: "ownViewport" }] : /* istanbul ignore next */ []));
|
|
52
|
+
viewport = computed(() => (this.viewportSource ?? this.ownViewport)(), /* @ts-ignore */
|
|
53
|
+
...(ngDevMode ? [{ debugName: "viewport" }] : /* istanbul ignore next */ []));
|
|
54
|
+
edges = computed(() => this.edgesSource?.() ?? EMPTY_EDGES, /* @ts-ignore */
|
|
55
|
+
...(ngDevMode ? [{ debugName: "edges" }] : /* istanbul ignore next */ []));
|
|
56
|
+
selectedNodes = signal(new Set(), /* @ts-ignore */
|
|
57
|
+
...(ngDevMode ? [{ debugName: "selectedNodes" }] : /* istanbul ignore next */ []));
|
|
58
|
+
selectedEdges = signal(new Set(), /* @ts-ignore */
|
|
59
|
+
...(ngDevMode ? [{ debugName: "selectedEdges" }] : /* istanbul ignore next */ []));
|
|
60
|
+
linking = signal(null, /* @ts-ignore */
|
|
61
|
+
...(ngDevMode ? [{ debugName: "linking" }] : /* istanbul ignore next */ []));
|
|
62
|
+
/** Size of the visible canvas in screen pixels; kept current by a ResizeObserver. */
|
|
63
|
+
surfaceSize = signal({ width: 0, height: 0 }, /* @ts-ignore */
|
|
64
|
+
...(ngDevMode ? [{ debugName: "surfaceSize" }] : /* istanbul ignore next */ []));
|
|
65
|
+
nodeMap = signal(new Map(), /* @ts-ignore */
|
|
66
|
+
...(ngDevMode ? [{ debugName: "nodeMap" }] : /* istanbul ignore next */ []));
|
|
67
|
+
portMap = signal(new Map(), /* @ts-ignore */
|
|
68
|
+
...(ngDevMode ? [{ debugName: "portMap" }] : /* istanbul ignore next */ []));
|
|
69
|
+
groupMap = signal(new Map(), /* @ts-ignore */
|
|
70
|
+
...(ngDevMode ? [{ debugName: "groupMap" }] : /* istanbul ignore next */ []));
|
|
71
|
+
nodes = computed(() => [...this.nodeMap().values()], /* @ts-ignore */
|
|
72
|
+
...(ngDevMode ? [{ debugName: "nodes" }] : /* istanbul ignore next */ []));
|
|
73
|
+
ports = computed(() => [...this.portMap().values()], /* @ts-ignore */
|
|
74
|
+
...(ngDevMode ? [{ debugName: "ports" }] : /* istanbul ignore next */ []));
|
|
75
|
+
groups = computed(() => [...this.groupMap().values()], /* @ts-ignore */
|
|
76
|
+
...(ngDevMode ? [{ debugName: "groups" }] : /* istanbul ignore next */ []));
|
|
77
|
+
settings = computed(() => this.settingsSource?.() ?? FALLBACK_SETTINGS, /* @ts-ignore */
|
|
78
|
+
...(ngDevMode ? [{ debugName: "settings" }] : /* istanbul ignore next */ []));
|
|
79
|
+
/** How many edges terminate at each port, keyed the same way as the registry. */
|
|
80
|
+
connectionCounts = computed(() => {
|
|
81
|
+
const counts = new Map();
|
|
82
|
+
for (const edge of this.edges()) {
|
|
83
|
+
for (const ref of [edge.source, edge.target]) {
|
|
84
|
+
const key = xuiGraphPortKey(ref.nodeId, ref.portId);
|
|
85
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return counts;
|
|
89
|
+
}, /* @ts-ignore */
|
|
90
|
+
...(ngDevMode ? [{ debugName: "connectionCounts" }] : /* istanbul ignore next */ []));
|
|
91
|
+
/**
|
|
92
|
+
* Wire the store to the graph that provides it. Called from the graph's
|
|
93
|
+
* constructor, before anything can read the bound signals.
|
|
94
|
+
*
|
|
95
|
+
* @internal
|
|
96
|
+
*/
|
|
97
|
+
configure(bindings) {
|
|
98
|
+
this.settingsSource = bindings.settings;
|
|
99
|
+
this.viewportSource = bindings.viewport;
|
|
100
|
+
this.edgesSource = bindings.edges;
|
|
101
|
+
this.listeners = bindings.listeners;
|
|
102
|
+
}
|
|
103
|
+
/** Writes through to whichever signal is bound as the viewport. */
|
|
104
|
+
updateViewport(next) {
|
|
105
|
+
const target = this.viewportSource ?? this.ownViewport;
|
|
106
|
+
target.set(next(untracked(target)));
|
|
107
|
+
}
|
|
108
|
+
/** @internal */
|
|
109
|
+
setSurface(element) {
|
|
110
|
+
this.surface = element;
|
|
111
|
+
}
|
|
112
|
+
// ------------------------------------------------------------------ registry
|
|
113
|
+
/** @internal */
|
|
114
|
+
registerNode(node) {
|
|
115
|
+
this.nodeMap.update(map => new Map(map).set(node.nodeId(), node));
|
|
116
|
+
}
|
|
117
|
+
/** @internal */
|
|
118
|
+
unregisterNode(node) {
|
|
119
|
+
this.nodeMap.update(map => {
|
|
120
|
+
const next = new Map(map);
|
|
121
|
+
// Guard against a re-registered id: a node that has already been replaced
|
|
122
|
+
// by another instance must not be deleted by its predecessor's teardown.
|
|
123
|
+
if (next.get(node.nodeId()) === node) {
|
|
124
|
+
next.delete(node.nodeId());
|
|
125
|
+
}
|
|
126
|
+
return next;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** @internal */
|
|
130
|
+
registerPort(port) {
|
|
131
|
+
this.portMap.update(map => new Map(map).set(port.key, port));
|
|
132
|
+
}
|
|
133
|
+
/** @internal */
|
|
134
|
+
unregisterPort(port) {
|
|
135
|
+
this.portMap.update(map => {
|
|
136
|
+
const next = new Map(map);
|
|
137
|
+
if (next.get(port.key) === port) {
|
|
138
|
+
next.delete(port.key);
|
|
139
|
+
}
|
|
140
|
+
return next;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** @internal */
|
|
144
|
+
registerGroup(group) {
|
|
145
|
+
this.groupMap.update(map => new Map(map).set(group.groupId(), group));
|
|
146
|
+
}
|
|
147
|
+
/** @internal */
|
|
148
|
+
unregisterGroup(group) {
|
|
149
|
+
this.groupMap.update(map => {
|
|
150
|
+
const next = new Map(map);
|
|
151
|
+
if (next.get(group.groupId()) === group) {
|
|
152
|
+
next.delete(group.groupId());
|
|
153
|
+
}
|
|
154
|
+
return next;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
node(nodeId) {
|
|
158
|
+
return this.nodeMap().get(nodeId);
|
|
159
|
+
}
|
|
160
|
+
port(key) {
|
|
161
|
+
return this.portMap().get(key);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Absolute position of a connector in graph space.
|
|
165
|
+
*
|
|
166
|
+
* Composed from the node's position and the port's node-relative offset rather
|
|
167
|
+
* than measured directly, so dragging a node re-routes its wires from a single
|
|
168
|
+
* signal write with no DOM reads.
|
|
169
|
+
*/
|
|
170
|
+
portPoint(key) {
|
|
171
|
+
const port = this.portMap().get(key);
|
|
172
|
+
const node = port && this.nodeMap().get(port.nodeId());
|
|
173
|
+
if (!port || !node) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const position = node.position();
|
|
177
|
+
const offset = port.offset();
|
|
178
|
+
return { x: position.x + offset.x, y: position.y + offset.y };
|
|
179
|
+
}
|
|
180
|
+
descriptor(key) {
|
|
181
|
+
const port = this.portMap().get(key);
|
|
182
|
+
if (!port) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
nodeId: port.nodeId(),
|
|
187
|
+
portId: port.portId(),
|
|
188
|
+
direction: port.direction(),
|
|
189
|
+
side: port.resolvedSide(),
|
|
190
|
+
dataType: port.dataType(),
|
|
191
|
+
maxConnections: port.resolvedMaxConnections(),
|
|
192
|
+
disabled: port.disabled(),
|
|
193
|
+
connections: this.connectionCounts().get(key) ?? 0
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
connectionCount(key) {
|
|
197
|
+
return this.connectionCounts().get(key) ?? 0;
|
|
198
|
+
}
|
|
199
|
+
// --------------------------------------------------------------- coordinates
|
|
200
|
+
/** Client (viewport) coordinates to graph space. */
|
|
201
|
+
toGraphPoint(clientX, clientY) {
|
|
202
|
+
const rect = this.surface?.getBoundingClientRect();
|
|
203
|
+
const { x, y, zoom } = this.viewport();
|
|
204
|
+
return {
|
|
205
|
+
x: (clientX - (rect?.left ?? 0) - x) / zoom,
|
|
206
|
+
y: (clientY - (rect?.top ?? 0) - y) / zoom
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/** Graph space to coordinates relative to the canvas element's top-left corner. */
|
|
210
|
+
toSurfacePoint(point) {
|
|
211
|
+
const { x, y, zoom } = this.viewport();
|
|
212
|
+
return { x: point.x * zoom + x, y: point.y * zoom + y };
|
|
213
|
+
}
|
|
214
|
+
/** Rounds to the nearest grid intersection when snapping is on. */
|
|
215
|
+
snap(point) {
|
|
216
|
+
const { snapToGrid, gridSize } = this.settings();
|
|
217
|
+
if (!snapToGrid || gridSize <= 0) {
|
|
218
|
+
return point;
|
|
219
|
+
}
|
|
220
|
+
return { x: Math.round(point.x / gridSize) * gridSize, y: Math.round(point.y / gridSize) * gridSize };
|
|
221
|
+
}
|
|
222
|
+
// ------------------------------------------------------------------ viewport
|
|
223
|
+
panBy(dx, dy) {
|
|
224
|
+
this.updateViewport(v => ({ ...v, x: v.x + dx, y: v.y + dy }));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Scale about a fixed point, given in surface coordinates.
|
|
228
|
+
*
|
|
229
|
+
* Holding that point still is what makes wheel-zoom feel anchored to the
|
|
230
|
+
* cursor instead of to the centre of the canvas.
|
|
231
|
+
*/
|
|
232
|
+
zoomTo(zoom, anchor) {
|
|
233
|
+
const { minZoom, maxZoom } = this.settings();
|
|
234
|
+
this.updateViewport(v => {
|
|
235
|
+
const next = Math.min(maxZoom, Math.max(minZoom, zoom));
|
|
236
|
+
const size = this.surfaceSize();
|
|
237
|
+
const point = anchor ?? { x: size.width / 2, y: size.height / 2 };
|
|
238
|
+
const ratio = next / v.zoom;
|
|
239
|
+
return { zoom: next, x: point.x - (point.x - v.x) * ratio, y: point.y - (point.y - v.y) * ratio };
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
zoomBy(factor, anchor) {
|
|
243
|
+
this.zoomTo(this.viewport().zoom * factor, anchor);
|
|
244
|
+
}
|
|
245
|
+
/** Bounding box of every node, or `null` while the graph is empty. */
|
|
246
|
+
contentBounds() {
|
|
247
|
+
const nodes = this.nodes();
|
|
248
|
+
if (nodes.length === 0) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
let minX = Infinity;
|
|
252
|
+
let minY = Infinity;
|
|
253
|
+
let maxX = -Infinity;
|
|
254
|
+
let maxY = -Infinity;
|
|
255
|
+
for (const node of nodes) {
|
|
256
|
+
const { x, y } = node.position();
|
|
257
|
+
const { width, height } = node.size();
|
|
258
|
+
minX = Math.min(minX, x);
|
|
259
|
+
minY = Math.min(minY, y);
|
|
260
|
+
maxX = Math.max(maxX, x + width);
|
|
261
|
+
maxY = Math.max(maxY, y + height);
|
|
262
|
+
}
|
|
263
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
264
|
+
}
|
|
265
|
+
/** Frame every node, leaving `padding` screen pixels of margin. */
|
|
266
|
+
fitView(padding = 40) {
|
|
267
|
+
const bounds = this.contentBounds();
|
|
268
|
+
const { width, height } = this.surfaceSize();
|
|
269
|
+
if (!bounds || width === 0 || height === 0) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const { minZoom, maxZoom } = this.settings();
|
|
273
|
+
const scale = Math.min((width - padding * 2) / Math.max(bounds.width, 1), (height - padding * 2) / Math.max(bounds.height, 1));
|
|
274
|
+
const zoom = Math.min(maxZoom, Math.max(minZoom, scale));
|
|
275
|
+
this.updateViewport(() => ({
|
|
276
|
+
zoom,
|
|
277
|
+
x: width / 2 - (bounds.x + bounds.width / 2) * zoom,
|
|
278
|
+
y: height / 2 - (bounds.y + bounds.height / 2) * zoom
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
/** Centre the viewport on a node without changing the zoom. */
|
|
282
|
+
centerOn(nodeId) {
|
|
283
|
+
const node = this.nodeMap().get(nodeId);
|
|
284
|
+
const { width, height } = this.surfaceSize();
|
|
285
|
+
if (!node) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const position = node.position();
|
|
289
|
+
const size = node.size();
|
|
290
|
+
this.updateViewport(v => ({
|
|
291
|
+
...v,
|
|
292
|
+
x: width / 2 - (position.x + size.width / 2) * v.zoom,
|
|
293
|
+
y: height / 2 - (position.y + size.height / 2) * v.zoom
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
// ----------------------------------------------------------------- selection
|
|
297
|
+
isNodeSelected(nodeId) {
|
|
298
|
+
return this.selectedNodes().has(nodeId);
|
|
299
|
+
}
|
|
300
|
+
isEdgeSelected(edgeId) {
|
|
301
|
+
return this.selectedEdges().has(edgeId);
|
|
302
|
+
}
|
|
303
|
+
selectNode(nodeId, additive = false) {
|
|
304
|
+
this.selectedNodes.update(current => {
|
|
305
|
+
if (!additive) {
|
|
306
|
+
return current.size === 1 && current.has(nodeId) ? current : new Set([nodeId]);
|
|
307
|
+
}
|
|
308
|
+
const next = new Set(current);
|
|
309
|
+
next.has(nodeId) ? next.delete(nodeId) : next.add(nodeId);
|
|
310
|
+
return next;
|
|
311
|
+
});
|
|
312
|
+
if (!additive) {
|
|
313
|
+
this.selectedEdges.set(new Set());
|
|
314
|
+
}
|
|
315
|
+
this.listeners?.selectionChange();
|
|
316
|
+
}
|
|
317
|
+
selectEdge(edgeId, additive = false) {
|
|
318
|
+
this.selectedEdges.update(current => {
|
|
319
|
+
if (!additive) {
|
|
320
|
+
return current.size === 1 && current.has(edgeId) ? current : new Set([edgeId]);
|
|
321
|
+
}
|
|
322
|
+
const next = new Set(current);
|
|
323
|
+
next.has(edgeId) ? next.delete(edgeId) : next.add(edgeId);
|
|
324
|
+
return next;
|
|
325
|
+
});
|
|
326
|
+
if (!additive) {
|
|
327
|
+
this.selectedNodes.set(new Set());
|
|
328
|
+
}
|
|
329
|
+
this.listeners?.selectionChange();
|
|
330
|
+
}
|
|
331
|
+
setSelection(nodeIds, edgeIds = []) {
|
|
332
|
+
this.selectedNodes.set(new Set(nodeIds));
|
|
333
|
+
this.selectedEdges.set(new Set(edgeIds));
|
|
334
|
+
this.listeners?.selectionChange();
|
|
335
|
+
}
|
|
336
|
+
clearSelection() {
|
|
337
|
+
if (this.selectedNodes().size === 0 && this.selectedEdges().size === 0) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
this.selectedNodes.set(new Set());
|
|
341
|
+
this.selectedEdges.set(new Set());
|
|
342
|
+
this.listeners?.selectionChange();
|
|
343
|
+
}
|
|
344
|
+
selectAll() {
|
|
345
|
+
this.setSelection(this.nodes().map(node => node.nodeId()), this.edges().map(edge => edge.id));
|
|
346
|
+
}
|
|
347
|
+
/** Select every node whose box intersects `rect`, in graph space. */
|
|
348
|
+
selectInRect(rect, additive = false) {
|
|
349
|
+
const hits = this.nodes()
|
|
350
|
+
.filter(node => {
|
|
351
|
+
const position = node.position();
|
|
352
|
+
const size = node.size();
|
|
353
|
+
return (position.x < rect.x + rect.width &&
|
|
354
|
+
position.x + size.width > rect.x &&
|
|
355
|
+
position.y < rect.y + rect.height &&
|
|
356
|
+
position.y + size.height > rect.y);
|
|
357
|
+
})
|
|
358
|
+
.map(node => node.nodeId());
|
|
359
|
+
this.setSelection(additive ? [...this.selectedNodes(), ...hits] : hits, additive ? this.selectedEdges() : []);
|
|
360
|
+
}
|
|
361
|
+
// -------------------------------------------------------------------- groups
|
|
362
|
+
/** The nodes that declare themselves members of a group, in registration order. */
|
|
363
|
+
nodesInGroup(groupId) {
|
|
364
|
+
return this.nodes().filter(node => node.group() === groupId);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The frame a group draws: its members' bounding box, grown by the group's own
|
|
368
|
+
* padding and title bar. A group owns no geometry, so an empty one has no box
|
|
369
|
+
* at all and renders nothing.
|
|
370
|
+
*
|
|
371
|
+
*/
|
|
372
|
+
groupBounds(groupId) {
|
|
373
|
+
const group = this.groupMap().get(groupId);
|
|
374
|
+
const members = this.nodesInGroup(groupId);
|
|
375
|
+
if (!group || members.length === 0) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
let minX = Infinity;
|
|
379
|
+
let minY = Infinity;
|
|
380
|
+
let maxX = -Infinity;
|
|
381
|
+
let maxY = -Infinity;
|
|
382
|
+
for (const node of members) {
|
|
383
|
+
const { x, y } = node.position();
|
|
384
|
+
const { width, height } = node.size();
|
|
385
|
+
minX = Math.min(minX, x);
|
|
386
|
+
minY = Math.min(minY, y);
|
|
387
|
+
maxX = Math.max(maxX, x + width);
|
|
388
|
+
maxY = Math.max(maxY, y + height);
|
|
389
|
+
}
|
|
390
|
+
const padding = group.padding();
|
|
391
|
+
const header = group.headerHeight();
|
|
392
|
+
return {
|
|
393
|
+
x: minX - padding,
|
|
394
|
+
y: minY - padding - header,
|
|
395
|
+
width: maxX - minX + padding * 2,
|
|
396
|
+
height: maxY - minY + padding * 2 + header
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* The box a frame should be drawn at right now: its live bounds, except while a
|
|
401
|
+
* node drag is in flight, when it is pinned to where it stood as that drag
|
|
402
|
+
* began. A group being dragged is never pinned — it has to travel with the
|
|
403
|
+
* members it is carrying.
|
|
404
|
+
*/
|
|
405
|
+
groupFrame(groupId) {
|
|
406
|
+
const drag = this.drag();
|
|
407
|
+
if (drag?.source === 'node') {
|
|
408
|
+
return drag.frames.get(groupId) ?? this.groupBounds(groupId);
|
|
409
|
+
}
|
|
410
|
+
return this.groupBounds(groupId);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Frames that would take one of the nodes being dragged if it were released
|
|
414
|
+
* now. Groups render this as a highlight, so which frame is about to claim the
|
|
415
|
+
* node is visible before the button comes up rather than after.
|
|
416
|
+
*/
|
|
417
|
+
dropTargetGroups = computed(() => {
|
|
418
|
+
const drag = this.drag();
|
|
419
|
+
if (!drag || drag.source === 'group') {
|
|
420
|
+
return EMPTY_IDS;
|
|
421
|
+
}
|
|
422
|
+
const frames = [...drag.frames];
|
|
423
|
+
const targets = new Set();
|
|
424
|
+
for (const id of drag.ids) {
|
|
425
|
+
const node = this.nodeMap().get(id);
|
|
426
|
+
if (!node) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const position = node.position();
|
|
430
|
+
const size = node.size();
|
|
431
|
+
const hit = smallestContaining({ x: position.x + size.width / 2, y: position.y + size.height / 2 }, frames);
|
|
432
|
+
if (hit) {
|
|
433
|
+
targets.add(hit);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return targets;
|
|
437
|
+
}, /* @ts-ignore */
|
|
438
|
+
...(ngDevMode ? [{ debugName: "dropTargetGroups" }] : /* istanbul ignore next */ []));
|
|
439
|
+
/**
|
|
440
|
+
* Innermost group frame containing a point. Smallest-first, so a frame nested
|
|
441
|
+
* inside another wins the node that lands in the overlap.
|
|
442
|
+
*/
|
|
443
|
+
groupAt(point) {
|
|
444
|
+
const frames = this.groups()
|
|
445
|
+
.map(group => [group.groupId(), this.groupBounds(group.groupId())])
|
|
446
|
+
.filter((entry) => !!entry[1]);
|
|
447
|
+
return smallestContaining(point, frames);
|
|
448
|
+
}
|
|
449
|
+
// ------------------------------------------------------------------ dragging
|
|
450
|
+
/**
|
|
451
|
+
* Capture the starting position of everything a drag will move.
|
|
452
|
+
*
|
|
453
|
+
* Deltas are applied to these captured origins rather than accumulated onto
|
|
454
|
+
* live positions, so snapping cannot compound rounding error over a long drag.
|
|
455
|
+
*/
|
|
456
|
+
beginNodeDrag(nodeId) {
|
|
457
|
+
const selected = this.selectedNodes();
|
|
458
|
+
this.captureDrag(selected.has(nodeId) ? selected : [nodeId], 'node');
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Drag a whole group: every member moves together, and the frame follows them
|
|
462
|
+
* because it is sized from where they are.
|
|
463
|
+
*
|
|
464
|
+
* @internal
|
|
465
|
+
*/
|
|
466
|
+
beginGroupDrag(groupId) {
|
|
467
|
+
this.captureDrag(this.nodesInGroup(groupId).map(node => node.nodeId()), 'group');
|
|
468
|
+
}
|
|
469
|
+
captureDrag(ids, source) {
|
|
470
|
+
const frames = new Map();
|
|
471
|
+
this.dragOrigins = new Map();
|
|
472
|
+
this.dragMoved = false;
|
|
473
|
+
for (const group of this.groups()) {
|
|
474
|
+
const bounds = this.groupBounds(group.groupId());
|
|
475
|
+
if (bounds) {
|
|
476
|
+
frames.set(group.groupId(), bounds);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
for (const id of ids) {
|
|
480
|
+
const node = this.nodeMap().get(id);
|
|
481
|
+
if (node && !node.locked()) {
|
|
482
|
+
this.dragOrigins.set(id, node.position());
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
this.drag.set({ source, ids: new Set(this.dragOrigins.keys()), frames });
|
|
486
|
+
}
|
|
487
|
+
/** Offset every dragged node from its captured origin, in graph units. */
|
|
488
|
+
dragNodesBy(delta) {
|
|
489
|
+
if (delta.x !== 0 || delta.y !== 0) {
|
|
490
|
+
this.dragMoved = true;
|
|
491
|
+
}
|
|
492
|
+
for (const [id, origin] of this.dragOrigins) {
|
|
493
|
+
this.nodeMap()
|
|
494
|
+
.get(id)
|
|
495
|
+
?.moveTo(this.snap({ x: origin.x + delta.x, y: origin.y + delta.y }));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/** Ends the drag and raises `nodeMove`. Returns whether anything actually moved. */
|
|
499
|
+
endNodeDrag() {
|
|
500
|
+
const moved = this.dragMoved;
|
|
501
|
+
const drag = this.drag();
|
|
502
|
+
const source = drag?.source ?? 'node';
|
|
503
|
+
if (moved) {
|
|
504
|
+
const nodes = [...this.dragOrigins.keys()]
|
|
505
|
+
.map(id => this.nodeMap().get(id))
|
|
506
|
+
.filter((node) => !!node)
|
|
507
|
+
.map(node => {
|
|
508
|
+
const position = node.position();
|
|
509
|
+
const size = node.size();
|
|
510
|
+
// The centre, not the corner: a node half over a frame's edge reads as
|
|
511
|
+
// being wherever the bulk of it sits.
|
|
512
|
+
const centre = { x: position.x + size.width / 2, y: position.y + size.height / 2 };
|
|
513
|
+
return {
|
|
514
|
+
nodeId: node.nodeId(),
|
|
515
|
+
position,
|
|
516
|
+
// A group drag carries its own members around; none of them can have
|
|
517
|
+
// changed hands, so there is nothing to report.
|
|
518
|
+
group: source === 'group' || !drag ? undefined : smallestContaining(centre, drag.frames)
|
|
519
|
+
};
|
|
520
|
+
});
|
|
521
|
+
this.listeners?.nodeMove({ source, nodes });
|
|
522
|
+
}
|
|
523
|
+
this.dragOrigins = new Map();
|
|
524
|
+
this.dragMoved = false;
|
|
525
|
+
this.drag.set(null);
|
|
526
|
+
return moved;
|
|
527
|
+
}
|
|
528
|
+
// ------------------------------------------------------------------- linking
|
|
529
|
+
/** @internal */
|
|
530
|
+
beginLink(port, pointer) {
|
|
531
|
+
this.linking.set({
|
|
532
|
+
from: { nodeId: port.nodeId(), portId: port.portId() },
|
|
533
|
+
fromKey: port.key,
|
|
534
|
+
reversed: port.direction() === 'input',
|
|
535
|
+
pointer,
|
|
536
|
+
hoverKey: null
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
/** @internal */
|
|
540
|
+
moveLink(pointer) {
|
|
541
|
+
this.linking.update(state => (state ? { ...state, pointer } : null));
|
|
542
|
+
}
|
|
543
|
+
/** @internal */
|
|
544
|
+
setLinkHover(key) {
|
|
545
|
+
this.linking.update(state => (state ? { ...state, hoverKey: key } : null));
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Finish a link. Dropping on a legal port raises `connect`; dropping anywhere
|
|
549
|
+
* else raises `connectionDrop` so the host can offer to create a node there.
|
|
550
|
+
*
|
|
551
|
+
* @internal
|
|
552
|
+
*/
|
|
553
|
+
endLink(dropKey) {
|
|
554
|
+
const state = this.linking();
|
|
555
|
+
this.linking.set(null);
|
|
556
|
+
if (!state) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const connection = dropKey && this.orient(state, dropKey);
|
|
560
|
+
if (connection && this.canConnect(connection)) {
|
|
561
|
+
this.listeners?.connect(connection);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (!dropKey) {
|
|
565
|
+
const port = this.descriptor(state.fromKey);
|
|
566
|
+
if (port) {
|
|
567
|
+
this.listeners?.connectionDrop({
|
|
568
|
+
source: state.from,
|
|
569
|
+
port,
|
|
570
|
+
position: state.pointer,
|
|
571
|
+
surfacePosition: this.toSurfacePoint(state.pointer)
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/** Resolve which end of a dragged wire is the source. */
|
|
577
|
+
orient(state, dropKey) {
|
|
578
|
+
const dropped = this.portMap().get(dropKey);
|
|
579
|
+
if (!dropped) {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
const droppedRef = { nodeId: dropped.nodeId(), portId: dropped.portId() };
|
|
583
|
+
return state.reversed ? { source: droppedRef, target: state.from } : { source: state.from, target: droppedRef };
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* How a port should render while a wire is in flight — the affordance that
|
|
587
|
+
* tells you where a wire may land before you release it.
|
|
588
|
+
*/
|
|
589
|
+
linkState(key) {
|
|
590
|
+
const state = this.linking();
|
|
591
|
+
if (!state) {
|
|
592
|
+
return 'idle';
|
|
593
|
+
}
|
|
594
|
+
if (state.fromKey === key) {
|
|
595
|
+
return 'source';
|
|
596
|
+
}
|
|
597
|
+
const connection = this.orient(state, key);
|
|
598
|
+
return connection && this.canConnect(connection) ? 'valid' : 'invalid';
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The full connection rule set: existence, arity, direction, duplicates and
|
|
602
|
+
* data type, then the host's own validator, which can only narrow the result.
|
|
603
|
+
*/
|
|
604
|
+
canConnect(connection) {
|
|
605
|
+
const settings = this.settings();
|
|
606
|
+
if (settings.locked) {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
const sourceKey = xuiGraphPortKey(connection.source.nodeId, connection.source.portId);
|
|
610
|
+
const targetKey = xuiGraphPortKey(connection.target.nodeId, connection.target.portId);
|
|
611
|
+
if (sourceKey === targetKey) {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
const source = this.descriptor(sourceKey);
|
|
615
|
+
const target = this.descriptor(targetKey);
|
|
616
|
+
if (!source || !target || source.disabled || target.disabled) {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
if (!settings.allowSelfConnection && source.nodeId === target.nodeId) {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
// An `inout` port plays either role; a plain input can never be a source.
|
|
623
|
+
if (source.direction === 'input' || target.direction === 'output') {
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
if (source.connections >= source.maxConnections || target.connections >= target.maxConnections) {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
const edges = this.edges();
|
|
630
|
+
const duplicate = edges.some(edge => samePort(edge.source, connection.source) && samePort(edge.target, connection.target));
|
|
631
|
+
if (duplicate) {
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
// An undeclared type is a wildcard, so untyped graphs need no configuration.
|
|
635
|
+
if (settings.enforcePortTypes && source.dataType && target.dataType && source.dataType !== target.dataType) {
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
return settings.isValidConnection?.(connection, { source, target, edges }) ?? true;
|
|
639
|
+
}
|
|
640
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiNodeGraphStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
641
|
+
/** @nocollapse */ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiNodeGraphStore });
|
|
642
|
+
}
|
|
643
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiNodeGraphStore, decorators: [{
|
|
644
|
+
type: Injectable
|
|
645
|
+
}] });
|
|
646
|
+
/**
|
|
647
|
+
* The smallest of the frames containing a point, so a frame nested inside
|
|
648
|
+
* another wins the node that lands in the overlap.
|
|
649
|
+
*/
|
|
650
|
+
function smallestContaining(point, frames) {
|
|
651
|
+
let best;
|
|
652
|
+
let bestArea = Infinity;
|
|
653
|
+
for (const [id, bounds] of frames) {
|
|
654
|
+
const inside = point.x >= bounds.x &&
|
|
655
|
+
point.x <= bounds.x + bounds.width &&
|
|
656
|
+
point.y >= bounds.y &&
|
|
657
|
+
point.y <= bounds.y + bounds.height;
|
|
658
|
+
const area = bounds.width * bounds.height;
|
|
659
|
+
if (inside && area < bestArea) {
|
|
660
|
+
bestArea = area;
|
|
661
|
+
best = id;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return best;
|
|
665
|
+
}
|
|
666
|
+
/** Shared so the `edges` computed keeps a stable identity while unbound. */
|
|
667
|
+
const EMPTY_EDGES = [];
|
|
668
|
+
const EMPTY_IDS = new Set();
|
|
669
|
+
function samePort(a, b) {
|
|
670
|
+
return a.nodeId === b.nodeId && a.portId === b.portId;
|
|
671
|
+
}
|
|
672
|
+
/** Used only if a node or port is somehow instantiated outside a graph. @internal */
|
|
673
|
+
const FALLBACK_SETTINGS = {
|
|
674
|
+
routing: 'bezier',
|
|
675
|
+
gridSize: 16,
|
|
676
|
+
snapToGrid: false,
|
|
677
|
+
minZoom: 0.15,
|
|
678
|
+
maxZoom: 4,
|
|
679
|
+
stubLength: 18,
|
|
680
|
+
cornerRadius: 8,
|
|
681
|
+
curvature: 0.5,
|
|
682
|
+
edgeWidth: 2,
|
|
683
|
+
portSize: 11,
|
|
684
|
+
portShape: 'circle',
|
|
685
|
+
portColor: 'var(--color-foreground-subtle)',
|
|
686
|
+
portTypes: {},
|
|
687
|
+
allowSelfConnection: false,
|
|
688
|
+
enforcePortTypes: true,
|
|
689
|
+
isValidConnection: undefined,
|
|
690
|
+
locked: false
|
|
691
|
+
};
|
|
692
|
+
/**
|
|
693
|
+
* The store for the enclosing `<xui-node-graph>`.
|
|
694
|
+
*
|
|
695
|
+
* Use it to drive the canvas from outside — `fitView()`, `zoomBy()`,
|
|
696
|
+
* `setSelection()` — from a toolbar or a host component.
|
|
697
|
+
*/
|
|
698
|
+
function injectXuiNodeGraphStore() {
|
|
699
|
+
return inject(XuiNodeGraphStore);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const defaultConfig = {
|
|
703
|
+
routing: 'bezier',
|
|
704
|
+
background: 'dots',
|
|
705
|
+
marker: 'none',
|
|
706
|
+
gridSize: 16,
|
|
707
|
+
gridMajorEvery: 5,
|
|
708
|
+
snapToGrid: false,
|
|
709
|
+
minZoom: 0.15,
|
|
710
|
+
maxZoom: 4,
|
|
711
|
+
zoomStep: 1.15,
|
|
712
|
+
stubLength: 18,
|
|
713
|
+
cornerRadius: 8,
|
|
714
|
+
curvature: 0.5,
|
|
715
|
+
edgeWidth: 2,
|
|
716
|
+
portSize: 11,
|
|
717
|
+
portShape: 'circle',
|
|
718
|
+
portColor: 'var(--color-foreground-subtle)',
|
|
719
|
+
portTypes: {},
|
|
720
|
+
allowSelfConnection: false,
|
|
721
|
+
enforcePortTypes: true
|
|
722
|
+
};
|
|
723
|
+
const XuiNodeGraphConfigToken = new InjectionToken('XuiNodeGraphConfig');
|
|
724
|
+
function provideXuiNodeGraphConfig(config) {
|
|
725
|
+
return { provide: XuiNodeGraphConfigToken, useValue: { ...defaultConfig, ...config } };
|
|
726
|
+
}
|
|
727
|
+
function injectXuiNodeGraphConfig() {
|
|
728
|
+
return inject(XuiNodeGraphConfigToken, { optional: true }) ?? defaultConfig;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* A ready-made data-type palette drawn from the categorical chart ramp, for
|
|
732
|
+
* graphs that need distinguishable typed ports without designing a palette.
|
|
733
|
+
*
|
|
734
|
+
* ```ts
|
|
735
|
+
* provideXuiNodeGraphConfig({ portTypes: { ...xuiGraphChartPortTypes(['float', 'vector', 'color']) } })
|
|
736
|
+
* ```
|
|
737
|
+
*/
|
|
738
|
+
function xuiGraphChartPortTypes(types) {
|
|
739
|
+
return Object.fromEntries(types.map((type, index) => [type, { color: `var(--color-chart-${(index % 8) + 1})`, label: type }]));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Zoom and framing buttons for the enclosing canvas.
|
|
744
|
+
*
|
|
745
|
+
* ```html
|
|
746
|
+
* <xui-node-graph>
|
|
747
|
+
* …
|
|
748
|
+
* <xui-graph-controls class="bottom-4 left-4" />
|
|
749
|
+
* </xui-node-graph>
|
|
750
|
+
* ```
|
|
751
|
+
*
|
|
752
|
+
* Placed anywhere inside `<xui-node-graph>`; it is projected into the overlay
|
|
753
|
+
* layer, above the canvas and outside its transform, so it neither pans nor
|
|
754
|
+
* scales with the content.
|
|
755
|
+
*/
|
|
756
|
+
class XuiGraphControls {
|
|
757
|
+
config = injectXuiNodeGraphConfig();
|
|
758
|
+
store = inject(XuiNodeGraphStore);
|
|
759
|
+
step = this.config.zoomStep;
|
|
760
|
+
buttonClass = 'text-foreground-muted hover:bg-hover-overlay hover:text-foreground focus-visible:ring-focus flex h-7 w-7 ' +
|
|
761
|
+
'cursor-pointer items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none';
|
|
762
|
+
class = input('', /* @ts-ignore */
|
|
763
|
+
...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
|
|
764
|
+
showFit = input(true, { ...(ngDevMode ? { debugName: "showFit" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
765
|
+
showZoomLevel = input(true, { ...(ngDevMode ? { debugName: "showZoomLevel" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
766
|
+
/** Lay the buttons out in a row rather than a column. */
|
|
767
|
+
horizontal = input(false, { ...(ngDevMode ? { debugName: "horizontal" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
768
|
+
zoomPercent = computed(() => Math.round(this.store.viewport().zoom * 100), /* @ts-ignore */
|
|
769
|
+
...(ngDevMode ? [{ debugName: "zoomPercent" }] : /* istanbul ignore next */ []));
|
|
770
|
+
computedClass = computed(() => xui('bg-surface-overlay border-border shadow-elevation-2 pointer-events-auto absolute z-10 flex gap-0.5 rounded-md border p-1 select-none', this.horizontal() ? 'flex-row items-center' : 'flex-col',
|
|
771
|
+
// A sensible default corner; override with `class` to move it.
|
|
772
|
+
'bottom-4 left-4', this.class()), /* @ts-ignore */
|
|
773
|
+
...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
|
|
774
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphControls, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
775
|
+
/** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiGraphControls, isStandalone: true, selector: "xui-graph-controls", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, showFit: { classPropertyName: "showFit", publicName: "showFit", isSignal: true, isRequired: false, transformFunction: null }, showZoomLevel: { classPropertyName: "showZoomLevel", publicName: "showZoomLevel", isSignal: true, isRequired: false, transformFunction: null }, horizontal: { classPropertyName: "horizontal", publicName: "horizontal", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "data-xui-graph-overlay": "" }, properties: { "class": "computedClass()" } }, ngImport: i0, template: `
|
|
776
|
+
<button type="button" [class]="buttonClass" aria-label="Zoom in" (click)="store.zoomBy(step)">
|
|
777
|
+
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
|
|
778
|
+
<path d="M7 2 V12 M2 7 H12" [attr.stroke]="'currentColor'" stroke-width="1.5" stroke-linecap="round" />
|
|
779
|
+
</svg>
|
|
780
|
+
</button>
|
|
781
|
+
|
|
782
|
+
<button type="button" [class]="buttonClass" aria-label="Zoom out" (click)="store.zoomBy(1 / step)">
|
|
783
|
+
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
|
|
784
|
+
<path d="M2 7 H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
785
|
+
</svg>
|
|
786
|
+
</button>
|
|
787
|
+
|
|
788
|
+
@if (showFit()) {
|
|
789
|
+
<button type="button" [class]="buttonClass" aria-label="Fit view" (click)="store.fitView()">
|
|
790
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
791
|
+
<path
|
|
792
|
+
d="M2 5 V2 H5 M9 2 H12 V5 M12 9 V12 H9 M5 12 H2 V9"
|
|
793
|
+
stroke="currentColor"
|
|
794
|
+
stroke-width="1.5"
|
|
795
|
+
stroke-linecap="round"
|
|
796
|
+
stroke-linejoin="round"
|
|
797
|
+
/>
|
|
798
|
+
</svg>
|
|
799
|
+
</button>
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
@if (showZoomLevel()) {
|
|
803
|
+
<span class="text-foreground-subtle px-1 text-center text-[10px] tabular-nums">{{ zoomPercent() }}%</span>
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
<ng-content />
|
|
807
|
+
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
808
|
+
}
|
|
809
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphControls, decorators: [{
|
|
810
|
+
type: Component,
|
|
811
|
+
args: [{
|
|
812
|
+
selector: 'xui-graph-controls',
|
|
813
|
+
template: `
|
|
814
|
+
<button type="button" [class]="buttonClass" aria-label="Zoom in" (click)="store.zoomBy(step)">
|
|
815
|
+
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
|
|
816
|
+
<path d="M7 2 V12 M2 7 H12" [attr.stroke]="'currentColor'" stroke-width="1.5" stroke-linecap="round" />
|
|
817
|
+
</svg>
|
|
818
|
+
</button>
|
|
819
|
+
|
|
820
|
+
<button type="button" [class]="buttonClass" aria-label="Zoom out" (click)="store.zoomBy(1 / step)">
|
|
821
|
+
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
|
|
822
|
+
<path d="M2 7 H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
823
|
+
</svg>
|
|
824
|
+
</button>
|
|
825
|
+
|
|
826
|
+
@if (showFit()) {
|
|
827
|
+
<button type="button" [class]="buttonClass" aria-label="Fit view" (click)="store.fitView()">
|
|
828
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
829
|
+
<path
|
|
830
|
+
d="M2 5 V2 H5 M9 2 H12 V5 M12 9 V12 H9 M5 12 H2 V9"
|
|
831
|
+
stroke="currentColor"
|
|
832
|
+
stroke-width="1.5"
|
|
833
|
+
stroke-linecap="round"
|
|
834
|
+
stroke-linejoin="round"
|
|
835
|
+
/>
|
|
836
|
+
</svg>
|
|
837
|
+
</button>
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
@if (showZoomLevel()) {
|
|
841
|
+
<span class="text-foreground-subtle px-1 text-center text-[10px] tabular-nums">{{ zoomPercent() }}%</span>
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
<ng-content />
|
|
845
|
+
`,
|
|
846
|
+
host: { '[class]': 'computedClass()', 'data-xui-graph-overlay': '' },
|
|
847
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
848
|
+
encapsulation: ViewEncapsulation.None
|
|
849
|
+
}]
|
|
850
|
+
}], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], showFit: [{ type: i0.Input, args: [{ isSignal: true, alias: "showFit", required: false }] }], showZoomLevel: [{ type: i0.Input, args: [{ isSignal: true, alias: "showZoomLevel", required: false }] }], horizontal: [{ type: i0.Input, args: [{ isSignal: true, alias: "horizontal", required: false }] }] } });
|
|
851
|
+
|
|
852
|
+
/** Room reserved above the members for the title bar, in graph units. */
|
|
853
|
+
const HEADER_HEIGHT = 26;
|
|
854
|
+
/**
|
|
855
|
+
* A frame around a set of nodes that moves them all together.
|
|
856
|
+
*
|
|
857
|
+
* ```html
|
|
858
|
+
* <xui-node-graph>
|
|
859
|
+
* <xui-graph-group groupId="filter" label="Filter stage" color="var(--color-chart-1)" />
|
|
860
|
+
*
|
|
861
|
+
* <xui-graph-node nodeId="lp" group="filter" [(position)]="a">…</xui-graph-node>
|
|
862
|
+
* <xui-graph-node nodeId="hp" group="filter" [(position)]="b">…</xui-graph-node>
|
|
863
|
+
* </xui-node-graph>
|
|
864
|
+
* ```
|
|
865
|
+
*
|
|
866
|
+
* Membership is declared by the nodes, not by nesting them inside the frame:
|
|
867
|
+
* a node keeps its own place in the template and its own `[(position)]`, and the
|
|
868
|
+
* frame is derived from wherever its members happen to be. That is what lets a
|
|
869
|
+
* group be added, removed or re-membered without moving anything in the DOM, and
|
|
870
|
+
* why dragging a member out of a frame is just an ordinary node drag.
|
|
871
|
+
*
|
|
872
|
+
* A group with no members has no box, so it renders nothing.
|
|
873
|
+
*
|
|
874
|
+
* To let a node join a frame by being dropped on it, read `group` off the graph's
|
|
875
|
+
* `nodeMove` event and reassign the node's own `group` input.
|
|
876
|
+
*/
|
|
877
|
+
class XuiGraphGroup {
|
|
878
|
+
store = inject(XuiNodeGraphStore);
|
|
879
|
+
element = inject(ElementRef).nativeElement;
|
|
880
|
+
dragOrigin = signal(null, /* @ts-ignore */
|
|
881
|
+
...(ngDevMode ? [{ debugName: "dragOrigin" }] : /* istanbul ignore next */ []));
|
|
882
|
+
/** Matches the `group` input on the nodes that belong to this frame. */
|
|
883
|
+
groupId = input.required(/* @ts-ignore */
|
|
884
|
+
...(ngDevMode ? [{ debugName: "groupId" }] : /* istanbul ignore next */ []));
|
|
885
|
+
class = input('', /* @ts-ignore */
|
|
886
|
+
...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
|
|
887
|
+
label = input('', /* @ts-ignore */
|
|
888
|
+
...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
889
|
+
/** Border and title colour; a tint of it fills the frame. Any CSS colour. */
|
|
890
|
+
color = input('var(--color-border-strong)', /* @ts-ignore */
|
|
891
|
+
...(ngDevMode ? [{ debugName: "color" }] : /* istanbul ignore next */ []));
|
|
892
|
+
/** Blank margin between the members' bounding box and the frame. */
|
|
893
|
+
padding = input(24, { ...(ngDevMode ? { debugName: "padding" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
894
|
+
selectable = input(true, { ...(ngDevMode ? { debugName: "selectable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
895
|
+
draggable = input(true, { ...(ngDevMode ? { debugName: "draggable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
896
|
+
/** Pins the frame and everything in it. */
|
|
897
|
+
locked = input(false, { ...(ngDevMode ? { debugName: "locked" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
898
|
+
/**
|
|
899
|
+
* `group` drags from anywhere on the frame, as a node editor's frames do.
|
|
900
|
+
* `header` restricts it to the title bar, which leaves the frame's interior
|
|
901
|
+
* free for panning and marquee selection.
|
|
902
|
+
*/
|
|
903
|
+
dragHandle = input('group', /* @ts-ignore */
|
|
904
|
+
...(ngDevMode ? [{ debugName: "dragHandle" }] : /* istanbul ignore next */ []));
|
|
905
|
+
/**
|
|
906
|
+
* @internal Part of {@link XuiGraphGroupHandle}.
|
|
907
|
+
*
|
|
908
|
+
* A frame only reserves room for a title bar when it has a label — an unlabelled
|
|
909
|
+
* frame is a plain box, and projected content rides along inside the labelled one.
|
|
910
|
+
*/
|
|
911
|
+
headerHeight = computed(() => (this.label() ? HEADER_HEIGHT : 0), /* @ts-ignore */
|
|
912
|
+
...(ngDevMode ? [{ debugName: "headerHeight" }] : /* istanbul ignore next */ []));
|
|
913
|
+
/**
|
|
914
|
+
* The frame, in graph space. `null` while the group has no members.
|
|
915
|
+
*
|
|
916
|
+
* Pinned to where it stood at the start of a node drag, so a member dragged out
|
|
917
|
+
* leaves a box that stays still instead of stretching it and snapping back.
|
|
918
|
+
*/
|
|
919
|
+
bounds = computed(() => this.store.groupFrame(this.groupId()), /* @ts-ignore */
|
|
920
|
+
...(ngDevMode ? [{ debugName: "bounds" }] : /* istanbul ignore next */ []));
|
|
921
|
+
/** `true` while a dragged node hovers over this frame and would land in it. */
|
|
922
|
+
dropTarget = computed(() => this.store.dropTargetGroups().has(this.groupId()), /* @ts-ignore */
|
|
923
|
+
...(ngDevMode ? [{ debugName: "dropTarget" }] : /* istanbul ignore next */ []));
|
|
924
|
+
members = computed(() => this.store.nodesInGroup(this.groupId()).map(node => node.nodeId()), /* @ts-ignore */
|
|
925
|
+
...(ngDevMode ? [{ debugName: "members" }] : /* istanbul ignore next */ []));
|
|
926
|
+
/** A frame reads as selected only when its whole membership is. */
|
|
927
|
+
selected = computed(() => {
|
|
928
|
+
const members = this.members();
|
|
929
|
+
return members.length > 0 && members.every(id => this.store.isNodeSelected(id));
|
|
930
|
+
}, /* @ts-ignore */
|
|
931
|
+
...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
|
|
932
|
+
immovable = computed(() => this.locked() || !this.draggable() || this.store.settings().locked, /* @ts-ignore */
|
|
933
|
+
...(ngDevMode ? [{ debugName: "immovable" }] : /* istanbul ignore next */ []));
|
|
934
|
+
transform = computed(() => {
|
|
935
|
+
const bounds = this.bounds();
|
|
936
|
+
return bounds ? `translate(${bounds.x}px, ${bounds.y}px)` : null;
|
|
937
|
+
}, /* @ts-ignore */
|
|
938
|
+
...(ngDevMode ? [{ debugName: "transform" }] : /* istanbul ignore next */ []));
|
|
939
|
+
cursor = computed(() => (this.dragOrigin() ? 'grabbing' : this.immovable() ? null : 'grab'), /* @ts-ignore */
|
|
940
|
+
...(ngDevMode ? [{ debugName: "cursor" }] : /* istanbul ignore next */ []));
|
|
941
|
+
// Deepen the tint when the frame is about to claim a node, so the answer to
|
|
942
|
+
// "which group is this going into?" is visible before the button comes up.
|
|
943
|
+
background = computed(() => `color-mix(in oklab, ${this.color()} ${this.dropTarget() ? 22 : 10}%, transparent)`, /* @ts-ignore */
|
|
944
|
+
...(ngDevMode ? [{ debugName: "background" }] : /* istanbul ignore next */ []));
|
|
945
|
+
computedClass = computed(() => xui(
|
|
946
|
+
// z-index 0 puts the frame under every node, so pressing a member still
|
|
947
|
+
// moves that member and only the gaps between them belong to the group.
|
|
948
|
+
'absolute top-0 left-0 z-0 flex flex-col rounded-xl border-2 border-dashed transition-colors select-none', (this.selected() || this.dropTarget()) && 'border-solid', this.class()), /* @ts-ignore */
|
|
949
|
+
...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
|
|
950
|
+
constructor() {
|
|
951
|
+
inject(DestroyRef).onDestroy(() => this.store.unregisterGroup(this));
|
|
952
|
+
}
|
|
953
|
+
ngOnInit() {
|
|
954
|
+
this.store.registerGroup(this);
|
|
955
|
+
}
|
|
956
|
+
onPointerDown(event) {
|
|
957
|
+
const target = event.target;
|
|
958
|
+
if (event.button !== 0) {
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
// When the frame only answers to its title bar, a press on its interior is
|
|
962
|
+
// left to bubble so the canvas can still pan or marquee through it.
|
|
963
|
+
if (this.dragHandle() === 'header' && !target.closest('[data-xui-graph-group-header]')) {
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
event.stopPropagation();
|
|
967
|
+
if (this.selectable()) {
|
|
968
|
+
const additive = event.shiftKey || event.ctrlKey || event.metaKey;
|
|
969
|
+
this.store.setSelection(additive ? [...this.store.selectedNodes(), ...this.members()] : this.members());
|
|
970
|
+
}
|
|
971
|
+
if (this.immovable()) {
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
event.preventDefault();
|
|
975
|
+
this.element.setPointerCapture(event.pointerId);
|
|
976
|
+
this.dragOrigin.set({ x: event.clientX, y: event.clientY });
|
|
977
|
+
this.store.beginGroupDrag(this.groupId());
|
|
978
|
+
}
|
|
979
|
+
onPointerMove(event) {
|
|
980
|
+
const origin = this.dragOrigin();
|
|
981
|
+
if (!origin) {
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const { zoom } = this.store.viewport();
|
|
985
|
+
this.store.dragNodesBy({ x: (event.clientX - origin.x) / zoom, y: (event.clientY - origin.y) / zoom });
|
|
986
|
+
}
|
|
987
|
+
onPointerUp(event) {
|
|
988
|
+
if (!this.dragOrigin()) {
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
this.dragOrigin.set(null);
|
|
992
|
+
this.element.releasePointerCapture(event.pointerId);
|
|
993
|
+
this.store.endNodeDrag();
|
|
994
|
+
}
|
|
995
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphGroup, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
996
|
+
/** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiGraphGroup, isStandalone: true, selector: "xui-graph-group", inputs: { groupId: { classPropertyName: "groupId", publicName: "groupId", isSignal: true, isRequired: true, transformFunction: null }, class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: false, transformFunction: null }, padding: { classPropertyName: "padding", publicName: "padding", isSignal: true, isRequired: false, transformFunction: null }, selectable: { classPropertyName: "selectable", publicName: "selectable", isSignal: true, isRequired: false, transformFunction: null }, draggable: { classPropertyName: "draggable", publicName: "draggable", isSignal: true, isRequired: false, transformFunction: null }, locked: { classPropertyName: "locked", publicName: "locked", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "pointerdown": "onPointerDown($event)", "pointermove": "onPointerMove($event)", "pointerup": "onPointerUp($event)", "pointercancel": "onPointerUp($event)" }, properties: { "class": "computedClass()", "attr.data-xui-graph-group": "groupId()", "style.transform": "transform()", "style.width.px": "bounds()?.width", "style.height.px": "bounds()?.height", "style.display": "bounds() ? null : \"none\"", "style.border-color": "color()", "style.background": "background()", "style.cursor": "cursor()" } }, ngImport: i0, template: `
|
|
997
|
+
@if (label()) {
|
|
998
|
+
<div
|
|
999
|
+
data-xui-graph-group-header
|
|
1000
|
+
class="flex items-center gap-2 truncate px-2.5 text-xs font-medium"
|
|
1001
|
+
[style.height.px]="headerHeight()"
|
|
1002
|
+
[style.color]="color()"
|
|
1003
|
+
>
|
|
1004
|
+
<span class="truncate">{{ label() }}</span>
|
|
1005
|
+
<ng-content />
|
|
1006
|
+
</div>
|
|
1007
|
+
}
|
|
1008
|
+
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
1009
|
+
}
|
|
1010
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphGroup, decorators: [{
|
|
1011
|
+
type: Component,
|
|
1012
|
+
args: [{
|
|
1013
|
+
selector: 'xui-graph-group',
|
|
1014
|
+
template: `
|
|
1015
|
+
@if (label()) {
|
|
1016
|
+
<div
|
|
1017
|
+
data-xui-graph-group-header
|
|
1018
|
+
class="flex items-center gap-2 truncate px-2.5 text-xs font-medium"
|
|
1019
|
+
[style.height.px]="headerHeight()"
|
|
1020
|
+
[style.color]="color()"
|
|
1021
|
+
>
|
|
1022
|
+
<span class="truncate">{{ label() }}</span>
|
|
1023
|
+
<ng-content />
|
|
1024
|
+
</div>
|
|
1025
|
+
}
|
|
1026
|
+
`,
|
|
1027
|
+
host: {
|
|
1028
|
+
'[class]': 'computedClass()',
|
|
1029
|
+
'[attr.data-xui-graph-group]': 'groupId()',
|
|
1030
|
+
'[style.transform]': 'transform()',
|
|
1031
|
+
'[style.width.px]': 'bounds()?.width',
|
|
1032
|
+
'[style.height.px]': 'bounds()?.height',
|
|
1033
|
+
'[style.display]': 'bounds() ? null : "none"',
|
|
1034
|
+
'[style.border-color]': 'color()',
|
|
1035
|
+
'[style.background]': 'background()',
|
|
1036
|
+
'[style.cursor]': 'cursor()',
|
|
1037
|
+
'(pointerdown)': 'onPointerDown($event)',
|
|
1038
|
+
'(pointermove)': 'onPointerMove($event)',
|
|
1039
|
+
'(pointerup)': 'onPointerUp($event)',
|
|
1040
|
+
'(pointercancel)': 'onPointerUp($event)'
|
|
1041
|
+
},
|
|
1042
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1043
|
+
encapsulation: ViewEncapsulation.None
|
|
1044
|
+
}]
|
|
1045
|
+
}], ctorParameters: () => [], propDecorators: { groupId: [{ type: i0.Input, args: [{ isSignal: true, alias: "groupId", required: true }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], color: [{ type: i0.Input, args: [{ isSignal: true, alias: "color", required: false }] }], padding: [{ type: i0.Input, args: [{ isSignal: true, alias: "padding", required: false }] }], selectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectable", required: false }] }], draggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "draggable", required: false }] }], locked: [{ type: i0.Input, args: [{ isSignal: true, alias: "locked", required: false }] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }] } });
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* An overview of the whole graph with the current viewport drawn on it.
|
|
1049
|
+
*
|
|
1050
|
+
* ```html
|
|
1051
|
+
* <xui-node-graph>
|
|
1052
|
+
* …
|
|
1053
|
+
* <xui-graph-minimap class="right-4 bottom-4" />
|
|
1054
|
+
* </xui-node-graph>
|
|
1055
|
+
* ```
|
|
1056
|
+
*
|
|
1057
|
+
* Click or drag inside it to move the view. Like the controls, it is projected
|
|
1058
|
+
* into the overlay layer and so stays put while the canvas pans.
|
|
1059
|
+
*/
|
|
1060
|
+
class XuiGraphMinimap {
|
|
1061
|
+
store = inject(XuiNodeGraphStore);
|
|
1062
|
+
element = inject(ElementRef).nativeElement;
|
|
1063
|
+
dragging = false;
|
|
1064
|
+
class = input('', /* @ts-ignore */
|
|
1065
|
+
...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
|
|
1066
|
+
/** Blank margin around the content, in graph units. */
|
|
1067
|
+
padding = input(40, { ...(ngDevMode ? { debugName: "padding" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
1068
|
+
nodes = computed(() => this.store.nodes().map(node => {
|
|
1069
|
+
const { x, y } = node.position();
|
|
1070
|
+
const { width, height } = node.size();
|
|
1071
|
+
return { id: node.nodeId(), x, y, width, height, selected: this.store.isNodeSelected(node.nodeId()) };
|
|
1072
|
+
}), /* @ts-ignore */
|
|
1073
|
+
...(ngDevMode ? [{ debugName: "nodes" }] : /* istanbul ignore next */ []));
|
|
1074
|
+
/**
|
|
1075
|
+
* The extent the minimap shows: everything drawn, unioned with wherever the
|
|
1076
|
+
* viewport currently is, so panning off into empty space still tells you where
|
|
1077
|
+
* you are instead of silently clamping.
|
|
1078
|
+
*/
|
|
1079
|
+
extent = computed(() => {
|
|
1080
|
+
const content = this.store.contentBounds();
|
|
1081
|
+
const view = this.viewportRect();
|
|
1082
|
+
const padding = this.padding();
|
|
1083
|
+
if (!content && !view) {
|
|
1084
|
+
return { x: 0, y: 0, width: 1, height: 1 };
|
|
1085
|
+
}
|
|
1086
|
+
const boxes = [content, view].filter((box) => !!box);
|
|
1087
|
+
const minX = Math.min(...boxes.map(box => box.x)) - padding;
|
|
1088
|
+
const minY = Math.min(...boxes.map(box => box.y)) - padding;
|
|
1089
|
+
const maxX = Math.max(...boxes.map(box => box.x + box.width)) + padding;
|
|
1090
|
+
const maxY = Math.max(...boxes.map(box => box.y + box.height)) + padding;
|
|
1091
|
+
return { x: minX, y: minY, width: Math.max(maxX - minX, 1), height: Math.max(maxY - minY, 1) };
|
|
1092
|
+
}, /* @ts-ignore */
|
|
1093
|
+
...(ngDevMode ? [{ debugName: "extent" }] : /* istanbul ignore next */ []));
|
|
1094
|
+
viewportRect = computed(() => {
|
|
1095
|
+
const { x, y, zoom } = this.store.viewport();
|
|
1096
|
+
const { width, height } = this.store.surfaceSize();
|
|
1097
|
+
if (width === 0 || height === 0) {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
return { x: -x / zoom, y: -y / zoom, width: width / zoom, height: height / zoom };
|
|
1101
|
+
}, /* @ts-ignore */
|
|
1102
|
+
...(ngDevMode ? [{ debugName: "viewportRect" }] : /* istanbul ignore next */ []));
|
|
1103
|
+
viewBox = computed(() => {
|
|
1104
|
+
const { x, y, width, height } = this.extent();
|
|
1105
|
+
return `${x} ${y} ${width} ${height}`;
|
|
1106
|
+
}, /* @ts-ignore */
|
|
1107
|
+
...(ngDevMode ? [{ debugName: "viewBox" }] : /* istanbul ignore next */ []));
|
|
1108
|
+
/** Keep hairlines and corners visually constant however far the extent zooms out. */
|
|
1109
|
+
unitScale = computed(() => this.extent().width / Math.max(this.element.clientWidth, 1), /* @ts-ignore */
|
|
1110
|
+
...(ngDevMode ? [{ debugName: "unitScale" }] : /* istanbul ignore next */ []));
|
|
1111
|
+
strokeWidth = computed(() => this.unitScale(), /* @ts-ignore */
|
|
1112
|
+
...(ngDevMode ? [{ debugName: "strokeWidth" }] : /* istanbul ignore next */ []));
|
|
1113
|
+
cornerRadius = computed(() => this.unitScale() * 2, /* @ts-ignore */
|
|
1114
|
+
...(ngDevMode ? [{ debugName: "cornerRadius" }] : /* istanbul ignore next */ []));
|
|
1115
|
+
computedClass = computed(() => xui('bg-surface-overlay/90 border-border shadow-elevation-2 pointer-events-auto absolute z-10 block h-32 w-48 ' +
|
|
1116
|
+
'overflow-hidden rounded-md border p-1 select-none', 'right-4 bottom-4', this.class()), /* @ts-ignore */
|
|
1117
|
+
...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
|
|
1118
|
+
onPointerDown(event) {
|
|
1119
|
+
if (event.button !== 0) {
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
event.stopPropagation();
|
|
1123
|
+
this.dragging = true;
|
|
1124
|
+
event.target.setPointerCapture(event.pointerId);
|
|
1125
|
+
this.centerOnPointer(event);
|
|
1126
|
+
}
|
|
1127
|
+
onPointerMove(event) {
|
|
1128
|
+
if (this.dragging) {
|
|
1129
|
+
this.centerOnPointer(event);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
onPointerUp(event) {
|
|
1133
|
+
this.dragging = false;
|
|
1134
|
+
event.target.releasePointerCapture(event.pointerId);
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Map the pointer through the SVG's `preserveAspectRatio` fit and centre the
|
|
1138
|
+
* canvas there. The fit letterboxes on one axis, so the scale has to be taken
|
|
1139
|
+
* from the tighter of the two — using the element's own aspect ratio would put
|
|
1140
|
+
* the target off by the size of the letterbox.
|
|
1141
|
+
*/
|
|
1142
|
+
centerOnPointer(event) {
|
|
1143
|
+
const rect = this.element.getBoundingClientRect();
|
|
1144
|
+
const extent = this.extent();
|
|
1145
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
const scale = Math.min(rect.width / extent.width, rect.height / extent.height);
|
|
1149
|
+
const offsetX = (rect.width - extent.width * scale) / 2;
|
|
1150
|
+
const offsetY = (rect.height - extent.height * scale) / 2;
|
|
1151
|
+
const graphX = (event.clientX - rect.left - offsetX) / scale + extent.x;
|
|
1152
|
+
const graphY = (event.clientY - rect.top - offsetY) / scale + extent.y;
|
|
1153
|
+
const surface = this.store.surfaceSize();
|
|
1154
|
+
const { zoom } = this.store.viewport();
|
|
1155
|
+
this.store.panBy(surface.width / 2 - (graphX * zoom + this.store.viewport().x), surface.height / 2 - (graphY * zoom + this.store.viewport().y));
|
|
1156
|
+
}
|
|
1157
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphMinimap, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1158
|
+
/** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiGraphMinimap, isStandalone: true, selector: "xui-graph-minimap", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, padding: { classPropertyName: "padding", publicName: "padding", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "data-xui-graph-overlay": "" }, properties: { "class": "computedClass()" } }, ngImport: i0, template: `
|
|
1159
|
+
<svg
|
|
1160
|
+
class="h-full w-full cursor-pointer"
|
|
1161
|
+
[attr.viewBox]="viewBox()"
|
|
1162
|
+
preserveAspectRatio="xMidYMid meet"
|
|
1163
|
+
role="img"
|
|
1164
|
+
aria-label="Graph overview"
|
|
1165
|
+
(pointerdown)="onPointerDown($event)"
|
|
1166
|
+
(pointermove)="onPointerMove($event)"
|
|
1167
|
+
(pointerup)="onPointerUp($event)"
|
|
1168
|
+
>
|
|
1169
|
+
@for (node of nodes(); track node.id) {
|
|
1170
|
+
<rect
|
|
1171
|
+
[attr.x]="node.x"
|
|
1172
|
+
[attr.y]="node.y"
|
|
1173
|
+
[attr.width]="node.width"
|
|
1174
|
+
[attr.height]="node.height"
|
|
1175
|
+
[attr.rx]="cornerRadius()"
|
|
1176
|
+
[attr.fill]="node.selected ? 'var(--color-primary)' : 'var(--color-border-strong)'"
|
|
1177
|
+
/>
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
@if (viewportRect(); as rect) {
|
|
1181
|
+
<rect
|
|
1182
|
+
[attr.x]="rect.x"
|
|
1183
|
+
[attr.y]="rect.y"
|
|
1184
|
+
[attr.width]="rect.width"
|
|
1185
|
+
[attr.height]="rect.height"
|
|
1186
|
+
fill="var(--color-selection)"
|
|
1187
|
+
stroke="var(--color-primary)"
|
|
1188
|
+
[attr.stroke-width]="strokeWidth()"
|
|
1189
|
+
/>
|
|
1190
|
+
}
|
|
1191
|
+
</svg>
|
|
1192
|
+
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
1193
|
+
}
|
|
1194
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphMinimap, decorators: [{
|
|
1195
|
+
type: Component,
|
|
1196
|
+
args: [{
|
|
1197
|
+
selector: 'xui-graph-minimap',
|
|
1198
|
+
template: `
|
|
1199
|
+
<svg
|
|
1200
|
+
class="h-full w-full cursor-pointer"
|
|
1201
|
+
[attr.viewBox]="viewBox()"
|
|
1202
|
+
preserveAspectRatio="xMidYMid meet"
|
|
1203
|
+
role="img"
|
|
1204
|
+
aria-label="Graph overview"
|
|
1205
|
+
(pointerdown)="onPointerDown($event)"
|
|
1206
|
+
(pointermove)="onPointerMove($event)"
|
|
1207
|
+
(pointerup)="onPointerUp($event)"
|
|
1208
|
+
>
|
|
1209
|
+
@for (node of nodes(); track node.id) {
|
|
1210
|
+
<rect
|
|
1211
|
+
[attr.x]="node.x"
|
|
1212
|
+
[attr.y]="node.y"
|
|
1213
|
+
[attr.width]="node.width"
|
|
1214
|
+
[attr.height]="node.height"
|
|
1215
|
+
[attr.rx]="cornerRadius()"
|
|
1216
|
+
[attr.fill]="node.selected ? 'var(--color-primary)' : 'var(--color-border-strong)'"
|
|
1217
|
+
/>
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
@if (viewportRect(); as rect) {
|
|
1221
|
+
<rect
|
|
1222
|
+
[attr.x]="rect.x"
|
|
1223
|
+
[attr.y]="rect.y"
|
|
1224
|
+
[attr.width]="rect.width"
|
|
1225
|
+
[attr.height]="rect.height"
|
|
1226
|
+
fill="var(--color-selection)"
|
|
1227
|
+
stroke="var(--color-primary)"
|
|
1228
|
+
[attr.stroke-width]="strokeWidth()"
|
|
1229
|
+
/>
|
|
1230
|
+
}
|
|
1231
|
+
</svg>
|
|
1232
|
+
`,
|
|
1233
|
+
host: { '[class]': 'computedClass()', 'data-xui-graph-overlay': '' },
|
|
1234
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1235
|
+
encapsulation: ViewEncapsulation.None
|
|
1236
|
+
}]
|
|
1237
|
+
}], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], padding: [{ type: i0.Input, args: [{ isSignal: true, alias: "padding", required: false }] }] } });
|
|
1238
|
+
|
|
1239
|
+
/** @internal */
|
|
1240
|
+
const XUI_GRAPH_NODE = new InjectionToken('XuiGraphNode');
|
|
1241
|
+
|
|
1242
|
+
/** Side a port takes when the author states only its direction. */
|
|
1243
|
+
const SIDE_FOR_DIRECTION = {
|
|
1244
|
+
input: 'left',
|
|
1245
|
+
output: 'right',
|
|
1246
|
+
inout: 'right'
|
|
1247
|
+
};
|
|
1248
|
+
/**
|
|
1249
|
+
* A connector on a node, together with the row of content that belongs to it.
|
|
1250
|
+
*
|
|
1251
|
+
* ```html
|
|
1252
|
+
* <xui-graph-node nodeId="mul" label="Multiply" [(position)]="position">
|
|
1253
|
+
* <xui-graph-port portId="a" direction="input" dataType="float" label="A" />
|
|
1254
|
+
* <xui-graph-port portId="b" direction="input" dataType="float" label="B" />
|
|
1255
|
+
* <xui-graph-port portId="out" direction="output" dataType="float" label="Result" />
|
|
1256
|
+
* </xui-graph-node>
|
|
1257
|
+
* ```
|
|
1258
|
+
*
|
|
1259
|
+
* The host element is the whole row, so anything projected into it — a numeric
|
|
1260
|
+
* input, a colour swatch, a dropdown — sits inline with the connector, the way a
|
|
1261
|
+
* shader or VFX editor lays out its sockets. The connector itself is pinned to
|
|
1262
|
+
* the node's border, because that is what a wire attaches to.
|
|
1263
|
+
*
|
|
1264
|
+
* A hollow connector has nothing attached to it; a filled one is wired up.
|
|
1265
|
+
*/
|
|
1266
|
+
class XuiGraphPort {
|
|
1267
|
+
store = inject(XuiNodeGraphStore);
|
|
1268
|
+
node = inject(XUI_GRAPH_NODE);
|
|
1269
|
+
document = inject(ElementRef).nativeElement.ownerDocument;
|
|
1270
|
+
connectorRef = viewChild.required('connector', /* @ts-ignore */
|
|
1271
|
+
...(ngDevMode ? [{ debugName: "connectorRef" }] : /* istanbul ignore next */ []));
|
|
1272
|
+
offsetState = signal({ x: 0, y: 0 }, /* @ts-ignore */
|
|
1273
|
+
...(ngDevMode ? [{ debugName: "offsetState" }] : /* istanbul ignore next */ []));
|
|
1274
|
+
/**
|
|
1275
|
+
* Registry identity. Empty until `ngOnInit`, because a required input is not
|
|
1276
|
+
* readable during construction.
|
|
1277
|
+
*/
|
|
1278
|
+
key = '';
|
|
1279
|
+
/** Unique within the owning node — the node id supplies the rest of the identity. */
|
|
1280
|
+
portId = input.required(/* @ts-ignore */
|
|
1281
|
+
...(ngDevMode ? [{ debugName: "portId" }] : /* istanbul ignore next */ []));
|
|
1282
|
+
class = input('', /* @ts-ignore */
|
|
1283
|
+
...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
|
|
1284
|
+
/**
|
|
1285
|
+
* Which way data flows. `inout` covers terminals that are neither source nor
|
|
1286
|
+
* sink — a component lead in a schematic, where either end of a wire may be
|
|
1287
|
+
* drawn first.
|
|
1288
|
+
*/
|
|
1289
|
+
direction = input('input', /* @ts-ignore */
|
|
1290
|
+
...(ngDevMode ? [{ debugName: "direction" }] : /* istanbul ignore next */ []));
|
|
1291
|
+
label = input('', /* @ts-ignore */
|
|
1292
|
+
...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
1293
|
+
/**
|
|
1294
|
+
* The compatibility key. Two ports may only be wired together when their data
|
|
1295
|
+
* types match, and the type also picks the connector's colour — see
|
|
1296
|
+
* `provideXuiNodeGraphConfig({ portTypes })`. Leaving it unset makes the port a
|
|
1297
|
+
* wildcard that connects to anything.
|
|
1298
|
+
*/
|
|
1299
|
+
dataType = input(/* @ts-ignore */
|
|
1300
|
+
...(ngDevMode ? [undefined, { debugName: "dataType" }] : /* istanbul ignore next */ []));
|
|
1301
|
+
/** Overrides the colour inherited from {@link dataType}. */
|
|
1302
|
+
color = input(/* @ts-ignore */
|
|
1303
|
+
...(ngDevMode ? [undefined, { debugName: "color" }] : /* istanbul ignore next */ []));
|
|
1304
|
+
disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1305
|
+
/**
|
|
1306
|
+
* Which node edge the connector sits on. Leave unset to take it from
|
|
1307
|
+
* {@link direction} — inputs on the left, outputs on the right.
|
|
1308
|
+
*/
|
|
1309
|
+
side = input(/* @ts-ignore */
|
|
1310
|
+
...(ngDevMode ? [undefined, { debugName: "side" }] : /* istanbul ignore next */ []));
|
|
1311
|
+
shape = input(/* @ts-ignore */
|
|
1312
|
+
...(ngDevMode ? [undefined, { debugName: "shape" }] : /* istanbul ignore next */ []));
|
|
1313
|
+
/**
|
|
1314
|
+
* How many wires may terminate here. Defaults to 1 for an input — the usual
|
|
1315
|
+
* rule that a value has exactly one producer — and unlimited for outputs and
|
|
1316
|
+
* `inout` terminals, which fan out freely.
|
|
1317
|
+
*/
|
|
1318
|
+
maxConnections = input(NaN, { ...(ngDevMode ? { debugName: "maxConnections" } : /* istanbul ignore next */ {}), transform: value => numberAttribute(value, NaN) });
|
|
1319
|
+
nodeId = this.node.nodeId;
|
|
1320
|
+
resolvedSide = computed(() => this.side() ?? SIDE_FOR_DIRECTION[this.direction()], /* @ts-ignore */
|
|
1321
|
+
...(ngDevMode ? [{ debugName: "resolvedSide" }] : /* istanbul ignore next */ []));
|
|
1322
|
+
resolvedMaxConnections = computed(() => {
|
|
1323
|
+
const explicit = this.maxConnections();
|
|
1324
|
+
return Number.isNaN(explicit) ? (this.direction() === 'input' ? 1 : Infinity) : explicit;
|
|
1325
|
+
}, /* @ts-ignore */
|
|
1326
|
+
...(ngDevMode ? [{ debugName: "resolvedMaxConnections" }] : /* istanbul ignore next */ []));
|
|
1327
|
+
/** Connector centre relative to the node's top-left corner, in graph units. */
|
|
1328
|
+
offset = this.offsetState.asReadonly();
|
|
1329
|
+
resolvedColor = computed(() => {
|
|
1330
|
+
const explicit = this.color();
|
|
1331
|
+
if (explicit) {
|
|
1332
|
+
return explicit;
|
|
1333
|
+
}
|
|
1334
|
+
const settings = this.store.settings();
|
|
1335
|
+
const type = this.dataType();
|
|
1336
|
+
return (type ? settings.portTypes[type]?.color : undefined) ?? settings.portColor;
|
|
1337
|
+
}, /* @ts-ignore */
|
|
1338
|
+
...(ngDevMode ? [{ debugName: "resolvedColor" }] : /* istanbul ignore next */ []));
|
|
1339
|
+
resolvedShape = computed(() => {
|
|
1340
|
+
const settings = this.store.settings();
|
|
1341
|
+
const type = this.dataType();
|
|
1342
|
+
return this.shape() ?? (type ? settings.portTypes[type]?.shape : undefined) ?? settings.portShape;
|
|
1343
|
+
}, /* @ts-ignore */
|
|
1344
|
+
...(ngDevMode ? [{ debugName: "resolvedShape" }] : /* istanbul ignore next */ []));
|
|
1345
|
+
horizontal = computed(() => this.resolvedSide() === 'left' || this.resolvedSide() === 'right', /* @ts-ignore */
|
|
1346
|
+
...(ngDevMode ? [{ debugName: "horizontal" }] : /* istanbul ignore next */ []));
|
|
1347
|
+
size = computed(() => this.store.settings().portSize, /* @ts-ignore */
|
|
1348
|
+
...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
|
|
1349
|
+
// A pin is a lead rather than a socket: stretched along the node's normal.
|
|
1350
|
+
connectorWidth = computed(() => this.resolvedShape() === 'pin' && this.horizontal() ? this.size() * 2 : this.size(), /* @ts-ignore */
|
|
1351
|
+
...(ngDevMode ? [{ debugName: "connectorWidth" }] : /* istanbul ignore next */ []));
|
|
1352
|
+
connectorHeight = computed(() => this.resolvedShape() === 'pin' && !this.horizontal() ? this.size() * 2 : this.size(), /* @ts-ignore */
|
|
1353
|
+
...(ngDevMode ? [{ debugName: "connectorHeight" }] : /* istanbul ignore next */ []));
|
|
1354
|
+
connected = computed(() => this.store.connectionCount(this.key) > 0, /* @ts-ignore */
|
|
1355
|
+
...(ngDevMode ? [{ debugName: "connected" }] : /* istanbul ignore next */ []));
|
|
1356
|
+
linkState = computed(() => this.store.linkState(this.key), /* @ts-ignore */
|
|
1357
|
+
...(ngDevMode ? [{ debugName: "linkState" }] : /* istanbul ignore next */ []));
|
|
1358
|
+
/** Position among the ports sharing this side; drives rails and column rows. */
|
|
1359
|
+
indexOnSide = computed(() => {
|
|
1360
|
+
const side = this.resolvedSide();
|
|
1361
|
+
const index = this.node
|
|
1362
|
+
.portSlots()
|
|
1363
|
+
.filter(slot => slot.resolvedSide() === side)
|
|
1364
|
+
.findIndex(slot => slot.key === this.key);
|
|
1365
|
+
return Math.max(index, 0);
|
|
1366
|
+
}, /* @ts-ignore */
|
|
1367
|
+
...(ngDevMode ? [{ debugName: "indexOnSide" }] : /* istanbul ignore next */ []));
|
|
1368
|
+
countOnSide = computed(() => this.node.portSlots().filter(slot => slot.resolvedSide() === this.resolvedSide()).length, /* @ts-ignore */
|
|
1369
|
+
...(ngDevMode ? [{ debugName: "countOnSide" }] : /* istanbul ignore next */ []));
|
|
1370
|
+
/**
|
|
1371
|
+
* Top and bottom ports spread evenly along their edge instead of stacking,
|
|
1372
|
+
* because a schematic reads those terminals as a rail, not as a list.
|
|
1373
|
+
*/
|
|
1374
|
+
railOffset = computed(() => this.horizontal() ? null : ((this.indexOnSide() + 1) / (this.countOnSide() + 1)) * 100, /* @ts-ignore */
|
|
1375
|
+
...(ngDevMode ? [{ debugName: "railOffset" }] : /* istanbul ignore next */ []));
|
|
1376
|
+
order = computed(() => this.node.portLayout() === 'stacked' && this.horizontal() ? (this.resolvedSide() === 'right' ? 0 : 1) : null, /* @ts-ignore */
|
|
1377
|
+
...(ngDevMode ? [{ debugName: "order" }] : /* istanbul ignore next */ []));
|
|
1378
|
+
gridColumn = computed(() => this.node.portLayout() === 'columns' && this.horizontal() ? (this.resolvedSide() === 'left' ? 1 : 2) : null, /* @ts-ignore */
|
|
1379
|
+
...(ngDevMode ? [{ debugName: "gridColumn" }] : /* istanbul ignore next */ []));
|
|
1380
|
+
gridRow = computed(() => this.node.portLayout() === 'columns' && this.horizontal() ? this.indexOnSide() + 1 : null, /* @ts-ignore */
|
|
1381
|
+
...(ngDevMode ? [{ debugName: "gridRow" }] : /* istanbul ignore next */ []));
|
|
1382
|
+
tooltip = computed(() => {
|
|
1383
|
+
const type = this.dataType();
|
|
1384
|
+
const typeLabel = type ? (this.store.settings().portTypes[type]?.label ?? type) : '';
|
|
1385
|
+
return [this.label(), typeLabel && `(${typeLabel})`].filter(Boolean).join(' ') || null;
|
|
1386
|
+
}, /* @ts-ignore */
|
|
1387
|
+
...(ngDevMode ? [{ debugName: "tooltip" }] : /* istanbul ignore next */ []));
|
|
1388
|
+
ariaLabel = computed(() => {
|
|
1389
|
+
const role = this.direction() === 'output' ? 'Output' : this.direction() === 'input' ? 'Input' : 'Terminal';
|
|
1390
|
+
return `${role} ${this.label() || this.portId()}`;
|
|
1391
|
+
}, /* @ts-ignore */
|
|
1392
|
+
...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
1393
|
+
computedClass = computed(() => xui('relative flex min-h-7 items-center gap-2 text-xs leading-tight', this.horizontal() ? 'px-3' : 'absolute w-max -translate-x-1/2 flex-col gap-1', this.resolvedSide() === 'left' && 'justify-start text-left', this.resolvedSide() === 'right' && 'flex-row-reverse justify-start text-right', this.resolvedSide() === 'top' && 'top-0 -translate-y-full flex-col-reverse pb-1', this.resolvedSide() === 'bottom' && 'bottom-0 translate-y-full pt-1',
|
|
1394
|
+
// Collapsed: every connector converges on the node's midline and the row's
|
|
1395
|
+
// own content is hidden, leaving the wiring intact but the card folded.
|
|
1396
|
+
// `inset-x-0` resolves against the node, since the ports container is not
|
|
1397
|
+
// itself positioned.
|
|
1398
|
+
this.node.collapsed() &&
|
|
1399
|
+
this.horizontal() &&
|
|
1400
|
+
'absolute inset-x-0 top-1/2 min-h-0 -translate-y-1/2 px-0 [&>:not(:first-child)]:hidden', this.disabled() ? 'text-foreground-subtle' : 'text-foreground-muted', this.class()), /* @ts-ignore */
|
|
1401
|
+
...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
|
|
1402
|
+
connectorClass = computed(() => {
|
|
1403
|
+
const shape = this.resolvedShape();
|
|
1404
|
+
const state = this.linkState();
|
|
1405
|
+
return xui('absolute box-border border-2 transition-transform duration-100',
|
|
1406
|
+
// Pinned to the node's border box. The ports container carries no
|
|
1407
|
+
// horizontal padding, so `left-0` really is the node's own edge.
|
|
1408
|
+
this.resolvedSide() === 'left' && 'top-1/2 left-0 -translate-x-1/2 -translate-y-1/2', this.resolvedSide() === 'right' && 'top-1/2 right-0 translate-x-1/2 -translate-y-1/2', this.resolvedSide() === 'top' && 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2', this.resolvedSide() === 'bottom' && 'top-0 left-1/2 -translate-x-1/2 -translate-y-1/2', shape === 'circle' && 'rounded-full', shape === 'square' && 'rounded-xs', shape === 'diamond' && 'rounded-xs rotate-45', shape === 'triangle' && 'rounded-none border-0 [clip-path:polygon(0_0,100%_50%,0_100%)]', shape === 'pin' && 'rounded-full', this.disabled() ? 'cursor-not-allowed opacity-40' : 'cursor-crosshair hover:scale-125', state === 'valid' && 'scale-150', state === 'source' && 'ring-focus scale-125 ring-2 ring-offset-1', 'focus-visible:ring-focus focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:outline-none');
|
|
1409
|
+
}, /* @ts-ignore */
|
|
1410
|
+
...(ngDevMode ? [{ debugName: "connectorClass" }] : /* istanbul ignore next */ []));
|
|
1411
|
+
constructor() {
|
|
1412
|
+
inject(DestroyRef).onDestroy(() => this.store.unregisterPort(this));
|
|
1413
|
+
// Re-measure whenever something that could move the connector changes. The
|
|
1414
|
+
// reads happen in the post-render read phase, so no layout is forced during
|
|
1415
|
+
// rendering.
|
|
1416
|
+
afterRenderEffect({
|
|
1417
|
+
read: () => {
|
|
1418
|
+
this.node.layoutEpoch();
|
|
1419
|
+
this.node.collapsed();
|
|
1420
|
+
this.node.portLayout();
|
|
1421
|
+
this.resolvedSide();
|
|
1422
|
+
this.connectorWidth();
|
|
1423
|
+
this.connectorHeight();
|
|
1424
|
+
this.indexOnSide();
|
|
1425
|
+
this.countOnSide();
|
|
1426
|
+
this.measure();
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
ngOnInit() {
|
|
1431
|
+
this.key = xuiGraphPortKey(untracked(this.nodeId), untracked(this.portId));
|
|
1432
|
+
this.store.registerPort(this);
|
|
1433
|
+
}
|
|
1434
|
+
measure() {
|
|
1435
|
+
const nodeRect = this.node.element.getBoundingClientRect();
|
|
1436
|
+
const rect = this.connectorRef().nativeElement.getBoundingClientRect();
|
|
1437
|
+
// Rects are post-transform, so undo the canvas scale to land in graph units.
|
|
1438
|
+
const zoom = untracked(this.store.viewport).zoom || 1;
|
|
1439
|
+
const next = {
|
|
1440
|
+
x: (rect.left + rect.width / 2 - nodeRect.left) / zoom,
|
|
1441
|
+
y: (rect.top + rect.height / 2 - nodeRect.top) / zoom
|
|
1442
|
+
};
|
|
1443
|
+
const current = untracked(this.offsetState);
|
|
1444
|
+
if (Math.abs(current.x - next.x) > 0.01 || Math.abs(current.y - next.y) > 0.01) {
|
|
1445
|
+
this.offsetState.set(next);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
onPointerDown(event) {
|
|
1449
|
+
if (event.button !== 0 || this.disabled() || this.store.settings().locked) {
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
// Keep the node from reading this as the start of a move.
|
|
1453
|
+
event.stopPropagation();
|
|
1454
|
+
event.preventDefault();
|
|
1455
|
+
this.connectorRef().nativeElement.setPointerCapture(event.pointerId);
|
|
1456
|
+
this.store.beginLink(this, this.store.toGraphPoint(event.clientX, event.clientY));
|
|
1457
|
+
}
|
|
1458
|
+
onPointerMove(event) {
|
|
1459
|
+
const linking = this.store.linking();
|
|
1460
|
+
if (!linking || linking.fromKey !== this.key) {
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
this.store.moveLink(this.store.toGraphPoint(event.clientX, event.clientY));
|
|
1464
|
+
this.store.setLinkHover(this.portKeyAt(event.clientX, event.clientY));
|
|
1465
|
+
}
|
|
1466
|
+
onPointerUp(event) {
|
|
1467
|
+
const linking = this.store.linking();
|
|
1468
|
+
if (!linking || linking.fromKey !== this.key) {
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
this.connectorRef().nativeElement.releasePointerCapture(event.pointerId);
|
|
1472
|
+
// Refresh the pointer before ending: a release with no preceding move would
|
|
1473
|
+
// otherwise report a create-on-drop position one event out of date.
|
|
1474
|
+
this.store.moveLink(this.store.toGraphPoint(event.clientX, event.clientY));
|
|
1475
|
+
this.store.endLink(this.portKeyAt(event.clientX, event.clientY));
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Keyboard equivalent of dragging a wire: commit on one port, move focus,
|
|
1479
|
+
* commit on the other. Without it the graph cannot be wired without a mouse.
|
|
1480
|
+
*/
|
|
1481
|
+
onKeydown(event) {
|
|
1482
|
+
if (this.disabled() || this.store.settings().locked) {
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
const linking = this.store.linking();
|
|
1486
|
+
if (event.key === 'Escape' && linking) {
|
|
1487
|
+
event.preventDefault();
|
|
1488
|
+
this.store.endLink(null);
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (event.key !== 'Enter' && event.key !== ' ') {
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
event.preventDefault();
|
|
1495
|
+
if (!linking) {
|
|
1496
|
+
this.store.beginLink(this, this.store.portPoint(this.key) ?? { x: 0, y: 0 });
|
|
1497
|
+
}
|
|
1498
|
+
else {
|
|
1499
|
+
this.store.endLink(linking.fromKey === this.key ? null : this.key);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Which port, if any, lies under a screen point. Used for drop targeting.
|
|
1504
|
+
*
|
|
1505
|
+
* The two halves of the identity are read back as separate attributes rather
|
|
1506
|
+
* than as one composite key, so the registry is free to key ports however it
|
|
1507
|
+
* likes without that encoding having to survive a DOM round-trip.
|
|
1508
|
+
*/
|
|
1509
|
+
portKeyAt(clientX, clientY) {
|
|
1510
|
+
const host = this.document.elementFromPoint(clientX, clientY)?.closest('[data-xui-graph-port]');
|
|
1511
|
+
const portId = host?.getAttribute('data-xui-graph-port');
|
|
1512
|
+
const nodeId = host?.getAttribute('data-xui-graph-port-node');
|
|
1513
|
+
if (portId == null || nodeId == null) {
|
|
1514
|
+
return null;
|
|
1515
|
+
}
|
|
1516
|
+
const key = xuiGraphPortKey(nodeId, portId);
|
|
1517
|
+
return key === this.key ? null : key;
|
|
1518
|
+
}
|
|
1519
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphPort, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1520
|
+
/** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiGraphPort, isStandalone: true, selector: "xui-graph-port", inputs: { portId: { classPropertyName: "portId", publicName: "portId", isSignal: true, isRequired: true, transformFunction: null }, class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, direction: { classPropertyName: "direction", publicName: "direction", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, dataType: { classPropertyName: "dataType", publicName: "dataType", isSignal: true, isRequired: false, transformFunction: null }, color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, side: { classPropertyName: "side", publicName: "side", isSignal: true, isRequired: false, transformFunction: null }, shape: { classPropertyName: "shape", publicName: "shape", isSignal: true, isRequired: false, transformFunction: null }, maxConnections: { classPropertyName: "maxConnections", publicName: "maxConnections", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "computedClass()", "attr.data-xui-graph-port": "portId()", "attr.data-xui-graph-port-node": "nodeId()", "attr.data-side": "resolvedSide()", "attr.data-link-state": "linkState()", "style.order": "order()", "style.grid-column": "gridColumn()", "style.grid-row": "gridRow()", "style.left.%": "railOffset()", "style.opacity": "linkState() === 'invalid' ? 0.35 : null" } }, viewQueries: [{ propertyName: "connectorRef", first: true, predicate: ["connector"], descendants: true, isSignal: true }], ngImport: i0, template: `
|
|
1521
|
+
<div
|
|
1522
|
+
#connector
|
|
1523
|
+
role="button"
|
|
1524
|
+
[class]="connectorClass()"
|
|
1525
|
+
[style.width.px]="connectorWidth()"
|
|
1526
|
+
[style.height.px]="connectorHeight()"
|
|
1527
|
+
[style.background]="connected() ? resolvedColor() : 'var(--color-surface)'"
|
|
1528
|
+
[style.border-color]="resolvedColor()"
|
|
1529
|
+
[attr.aria-label]="ariaLabel()"
|
|
1530
|
+
[attr.aria-disabled]="disabled() || null"
|
|
1531
|
+
[attr.title]="tooltip()"
|
|
1532
|
+
[attr.tabindex]="disabled() ? null : 0"
|
|
1533
|
+
(pointerdown)="onPointerDown($event)"
|
|
1534
|
+
(pointermove)="onPointerMove($event)"
|
|
1535
|
+
(pointerup)="onPointerUp($event)"
|
|
1536
|
+
(pointercancel)="onPointerUp($event)"
|
|
1537
|
+
(keydown)="onKeydown($event)"
|
|
1538
|
+
></div>
|
|
1539
|
+
|
|
1540
|
+
@if (label()) {
|
|
1541
|
+
<span class="truncate">{{ label() }}</span>
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
<ng-content />
|
|
1545
|
+
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
1546
|
+
}
|
|
1547
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphPort, decorators: [{
|
|
1548
|
+
type: Component,
|
|
1549
|
+
args: [{
|
|
1550
|
+
selector: 'xui-graph-port',
|
|
1551
|
+
template: `
|
|
1552
|
+
<div
|
|
1553
|
+
#connector
|
|
1554
|
+
role="button"
|
|
1555
|
+
[class]="connectorClass()"
|
|
1556
|
+
[style.width.px]="connectorWidth()"
|
|
1557
|
+
[style.height.px]="connectorHeight()"
|
|
1558
|
+
[style.background]="connected() ? resolvedColor() : 'var(--color-surface)'"
|
|
1559
|
+
[style.border-color]="resolvedColor()"
|
|
1560
|
+
[attr.aria-label]="ariaLabel()"
|
|
1561
|
+
[attr.aria-disabled]="disabled() || null"
|
|
1562
|
+
[attr.title]="tooltip()"
|
|
1563
|
+
[attr.tabindex]="disabled() ? null : 0"
|
|
1564
|
+
(pointerdown)="onPointerDown($event)"
|
|
1565
|
+
(pointermove)="onPointerMove($event)"
|
|
1566
|
+
(pointerup)="onPointerUp($event)"
|
|
1567
|
+
(pointercancel)="onPointerUp($event)"
|
|
1568
|
+
(keydown)="onKeydown($event)"
|
|
1569
|
+
></div>
|
|
1570
|
+
|
|
1571
|
+
@if (label()) {
|
|
1572
|
+
<span class="truncate">{{ label() }}</span>
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
<ng-content />
|
|
1576
|
+
`,
|
|
1577
|
+
host: {
|
|
1578
|
+
'[class]': 'computedClass()',
|
|
1579
|
+
'[attr.data-xui-graph-port]': 'portId()',
|
|
1580
|
+
'[attr.data-xui-graph-port-node]': 'nodeId()',
|
|
1581
|
+
'[attr.data-side]': 'resolvedSide()',
|
|
1582
|
+
'[attr.data-link-state]': 'linkState()',
|
|
1583
|
+
'[style.order]': 'order()',
|
|
1584
|
+
'[style.grid-column]': 'gridColumn()',
|
|
1585
|
+
'[style.grid-row]': 'gridRow()',
|
|
1586
|
+
'[style.left.%]': 'railOffset()',
|
|
1587
|
+
'[style.opacity]': "linkState() === 'invalid' ? 0.35 : null"
|
|
1588
|
+
},
|
|
1589
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1590
|
+
encapsulation: ViewEncapsulation.None
|
|
1591
|
+
}]
|
|
1592
|
+
}], ctorParameters: () => [], propDecorators: { connectorRef: [{ type: i0.ViewChild, args: ['connector', { isSignal: true }] }], portId: [{ type: i0.Input, args: [{ isSignal: true, alias: "portId", required: true }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], direction: [{ type: i0.Input, args: [{ isSignal: true, alias: "direction", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], dataType: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataType", required: false }] }], color: [{ type: i0.Input, args: [{ isSignal: true, alias: "color", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], side: [{ type: i0.Input, args: [{ isSignal: true, alias: "side", required: false }] }], shape: [{ type: i0.Input, args: [{ isSignal: true, alias: "shape", required: false }] }], maxConnections: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxConnections", required: false }] }] } });
|
|
1593
|
+
|
|
1594
|
+
const graphNodeVariants = cva('bg-surface-raised text-foreground absolute top-0 left-0 flex flex-col rounded-lg border select-none', {
|
|
1595
|
+
variants: {
|
|
1596
|
+
selected: {
|
|
1597
|
+
true: 'border-primary ring-primary/60 ring-2',
|
|
1598
|
+
false: 'border-border shadow-elevation-2'
|
|
1599
|
+
},
|
|
1600
|
+
locked: {
|
|
1601
|
+
true: 'cursor-default',
|
|
1602
|
+
false: ''
|
|
1603
|
+
},
|
|
1604
|
+
dimmed: {
|
|
1605
|
+
true: 'opacity-45',
|
|
1606
|
+
false: ''
|
|
1607
|
+
}
|
|
1608
|
+
},
|
|
1609
|
+
defaultVariants: { selected: false, locked: false, dimmed: false }
|
|
1610
|
+
});
|
|
1611
|
+
/**
|
|
1612
|
+
* A card on the canvas: a header, an optional body, and a set of ports.
|
|
1613
|
+
*
|
|
1614
|
+
* ```html
|
|
1615
|
+
* <xui-graph-node nodeId="osc" label="Oscillator" [(position)]="position" accent="var(--color-chart-4)">
|
|
1616
|
+
* <xui-graph-port portId="freq" direction="input" dataType="float" label="Frequency" />
|
|
1617
|
+
* <xui-graph-port portId="out" direction="output" dataType="signal" label="Out" />
|
|
1618
|
+
* </xui-graph-node>
|
|
1619
|
+
* ```
|
|
1620
|
+
*
|
|
1621
|
+
* The node is deliberately unopinionated about its body — projected content
|
|
1622
|
+
* renders below the ports, so the same component serves a shader node, a
|
|
1623
|
+
* two-terminal resistor and a form-like settings card. What it does own is the
|
|
1624
|
+
* geometry: its position drives every wire attached to it, and its border is
|
|
1625
|
+
* where connectors sit.
|
|
1626
|
+
*
|
|
1627
|
+
* `position` is a model, so `[(position)]` keeps the host's own state authoritative
|
|
1628
|
+
* — nothing is mutated behind the caller's back.
|
|
1629
|
+
*/
|
|
1630
|
+
class XuiGraphNode {
|
|
1631
|
+
store = inject(XuiNodeGraphStore);
|
|
1632
|
+
sizeState = signal({ width: 0, height: 0 }, /* @ts-ignore */
|
|
1633
|
+
...(ngDevMode ? [{ debugName: "sizeState" }] : /* istanbul ignore next */ []));
|
|
1634
|
+
layoutEpochState = signal(0, /* @ts-ignore */
|
|
1635
|
+
...(ngDevMode ? [{ debugName: "layoutEpochState" }] : /* istanbul ignore next */ []));
|
|
1636
|
+
dragOrigin = null;
|
|
1637
|
+
/** Host element. Ports measure their connector offsets against this box. */
|
|
1638
|
+
element = inject(ElementRef).nativeElement;
|
|
1639
|
+
/** Identifies the node to edges and to the selection. Must be stable and unique. */
|
|
1640
|
+
nodeId = input.required(/* @ts-ignore */
|
|
1641
|
+
...(ngDevMode ? [{ debugName: "nodeId" }] : /* istanbul ignore next */ []));
|
|
1642
|
+
class = input('', /* @ts-ignore */
|
|
1643
|
+
...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
|
|
1644
|
+
/** Top-left corner in graph space. Two-way bound so the host owns the value. */
|
|
1645
|
+
position = model({ x: 0, y: 0 }, /* @ts-ignore */
|
|
1646
|
+
...(ngDevMode ? [{ debugName: "position" }] : /* istanbul ignore next */ []));
|
|
1647
|
+
label = input('', /* @ts-ignore */
|
|
1648
|
+
...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
1649
|
+
/** Fixed width in graph units. Leave unset to size to content. */
|
|
1650
|
+
width = input(undefined, { ...(ngDevMode ? { debugName: "width" } : /* istanbul ignore next */ {}), transform: value => (value == null || value === '' ? undefined : numberAttribute(value)) });
|
|
1651
|
+
/**
|
|
1652
|
+
* Accent colour for the header's top rule — the usual way a node editor shows
|
|
1653
|
+
* which category a node belongs to. Any CSS colour; prefer a theme token.
|
|
1654
|
+
*/
|
|
1655
|
+
accent = input(/* @ts-ignore */
|
|
1656
|
+
...(ngDevMode ? [undefined, { debugName: "accent" }] : /* istanbul ignore next */ []));
|
|
1657
|
+
/**
|
|
1658
|
+
* Id of the `<xui-graph-group>` this node belongs to. Membership lives on the
|
|
1659
|
+
* node rather than in the frame, so a node never has to move in the template to
|
|
1660
|
+
* join or leave one.
|
|
1661
|
+
*/
|
|
1662
|
+
group = input(/* @ts-ignore */
|
|
1663
|
+
...(ngDevMode ? [undefined, { debugName: "group" }] : /* istanbul ignore next */ []));
|
|
1664
|
+
/** Arrangement of the port rows. */
|
|
1665
|
+
portLayout = input('stacked', /* @ts-ignore */
|
|
1666
|
+
...(ngDevMode ? [{ debugName: "portLayout" }] : /* istanbul ignore next */ []));
|
|
1667
|
+
/**
|
|
1668
|
+
* Hides the body and pulls every connector onto the header, so a finished
|
|
1669
|
+
* subtree can be folded away without breaking its wiring.
|
|
1670
|
+
*/
|
|
1671
|
+
collapsed = model(false, /* @ts-ignore */
|
|
1672
|
+
...(ngDevMode ? [{ debugName: "collapsed" }] : /* istanbul ignore next */ []));
|
|
1673
|
+
collapsible = input(false, { ...(ngDevMode ? { debugName: "collapsible" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1674
|
+
selectable = input(true, { ...(ngDevMode ? { debugName: "selectable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1675
|
+
draggable = input(true, { ...(ngDevMode ? { debugName: "draggable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1676
|
+
/** Pins the node in place while leaving it selectable. */
|
|
1677
|
+
locked = input(false, { ...(ngDevMode ? { debugName: "locked" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1678
|
+
/** Restrict dragging to the header, as a window manager does. */
|
|
1679
|
+
dragHandle = input('node', /* @ts-ignore */
|
|
1680
|
+
...(ngDevMode ? [{ debugName: "dragHandle" }] : /* istanbul ignore next */ []));
|
|
1681
|
+
/** Renders the node faded — for a disabled branch or a bypassed effect. */
|
|
1682
|
+
dimmed = input(false, { ...(ngDevMode ? { debugName: "dimmed" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1683
|
+
/** Double-click on the header. The usual gesture for entering a subgraph. */
|
|
1684
|
+
activated = output();
|
|
1685
|
+
/** Measured border-box size in graph units, from a ResizeObserver. */
|
|
1686
|
+
size = this.sizeState.asReadonly();
|
|
1687
|
+
/** @internal Bumped on reflow so ports know to re-measure. */
|
|
1688
|
+
layoutEpoch = this.layoutEpochState.asReadonly();
|
|
1689
|
+
/** @internal Ports in DOM order, for side-relative indexing. */
|
|
1690
|
+
portSlots = contentChildren(XuiGraphPort, /* @ts-ignore */
|
|
1691
|
+
...(ngDevMode ? [{ debugName: "portSlots" }] : /* istanbul ignore next */ []));
|
|
1692
|
+
selected = computed(() => this.store.isNodeSelected(this.nodeId()), /* @ts-ignore */
|
|
1693
|
+
...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
|
|
1694
|
+
immovable = computed(() => this.locked() || !this.draggable() || this.store.settings().locked, /* @ts-ignore */
|
|
1695
|
+
...(ngDevMode ? [{ debugName: "immovable" }] : /* istanbul ignore next */ []));
|
|
1696
|
+
transform = computed(() => {
|
|
1697
|
+
const { x, y } = this.position();
|
|
1698
|
+
return `translate(${x}px, ${y}px)`;
|
|
1699
|
+
}, /* @ts-ignore */
|
|
1700
|
+
...(ngDevMode ? [{ debugName: "transform" }] : /* istanbul ignore next */ []));
|
|
1701
|
+
dragCursor = computed(() => this.immovable() ? null : this.dragHandle() === 'header' ? null : 'grab', /* @ts-ignore */
|
|
1702
|
+
...(ngDevMode ? [{ debugName: "dragCursor" }] : /* istanbul ignore next */ []));
|
|
1703
|
+
computedClass = computed(() => xui(graphNodeVariants({ selected: this.selected(), locked: this.locked(), dimmed: this.dimmed() }), !this.width() && 'min-w-40', this.class()), /* @ts-ignore */
|
|
1704
|
+
...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
|
|
1705
|
+
headerClass = computed(() => xui('flex items-center gap-2 rounded-t-lg px-3 py-2 text-sm font-medium',
|
|
1706
|
+
// The accent is painted as a thicker top rule, so it survives a dark theme
|
|
1707
|
+
// and never fights the node's own surface colour for contrast.
|
|
1708
|
+
this.accent() ? 'border-t-3' : 'border-t-3 border-t-transparent', !this.collapsed() && 'border-border-muted border-b', this.immovable() || this.dragHandle() === 'node' ? '' : 'cursor-grab'), /* @ts-ignore */
|
|
1709
|
+
...(ngDevMode ? [{ debugName: "headerClass" }] : /* istanbul ignore next */ []));
|
|
1710
|
+
portsClass = computed(() => xui(this.portLayout() === 'columns' ? 'grid grid-cols-2' : 'flex flex-col',
|
|
1711
|
+
// Collapsed ports are absolute, so the container takes no room on its own.
|
|
1712
|
+
!this.collapsed() && this.portSlots().length > 0 && 'py-1.5'), /* @ts-ignore */
|
|
1713
|
+
...(ngDevMode ? [{ debugName: "portsClass" }] : /* istanbul ignore next */ []));
|
|
1714
|
+
constructor() {
|
|
1715
|
+
const observer = new ResizeObserver(entries => {
|
|
1716
|
+
const box = entries[0]?.borderBoxSize?.[0];
|
|
1717
|
+
const width = box ? box.inlineSize : this.element.offsetWidth;
|
|
1718
|
+
const height = box ? box.blockSize : this.element.offsetHeight;
|
|
1719
|
+
const current = untracked(this.sizeState);
|
|
1720
|
+
if (Math.abs(current.width - width) > 0.5 || Math.abs(current.height - height) > 0.5) {
|
|
1721
|
+
this.sizeState.set({ width, height });
|
|
1722
|
+
}
|
|
1723
|
+
// Bump unconditionally: a reflow that leaves the node's own box unchanged
|
|
1724
|
+
// — a label swap, a row appearing and another disappearing — still moves
|
|
1725
|
+
// the ports inside it.
|
|
1726
|
+
this.layoutEpochState.update(epoch => epoch + 1);
|
|
1727
|
+
});
|
|
1728
|
+
observer.observe(this.element);
|
|
1729
|
+
inject(DestroyRef).onDestroy(() => {
|
|
1730
|
+
observer.disconnect();
|
|
1731
|
+
this.store.unregisterNode(this);
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
ngOnInit() {
|
|
1735
|
+
this.store.registerNode(this);
|
|
1736
|
+
}
|
|
1737
|
+
/** @internal Called by the store while dragging a selection. */
|
|
1738
|
+
moveTo(point) {
|
|
1739
|
+
this.position.set(point);
|
|
1740
|
+
}
|
|
1741
|
+
onPointerDown(event) {
|
|
1742
|
+
if (event.button !== 0) {
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
// A press anywhere on a node belongs to the node, never to the canvas — even
|
|
1746
|
+
// when it lands on a control and starts no drag at all.
|
|
1747
|
+
event.stopPropagation();
|
|
1748
|
+
const target = event.target;
|
|
1749
|
+
if (this.selectable()) {
|
|
1750
|
+
const additive = event.shiftKey || event.ctrlKey || event.metaKey;
|
|
1751
|
+
if (additive || !this.store.isNodeSelected(this.nodeId())) {
|
|
1752
|
+
this.store.selectNode(this.nodeId(), additive);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
if (this.immovable() || target.closest(NON_DRAGGABLE)) {
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
if (this.dragHandle() === 'header' && !target.closest('[data-xui-graph-node-header]')) {
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
event.preventDefault();
|
|
1762
|
+
this.element.setPointerCapture(event.pointerId);
|
|
1763
|
+
this.dragOrigin = { x: event.clientX, y: event.clientY };
|
|
1764
|
+
this.store.beginNodeDrag(this.nodeId());
|
|
1765
|
+
}
|
|
1766
|
+
onPointerMove(event) {
|
|
1767
|
+
if (!this.dragOrigin) {
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
const { zoom } = this.store.viewport();
|
|
1771
|
+
this.store.dragNodesBy({
|
|
1772
|
+
x: (event.clientX - this.dragOrigin.x) / zoom,
|
|
1773
|
+
y: (event.clientY - this.dragOrigin.y) / zoom
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
onPointerUp(event) {
|
|
1777
|
+
if (!this.dragOrigin) {
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
this.dragOrigin = null;
|
|
1781
|
+
this.element.releasePointerCapture(event.pointerId);
|
|
1782
|
+
this.store.endNodeDrag();
|
|
1783
|
+
}
|
|
1784
|
+
onHeaderDoubleClick(event) {
|
|
1785
|
+
event.stopPropagation();
|
|
1786
|
+
this.activated.emit();
|
|
1787
|
+
}
|
|
1788
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNode, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1789
|
+
/** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiGraphNode, isStandalone: true, selector: "xui-graph-node", inputs: { nodeId: { classPropertyName: "nodeId", publicName: "nodeId", isSignal: true, isRequired: true, transformFunction: null }, class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, position: { classPropertyName: "position", publicName: "position", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, accent: { classPropertyName: "accent", publicName: "accent", isSignal: true, isRequired: false, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: false, transformFunction: null }, portLayout: { classPropertyName: "portLayout", publicName: "portLayout", isSignal: true, isRequired: false, transformFunction: null }, collapsed: { classPropertyName: "collapsed", publicName: "collapsed", isSignal: true, isRequired: false, transformFunction: null }, collapsible: { classPropertyName: "collapsible", publicName: "collapsible", isSignal: true, isRequired: false, transformFunction: null }, selectable: { classPropertyName: "selectable", publicName: "selectable", isSignal: true, isRequired: false, transformFunction: null }, draggable: { classPropertyName: "draggable", publicName: "draggable", isSignal: true, isRequired: false, transformFunction: null }, locked: { classPropertyName: "locked", publicName: "locked", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null }, dimmed: { classPropertyName: "dimmed", publicName: "dimmed", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { position: "positionChange", collapsed: "collapsedChange", activated: "activated" }, host: { listeners: { "pointerdown": "onPointerDown($event)", "pointermove": "onPointerMove($event)", "pointerup": "onPointerUp($event)", "pointercancel": "onPointerUp($event)" }, properties: { "class": "computedClass()", "attr.data-xui-graph-node": "nodeId()", "attr.aria-selected": "selectable() ? selected() : null", "attr.role": "selectable() ? 'option' : null", "style.transform": "transform()", "style.width.px": "width()", "style.z-index": "selected() ? 2 : 1", "style.cursor": "dragCursor()" } }, providers: [{ provide: XUI_GRAPH_NODE, useExisting: XuiGraphNode }], queries: [{ propertyName: "portSlots", predicate: XuiGraphPort, isSignal: true }], ngImport: i0, template: `
|
|
1790
|
+
<div
|
|
1791
|
+
data-xui-graph-node-header
|
|
1792
|
+
[class]="headerClass()"
|
|
1793
|
+
[style.border-top-color]="accent()"
|
|
1794
|
+
(dblclick)="onHeaderDoubleClick($event)"
|
|
1795
|
+
>
|
|
1796
|
+
@if (collapsible()) {
|
|
1797
|
+
<button
|
|
1798
|
+
type="button"
|
|
1799
|
+
class="text-foreground-subtle hover:text-foreground -ml-1 shrink-0 cursor-pointer p-0.5 leading-none"
|
|
1800
|
+
[attr.aria-label]="collapsed() ? 'Expand node' : 'Collapse node'"
|
|
1801
|
+
[attr.aria-expanded]="!collapsed()"
|
|
1802
|
+
(click)="collapsed.set(!collapsed())"
|
|
1803
|
+
>
|
|
1804
|
+
<svg
|
|
1805
|
+
width="10"
|
|
1806
|
+
height="10"
|
|
1807
|
+
viewBox="0 0 10 10"
|
|
1808
|
+
aria-hidden="true"
|
|
1809
|
+
class="transition-transform duration-150"
|
|
1810
|
+
[style.transform]="collapsed() ? 'rotate(-90deg)' : null"
|
|
1811
|
+
>
|
|
1812
|
+
<path d="M1 3 L5 7 L9 3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
1813
|
+
</svg>
|
|
1814
|
+
</button>
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
<ng-content select="xui-graph-node-header" />
|
|
1818
|
+
|
|
1819
|
+
@if (label()) {
|
|
1820
|
+
<span class="min-w-0 flex-1 truncate">{{ label() }}</span>
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
<ng-content select="xui-graph-node-actions" />
|
|
1824
|
+
</div>
|
|
1825
|
+
|
|
1826
|
+
@if (!collapsed()) {
|
|
1827
|
+
<ng-content select="xui-graph-node-preview" />
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
<!--
|
|
1831
|
+
Deliberately not positioned: left/right connectors resolve their inset
|
|
1832
|
+
against this box (full node width, no horizontal padding), while top and
|
|
1833
|
+
bottom ports are absolute and must resolve against the node itself.
|
|
1834
|
+
-->
|
|
1835
|
+
<div [class]="portsClass()">
|
|
1836
|
+
<ng-content select="xui-graph-port" />
|
|
1837
|
+
</div>
|
|
1838
|
+
|
|
1839
|
+
@if (!collapsed()) {
|
|
1840
|
+
<ng-content />
|
|
1841
|
+
}
|
|
1842
|
+
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
1843
|
+
}
|
|
1844
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNode, decorators: [{
|
|
1845
|
+
type: Component,
|
|
1846
|
+
args: [{
|
|
1847
|
+
selector: 'xui-graph-node',
|
|
1848
|
+
template: `
|
|
1849
|
+
<div
|
|
1850
|
+
data-xui-graph-node-header
|
|
1851
|
+
[class]="headerClass()"
|
|
1852
|
+
[style.border-top-color]="accent()"
|
|
1853
|
+
(dblclick)="onHeaderDoubleClick($event)"
|
|
1854
|
+
>
|
|
1855
|
+
@if (collapsible()) {
|
|
1856
|
+
<button
|
|
1857
|
+
type="button"
|
|
1858
|
+
class="text-foreground-subtle hover:text-foreground -ml-1 shrink-0 cursor-pointer p-0.5 leading-none"
|
|
1859
|
+
[attr.aria-label]="collapsed() ? 'Expand node' : 'Collapse node'"
|
|
1860
|
+
[attr.aria-expanded]="!collapsed()"
|
|
1861
|
+
(click)="collapsed.set(!collapsed())"
|
|
1862
|
+
>
|
|
1863
|
+
<svg
|
|
1864
|
+
width="10"
|
|
1865
|
+
height="10"
|
|
1866
|
+
viewBox="0 0 10 10"
|
|
1867
|
+
aria-hidden="true"
|
|
1868
|
+
class="transition-transform duration-150"
|
|
1869
|
+
[style.transform]="collapsed() ? 'rotate(-90deg)' : null"
|
|
1870
|
+
>
|
|
1871
|
+
<path d="M1 3 L5 7 L9 3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
1872
|
+
</svg>
|
|
1873
|
+
</button>
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
<ng-content select="xui-graph-node-header" />
|
|
1877
|
+
|
|
1878
|
+
@if (label()) {
|
|
1879
|
+
<span class="min-w-0 flex-1 truncate">{{ label() }}</span>
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
<ng-content select="xui-graph-node-actions" />
|
|
1883
|
+
</div>
|
|
1884
|
+
|
|
1885
|
+
@if (!collapsed()) {
|
|
1886
|
+
<ng-content select="xui-graph-node-preview" />
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
<!--
|
|
1890
|
+
Deliberately not positioned: left/right connectors resolve their inset
|
|
1891
|
+
against this box (full node width, no horizontal padding), while top and
|
|
1892
|
+
bottom ports are absolute and must resolve against the node itself.
|
|
1893
|
+
-->
|
|
1894
|
+
<div [class]="portsClass()">
|
|
1895
|
+
<ng-content select="xui-graph-port" />
|
|
1896
|
+
</div>
|
|
1897
|
+
|
|
1898
|
+
@if (!collapsed()) {
|
|
1899
|
+
<ng-content />
|
|
1900
|
+
}
|
|
1901
|
+
`,
|
|
1902
|
+
host: {
|
|
1903
|
+
'[class]': 'computedClass()',
|
|
1904
|
+
'[attr.data-xui-graph-node]': 'nodeId()',
|
|
1905
|
+
'[attr.aria-selected]': 'selectable() ? selected() : null',
|
|
1906
|
+
'[attr.role]': "selectable() ? 'option' : null",
|
|
1907
|
+
'[style.transform]': 'transform()',
|
|
1908
|
+
'[style.width.px]': 'width()',
|
|
1909
|
+
'[style.z-index]': 'selected() ? 2 : 1',
|
|
1910
|
+
'[style.cursor]': 'dragCursor()',
|
|
1911
|
+
'(pointerdown)': 'onPointerDown($event)',
|
|
1912
|
+
'(pointermove)': 'onPointerMove($event)',
|
|
1913
|
+
'(pointerup)': 'onPointerUp($event)',
|
|
1914
|
+
'(pointercancel)': 'onPointerUp($event)'
|
|
1915
|
+
},
|
|
1916
|
+
providers: [{ provide: XUI_GRAPH_NODE, useExisting: XuiGraphNode }],
|
|
1917
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1918
|
+
encapsulation: ViewEncapsulation.None
|
|
1919
|
+
}]
|
|
1920
|
+
}], ctorParameters: () => [], propDecorators: { nodeId: [{ type: i0.Input, args: [{ isSignal: true, alias: "nodeId", required: true }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], position: [{ type: i0.Input, args: [{ isSignal: true, alias: "position", required: false }] }, { type: i0.Output, args: ["positionChange"] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], width: [{ type: i0.Input, args: [{ isSignal: true, alias: "width", required: false }] }], accent: [{ type: i0.Input, args: [{ isSignal: true, alias: "accent", required: false }] }], group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", required: false }] }], portLayout: [{ type: i0.Input, args: [{ isSignal: true, alias: "portLayout", required: false }] }], collapsed: [{ type: i0.Input, args: [{ isSignal: true, alias: "collapsed", required: false }] }, { type: i0.Output, args: ["collapsedChange"] }], collapsible: [{ type: i0.Input, args: [{ isSignal: true, alias: "collapsible", required: false }] }], selectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectable", required: false }] }], draggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "draggable", required: false }] }], locked: [{ type: i0.Input, args: [{ isSignal: true, alias: "locked", required: false }] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }], dimmed: [{ type: i0.Input, args: [{ isSignal: true, alias: "dimmed", required: false }] }], activated: [{ type: i0.Output, args: ["activated"] }], portSlots: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => XuiGraphPort), { isSignal: true }] }] } });
|
|
1921
|
+
/**
|
|
1922
|
+
* Descendants that own their own pointer gestures. Ports link, native controls
|
|
1923
|
+
* take input, and anything marked `xuiGraphNoDrag` opts out explicitly.
|
|
1924
|
+
*/
|
|
1925
|
+
const NON_DRAGGABLE = '[data-xui-graph-port], [data-xui-graph-no-drag], input, textarea, select, button, a, [contenteditable="true"]';
|
|
1926
|
+
|
|
1927
|
+
/*
|
|
1928
|
+
* The slots are elements rather than attribute directives on purpose.
|
|
1929
|
+
*
|
|
1930
|
+
* Content projection matches `ng-content select` against the template's own
|
|
1931
|
+
* markup, and an attribute selector that fails to match raises nothing — the
|
|
1932
|
+
* element quietly lands in the default slot instead. For a node header that means
|
|
1933
|
+
* rendering below the ports; for an overlay it means being placed inside the
|
|
1934
|
+
* canvas transform, where it pans and zooms with the content and sizes itself
|
|
1935
|
+
* against a zero-height box. Element selectors match reliably, so a slot cannot
|
|
1936
|
+
* end up in the wrong layer without anyone noticing.
|
|
1937
|
+
*/
|
|
1938
|
+
/**
|
|
1939
|
+
* Replaces a node's default header. Use it when the title needs an icon, a
|
|
1940
|
+
* badge, or anything beyond the `label` input.
|
|
1941
|
+
*
|
|
1942
|
+
* ```html
|
|
1943
|
+
* <xui-graph-node nodeId="n1" [(position)]="position">
|
|
1944
|
+
* <xui-graph-node-header><ng-icon name="matBolt" /> Amplifier</xui-graph-node-header>
|
|
1945
|
+
* </xui-graph-node>
|
|
1946
|
+
* ```
|
|
1947
|
+
*/
|
|
1948
|
+
class XuiGraphNodeHeader {
|
|
1949
|
+
class = input('', /* @ts-ignore */
|
|
1950
|
+
...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
|
|
1951
|
+
computedClass = computed(() => xui('flex min-w-0 flex-1 items-center gap-2 truncate', this.class()), /* @ts-ignore */
|
|
1952
|
+
...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
|
|
1953
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNodeHeader, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1954
|
+
/** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.8", type: XuiGraphNodeHeader, isStandalone: true, selector: "xui-graph-node-header", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "computedClass()" } }, ngImport: i0, template: '<ng-content />', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
1955
|
+
}
|
|
1956
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNodeHeader, decorators: [{
|
|
1957
|
+
type: Component,
|
|
1958
|
+
args: [{
|
|
1959
|
+
selector: 'xui-graph-node-header',
|
|
1960
|
+
template: '<ng-content />',
|
|
1961
|
+
host: { '[class]': 'computedClass()' },
|
|
1962
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1963
|
+
encapsulation: ViewEncapsulation.None
|
|
1964
|
+
}]
|
|
1965
|
+
}], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }] } });
|
|
1966
|
+
/** Trailing controls in a node header — a menu button, a toggle, a status dot. */
|
|
1967
|
+
class XuiGraphNodeActions {
|
|
1968
|
+
class = input('', /* @ts-ignore */
|
|
1969
|
+
...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
|
|
1970
|
+
computedClass = computed(() => xui('flex shrink-0 items-center gap-1', this.class()), /* @ts-ignore */
|
|
1971
|
+
...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
|
|
1972
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNodeActions, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1973
|
+
/** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.8", type: XuiGraphNodeActions, isStandalone: true, selector: "xui-graph-node-actions", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "computedClass()" } }, ngImport: i0, template: '<ng-content />', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
1974
|
+
}
|
|
1975
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNodeActions, decorators: [{
|
|
1976
|
+
type: Component,
|
|
1977
|
+
args: [{
|
|
1978
|
+
selector: 'xui-graph-node-actions',
|
|
1979
|
+
template: '<ng-content />',
|
|
1980
|
+
host: { '[class]': 'computedClass()' },
|
|
1981
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1982
|
+
encapsulation: ViewEncapsulation.None
|
|
1983
|
+
}]
|
|
1984
|
+
}], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }] } });
|
|
1985
|
+
/**
|
|
1986
|
+
* A full-bleed block between the header and the ports — a thumbnail, a preview
|
|
1987
|
+
* render, a waveform. Sits above the port rows, as it does in a VFX graph.
|
|
1988
|
+
*/
|
|
1989
|
+
class XuiGraphNodePreview {
|
|
1990
|
+
class = input('', /* @ts-ignore */
|
|
1991
|
+
...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
|
|
1992
|
+
computedClass = computed(() => xui('border-border-muted block overflow-hidden border-b', this.class()), /* @ts-ignore */
|
|
1993
|
+
...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
|
|
1994
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNodePreview, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1995
|
+
/** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.8", type: XuiGraphNodePreview, isStandalone: true, selector: "xui-graph-node-preview", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "computedClass()" } }, ngImport: i0, template: '<ng-content />', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
1996
|
+
}
|
|
1997
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNodePreview, decorators: [{
|
|
1998
|
+
type: Component,
|
|
1999
|
+
args: [{
|
|
2000
|
+
selector: 'xui-graph-node-preview',
|
|
2001
|
+
template: '<ng-content />',
|
|
2002
|
+
host: { '[class]': 'computedClass()' },
|
|
2003
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
2004
|
+
encapsulation: ViewEncapsulation.None
|
|
2005
|
+
}]
|
|
2006
|
+
}], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }] } });
|
|
2007
|
+
/**
|
|
2008
|
+
* Furniture layered over the canvas — a legend, a toolbar, a context menu.
|
|
2009
|
+
*
|
|
2010
|
+
* It sits outside the canvas transform, so it neither pans nor scales with the
|
|
2011
|
+
* content, and positions itself against the graph's own box:
|
|
2012
|
+
*
|
|
2013
|
+
* ```html
|
|
2014
|
+
* <xui-node-graph>
|
|
2015
|
+
* …
|
|
2016
|
+
* <xui-graph-overlay class="top-4 right-4">Read only</xui-graph-overlay>
|
|
2017
|
+
* </xui-node-graph>
|
|
2018
|
+
* ```
|
|
2019
|
+
*
|
|
2020
|
+
* Keep the element itself outside any `@if` or `@for`, and put the condition on
|
|
2021
|
+
* its contents instead. A control-flow block is projected as one embedded view,
|
|
2022
|
+
* so anything wrapped in one goes to the canvas layer whatever its own selector
|
|
2023
|
+
* says — an overlay put there pans and zooms away with the content:
|
|
2024
|
+
*
|
|
2025
|
+
* ```html
|
|
2026
|
+
* <xui-graph-overlay class="inset-0" [class.pointer-events-none]="!menu()">
|
|
2027
|
+
* @if (menu(); as open) { … }
|
|
2028
|
+
* </xui-graph-overlay>
|
|
2029
|
+
* ```
|
|
2030
|
+
*/
|
|
2031
|
+
class XuiGraphOverlay {
|
|
2032
|
+
class = input('', /* @ts-ignore */
|
|
2033
|
+
...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
|
|
2034
|
+
computedClass = computed(() => xui('pointer-events-auto absolute z-10 block', this.class()), /* @ts-ignore */
|
|
2035
|
+
...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
|
|
2036
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphOverlay, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2037
|
+
/** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.8", type: XuiGraphOverlay, isStandalone: true, selector: "xui-graph-overlay", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "data-xui-graph-overlay": "" }, properties: { "class": "computedClass()" } }, ngImport: i0, template: '<ng-content />', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
2038
|
+
}
|
|
2039
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphOverlay, decorators: [{
|
|
2040
|
+
type: Component,
|
|
2041
|
+
args: [{
|
|
2042
|
+
selector: 'xui-graph-overlay',
|
|
2043
|
+
template: '<ng-content />',
|
|
2044
|
+
host: {
|
|
2045
|
+
'[class]': 'computedClass()',
|
|
2046
|
+
// Tells the canvas to keep its hands off presses that land here. Without it
|
|
2047
|
+
// the canvas captures the pointer for a pan and the release never reaches
|
|
2048
|
+
// whatever was pressed, so buttons in an overlay never see a click.
|
|
2049
|
+
'data-xui-graph-overlay': ''
|
|
2050
|
+
},
|
|
2051
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
2052
|
+
encapsulation: ViewEncapsulation.None
|
|
2053
|
+
}]
|
|
2054
|
+
}], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }] } });
|
|
2055
|
+
/**
|
|
2056
|
+
* Marks a subtree inside a node as not draggable, so pointer gestures on it
|
|
2057
|
+
* belong to the control rather than moving the node.
|
|
2058
|
+
*
|
|
2059
|
+
* Native form controls, buttons and links are exempt already; this is for
|
|
2060
|
+
* anything custom — a slider, a curve editor, a colour wheel.
|
|
2061
|
+
*/
|
|
2062
|
+
class XuiGraphNoDrag {
|
|
2063
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNoDrag, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2064
|
+
/** @nocollapse */ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.8", type: XuiGraphNoDrag, isStandalone: true, selector: "[xuiGraphNoDrag]", host: { attributes: { "data-xui-graph-no-drag": "" } }, ngImport: i0 });
|
|
2065
|
+
}
|
|
2066
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNoDrag, decorators: [{
|
|
2067
|
+
type: Directive,
|
|
2068
|
+
args: [{
|
|
2069
|
+
selector: '[xuiGraphNoDrag]',
|
|
2070
|
+
host: { 'data-xui-graph-no-drag': '' }
|
|
2071
|
+
}]
|
|
2072
|
+
}] });
|
|
2073
|
+
|
|
2074
|
+
/** Unit vector pointing away from the node, per port side. */
|
|
2075
|
+
const NORMALS = {
|
|
2076
|
+
left: { x: -1, y: 0 },
|
|
2077
|
+
right: { x: 1, y: 0 },
|
|
2078
|
+
top: { x: 0, y: -1 },
|
|
2079
|
+
bottom: { x: 0, y: 1 }
|
|
2080
|
+
};
|
|
2081
|
+
function xuiGraphPortNormal(side) {
|
|
2082
|
+
return NORMALS[side];
|
|
2083
|
+
}
|
|
2084
|
+
const XUI_GRAPH_DEFAULT_ROUTE_OPTIONS = {
|
|
2085
|
+
stubLength: 18,
|
|
2086
|
+
cornerRadius: 8,
|
|
2087
|
+
curvature: 0.5
|
|
2088
|
+
};
|
|
2089
|
+
/**
|
|
2090
|
+
* Build the `d` attribute for a wire between two ports.
|
|
2091
|
+
*
|
|
2092
|
+
* The two sides matter as much as the two points: an edge leaving a right-hand
|
|
2093
|
+
* port must set off rightwards even when its target sits to the left, otherwise
|
|
2094
|
+
* it appears to pass through the node it came from.
|
|
2095
|
+
*/
|
|
2096
|
+
function xuiGraphEdgePath(source, sourceSide, target, targetSide, routing, options = XUI_GRAPH_DEFAULT_ROUTE_OPTIONS) {
|
|
2097
|
+
switch (routing) {
|
|
2098
|
+
case 'straight':
|
|
2099
|
+
return `M ${round(source.x)} ${round(source.y)} L ${round(target.x)} ${round(target.y)}`;
|
|
2100
|
+
case 'orthogonal':
|
|
2101
|
+
return polylinePath(orthogonalPoints(source, sourceSide, target, targetSide, options.stubLength), 0);
|
|
2102
|
+
case 'smoothstep':
|
|
2103
|
+
return polylinePath(orthogonalPoints(source, sourceSide, target, targetSide, options.stubLength), options.cornerRadius);
|
|
2104
|
+
case 'bezier':
|
|
2105
|
+
default:
|
|
2106
|
+
return bezierPath(source, sourceSide, target, targetSide, options.curvature);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
/**
|
|
2110
|
+
* The point at the middle of a route, used to place edge labels.
|
|
2111
|
+
*
|
|
2112
|
+
* For a bezier this is the curve's t=0.5 point; for the orthogonal families it
|
|
2113
|
+
* is the point half the total run from the source. Picking the longest segment
|
|
2114
|
+
* instead would flip the label between two legs of equal length as the wire
|
|
2115
|
+
* moved, and the halfway point is where a reader looks anyway.
|
|
2116
|
+
*/
|
|
2117
|
+
function xuiGraphEdgeMidpoint(source, sourceSide, target, targetSide, routing, options = XUI_GRAPH_DEFAULT_ROUTE_OPTIONS) {
|
|
2118
|
+
if (routing === 'straight') {
|
|
2119
|
+
return { x: (source.x + target.x) / 2, y: (source.y + target.y) / 2 };
|
|
2120
|
+
}
|
|
2121
|
+
if (routing === 'bezier') {
|
|
2122
|
+
const [c1, c2] = bezierControlPoints(source, sourceSide, target, targetSide, options.curvature);
|
|
2123
|
+
// Cubic Bezier at t = 0.5 reduces to the average of the four weights 1:3:3:1.
|
|
2124
|
+
return {
|
|
2125
|
+
x: (source.x + 3 * c1.x + 3 * c2.x + target.x) / 8,
|
|
2126
|
+
y: (source.y + 3 * c1.y + 3 * c2.y + target.y) / 8
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
const points = orthogonalPoints(source, sourceSide, target, targetSide, options.stubLength);
|
|
2130
|
+
const lengths = points.slice(1).map((point, index) => distance(points[index], point));
|
|
2131
|
+
const half = lengths.reduce((sum, length) => sum + length, 0) / 2;
|
|
2132
|
+
let travelled = 0;
|
|
2133
|
+
for (let i = 0; i < lengths.length; i++) {
|
|
2134
|
+
if (travelled + lengths[i] >= half) {
|
|
2135
|
+
return lerp(points[i], points[i + 1], lengths[i] === 0 ? 0 : (half - travelled) / lengths[i]);
|
|
2136
|
+
}
|
|
2137
|
+
travelled += lengths[i];
|
|
2138
|
+
}
|
|
2139
|
+
return { x: (source.x + target.x) / 2, y: (source.y + target.y) / 2 };
|
|
2140
|
+
}
|
|
2141
|
+
function bezierControlPoints(source, sourceSide, target, targetSide, curvature) {
|
|
2142
|
+
const sourceNormal = NORMALS[sourceSide];
|
|
2143
|
+
const targetNormal = NORMALS[targetSide];
|
|
2144
|
+
// Pull along each normal, scaled by the separation *on that normal's axis*.
|
|
2145
|
+
// A floor keeps near-coincident ports from collapsing into a straight stub,
|
|
2146
|
+
// which is what makes a self-connection or a tight back-link still legible.
|
|
2147
|
+
const dx = Math.abs(target.x - source.x);
|
|
2148
|
+
const dy = Math.abs(target.y - source.y);
|
|
2149
|
+
const sourcePull = Math.max(30, (sourceNormal.x !== 0 ? dx : dy) * curvature);
|
|
2150
|
+
const targetPull = Math.max(30, (targetNormal.x !== 0 ? dx : dy) * curvature);
|
|
2151
|
+
return [
|
|
2152
|
+
{ x: source.x + sourceNormal.x * sourcePull, y: source.y + sourceNormal.y * sourcePull },
|
|
2153
|
+
{ x: target.x + targetNormal.x * targetPull, y: target.y + targetNormal.y * targetPull }
|
|
2154
|
+
];
|
|
2155
|
+
}
|
|
2156
|
+
function bezierPath(source, sourceSide, target, targetSide, curvature) {
|
|
2157
|
+
const [c1, c2] = bezierControlPoints(source, sourceSide, target, targetSide, curvature);
|
|
2158
|
+
return (`M ${round(source.x)} ${round(source.y)} ` +
|
|
2159
|
+
`C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(target.x)} ${round(target.y)}`);
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* Manhattan route between two ports.
|
|
2163
|
+
*
|
|
2164
|
+
* Both ends first travel `stub` along their normal; the remaining gap is closed
|
|
2165
|
+
* with one or two turns. Which turn pattern applies depends on whether the two
|
|
2166
|
+
* normals share an axis and whether the far stub is already "ahead" of the near
|
|
2167
|
+
* one — when it is not, the wire has to detour on the cross axis to get around.
|
|
2168
|
+
*/
|
|
2169
|
+
function orthogonalPoints(source, sourceSide, target, targetSide, stub) {
|
|
2170
|
+
const sourceNormal = NORMALS[sourceSide];
|
|
2171
|
+
const targetNormal = NORMALS[targetSide];
|
|
2172
|
+
const a = { x: source.x + sourceNormal.x * stub, y: source.y + sourceNormal.y * stub };
|
|
2173
|
+
const b = { x: target.x + targetNormal.x * stub, y: target.y + targetNormal.y * stub };
|
|
2174
|
+
const sourceHorizontal = sourceNormal.x !== 0;
|
|
2175
|
+
const targetHorizontal = targetNormal.x !== 0;
|
|
2176
|
+
const middle = [];
|
|
2177
|
+
if (sourceHorizontal && targetHorizontal) {
|
|
2178
|
+
// Facing each other with room between them: split the gap and turn twice.
|
|
2179
|
+
const facing = sourceNormal.x !== targetNormal.x;
|
|
2180
|
+
const hasRoom = facing && (b.x - a.x) * sourceNormal.x > 0;
|
|
2181
|
+
if (hasRoom) {
|
|
2182
|
+
const midX = (a.x + b.x) / 2;
|
|
2183
|
+
middle.push({ x: midX, y: a.y }, { x: midX, y: b.y });
|
|
2184
|
+
}
|
|
2185
|
+
else if (facing) {
|
|
2186
|
+
// Facing but overlapping — go around on the cross axis.
|
|
2187
|
+
const midY = (a.y + b.y) / 2;
|
|
2188
|
+
middle.push({ x: a.x, y: midY }, { x: b.x, y: midY });
|
|
2189
|
+
}
|
|
2190
|
+
else {
|
|
2191
|
+
// Same direction — run both stubs out to the furthest of the two.
|
|
2192
|
+
const x = sourceNormal.x > 0 ? Math.max(a.x, b.x) : Math.min(a.x, b.x);
|
|
2193
|
+
middle.push({ x, y: a.y }, { x, y: b.y });
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
else if (!sourceHorizontal && !targetHorizontal) {
|
|
2197
|
+
const facing = sourceNormal.y !== targetNormal.y;
|
|
2198
|
+
const hasRoom = facing && (b.y - a.y) * sourceNormal.y > 0;
|
|
2199
|
+
if (hasRoom) {
|
|
2200
|
+
const midY = (a.y + b.y) / 2;
|
|
2201
|
+
middle.push({ x: a.x, y: midY }, { x: b.x, y: midY });
|
|
2202
|
+
}
|
|
2203
|
+
else if (facing) {
|
|
2204
|
+
const midX = (a.x + b.x) / 2;
|
|
2205
|
+
middle.push({ x: midX, y: a.y }, { x: midX, y: b.y });
|
|
2206
|
+
}
|
|
2207
|
+
else {
|
|
2208
|
+
const y = sourceNormal.y > 0 ? Math.max(a.y, b.y) : Math.min(a.y, b.y);
|
|
2209
|
+
middle.push({ x: a.x, y }, { x: b.x, y });
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
else if (sourceHorizontal) {
|
|
2213
|
+
// Horizontal into vertical: a single elbow, turning under/over the target.
|
|
2214
|
+
middle.push({ x: b.x, y: a.y });
|
|
2215
|
+
}
|
|
2216
|
+
else {
|
|
2217
|
+
middle.push({ x: a.x, y: b.y });
|
|
2218
|
+
}
|
|
2219
|
+
return simplify([source, a, ...middle, b, target]);
|
|
2220
|
+
}
|
|
2221
|
+
/** Drop duplicate and collinear vertices so corner rounding never sees a zero-length leg. */
|
|
2222
|
+
function simplify(points) {
|
|
2223
|
+
const result = [];
|
|
2224
|
+
for (const point of points) {
|
|
2225
|
+
const last = result[result.length - 1];
|
|
2226
|
+
if (last && Math.abs(last.x - point.x) < 0.01 && Math.abs(last.y - point.y) < 0.01) {
|
|
2227
|
+
continue;
|
|
2228
|
+
}
|
|
2229
|
+
const previous = result[result.length - 2];
|
|
2230
|
+
if (previous && last && isCollinear(previous, last, point)) {
|
|
2231
|
+
result[result.length - 1] = point;
|
|
2232
|
+
continue;
|
|
2233
|
+
}
|
|
2234
|
+
result.push(point);
|
|
2235
|
+
}
|
|
2236
|
+
return result;
|
|
2237
|
+
}
|
|
2238
|
+
function isCollinear(a, b, c) {
|
|
2239
|
+
return ((Math.abs(a.x - b.x) < 0.01 && Math.abs(b.x - c.x) < 0.01) ||
|
|
2240
|
+
(Math.abs(a.y - b.y) < 0.01 && Math.abs(b.y - c.y) < 0.01));
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Turn a polyline into a path, optionally rounding each interior corner.
|
|
2244
|
+
*
|
|
2245
|
+
* The rounding radius is clamped to half the shorter of the two legs meeting at
|
|
2246
|
+
* the corner, so a tight zig-zag degrades to sharp corners instead of producing
|
|
2247
|
+
* arcs that overshoot their own segments.
|
|
2248
|
+
*/
|
|
2249
|
+
function polylinePath(points, radius) {
|
|
2250
|
+
if (points.length === 0) {
|
|
2251
|
+
return '';
|
|
2252
|
+
}
|
|
2253
|
+
if (radius <= 0 || points.length < 3) {
|
|
2254
|
+
return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${round(p.x)} ${round(p.y)}`).join(' ');
|
|
2255
|
+
}
|
|
2256
|
+
let path = `M ${round(points[0].x)} ${round(points[0].y)}`;
|
|
2257
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
2258
|
+
const previous = points[i - 1];
|
|
2259
|
+
const corner = points[i];
|
|
2260
|
+
const next = points[i + 1];
|
|
2261
|
+
const inLength = distance(previous, corner);
|
|
2262
|
+
const outLength = distance(corner, next);
|
|
2263
|
+
const r = Math.min(radius, inLength / 2, outLength / 2);
|
|
2264
|
+
if (r < 0.5) {
|
|
2265
|
+
path += ` L ${round(corner.x)} ${round(corner.y)}`;
|
|
2266
|
+
continue;
|
|
2267
|
+
}
|
|
2268
|
+
const enter = lerp(corner, previous, r / inLength);
|
|
2269
|
+
const exit = lerp(corner, next, r / outLength);
|
|
2270
|
+
path += ` L ${round(enter.x)} ${round(enter.y)}`;
|
|
2271
|
+
path += ` Q ${round(corner.x)} ${round(corner.y)}, ${round(exit.x)} ${round(exit.y)}`;
|
|
2272
|
+
}
|
|
2273
|
+
const last = points[points.length - 1];
|
|
2274
|
+
return `${path} L ${round(last.x)} ${round(last.y)}`;
|
|
2275
|
+
}
|
|
2276
|
+
function distance(a, b) {
|
|
2277
|
+
return Math.hypot(b.x - a.x, b.y - a.y);
|
|
2278
|
+
}
|
|
2279
|
+
function lerp(from, to, t) {
|
|
2280
|
+
return { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
|
|
2281
|
+
}
|
|
2282
|
+
function round(value) {
|
|
2283
|
+
return Math.round(value * 100) / 100;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
let nextGraphId = 0;
|
|
2287
|
+
/**
|
|
2288
|
+
* A pannable, zoomable canvas for node-based editors and schematics.
|
|
2289
|
+
*
|
|
2290
|
+
* ```html
|
|
2291
|
+
* <xui-node-graph class="h-[600px]" [edges]="edges()" routing="orthogonal" snapToGrid (connect)="add($event)">
|
|
2292
|
+
* @for (node of nodes(); track node.id) {
|
|
2293
|
+
* <xui-graph-node [nodeId]="node.id" [label]="node.label" [(position)]="node.position">
|
|
2294
|
+
* <xui-graph-port portId="in" direction="input" />
|
|
2295
|
+
* <xui-graph-port portId="out" direction="output" />
|
|
2296
|
+
* </xui-graph-node>
|
|
2297
|
+
* }
|
|
2298
|
+
* </xui-node-graph>
|
|
2299
|
+
* ```
|
|
2300
|
+
*
|
|
2301
|
+
* Nodes are ordinary projected content, so they keep Angular's template model —
|
|
2302
|
+
* `@for`, `@if`, your own components — instead of being described by a
|
|
2303
|
+
* configuration object. Edges are an input rather than content because a wire
|
|
2304
|
+
* has no content of its own: its whole geometry is derived from the two ports it
|
|
2305
|
+
* joins, which the graph already tracks.
|
|
2306
|
+
*
|
|
2307
|
+
* The graph never mutates `edges`. `connect` reports an approved connection and
|
|
2308
|
+
* the host decides what to add, which is what keeps undo/redo and persistence in
|
|
2309
|
+
* the host's hands.
|
|
2310
|
+
*/
|
|
2311
|
+
class XuiNodeGraph {
|
|
2312
|
+
config = injectXuiNodeGraphConfig();
|
|
2313
|
+
element = inject(ElementRef).nativeElement;
|
|
2314
|
+
uid = nextGraphId++;
|
|
2315
|
+
/** Pointer position where the current gesture began, in client coordinates. */
|
|
2316
|
+
gestureOrigin = { x: 0, y: 0 };
|
|
2317
|
+
gesture = 'none';
|
|
2318
|
+
gestureMoved = false;
|
|
2319
|
+
marqueeState = signal(null, /* @ts-ignore */
|
|
2320
|
+
...(ngDevMode ? [{ debugName: "marqueeState" }] : /* istanbul ignore next */ []));
|
|
2321
|
+
/** The store for this canvas. Use it to drive the view from a host component. */
|
|
2322
|
+
store = inject(XuiNodeGraphStore);
|
|
2323
|
+
class = input('', /* @ts-ignore */
|
|
2324
|
+
...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
|
|
2325
|
+
/** The wires to draw. Never mutated — see `connect`. */
|
|
2326
|
+
edges = input([], /* @ts-ignore */
|
|
2327
|
+
...(ngDevMode ? [{ debugName: "edges" }] : /* istanbul ignore next */ []));
|
|
2328
|
+
/** Pan and zoom. Two-way bindable, so a host can save and restore the view. */
|
|
2329
|
+
viewport = model({ x: 0, y: 0, zoom: 1 }, /* @ts-ignore */
|
|
2330
|
+
...(ngDevMode ? [{ debugName: "viewport" }] : /* istanbul ignore next */ []));
|
|
2331
|
+
routing = input(this.config.routing, /* @ts-ignore */
|
|
2332
|
+
...(ngDevMode ? [{ debugName: "routing" }] : /* istanbul ignore next */ []));
|
|
2333
|
+
background = input(this.config.background, /* @ts-ignore */
|
|
2334
|
+
...(ngDevMode ? [{ debugName: "background" }] : /* istanbul ignore next */ []));
|
|
2335
|
+
marker = input(this.config.marker, /* @ts-ignore */
|
|
2336
|
+
...(ngDevMode ? [{ debugName: "marker" }] : /* istanbul ignore next */ []));
|
|
2337
|
+
gridSize = input(this.config.gridSize, { ...(ngDevMode ? { debugName: "gridSize" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
2338
|
+
snapToGrid = input(this.config.snapToGrid, { ...(ngDevMode ? { debugName: "snapToGrid" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
2339
|
+
minZoom = input(this.config.minZoom, { ...(ngDevMode ? { debugName: "minZoom" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
2340
|
+
maxZoom = input(this.config.maxZoom, { ...(ngDevMode ? { debugName: "maxZoom" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
2341
|
+
edgeWidth = input(this.config.edgeWidth, { ...(ngDevMode ? { debugName: "edgeWidth" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
2342
|
+
portSize = input(this.config.portSize, { ...(ngDevMode ? { debugName: "portSize" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
2343
|
+
portShape = input(this.config.portShape, /* @ts-ignore */
|
|
2344
|
+
...(ngDevMode ? [{ debugName: "portShape" }] : /* istanbul ignore next */ []));
|
|
2345
|
+
portColor = input(this.config.portColor, /* @ts-ignore */
|
|
2346
|
+
...(ngDevMode ? [{ debugName: "portColor" }] : /* istanbul ignore next */ []));
|
|
2347
|
+
/** Data-type registry: colour, glyph and label per `dataType`. */
|
|
2348
|
+
portTypes = input(this.config.portTypes, /* @ts-ignore */
|
|
2349
|
+
...(ngDevMode ? [{ debugName: "portTypes" }] : /* istanbul ignore next */ []));
|
|
2350
|
+
allowSelfConnection = input(this.config.allowSelfConnection, { ...(ngDevMode ? { debugName: "allowSelfConnection" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
2351
|
+
enforcePortTypes = input(this.config.enforcePortTypes, { ...(ngDevMode ? { debugName: "enforcePortTypes" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
2352
|
+
/** Last say on whether a connection may be made. Runs after the built-in rules. */
|
|
2353
|
+
isValidConnection = input(/* @ts-ignore */
|
|
2354
|
+
...(ngDevMode ? [undefined, { debugName: "isValidConnection" }] : /* istanbul ignore next */ []));
|
|
2355
|
+
/** Read-only canvas: pan and zoom still work, editing does not. */
|
|
2356
|
+
locked = input(false, { ...(ngDevMode ? { debugName: "locked" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
2357
|
+
/** Dragging empty canvas pans. Turn it off to make dragging a marquee select instead. */
|
|
2358
|
+
panOnDrag = input(true, { ...(ngDevMode ? { debugName: "panOnDrag" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
2359
|
+
/**
|
|
2360
|
+
* `wheel` zooms on a bare wheel, as node editors do. `ctrl-wheel` reserves the
|
|
2361
|
+
* bare wheel for panning and zooms only with the modifier, as design tools do.
|
|
2362
|
+
*/
|
|
2363
|
+
zoomActivation = input('wheel', /* @ts-ignore */
|
|
2364
|
+
...(ngDevMode ? [{ debugName: "zoomActivation" }] : /* istanbul ignore next */ []));
|
|
2365
|
+
zoomOnScroll = input(true, { ...(ngDevMode ? { debugName: "zoomOnScroll" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
2366
|
+
/** A connection that passed every rule. The host decides whether to add it. */
|
|
2367
|
+
connect = output();
|
|
2368
|
+
/** A wire dropped on empty canvas — the hook for "drag out to create a node". */
|
|
2369
|
+
connectionDrop = output();
|
|
2370
|
+
edgeClick = output();
|
|
2371
|
+
/** Fired once per drag, when the pointer is released. */
|
|
2372
|
+
nodeMove = output();
|
|
2373
|
+
selectionChange = output();
|
|
2374
|
+
/** Delete or Backspace over the canvas. The host performs the removal. */
|
|
2375
|
+
deleteSelection = output();
|
|
2376
|
+
canvasContextMenu = output();
|
|
2377
|
+
Math = Math;
|
|
2378
|
+
arrowId = `xui-graph-arrow-${this.uid}`;
|
|
2379
|
+
arrowClosedId = `xui-graph-arrow-closed-${this.uid}`;
|
|
2380
|
+
dotId = `xui-graph-dot-${this.uid}`;
|
|
2381
|
+
marqueeRect = computed(() => {
|
|
2382
|
+
const rect = this.marqueeState();
|
|
2383
|
+
if (!rect) {
|
|
2384
|
+
return null;
|
|
2385
|
+
}
|
|
2386
|
+
const origin = this.store.toSurfacePoint(rect);
|
|
2387
|
+
const { zoom } = this.viewport();
|
|
2388
|
+
return { x: origin.x, y: origin.y, width: rect.width * zoom, height: rect.height * zoom };
|
|
2389
|
+
}, /* @ts-ignore */
|
|
2390
|
+
...(ngDevMode ? [{ debugName: "marqueeRect" }] : /* istanbul ignore next */ []));
|
|
2391
|
+
settings = computed(() => ({
|
|
2392
|
+
routing: this.routing(),
|
|
2393
|
+
gridSize: this.gridSize(),
|
|
2394
|
+
snapToGrid: this.snapToGrid(),
|
|
2395
|
+
minZoom: this.minZoom(),
|
|
2396
|
+
maxZoom: this.maxZoom(),
|
|
2397
|
+
stubLength: this.config.stubLength,
|
|
2398
|
+
cornerRadius: this.config.cornerRadius,
|
|
2399
|
+
curvature: this.config.curvature,
|
|
2400
|
+
edgeWidth: this.edgeWidth(),
|
|
2401
|
+
portSize: this.portSize(),
|
|
2402
|
+
portShape: this.portShape(),
|
|
2403
|
+
portColor: this.portColor(),
|
|
2404
|
+
portTypes: this.portTypes(),
|
|
2405
|
+
allowSelfConnection: this.allowSelfConnection(),
|
|
2406
|
+
enforcePortTypes: this.enforcePortTypes(),
|
|
2407
|
+
isValidConnection: this.isValidConnection(),
|
|
2408
|
+
locked: this.locked()
|
|
2409
|
+
}), /* @ts-ignore */
|
|
2410
|
+
...(ngDevMode ? [{ debugName: "settings" }] : /* istanbul ignore next */ []));
|
|
2411
|
+
routeOptions = computed(() => ({
|
|
2412
|
+
stubLength: this.config.stubLength,
|
|
2413
|
+
cornerRadius: this.config.cornerRadius,
|
|
2414
|
+
curvature: this.config.curvature
|
|
2415
|
+
}), /* @ts-ignore */
|
|
2416
|
+
...(ngDevMode ? [{ debugName: "routeOptions" }] : /* istanbul ignore next */ []));
|
|
2417
|
+
canvasTransform = computed(() => {
|
|
2418
|
+
const { x, y, zoom } = this.viewport();
|
|
2419
|
+
return `translate(${x}px, ${y}px) scale(${zoom})`;
|
|
2420
|
+
}, /* @ts-ignore */
|
|
2421
|
+
...(ngDevMode ? [{ debugName: "canvasTransform" }] : /* istanbul ignore next */ []));
|
|
2422
|
+
renderedEdges = computed(() => {
|
|
2423
|
+
const routing = this.routing();
|
|
2424
|
+
const options = this.routeOptions();
|
|
2425
|
+
const defaultWidth = this.edgeWidth();
|
|
2426
|
+
const defaultMarker = this.marker();
|
|
2427
|
+
const selected = this.store.selectedEdges();
|
|
2428
|
+
const rendered = [];
|
|
2429
|
+
for (const edge of this.edges()) {
|
|
2430
|
+
const sourceKey = xuiGraphPortKey(edge.source.nodeId, edge.source.portId);
|
|
2431
|
+
const targetKey = xuiGraphPortKey(edge.target.nodeId, edge.target.portId);
|
|
2432
|
+
const sourcePort = this.store.port(sourceKey);
|
|
2433
|
+
const targetPort = this.store.port(targetKey);
|
|
2434
|
+
const sourcePoint = this.store.portPoint(sourceKey);
|
|
2435
|
+
const targetPoint = this.store.portPoint(targetKey);
|
|
2436
|
+
// An edge naming a port that has not rendered yet is skipped rather than
|
|
2437
|
+
// guessed at, so a lazily-created node never draws a wire to the origin.
|
|
2438
|
+
if (!sourcePort || !targetPort || !sourcePoint || !targetPoint) {
|
|
2439
|
+
continue;
|
|
2440
|
+
}
|
|
2441
|
+
const edgeRouting = edge.routing ?? routing;
|
|
2442
|
+
const sourceSide = sourcePort.resolvedSide();
|
|
2443
|
+
const targetSide = targetPort.resolvedSide();
|
|
2444
|
+
const markerType = edge.marker ?? defaultMarker;
|
|
2445
|
+
rendered.push({
|
|
2446
|
+
edge,
|
|
2447
|
+
d: xuiGraphEdgePath(sourcePoint, sourceSide, targetPoint, targetSide, edgeRouting, options),
|
|
2448
|
+
color: edge.color ?? sourcePort.resolvedColor(),
|
|
2449
|
+
width: edge.width ?? defaultWidth,
|
|
2450
|
+
selected: selected.has(edge.id),
|
|
2451
|
+
dashArray: edge.dashed && !edge.animated ? '5 5' : null,
|
|
2452
|
+
animated: !!edge.animated,
|
|
2453
|
+
markerId: this.markerUrl(markerType),
|
|
2454
|
+
label: edge.label ?? null,
|
|
2455
|
+
labelPosition: xuiGraphEdgeMidpoint(sourcePoint, sourceSide, targetPoint, targetSide, edgeRouting, options)
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
return rendered;
|
|
2459
|
+
}, /* @ts-ignore */
|
|
2460
|
+
...(ngDevMode ? [{ debugName: "renderedEdges" }] : /* istanbul ignore next */ []));
|
|
2461
|
+
/** The wire that follows the pointer while a connection is being drawn. */
|
|
2462
|
+
linkPreview = computed(() => {
|
|
2463
|
+
const linking = this.store.linking();
|
|
2464
|
+
if (!linking) {
|
|
2465
|
+
return null;
|
|
2466
|
+
}
|
|
2467
|
+
const port = this.store.port(linking.fromKey);
|
|
2468
|
+
const from = this.store.portPoint(linking.fromKey);
|
|
2469
|
+
if (!port || !from) {
|
|
2470
|
+
return null;
|
|
2471
|
+
}
|
|
2472
|
+
// Land on whichever port is hovered, so the preview snaps before release.
|
|
2473
|
+
const hoverPoint = linking.hoverKey ? this.store.portPoint(linking.hoverKey) : null;
|
|
2474
|
+
const hoverSide = linking.hoverKey ? this.store.port(linking.hoverKey)?.resolvedSide() : undefined;
|
|
2475
|
+
const to = hoverPoint ?? linking.pointer;
|
|
2476
|
+
const toSide = hoverSide ?? OPPOSITE[port.resolvedSide()];
|
|
2477
|
+
const valid = linking.hoverKey ? this.store.linkState(linking.hoverKey) === 'valid' : true;
|
|
2478
|
+
return {
|
|
2479
|
+
d: xuiGraphEdgePath(from, port.resolvedSide(), to, toSide, this.routing(), this.routeOptions()),
|
|
2480
|
+
color: valid ? port.resolvedColor() : 'var(--color-error)'
|
|
2481
|
+
};
|
|
2482
|
+
}, /* @ts-ignore */
|
|
2483
|
+
...(ngDevMode ? [{ debugName: "linkPreview" }] : /* istanbul ignore next */ []));
|
|
2484
|
+
backgroundImage = computed(() => {
|
|
2485
|
+
const background = this.background();
|
|
2486
|
+
const step = this.gridSize() * this.viewport().zoom;
|
|
2487
|
+
// Below a few pixels a rule turns into noise, so it is dropped entirely.
|
|
2488
|
+
if (background === 'none' || step < 4) {
|
|
2489
|
+
return null;
|
|
2490
|
+
}
|
|
2491
|
+
if (background === 'dots') {
|
|
2492
|
+
return 'radial-gradient(circle, var(--color-border-strong) 1px, transparent 1px)';
|
|
2493
|
+
}
|
|
2494
|
+
return [
|
|
2495
|
+
'linear-gradient(to right, var(--color-border-muted) 1px, transparent 1px)',
|
|
2496
|
+
'linear-gradient(to bottom, var(--color-border-muted) 1px, transparent 1px)',
|
|
2497
|
+
'linear-gradient(to right, var(--color-border) 1px, transparent 1px)',
|
|
2498
|
+
'linear-gradient(to bottom, var(--color-border) 1px, transparent 1px)'
|
|
2499
|
+
].join(', ');
|
|
2500
|
+
}, /* @ts-ignore */
|
|
2501
|
+
...(ngDevMode ? [{ debugName: "backgroundImage" }] : /* istanbul ignore next */ []));
|
|
2502
|
+
backgroundSize = computed(() => {
|
|
2503
|
+
const step = this.gridSize() * this.viewport().zoom;
|
|
2504
|
+
const major = step * this.config.gridMajorEvery;
|
|
2505
|
+
return this.background() === 'dots'
|
|
2506
|
+
? `${step}px ${step}px`
|
|
2507
|
+
: `${step}px ${step}px, ${step}px ${step}px, ${major}px ${major}px, ${major}px ${major}px`;
|
|
2508
|
+
}, /* @ts-ignore */
|
|
2509
|
+
...(ngDevMode ? [{ debugName: "backgroundSize" }] : /* istanbul ignore next */ []));
|
|
2510
|
+
backgroundPosition = computed(() => {
|
|
2511
|
+
const { x, y } = this.viewport();
|
|
2512
|
+
const offset = `${x}px ${y}px`;
|
|
2513
|
+
return this.background() === 'dots' ? offset : `${offset}, ${offset}, ${offset}, ${offset}`;
|
|
2514
|
+
}, /* @ts-ignore */
|
|
2515
|
+
...(ngDevMode ? [{ debugName: "backgroundPosition" }] : /* istanbul ignore next */ []));
|
|
2516
|
+
computedClass = computed(() => xui(
|
|
2517
|
+
// The canvas is focusable so its shortcuts work, but draws no focus ring:
|
|
2518
|
+
// a ring around the whole editor reads as a selection the user did not make,
|
|
2519
|
+
// and every element inside it that can hold focus rings on its own.
|
|
2520
|
+
'bg-background relative block touch-none overflow-hidden outline-none', this.class()), /* @ts-ignore */
|
|
2521
|
+
...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
|
|
2522
|
+
constructor() {
|
|
2523
|
+
this.store.configure({
|
|
2524
|
+
settings: this.settings,
|
|
2525
|
+
viewport: this.viewport,
|
|
2526
|
+
edges: this.edges,
|
|
2527
|
+
listeners: {
|
|
2528
|
+
connect: connection => this.connect.emit(connection),
|
|
2529
|
+
connectionDrop: drop => this.connectionDrop.emit(drop),
|
|
2530
|
+
nodeMove: move => this.nodeMove.emit(move),
|
|
2531
|
+
selectionChange: () => this.selectionChange.emit({
|
|
2532
|
+
nodes: [...this.store.selectedNodes()],
|
|
2533
|
+
edges: [...this.store.selectedEdges()]
|
|
2534
|
+
})
|
|
2535
|
+
}
|
|
2536
|
+
});
|
|
2537
|
+
this.store.setSurface(this.element);
|
|
2538
|
+
const observer = new ResizeObserver(() => this.store.surfaceSize.set({ width: this.element.clientWidth, height: this.element.clientHeight }));
|
|
2539
|
+
observer.observe(this.element);
|
|
2540
|
+
inject(DestroyRef).onDestroy(() => observer.disconnect());
|
|
2541
|
+
}
|
|
2542
|
+
/** Frame every node, leaving `padding` screen pixels of margin. */
|
|
2543
|
+
fitView(padding) {
|
|
2544
|
+
this.store.fitView(padding);
|
|
2545
|
+
}
|
|
2546
|
+
zoomIn() {
|
|
2547
|
+
this.store.zoomBy(this.config.zoomStep);
|
|
2548
|
+
}
|
|
2549
|
+
zoomOut() {
|
|
2550
|
+
this.store.zoomBy(1 / this.config.zoomStep);
|
|
2551
|
+
}
|
|
2552
|
+
/** Return to 1:1 without moving the centre of the view. */
|
|
2553
|
+
resetZoom() {
|
|
2554
|
+
this.store.zoomTo(1);
|
|
2555
|
+
}
|
|
2556
|
+
markerUrl(marker) {
|
|
2557
|
+
switch (marker) {
|
|
2558
|
+
case 'arrow':
|
|
2559
|
+
return `url(#${this.arrowId})`;
|
|
2560
|
+
case 'arrow-closed':
|
|
2561
|
+
return `url(#${this.arrowClosedId})`;
|
|
2562
|
+
case 'dot':
|
|
2563
|
+
return `url(#${this.dotId})`;
|
|
2564
|
+
default:
|
|
2565
|
+
return null;
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
onEdgePointerDown(edge, event) {
|
|
2569
|
+
if (event.button !== 0 || this.locked()) {
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
event.stopPropagation();
|
|
2573
|
+
this.store.selectEdge(edge.id, event.shiftKey || event.ctrlKey || event.metaKey);
|
|
2574
|
+
this.edgeClick.emit(edge);
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* Presses that reach here landed on empty canvas — nodes, ports and edges all
|
|
2578
|
+
* stop propagation — so this only ever starts a pan or a marquee.
|
|
2579
|
+
*/
|
|
2580
|
+
onPointerDown(event) {
|
|
2581
|
+
const middle = event.button === 1;
|
|
2582
|
+
if (event.button !== 0 && !middle) {
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
2585
|
+
// Overlay furniture sits above the canvas but still bubbles to it. Capturing
|
|
2586
|
+
// the pointer here would retarget the release and the button would never see
|
|
2587
|
+
// a click at all, so the whole overlay layer is left alone.
|
|
2588
|
+
if (event.target.closest('[data-xui-graph-overlay]')) {
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
this.element.focus({ preventScroll: true });
|
|
2592
|
+
this.element.setPointerCapture(event.pointerId);
|
|
2593
|
+
this.gestureOrigin = { x: event.clientX, y: event.clientY };
|
|
2594
|
+
this.gestureMoved = false;
|
|
2595
|
+
// Middle-drag always pans, whatever the primary gesture is bound to;
|
|
2596
|
+
// shift inverts the primary gesture, which is the convention everywhere.
|
|
2597
|
+
const pan = middle || this.panOnDrag() !== event.shiftKey;
|
|
2598
|
+
if (pan) {
|
|
2599
|
+
this.gesture = 'pan';
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
this.gesture = 'marquee';
|
|
2603
|
+
const start = this.store.toGraphPoint(event.clientX, event.clientY);
|
|
2604
|
+
this.marqueeState.set({ x: start.x, y: start.y, width: 0, height: 0 });
|
|
2605
|
+
}
|
|
2606
|
+
onPointerMove(event) {
|
|
2607
|
+
if (this.gesture === 'none') {
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
const dx = event.clientX - this.gestureOrigin.x;
|
|
2611
|
+
const dy = event.clientY - this.gestureOrigin.y;
|
|
2612
|
+
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
|
|
2613
|
+
this.gestureMoved = true;
|
|
2614
|
+
}
|
|
2615
|
+
if (this.gesture === 'pan') {
|
|
2616
|
+
this.store.panBy(dx, dy);
|
|
2617
|
+
this.gestureOrigin = { x: event.clientX, y: event.clientY };
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
const start = this.store.toGraphPoint(this.gestureOrigin.x, this.gestureOrigin.y);
|
|
2621
|
+
const current = this.store.toGraphPoint(event.clientX, event.clientY);
|
|
2622
|
+
this.marqueeState.set({
|
|
2623
|
+
x: Math.min(start.x, current.x),
|
|
2624
|
+
y: Math.min(start.y, current.y),
|
|
2625
|
+
width: Math.abs(current.x - start.x),
|
|
2626
|
+
height: Math.abs(current.y - start.y)
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
onPointerUp(event) {
|
|
2630
|
+
if (this.gesture === 'none') {
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
this.element.releasePointerCapture(event.pointerId);
|
|
2634
|
+
const rect = untracked(this.marqueeState);
|
|
2635
|
+
const gesture = this.gesture;
|
|
2636
|
+
this.gesture = 'none';
|
|
2637
|
+
this.marqueeState.set(null);
|
|
2638
|
+
if (gesture === 'marquee' && rect && this.gestureMoved) {
|
|
2639
|
+
this.store.selectInRect(rect, event.shiftKey || event.ctrlKey || event.metaKey);
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
// A press that never moved is a click on the backdrop: deselect.
|
|
2643
|
+
if (!this.gestureMoved) {
|
|
2644
|
+
this.store.clearSelection();
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
onWheel(event) {
|
|
2648
|
+
if (!this.zoomOnScroll()) {
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
const rect = this.element.getBoundingClientRect();
|
|
2652
|
+
const anchor = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
2653
|
+
if (this.zoomActivation() === 'ctrl-wheel' && !event.ctrlKey && !event.metaKey) {
|
|
2654
|
+
event.preventDefault();
|
|
2655
|
+
this.store.panBy(-event.deltaX, -event.deltaY);
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
event.preventDefault();
|
|
2659
|
+
// Exponential in the raw delta so a trackpad's fine-grained events and a
|
|
2660
|
+
// mouse wheel's coarse notches both feel proportional.
|
|
2661
|
+
this.store.zoomBy(Math.exp(-clamp(event.deltaY, -120, 120) * 0.0025), anchor);
|
|
2662
|
+
}
|
|
2663
|
+
onKeydown(event) {
|
|
2664
|
+
const modifier = event.ctrlKey || event.metaKey;
|
|
2665
|
+
if (modifier && event.key.toLowerCase() === 'a') {
|
|
2666
|
+
event.preventDefault();
|
|
2667
|
+
this.store.selectAll();
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
if (event.key === 'Escape') {
|
|
2671
|
+
this.store.endLink(null);
|
|
2672
|
+
this.store.clearSelection();
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
if ((event.key === 'Delete' || event.key === 'Backspace') && !this.locked()) {
|
|
2676
|
+
const nodes = [...this.store.selectedNodes()];
|
|
2677
|
+
const edges = [...this.store.selectedEdges()];
|
|
2678
|
+
if (nodes.length || edges.length) {
|
|
2679
|
+
event.preventDefault();
|
|
2680
|
+
this.deleteSelection.emit({ nodes, edges });
|
|
2681
|
+
}
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
const nudge = ARROW_NUDGE[event.key];
|
|
2685
|
+
if (nudge && !this.locked() && this.store.selectedNodes().size > 0) {
|
|
2686
|
+
event.preventDefault();
|
|
2687
|
+
// Shift steps a whole grid cell; a bare arrow moves by one unit, or by the
|
|
2688
|
+
// grid when snapping is on and any smaller step would be discarded anyway.
|
|
2689
|
+
const step = event.shiftKey || this.snapToGrid() ? this.gridSize() : 1;
|
|
2690
|
+
const first = [...this.store.selectedNodes()][0];
|
|
2691
|
+
this.store.beginNodeDrag(first);
|
|
2692
|
+
this.store.dragNodesBy({ x: nudge.x * step, y: nudge.y * step });
|
|
2693
|
+
this.store.endNodeDrag();
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
onContextMenu(event) {
|
|
2697
|
+
this.canvasContextMenu.emit({ event, position: this.store.toGraphPoint(event.clientX, event.clientY) });
|
|
2698
|
+
}
|
|
2699
|
+
/** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiNodeGraph, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2700
|
+
/** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiNodeGraph, isStandalone: true, selector: "xui-node-graph", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, edges: { classPropertyName: "edges", publicName: "edges", isSignal: true, isRequired: false, transformFunction: null }, viewport: { classPropertyName: "viewport", publicName: "viewport", isSignal: true, isRequired: false, transformFunction: null }, routing: { classPropertyName: "routing", publicName: "routing", isSignal: true, isRequired: false, transformFunction: null }, background: { classPropertyName: "background", publicName: "background", isSignal: true, isRequired: false, transformFunction: null }, marker: { classPropertyName: "marker", publicName: "marker", isSignal: true, isRequired: false, transformFunction: null }, gridSize: { classPropertyName: "gridSize", publicName: "gridSize", isSignal: true, isRequired: false, transformFunction: null }, snapToGrid: { classPropertyName: "snapToGrid", publicName: "snapToGrid", isSignal: true, isRequired: false, transformFunction: null }, minZoom: { classPropertyName: "minZoom", publicName: "minZoom", isSignal: true, isRequired: false, transformFunction: null }, maxZoom: { classPropertyName: "maxZoom", publicName: "maxZoom", isSignal: true, isRequired: false, transformFunction: null }, edgeWidth: { classPropertyName: "edgeWidth", publicName: "edgeWidth", isSignal: true, isRequired: false, transformFunction: null }, portSize: { classPropertyName: "portSize", publicName: "portSize", isSignal: true, isRequired: false, transformFunction: null }, portShape: { classPropertyName: "portShape", publicName: "portShape", isSignal: true, isRequired: false, transformFunction: null }, portColor: { classPropertyName: "portColor", publicName: "portColor", isSignal: true, isRequired: false, transformFunction: null }, portTypes: { classPropertyName: "portTypes", publicName: "portTypes", isSignal: true, isRequired: false, transformFunction: null }, allowSelfConnection: { classPropertyName: "allowSelfConnection", publicName: "allowSelfConnection", isSignal: true, isRequired: false, transformFunction: null }, enforcePortTypes: { classPropertyName: "enforcePortTypes", publicName: "enforcePortTypes", isSignal: true, isRequired: false, transformFunction: null }, isValidConnection: { classPropertyName: "isValidConnection", publicName: "isValidConnection", isSignal: true, isRequired: false, transformFunction: null }, locked: { classPropertyName: "locked", publicName: "locked", isSignal: true, isRequired: false, transformFunction: null }, panOnDrag: { classPropertyName: "panOnDrag", publicName: "panOnDrag", isSignal: true, isRequired: false, transformFunction: null }, zoomActivation: { classPropertyName: "zoomActivation", publicName: "zoomActivation", isSignal: true, isRequired: false, transformFunction: null }, zoomOnScroll: { classPropertyName: "zoomOnScroll", publicName: "zoomOnScroll", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { viewport: "viewportChange", connect: "connect", connectionDrop: "connectionDrop", edgeClick: "edgeClick", nodeMove: "nodeMove", selectionChange: "selectionChange", deleteSelection: "deleteSelection", canvasContextMenu: "canvasContextMenu" }, host: { attributes: { "role": "application" }, listeners: { "pointerdown": "onPointerDown($event)", "pointermove": "onPointerMove($event)", "pointerup": "onPointerUp($event)", "pointercancel": "onPointerUp($event)", "wheel": "onWheel($event)", "keydown": "onKeydown($event)", "contextmenu": "onContextMenu($event)" }, properties: { "class": "computedClass()", "attr.tabindex": "0" } }, providers: [XuiNodeGraphStore], ngImport: i0, template: `
|
|
2701
|
+
<div
|
|
2702
|
+
class="pointer-events-none absolute inset-0"
|
|
2703
|
+
[style.background-image]="backgroundImage()"
|
|
2704
|
+
[style.background-size]="backgroundSize()"
|
|
2705
|
+
[style.background-position]="backgroundPosition()"
|
|
2706
|
+
></div>
|
|
2707
|
+
|
|
2708
|
+
<div class="absolute top-0 left-0 origin-top-left" [style.transform]="canvasTransform()">
|
|
2709
|
+
<svg class="pointer-events-none absolute overflow-visible" width="1" height="1" aria-hidden="true">
|
|
2710
|
+
<defs>
|
|
2711
|
+
<marker
|
|
2712
|
+
[id]="arrowId"
|
|
2713
|
+
markerWidth="10"
|
|
2714
|
+
markerHeight="10"
|
|
2715
|
+
refX="9"
|
|
2716
|
+
refY="5"
|
|
2717
|
+
orient="auto-start-reverse"
|
|
2718
|
+
markerUnits="userSpaceOnUse"
|
|
2719
|
+
>
|
|
2720
|
+
<path d="M 1 1 L 9 5 L 1 9" fill="none" stroke="context-stroke" stroke-width="1.5" stroke-linecap="round" />
|
|
2721
|
+
</marker>
|
|
2722
|
+
<marker
|
|
2723
|
+
[id]="arrowClosedId"
|
|
2724
|
+
markerWidth="10"
|
|
2725
|
+
markerHeight="10"
|
|
2726
|
+
refX="9"
|
|
2727
|
+
refY="5"
|
|
2728
|
+
orient="auto-start-reverse"
|
|
2729
|
+
markerUnits="userSpaceOnUse"
|
|
2730
|
+
>
|
|
2731
|
+
<path d="M 1 1 L 9 5 L 1 9 Z" fill="context-stroke" stroke="context-stroke" stroke-width="1" />
|
|
2732
|
+
</marker>
|
|
2733
|
+
<marker [id]="dotId" markerWidth="8" markerHeight="8" refX="4" refY="4" markerUnits="userSpaceOnUse">
|
|
2734
|
+
<circle cx="4" cy="4" r="3" fill="context-stroke" />
|
|
2735
|
+
</marker>
|
|
2736
|
+
</defs>
|
|
2737
|
+
|
|
2738
|
+
@for (item of renderedEdges(); track item.edge.id) {
|
|
2739
|
+
<g class="pointer-events-auto">
|
|
2740
|
+
@if (item.selected) {
|
|
2741
|
+
<path
|
|
2742
|
+
[attr.d]="item.d"
|
|
2743
|
+
fill="none"
|
|
2744
|
+
stroke="var(--color-selection)"
|
|
2745
|
+
[attr.stroke-width]="item.width + 8"
|
|
2746
|
+
stroke-linecap="round"
|
|
2747
|
+
stroke-linejoin="round"
|
|
2748
|
+
/>
|
|
2749
|
+
}
|
|
2750
|
+
<path
|
|
2751
|
+
class="cursor-pointer"
|
|
2752
|
+
[attr.d]="item.d"
|
|
2753
|
+
fill="none"
|
|
2754
|
+
stroke="transparent"
|
|
2755
|
+
[attr.stroke-width]="Math.max(item.width * 4, 14)"
|
|
2756
|
+
[attr.pointer-events]="locked() ? 'none' : 'stroke'"
|
|
2757
|
+
(pointerdown)="onEdgePointerDown(item.edge, $event)"
|
|
2758
|
+
/>
|
|
2759
|
+
<path
|
|
2760
|
+
[class]="item.animated ? 'xui-graph-edge-flow' : null"
|
|
2761
|
+
[attr.d]="item.d"
|
|
2762
|
+
fill="none"
|
|
2763
|
+
[attr.stroke]="item.color"
|
|
2764
|
+
[attr.stroke-width]="item.width"
|
|
2765
|
+
[attr.stroke-dasharray]="item.dashArray"
|
|
2766
|
+
stroke-linecap="round"
|
|
2767
|
+
stroke-linejoin="round"
|
|
2768
|
+
[attr.marker-end]="item.markerId"
|
|
2769
|
+
pointer-events="none"
|
|
2770
|
+
/>
|
|
2771
|
+
</g>
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
@if (linkPreview(); as preview) {
|
|
2775
|
+
<path
|
|
2776
|
+
[attr.d]="preview.d"
|
|
2777
|
+
fill="none"
|
|
2778
|
+
[attr.stroke]="preview.color"
|
|
2779
|
+
[attr.stroke-width]="edgeWidth()"
|
|
2780
|
+
stroke-dasharray="6 5"
|
|
2781
|
+
stroke-linecap="round"
|
|
2782
|
+
pointer-events="none"
|
|
2783
|
+
/>
|
|
2784
|
+
}
|
|
2785
|
+
</svg>
|
|
2786
|
+
|
|
2787
|
+
<ng-content />
|
|
2788
|
+
|
|
2789
|
+
@for (item of renderedEdges(); track item.edge.id) {
|
|
2790
|
+
@if (item.label) {
|
|
2791
|
+
<div
|
|
2792
|
+
class="bg-surface-overlay text-foreground-muted border-border-muted pointer-events-none absolute rounded border px-1.5 py-0.5 text-[10px] whitespace-nowrap"
|
|
2793
|
+
[style.left.px]="item.labelPosition.x"
|
|
2794
|
+
[style.top.px]="item.labelPosition.y"
|
|
2795
|
+
style="transform: translate(-50%, -50%)"
|
|
2796
|
+
>
|
|
2797
|
+
{{ item.label }}
|
|
2798
|
+
</div>
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
</div>
|
|
2802
|
+
|
|
2803
|
+
@if (marqueeRect(); as rect) {
|
|
2804
|
+
<div
|
|
2805
|
+
class="border-primary bg-primary/15 pointer-events-none absolute border"
|
|
2806
|
+
[style.left.px]="rect.x"
|
|
2807
|
+
[style.top.px]="rect.y"
|
|
2808
|
+
[style.width.px]="rect.width"
|
|
2809
|
+
[style.height.px]="rect.height"
|
|
2810
|
+
></div>
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
<ng-content select="xui-graph-overlay, xui-graph-controls, xui-graph-minimap" />
|
|
2814
|
+
`, isInline: true, styles: ["@keyframes xui-graph-edge-flow{to{stroke-dashoffset:-16}}.xui-graph-edge-flow{stroke-dasharray:8 8;animation:xui-graph-edge-flow .6s linear infinite}@media(prefers-reduced-motion:reduce){.xui-graph-edge-flow{animation:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
2815
|
+
}
|
|
2816
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiNodeGraph, decorators: [{
|
|
2817
|
+
type: Component,
|
|
2818
|
+
args: [{ selector: 'xui-node-graph', template: `
|
|
2819
|
+
<div
|
|
2820
|
+
class="pointer-events-none absolute inset-0"
|
|
2821
|
+
[style.background-image]="backgroundImage()"
|
|
2822
|
+
[style.background-size]="backgroundSize()"
|
|
2823
|
+
[style.background-position]="backgroundPosition()"
|
|
2824
|
+
></div>
|
|
2825
|
+
|
|
2826
|
+
<div class="absolute top-0 left-0 origin-top-left" [style.transform]="canvasTransform()">
|
|
2827
|
+
<svg class="pointer-events-none absolute overflow-visible" width="1" height="1" aria-hidden="true">
|
|
2828
|
+
<defs>
|
|
2829
|
+
<marker
|
|
2830
|
+
[id]="arrowId"
|
|
2831
|
+
markerWidth="10"
|
|
2832
|
+
markerHeight="10"
|
|
2833
|
+
refX="9"
|
|
2834
|
+
refY="5"
|
|
2835
|
+
orient="auto-start-reverse"
|
|
2836
|
+
markerUnits="userSpaceOnUse"
|
|
2837
|
+
>
|
|
2838
|
+
<path d="M 1 1 L 9 5 L 1 9" fill="none" stroke="context-stroke" stroke-width="1.5" stroke-linecap="round" />
|
|
2839
|
+
</marker>
|
|
2840
|
+
<marker
|
|
2841
|
+
[id]="arrowClosedId"
|
|
2842
|
+
markerWidth="10"
|
|
2843
|
+
markerHeight="10"
|
|
2844
|
+
refX="9"
|
|
2845
|
+
refY="5"
|
|
2846
|
+
orient="auto-start-reverse"
|
|
2847
|
+
markerUnits="userSpaceOnUse"
|
|
2848
|
+
>
|
|
2849
|
+
<path d="M 1 1 L 9 5 L 1 9 Z" fill="context-stroke" stroke="context-stroke" stroke-width="1" />
|
|
2850
|
+
</marker>
|
|
2851
|
+
<marker [id]="dotId" markerWidth="8" markerHeight="8" refX="4" refY="4" markerUnits="userSpaceOnUse">
|
|
2852
|
+
<circle cx="4" cy="4" r="3" fill="context-stroke" />
|
|
2853
|
+
</marker>
|
|
2854
|
+
</defs>
|
|
2855
|
+
|
|
2856
|
+
@for (item of renderedEdges(); track item.edge.id) {
|
|
2857
|
+
<g class="pointer-events-auto">
|
|
2858
|
+
@if (item.selected) {
|
|
2859
|
+
<path
|
|
2860
|
+
[attr.d]="item.d"
|
|
2861
|
+
fill="none"
|
|
2862
|
+
stroke="var(--color-selection)"
|
|
2863
|
+
[attr.stroke-width]="item.width + 8"
|
|
2864
|
+
stroke-linecap="round"
|
|
2865
|
+
stroke-linejoin="round"
|
|
2866
|
+
/>
|
|
2867
|
+
}
|
|
2868
|
+
<path
|
|
2869
|
+
class="cursor-pointer"
|
|
2870
|
+
[attr.d]="item.d"
|
|
2871
|
+
fill="none"
|
|
2872
|
+
stroke="transparent"
|
|
2873
|
+
[attr.stroke-width]="Math.max(item.width * 4, 14)"
|
|
2874
|
+
[attr.pointer-events]="locked() ? 'none' : 'stroke'"
|
|
2875
|
+
(pointerdown)="onEdgePointerDown(item.edge, $event)"
|
|
2876
|
+
/>
|
|
2877
|
+
<path
|
|
2878
|
+
[class]="item.animated ? 'xui-graph-edge-flow' : null"
|
|
2879
|
+
[attr.d]="item.d"
|
|
2880
|
+
fill="none"
|
|
2881
|
+
[attr.stroke]="item.color"
|
|
2882
|
+
[attr.stroke-width]="item.width"
|
|
2883
|
+
[attr.stroke-dasharray]="item.dashArray"
|
|
2884
|
+
stroke-linecap="round"
|
|
2885
|
+
stroke-linejoin="round"
|
|
2886
|
+
[attr.marker-end]="item.markerId"
|
|
2887
|
+
pointer-events="none"
|
|
2888
|
+
/>
|
|
2889
|
+
</g>
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
@if (linkPreview(); as preview) {
|
|
2893
|
+
<path
|
|
2894
|
+
[attr.d]="preview.d"
|
|
2895
|
+
fill="none"
|
|
2896
|
+
[attr.stroke]="preview.color"
|
|
2897
|
+
[attr.stroke-width]="edgeWidth()"
|
|
2898
|
+
stroke-dasharray="6 5"
|
|
2899
|
+
stroke-linecap="round"
|
|
2900
|
+
pointer-events="none"
|
|
2901
|
+
/>
|
|
2902
|
+
}
|
|
2903
|
+
</svg>
|
|
2904
|
+
|
|
2905
|
+
<ng-content />
|
|
2906
|
+
|
|
2907
|
+
@for (item of renderedEdges(); track item.edge.id) {
|
|
2908
|
+
@if (item.label) {
|
|
2909
|
+
<div
|
|
2910
|
+
class="bg-surface-overlay text-foreground-muted border-border-muted pointer-events-none absolute rounded border px-1.5 py-0.5 text-[10px] whitespace-nowrap"
|
|
2911
|
+
[style.left.px]="item.labelPosition.x"
|
|
2912
|
+
[style.top.px]="item.labelPosition.y"
|
|
2913
|
+
style="transform: translate(-50%, -50%)"
|
|
2914
|
+
>
|
|
2915
|
+
{{ item.label }}
|
|
2916
|
+
</div>
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
</div>
|
|
2920
|
+
|
|
2921
|
+
@if (marqueeRect(); as rect) {
|
|
2922
|
+
<div
|
|
2923
|
+
class="border-primary bg-primary/15 pointer-events-none absolute border"
|
|
2924
|
+
[style.left.px]="rect.x"
|
|
2925
|
+
[style.top.px]="rect.y"
|
|
2926
|
+
[style.width.px]="rect.width"
|
|
2927
|
+
[style.height.px]="rect.height"
|
|
2928
|
+
></div>
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
<ng-content select="xui-graph-overlay, xui-graph-controls, xui-graph-minimap" />
|
|
2932
|
+
`, host: {
|
|
2933
|
+
'[class]': 'computedClass()',
|
|
2934
|
+
'[attr.tabindex]': '0',
|
|
2935
|
+
role: 'application',
|
|
2936
|
+
'(pointerdown)': 'onPointerDown($event)',
|
|
2937
|
+
'(pointermove)': 'onPointerMove($event)',
|
|
2938
|
+
'(pointerup)': 'onPointerUp($event)',
|
|
2939
|
+
'(pointercancel)': 'onPointerUp($event)',
|
|
2940
|
+
'(wheel)': 'onWheel($event)',
|
|
2941
|
+
'(keydown)': 'onKeydown($event)',
|
|
2942
|
+
'(contextmenu)': 'onContextMenu($event)'
|
|
2943
|
+
}, providers: [XuiNodeGraphStore], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, styles: ["@keyframes xui-graph-edge-flow{to{stroke-dashoffset:-16}}.xui-graph-edge-flow{stroke-dasharray:8 8;animation:xui-graph-edge-flow .6s linear infinite}@media(prefers-reduced-motion:reduce){.xui-graph-edge-flow{animation:none}}\n"] }]
|
|
2944
|
+
}], ctorParameters: () => [], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], edges: [{ type: i0.Input, args: [{ isSignal: true, alias: "edges", required: false }] }], viewport: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewport", required: false }] }, { type: i0.Output, args: ["viewportChange"] }], routing: [{ type: i0.Input, args: [{ isSignal: true, alias: "routing", required: false }] }], background: [{ type: i0.Input, args: [{ isSignal: true, alias: "background", required: false }] }], marker: [{ type: i0.Input, args: [{ isSignal: true, alias: "marker", required: false }] }], gridSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "gridSize", required: false }] }], snapToGrid: [{ type: i0.Input, args: [{ isSignal: true, alias: "snapToGrid", required: false }] }], minZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "minZoom", required: false }] }], maxZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxZoom", required: false }] }], edgeWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "edgeWidth", required: false }] }], portSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "portSize", required: false }] }], portShape: [{ type: i0.Input, args: [{ isSignal: true, alias: "portShape", required: false }] }], portColor: [{ type: i0.Input, args: [{ isSignal: true, alias: "portColor", required: false }] }], portTypes: [{ type: i0.Input, args: [{ isSignal: true, alias: "portTypes", required: false }] }], allowSelfConnection: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowSelfConnection", required: false }] }], enforcePortTypes: [{ type: i0.Input, args: [{ isSignal: true, alias: "enforcePortTypes", required: false }] }], isValidConnection: [{ type: i0.Input, args: [{ isSignal: true, alias: "isValidConnection", required: false }] }], locked: [{ type: i0.Input, args: [{ isSignal: true, alias: "locked", required: false }] }], panOnDrag: [{ type: i0.Input, args: [{ isSignal: true, alias: "panOnDrag", required: false }] }], zoomActivation: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomActivation", required: false }] }], zoomOnScroll: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomOnScroll", required: false }] }], connect: [{ type: i0.Output, args: ["connect"] }], connectionDrop: [{ type: i0.Output, args: ["connectionDrop"] }], edgeClick: [{ type: i0.Output, args: ["edgeClick"] }], nodeMove: [{ type: i0.Output, args: ["nodeMove"] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], deleteSelection: [{ type: i0.Output, args: ["deleteSelection"] }], canvasContextMenu: [{ type: i0.Output, args: ["canvasContextMenu"] }] } });
|
|
2945
|
+
const OPPOSITE = { left: 'right', right: 'left', top: 'bottom', bottom: 'top' };
|
|
2946
|
+
const ARROW_NUDGE = {
|
|
2947
|
+
ArrowLeft: { x: -1, y: 0 },
|
|
2948
|
+
ArrowRight: { x: 1, y: 0 },
|
|
2949
|
+
ArrowUp: { x: 0, y: -1 },
|
|
2950
|
+
ArrowDown: { x: 0, y: 1 }
|
|
2951
|
+
};
|
|
2952
|
+
function clamp(value, min, max) {
|
|
2953
|
+
return Math.min(max, Math.max(min, value));
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
const XuiNodeGraphImports = [
|
|
2957
|
+
XuiNodeGraph,
|
|
2958
|
+
XuiGraphNode,
|
|
2959
|
+
XuiGraphPort,
|
|
2960
|
+
XuiGraphGroup,
|
|
2961
|
+
XuiGraphControls,
|
|
2962
|
+
XuiGraphMinimap,
|
|
2963
|
+
XuiGraphNodeHeader,
|
|
2964
|
+
XuiGraphNodeActions,
|
|
2965
|
+
XuiGraphNodePreview,
|
|
2966
|
+
XuiGraphNoDrag,
|
|
2967
|
+
XuiGraphOverlay
|
|
2968
|
+
];
|
|
2969
|
+
|
|
2970
|
+
/**
|
|
2971
|
+
* Generated bundle index. Do not edit.
|
|
2972
|
+
*/
|
|
2973
|
+
|
|
2974
|
+
export { XUI_GRAPH_DEFAULT_ROUTE_OPTIONS, XuiGraphControls, XuiGraphGroup, XuiGraphMinimap, XuiGraphNoDrag, XuiGraphNode, XuiGraphNodeActions, XuiGraphNodeHeader, XuiGraphNodePreview, XuiGraphOverlay, XuiGraphPort, XuiNodeGraph, XuiNodeGraphImports, XuiNodeGraphStore, graphNodeVariants, injectXuiNodeGraphConfig, injectXuiNodeGraphStore, provideXuiNodeGraphConfig, xuiGraphChartPortTypes, xuiGraphEdgeMidpoint, xuiGraphEdgePath, xuiGraphPortKey, xuiGraphPortNormal };
|
|
2975
|
+
//# sourceMappingURL=xui-node-graph.mjs.map
|