html-overlay-node 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/example.json +522 -0
- package/dist/html-overlay-node.es.js +3596 -0
- package/dist/html-overlay-node.es.js.map +1 -0
- package/dist/html-overlay-node.umd.js +2 -0
- package/dist/html-overlay-node.umd.js.map +1 -0
- package/index.css +232 -0
- package/package.json +65 -0
- package/readme.md +437 -0
- package/src/core/CommandStack.js +26 -0
- package/src/core/Edge.js +28 -0
- package/src/core/Edge.test.js +73 -0
- package/src/core/Graph.js +267 -0
- package/src/core/Graph.test.js +256 -0
- package/src/core/Group.js +77 -0
- package/src/core/Hooks.js +12 -0
- package/src/core/Hooks.test.js +108 -0
- package/src/core/Node.js +70 -0
- package/src/core/Node.test.js +113 -0
- package/src/core/Registry.js +71 -0
- package/src/core/Registry.test.js +88 -0
- package/src/core/Runner.js +211 -0
- package/src/core/commands.js +125 -0
- package/src/groups/GroupManager.js +116 -0
- package/src/index.js +1030 -0
- package/src/interact/ContextMenu.js +400 -0
- package/src/interact/Controller.js +856 -0
- package/src/minimap/Minimap.js +146 -0
- package/src/render/CanvasRenderer.js +606 -0
- package/src/render/HtmlOverlay.js +161 -0
- package/src/render/hitTest.js +38 -0
- package/src/ui/PropertyPanel.css +277 -0
- package/src/ui/PropertyPanel.js +269 -0
- package/src/utils/utils.js +75 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
import { hitTestNode, portRect } from "../render/hitTest.js";
|
|
2
|
+
import {
|
|
3
|
+
MoveNodeCmd,
|
|
4
|
+
AddEdgeCmd,
|
|
5
|
+
RemoveEdgeCmd,
|
|
6
|
+
CompoundCmd,
|
|
7
|
+
RemoveNodeCmd,
|
|
8
|
+
ResizeNodeCmd,
|
|
9
|
+
} from "../core/commands.js";
|
|
10
|
+
import { CommandStack } from "../core/CommandStack.js";
|
|
11
|
+
|
|
12
|
+
export class Controller {
|
|
13
|
+
|
|
14
|
+
static MIN_NODE_WIDTH = 80;
|
|
15
|
+
static MIN_NODE_HEIGHT = 60;
|
|
16
|
+
|
|
17
|
+
constructor({ graph, renderer, hooks, htmlOverlay, contextMenu, portRenderer }) {
|
|
18
|
+
this.graph = graph;
|
|
19
|
+
this.renderer = renderer;
|
|
20
|
+
this.hooks = hooks;
|
|
21
|
+
this.htmlOverlay = htmlOverlay;
|
|
22
|
+
this.contextMenu = contextMenu;
|
|
23
|
+
this.portRenderer = portRenderer; // Separate renderer for ports above HTML
|
|
24
|
+
|
|
25
|
+
this.stack = new CommandStack();
|
|
26
|
+
this.selection = new Set();
|
|
27
|
+
this.dragging = null; // { nodeId, dx, dy }
|
|
28
|
+
this.connecting = null; // { fromNode, fromPort, x(screen), y(screen) }
|
|
29
|
+
this.panning = null; // { x(screen), y(screen) }
|
|
30
|
+
this.resizing = null;
|
|
31
|
+
this.gDragging = null;
|
|
32
|
+
this.gResizing = null;
|
|
33
|
+
this.boxSelecting = null; // { startX, startY, currentX, currentY } - world coords
|
|
34
|
+
|
|
35
|
+
// Feature flags
|
|
36
|
+
this.snapToGrid = true; // Snap nodes to grid (toggle with G key)
|
|
37
|
+
this.gridSize = 20; // Grid size for snapping
|
|
38
|
+
|
|
39
|
+
this._cursor = "default";
|
|
40
|
+
|
|
41
|
+
this._onKeyPressEvt = this._onKeyPress.bind(this);
|
|
42
|
+
this._onDownEvt = this._onDown.bind(this);
|
|
43
|
+
this._onWheelEvt = this._onWheel.bind(this);
|
|
44
|
+
this._onMoveEvt = this._onMove.bind(this);
|
|
45
|
+
this._onUpEvt = this._onUp.bind(this);
|
|
46
|
+
this._onContextMenuEvt = this._onContextMenu.bind(this);
|
|
47
|
+
this._onDblClickEvt = this._onDblClick.bind(this);
|
|
48
|
+
|
|
49
|
+
this._bindEvents();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
destroy() {
|
|
53
|
+
const c = this.renderer.canvas;
|
|
54
|
+
c.removeEventListener("mousedown", this._onDownEvt);
|
|
55
|
+
c.removeEventListener("dblclick", this._onDblClickEvt);
|
|
56
|
+
c.removeEventListener("wheel", this._onWheelEvt, { passive: false });
|
|
57
|
+
c.removeEventListener("contextmenu", this._onContextMenuEvt);
|
|
58
|
+
window.removeEventListener("mousemove", this._onMoveEvt);
|
|
59
|
+
window.removeEventListener("mouseup", this._onUpEvt);
|
|
60
|
+
window.removeEventListener("keydown", this._onKeyPressEvt);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_bindEvents() {
|
|
64
|
+
const c = this.renderer.canvas;
|
|
65
|
+
c.addEventListener("mousedown", this._onDownEvt);
|
|
66
|
+
c.addEventListener("dblclick", this._onDblClickEvt);
|
|
67
|
+
c.addEventListener("wheel", this._onWheelEvt, { passive: false });
|
|
68
|
+
c.addEventListener("contextmenu", this._onContextMenuEvt);
|
|
69
|
+
window.addEventListener("mousemove", this._onMoveEvt);
|
|
70
|
+
window.addEventListener("mouseup", this._onUpEvt);
|
|
71
|
+
window.addEventListener("keydown", this._onKeyPressEvt);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_onKeyPress(e) {
|
|
75
|
+
this.isAlt = e.altKey;
|
|
76
|
+
this.isShift = e.shiftKey;
|
|
77
|
+
this.isCtrl = e.ctrlKey;
|
|
78
|
+
|
|
79
|
+
// Toggle snap-to-grid with G key
|
|
80
|
+
if (e.key.toLowerCase() === "g" && !e.ctrlKey && !e.metaKey) {
|
|
81
|
+
this.snapToGrid = !this.snapToGrid;
|
|
82
|
+
this.render(); // Update UI
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Group selected nodes: Ctrl/Cmd + G
|
|
87
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "g") {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
this._createGroupFromSelection();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Undo: Ctrl/Cmd + Z (Shift+Z → Redo)
|
|
94
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
if (e.shiftKey) this.stack.redo();
|
|
97
|
+
else this.stack.undo();
|
|
98
|
+
this.render();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Redo: Ctrl/Cmd + Y
|
|
103
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
this.stack.redo();
|
|
106
|
+
this.render();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Align nodes: A (horizontal), Shift+A (vertical)
|
|
111
|
+
if (e.key.toLowerCase() === "a" && this.selection.size > 1) {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
if (e.shiftKey) {
|
|
114
|
+
this._alignNodesVertical();
|
|
115
|
+
} else {
|
|
116
|
+
this._alignNodesHorizontal();
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// remove the selected nodes
|
|
122
|
+
if (e.key === "Delete") {
|
|
123
|
+
[...this.selection].forEach((node) => {
|
|
124
|
+
const nodeObj = this.graph.getNodeById(node);
|
|
125
|
+
this.stack.exec(RemoveNodeCmd(this.graph, nodeObj));
|
|
126
|
+
this.graph.removeNode(node);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
this.render();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_setCursor(c) {
|
|
134
|
+
if (this._cursor !== c) {
|
|
135
|
+
this._cursor = c;
|
|
136
|
+
this.renderer.canvas.style.cursor = c;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_posScreen(e) {
|
|
141
|
+
const r = this.renderer.canvas.getBoundingClientRect();
|
|
142
|
+
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_posWorld(e) {
|
|
146
|
+
const s = this._posScreen(e);
|
|
147
|
+
return this.renderer.screenToWorld(s.x, s.y);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_findNodeAtWorld(x, y) {
|
|
151
|
+
// Reverse order (top to bottom)
|
|
152
|
+
const list = [...this.graph.nodes.values()].reverse();
|
|
153
|
+
|
|
154
|
+
for (const n of list) {
|
|
155
|
+
// Use computed world transform for hit testing
|
|
156
|
+
const { x: nx, y: ny, w, h } = n.computed;
|
|
157
|
+
if (x >= nx && x <= nx + w && y >= ny && y <= ny + h) {
|
|
158
|
+
// If this is a group, check if any of its children are under the cursor
|
|
159
|
+
if (n.type === "core/Group") {
|
|
160
|
+
// Check all children of this group (recursively)
|
|
161
|
+
const child = this._findChildNodeAtWorld(n, x, y);
|
|
162
|
+
if (child) {
|
|
163
|
+
return child; // Return the child instead of the group
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Find child node at world coordinates (recursive helper for _findNodeAtWorld)
|
|
174
|
+
* @param {Node} parentNode - Parent node (group)
|
|
175
|
+
* @param {number} x - World x coordinate
|
|
176
|
+
* @param {number} y - World y coordinate
|
|
177
|
+
* @returns {Node|null} - Child node at position, or null
|
|
178
|
+
*/
|
|
179
|
+
_findChildNodeAtWorld(parentNode, x, y) {
|
|
180
|
+
// Get all children of this parent
|
|
181
|
+
const children = [];
|
|
182
|
+
for (const node of this.graph.nodes.values()) {
|
|
183
|
+
if (node.parent === parentNode) {
|
|
184
|
+
children.push(node);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check children in reverse order (top to bottom)
|
|
189
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
190
|
+
const child = children[i];
|
|
191
|
+
const { x: nx, y: ny, w, h } = child.computed;
|
|
192
|
+
|
|
193
|
+
if (x >= nx && x <= nx + w && y >= ny && y <= ny + h) {
|
|
194
|
+
// If this child is also a group, recursively check its children
|
|
195
|
+
if (child.type === "core/Group") {
|
|
196
|
+
const grandchild = this._findChildNodeAtWorld(child, x, y);
|
|
197
|
+
if (grandchild) {
|
|
198
|
+
return grandchild;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return child;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_findPortAtWorld(x, y) {
|
|
209
|
+
for (const n of this.graph.nodes.values()) {
|
|
210
|
+
for (let i = 0; i < n.inputs.length; i++) {
|
|
211
|
+
const r = portRect(n, n.inputs[i], i, "in");
|
|
212
|
+
if (rectHas(r, x, y))
|
|
213
|
+
return { node: n, port: n.inputs[i], dir: "in", idx: i };
|
|
214
|
+
}
|
|
215
|
+
for (let i = 0; i < n.outputs.length; i++) {
|
|
216
|
+
const r = portRect(n, n.outputs[i], i, "out");
|
|
217
|
+
if (rectHas(r, x, y))
|
|
218
|
+
return { node: n, port: n.outputs[i], dir: "out", idx: i };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_findIncomingEdge(nodeId, portId) {
|
|
225
|
+
for (const [eid, e] of this.graph.edges) {
|
|
226
|
+
if (e.toNode === nodeId && e.toPort === portId) {
|
|
227
|
+
return { id: eid, edge: e };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
_onWheel(e) {
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
const { x, y } = this._posScreen(e);
|
|
236
|
+
const factor = Math.pow(1.0015, -e.deltaY); // smooth zoom
|
|
237
|
+
this.renderer.zoomAt(factor, x, y);
|
|
238
|
+
this.render();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
_onContextMenu(e) {
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
|
|
244
|
+
// Only show context menu if we have a contextMenu instance
|
|
245
|
+
if (!this.contextMenu) return;
|
|
246
|
+
|
|
247
|
+
const w = this._posWorld(e);
|
|
248
|
+
const node = this._findNodeAtWorld(w.x, w.y);
|
|
249
|
+
|
|
250
|
+
// Show menu with node or null (for canvas background) and world position
|
|
251
|
+
this.contextMenu.show(node, e.clientX, e.clientY, w);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
_onDblClick(e) {
|
|
255
|
+
const w = this._posWorld(e);
|
|
256
|
+
const node = this._findNodeAtWorld(w.x, w.y);
|
|
257
|
+
|
|
258
|
+
if (node) {
|
|
259
|
+
this.hooks?.emit("node:dblclick", node);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_resizeHandleRect(node) {
|
|
264
|
+
const s = 10;
|
|
265
|
+
const { x, y, w, h } = node.computed;
|
|
266
|
+
return {
|
|
267
|
+
x: x + w - s,
|
|
268
|
+
y: y + h - s,
|
|
269
|
+
w: s,
|
|
270
|
+
h: s,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_hitResizeHandle(node, wx, wy) {
|
|
275
|
+
const r = this._resizeHandleRect(node);
|
|
276
|
+
return wx >= r.x && wx <= r.x + r.w && wy >= r.y && wy <= r.y + r.h;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_onDown(e) {
|
|
280
|
+
const s = this._posScreen(e);
|
|
281
|
+
const w = this._posWorld(e);
|
|
282
|
+
|
|
283
|
+
if (e.button === 1) {
|
|
284
|
+
this.panning = { x: s.x, y: s.y };
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 1. Resize Handle Hit Test (for all nodes including groups)
|
|
289
|
+
const node = this._findNodeAtWorld(w.x, w.y);
|
|
290
|
+
if (e.button === 0 && node && this._hitResizeHandle(node, w.x, w.y)) {
|
|
291
|
+
this.resizing = {
|
|
292
|
+
nodeId: node.id,
|
|
293
|
+
startW: node.size.width,
|
|
294
|
+
startH: node.size.height,
|
|
295
|
+
startX: w.x,
|
|
296
|
+
startY: w.y,
|
|
297
|
+
};
|
|
298
|
+
if (!e.shiftKey) this.selection.clear();
|
|
299
|
+
this.selection.add(node.id);
|
|
300
|
+
this._setCursor("se-resize");
|
|
301
|
+
this.render();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 2. Port Hit Test
|
|
306
|
+
const port = this._findPortAtWorld(w.x, w.y);
|
|
307
|
+
|
|
308
|
+
// Handle input port click - disconnect existing connection
|
|
309
|
+
if (e.button === 0 && port && port.dir === "in") {
|
|
310
|
+
const incoming = this._findIncomingEdge(port.node.id, port.port.id);
|
|
311
|
+
if (incoming) {
|
|
312
|
+
// Disconnect the existing edge
|
|
313
|
+
this.stack.exec(RemoveEdgeCmd(this.graph, incoming.id));
|
|
314
|
+
this.render();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Handle output port click - start new connection
|
|
320
|
+
if (e.button === 0 && port && port.dir === "out") {
|
|
321
|
+
const outR = portRect(port.node, port.port, port.idx, "out");
|
|
322
|
+
const screenFrom = this.renderer.worldToScreen(outR.x, outR.y + 7);
|
|
323
|
+
this.connecting = {
|
|
324
|
+
fromNode: port.node.id,
|
|
325
|
+
fromPort: port.port.id,
|
|
326
|
+
x: screenFrom.x,
|
|
327
|
+
y: screenFrom.y,
|
|
328
|
+
};
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 3. Node Hit Test (Selection & Drag)
|
|
333
|
+
if (e.button === 0 && node) {
|
|
334
|
+
if (!e.shiftKey) this.selection.clear();
|
|
335
|
+
this.selection.add(node.id);
|
|
336
|
+
|
|
337
|
+
// Dragging: store initial world pos difference for all selected nodes
|
|
338
|
+
this.dragging = {
|
|
339
|
+
nodeId: node.id,
|
|
340
|
+
offsetX: w.x - node.computed.x,
|
|
341
|
+
offsetY: w.y - node.computed.y,
|
|
342
|
+
startPos: { ...node.pos }, // for undo
|
|
343
|
+
selectedNodes: [], // Store all selected nodes and their initial positions
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// Store positions of all selected nodes
|
|
347
|
+
for (const selectedId of this.selection) {
|
|
348
|
+
const selectedNode = this.graph.nodes.get(selectedId);
|
|
349
|
+
if (selectedNode) {
|
|
350
|
+
this.dragging.selectedNodes.push({
|
|
351
|
+
node: selectedNode,
|
|
352
|
+
startWorldX: selectedNode.computed.x,
|
|
353
|
+
startWorldY: selectedNode.computed.y,
|
|
354
|
+
startLocalX: selectedNode.pos.x,
|
|
355
|
+
startLocalY: selectedNode.pos.y,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// If dragging a group, store children's world positions
|
|
361
|
+
if (node.type === "core/Group") {
|
|
362
|
+
this.dragging.childrenWorldPos = [];
|
|
363
|
+
for (const child of this.graph.nodes.values()) {
|
|
364
|
+
if (child.parent === node) {
|
|
365
|
+
this.dragging.childrenWorldPos.push({
|
|
366
|
+
node: child,
|
|
367
|
+
worldX: child.computed.x,
|
|
368
|
+
worldY: child.computed.y,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
this.render();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 4. Background Click (Pan or Box Selection)
|
|
379
|
+
if (e.button === 0) {
|
|
380
|
+
if (this.selection.size) this.selection.clear();
|
|
381
|
+
|
|
382
|
+
// Start box selection if Ctrl is held
|
|
383
|
+
if (e.ctrlKey || e.metaKey) {
|
|
384
|
+
this.boxSelecting = {
|
|
385
|
+
startX: w.x,
|
|
386
|
+
startY: w.y,
|
|
387
|
+
currentX: w.x,
|
|
388
|
+
currentY: w.y,
|
|
389
|
+
};
|
|
390
|
+
} else {
|
|
391
|
+
this.panning = { x: s.x, y: s.y };
|
|
392
|
+
}
|
|
393
|
+
this.render();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
_onMove(e) {
|
|
399
|
+
// Track key states
|
|
400
|
+
this.isAlt = e.altKey;
|
|
401
|
+
this.isShift = e.shiftKey;
|
|
402
|
+
this.isCtrl = e.ctrlKey;
|
|
403
|
+
|
|
404
|
+
const s = this._posScreen(e);
|
|
405
|
+
const w = this.renderer.screenToWorld(s.x, s.y);
|
|
406
|
+
|
|
407
|
+
if (this.resizing) {
|
|
408
|
+
const n = this.graph.nodes.get(this.resizing.nodeId);
|
|
409
|
+
const dx = w.x - this.resizing.startX;
|
|
410
|
+
const dy = w.y - this.resizing.startY;
|
|
411
|
+
|
|
412
|
+
const minW = Controller.MIN_NODE_WIDTH;
|
|
413
|
+
const minH = Controller.MIN_NODE_HEIGHT;
|
|
414
|
+
n.size.width = Math.max(minW, this.resizing.startW + dx);
|
|
415
|
+
n.size.height = Math.max(minH, this.resizing.startH + dy);
|
|
416
|
+
|
|
417
|
+
this.hooks?.emit("node:resize", n);
|
|
418
|
+
this._setCursor("se-resize");
|
|
419
|
+
this.render();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (this.panning) {
|
|
424
|
+
const dx = s.x - this.panning.x;
|
|
425
|
+
const dy = s.y - this.panning.y;
|
|
426
|
+
this.panning = { x: s.x, y: s.y };
|
|
427
|
+
this.renderer.panBy(dx, dy);
|
|
428
|
+
this.render();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (this.dragging) {
|
|
433
|
+
const n = this.graph.nodes.get(this.dragging.nodeId);
|
|
434
|
+
|
|
435
|
+
// Calculate delta for main node
|
|
436
|
+
let targetWx = w.x - this.dragging.offsetX;
|
|
437
|
+
let targetWy = this.isShift ? w.y - 0 : w.y - this.dragging.offsetY;
|
|
438
|
+
|
|
439
|
+
// Apply snap-to-grid if enabled
|
|
440
|
+
if (this.snapToGrid) {
|
|
441
|
+
targetWx = this._snapToGrid(targetWx);
|
|
442
|
+
targetWy = this._snapToGrid(targetWy);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Calculate delta from original position
|
|
446
|
+
const deltaX = targetWx - this.dragging.selectedNodes.find(sn => sn.node.id === n.id).startWorldX;
|
|
447
|
+
const deltaY = targetWy - this.dragging.selectedNodes.find(sn => sn.node.id === n.id).startWorldY;
|
|
448
|
+
|
|
449
|
+
// Update world transforms
|
|
450
|
+
this.graph.updateWorldTransforms();
|
|
451
|
+
|
|
452
|
+
// Move all selected nodes by the same delta
|
|
453
|
+
for (const { node: selectedNode, startWorldX, startWorldY } of this.dragging.selectedNodes) {
|
|
454
|
+
// Skip group nodes when shift-dragging (vertical only)
|
|
455
|
+
if (this.isShift && selectedNode.type === "core/Group") {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let newWorldX = startWorldX + deltaX;
|
|
460
|
+
let newWorldY = startWorldY + deltaY;
|
|
461
|
+
|
|
462
|
+
// Convert to local position
|
|
463
|
+
let parentWx = 0;
|
|
464
|
+
let parentWy = 0;
|
|
465
|
+
if (selectedNode.parent) {
|
|
466
|
+
parentWx = selectedNode.parent.computed.x;
|
|
467
|
+
parentWy = selectedNode.parent.computed.y;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
selectedNode.pos.x = newWorldX - parentWx;
|
|
471
|
+
selectedNode.pos.y = newWorldY - parentWy;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// If Alt is held and dragging a group, restore children to original world positions
|
|
475
|
+
if (this.isAlt && n.type === "core/Group" && this.dragging.childrenWorldPos) {
|
|
476
|
+
this.graph.updateWorldTransforms();
|
|
477
|
+
for (const childInfo of this.dragging.childrenWorldPos) {
|
|
478
|
+
const child = childInfo.node;
|
|
479
|
+
const newGroupX = n.computed.x;
|
|
480
|
+
const newGroupY = n.computed.y;
|
|
481
|
+
|
|
482
|
+
child.pos.x = childInfo.worldX - newGroupX;
|
|
483
|
+
child.pos.y = childInfo.worldY - newGroupY;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
this.hooks?.emit("node:move", n);
|
|
488
|
+
this.render();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (this.boxSelecting) {
|
|
493
|
+
this.boxSelecting.currentX = w.x;
|
|
494
|
+
this.boxSelecting.currentY = w.y;
|
|
495
|
+
this.render();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (this.connecting) {
|
|
500
|
+
this.connecting.x = s.x;
|
|
501
|
+
this.connecting.y = s.y;
|
|
502
|
+
this.render();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Cursor update
|
|
506
|
+
const port = this._findPortAtWorld(w.x, w.y);
|
|
507
|
+
const node = this._findNodeAtWorld(w.x, w.y);
|
|
508
|
+
|
|
509
|
+
if (node && this._hitResizeHandle(node, w.x, w.y)) {
|
|
510
|
+
this._setCursor("se-resize");
|
|
511
|
+
} else if (port) {
|
|
512
|
+
// Show pointer cursor over ports (for connecting/disconnecting)
|
|
513
|
+
this._setCursor("pointer");
|
|
514
|
+
} else {
|
|
515
|
+
this._setCursor("default");
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
_onUp(e) {
|
|
520
|
+
this.isAlt = e.altKey;
|
|
521
|
+
this.isShift = e.shiftKey;
|
|
522
|
+
this.isCtrl = e.ctrlKey;
|
|
523
|
+
|
|
524
|
+
const w = this._posWorld(e);
|
|
525
|
+
|
|
526
|
+
if (this.panning) {
|
|
527
|
+
this.panning = null;
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (this.connecting) {
|
|
532
|
+
// ... (existing connection logic)
|
|
533
|
+
const from = this.connecting;
|
|
534
|
+
const portIn = this._findPortAtWorld(w.x, w.y);
|
|
535
|
+
if (portIn && portIn.dir === "in") {
|
|
536
|
+
this.stack.exec(
|
|
537
|
+
AddEdgeCmd(
|
|
538
|
+
this.graph,
|
|
539
|
+
from.fromNode,
|
|
540
|
+
from.fromPort,
|
|
541
|
+
portIn.node.id,
|
|
542
|
+
portIn.port.id
|
|
543
|
+
)
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
this.connecting = null;
|
|
547
|
+
this.render();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (this.resizing) {
|
|
551
|
+
const n = this.graph.nodes.get(this.resizing.nodeId);
|
|
552
|
+
const from = { w: this.resizing.startW, h: this.resizing.startH };
|
|
553
|
+
const to = { w: n.size.width, h: n.size.height };
|
|
554
|
+
if (from.w !== to.w || from.h !== to.h) {
|
|
555
|
+
this.stack.exec(ResizeNodeCmd(n, from, to));
|
|
556
|
+
}
|
|
557
|
+
this.resizing = null;
|
|
558
|
+
this._setCursor("default");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (this.dragging) {
|
|
562
|
+
const n = this.graph.nodes.get(this.dragging.nodeId);
|
|
563
|
+
|
|
564
|
+
// If we're dragging a GROUP with Alt, only move the group (keep children in place)
|
|
565
|
+
if (n.type === "core/Group" && this.isAlt && this.dragging.childrenWorldPos) {
|
|
566
|
+
// Restore children to their original world positions
|
|
567
|
+
for (const childInfo of this.dragging.childrenWorldPos) {
|
|
568
|
+
const child = childInfo.node;
|
|
569
|
+
// Convert world position back to local position relative to new group position
|
|
570
|
+
this.graph.updateWorldTransforms();
|
|
571
|
+
const newGroupX = n.computed.x;
|
|
572
|
+
const newGroupY = n.computed.y;
|
|
573
|
+
|
|
574
|
+
child.pos.x = childInfo.worldX - newGroupX;
|
|
575
|
+
child.pos.y = childInfo.worldY - newGroupY;
|
|
576
|
+
}
|
|
577
|
+
} else if (n.type === "core/Group" && !this.isAlt) {
|
|
578
|
+
// Normal group drag - auto-parent nodes
|
|
579
|
+
this._autoParentNodesInGroup(n);
|
|
580
|
+
} else if (n.type !== "core/Group") {
|
|
581
|
+
// Normal node: Reparenting Logic
|
|
582
|
+
// Check if dropped onto a group
|
|
583
|
+
const potentialParent = this._findPotentialParent(w.x, w.y, n);
|
|
584
|
+
|
|
585
|
+
if (potentialParent && potentialParent !== n.parent) {
|
|
586
|
+
this.graph.reparent(n, potentialParent);
|
|
587
|
+
} else if (!potentialParent && n.parent) {
|
|
588
|
+
// Dropped on empty space -> move to root
|
|
589
|
+
this.graph.reparent(n, null);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
this.dragging = null;
|
|
594
|
+
this.render();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (this.boxSelecting) {
|
|
598
|
+
// Select all nodes within the box
|
|
599
|
+
const { startX, startY, currentX, currentY } = this.boxSelecting;
|
|
600
|
+
const minX = Math.min(startX, currentX);
|
|
601
|
+
const maxX = Math.max(startX, currentX);
|
|
602
|
+
const minY = Math.min(startY, currentY);
|
|
603
|
+
const maxY = Math.max(startY, currentY);
|
|
604
|
+
|
|
605
|
+
for (const node of this.graph.nodes.values()) {
|
|
606
|
+
const { x, y, w, h } = node.computed;
|
|
607
|
+
// Check if node intersects with selection box
|
|
608
|
+
if (x + w >= minX && x <= maxX && y + h >= minY && y <= maxY) {
|
|
609
|
+
this.selection.add(node.id);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
this.boxSelecting = null;
|
|
614
|
+
this.render();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Automatically parent nodes that are within the group's bounds
|
|
620
|
+
* @param {Node} groupNode - The group node
|
|
621
|
+
*/
|
|
622
|
+
_autoParentNodesInGroup(groupNode) {
|
|
623
|
+
const { x: gx, y: gy, w: gw, h: gh } = groupNode.computed;
|
|
624
|
+
|
|
625
|
+
// Find all nodes that are within the group bounds
|
|
626
|
+
for (const node of this.graph.nodes.values()) {
|
|
627
|
+
// Skip the group itself
|
|
628
|
+
if (node === groupNode) continue;
|
|
629
|
+
|
|
630
|
+
// Skip if it's already a child of this group
|
|
631
|
+
if (node.parent === groupNode) continue;
|
|
632
|
+
|
|
633
|
+
// Skip if it's another group (prevent nested groups for now)
|
|
634
|
+
if (node.type === "core/Group") continue;
|
|
635
|
+
|
|
636
|
+
// Check if node is within group bounds
|
|
637
|
+
const { x: nx, y: ny, w: nw, h: nh } = node.computed;
|
|
638
|
+
const nodeCenterX = nx + nw / 2;
|
|
639
|
+
const nodeCenterY = ny + nh / 2;
|
|
640
|
+
|
|
641
|
+
// Use center point to determine if node is inside group
|
|
642
|
+
if (
|
|
643
|
+
nodeCenterX >= gx &&
|
|
644
|
+
nodeCenterX <= gx + gw &&
|
|
645
|
+
nodeCenterY >= gy &&
|
|
646
|
+
nodeCenterY <= gy + gh
|
|
647
|
+
) {
|
|
648
|
+
// Parent this node to the group
|
|
649
|
+
this.graph.reparent(node, groupNode);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
_findPotentialParent(x, y, excludeNode) {
|
|
655
|
+
// Find top-most group under x,y that is not excludeNode or its descendants
|
|
656
|
+
const list = [...this.graph.nodes.values()].reverse();
|
|
657
|
+
for (const n of list) {
|
|
658
|
+
if (n.type !== "core/Group") continue;
|
|
659
|
+
if (n === excludeNode) continue;
|
|
660
|
+
// Check if n is descendant of excludeNode
|
|
661
|
+
let p = n.parent;
|
|
662
|
+
let isDescendant = false;
|
|
663
|
+
while (p) {
|
|
664
|
+
if (p === excludeNode) {
|
|
665
|
+
isDescendant = true;
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
p = p.parent;
|
|
669
|
+
}
|
|
670
|
+
if (isDescendant) continue;
|
|
671
|
+
|
|
672
|
+
const { x: nx, y: ny, w, h } = n.computed;
|
|
673
|
+
if (x >= nx && x <= nx + w && y >= ny && y <= ny + h) {
|
|
674
|
+
return n;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Snap a coordinate to the grid
|
|
682
|
+
* @param {number} value - The value to snap
|
|
683
|
+
* @returns {number} - Snapped value
|
|
684
|
+
*/
|
|
685
|
+
_snapToGrid(value) {
|
|
686
|
+
return Math.round(value / this.gridSize) * this.gridSize;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Create a group from currently selected nodes
|
|
691
|
+
*/
|
|
692
|
+
_createGroupFromSelection() {
|
|
693
|
+
if (this.selection.size === 0) {
|
|
694
|
+
console.warn("No nodes selected to group");
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Get selected nodes
|
|
699
|
+
const selectedNodes = Array.from(this.selection).map(id => this.graph.getNodeById(id));
|
|
700
|
+
|
|
701
|
+
// Calculate bounding box
|
|
702
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
703
|
+
for (const node of selectedNodes) {
|
|
704
|
+
const { x, y, w, h } = node.computed;
|
|
705
|
+
minX = Math.min(minX, x);
|
|
706
|
+
minY = Math.min(minY, y);
|
|
707
|
+
maxX = Math.max(maxX, x + w);
|
|
708
|
+
maxY = Math.max(maxY, y + h);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const margin = 20;
|
|
712
|
+
const groupX = minX - margin;
|
|
713
|
+
const groupY = minY - margin;
|
|
714
|
+
const groupWidth = maxX - minX + margin * 2;
|
|
715
|
+
const groupHeight = maxY - minY + margin * 2;
|
|
716
|
+
|
|
717
|
+
// Create group via GroupManager
|
|
718
|
+
if (this.graph.groupManager) {
|
|
719
|
+
this.graph.groupManager.addGroup({
|
|
720
|
+
title: "Group",
|
|
721
|
+
x: groupX,
|
|
722
|
+
y: groupY,
|
|
723
|
+
width: groupWidth,
|
|
724
|
+
height: groupHeight,
|
|
725
|
+
members: Array.from(this.selection),
|
|
726
|
+
});
|
|
727
|
+
this.selection.clear();
|
|
728
|
+
this.render();
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Align selected nodes horizontally (same Y position)
|
|
734
|
+
*/
|
|
735
|
+
_alignNodesHorizontal() {
|
|
736
|
+
if (this.selection.size < 2) return;
|
|
737
|
+
|
|
738
|
+
const nodes = Array.from(this.selection).map(id => this.graph.getNodeById(id));
|
|
739
|
+
const avgY = nodes.reduce((sum, n) => sum + n.computed.y, 0) / nodes.length;
|
|
740
|
+
|
|
741
|
+
for (const node of nodes) {
|
|
742
|
+
const parentY = node.parent ? node.parent.computed.y : 0;
|
|
743
|
+
node.pos.y = avgY - parentY;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
this.graph.updateWorldTransforms();
|
|
747
|
+
this.render();
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Align selected nodes vertically (same X position)
|
|
752
|
+
*/
|
|
753
|
+
_alignNodesVertical() {
|
|
754
|
+
if (this.selection.size < 2) return;
|
|
755
|
+
|
|
756
|
+
const nodes = Array.from(this.selection).map(id => this.graph.getNodeById(id));
|
|
757
|
+
const avgX = nodes.reduce((sum, n) => sum + n.computed.x, 0) / nodes.length;
|
|
758
|
+
|
|
759
|
+
for (const node of nodes) {
|
|
760
|
+
const parentX = node.parent ? node.parent.computed.x : 0;
|
|
761
|
+
node.pos.x = avgX - parentX;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
this.graph.updateWorldTransforms();
|
|
765
|
+
this.render();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
render() {
|
|
769
|
+
const tEdge = this.renderTempEdge();
|
|
770
|
+
|
|
771
|
+
this.renderer.draw(this.graph, {
|
|
772
|
+
selection: this.selection,
|
|
773
|
+
tempEdge: tEdge,
|
|
774
|
+
boxSelecting: this.boxSelecting,
|
|
775
|
+
activeEdges: this.activeEdges || new Set(), // For animation
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
this.htmlOverlay?.draw(this.graph, this.selection);
|
|
779
|
+
|
|
780
|
+
// Draw box selection rectangle on top of everything
|
|
781
|
+
if (this.boxSelecting) {
|
|
782
|
+
const { startX, startY, currentX, currentY } = this.boxSelecting;
|
|
783
|
+
const minX = Math.min(startX, currentX);
|
|
784
|
+
const minY = Math.min(startY, currentY);
|
|
785
|
+
const width = Math.abs(currentX - startX);
|
|
786
|
+
const height = Math.abs(currentY - startY);
|
|
787
|
+
|
|
788
|
+
const screenStart = this.renderer.worldToScreen(minX, minY);
|
|
789
|
+
const screenEnd = this.renderer.worldToScreen(minX + width, minY + height);
|
|
790
|
+
|
|
791
|
+
const ctx = this.renderer.ctx;
|
|
792
|
+
ctx.save();
|
|
793
|
+
this.renderer._resetTransform();
|
|
794
|
+
|
|
795
|
+
// Draw selection box
|
|
796
|
+
ctx.strokeStyle = "#6cf";
|
|
797
|
+
ctx.fillStyle = "rgba(102, 204, 255, 0.1)";
|
|
798
|
+
ctx.lineWidth = 2;
|
|
799
|
+
ctx.strokeRect(screenStart.x, screenStart.y, screenEnd.x - screenStart.x, screenEnd.y - screenStart.y);
|
|
800
|
+
ctx.fillRect(screenStart.x, screenStart.y, screenEnd.x - screenStart.x, screenEnd.y - screenStart.y);
|
|
801
|
+
|
|
802
|
+
ctx.restore();
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Draw ports for HTML overlay nodes on separate canvas (above HTML)
|
|
806
|
+
if (this.portRenderer) {
|
|
807
|
+
// Clear port canvas
|
|
808
|
+
const portCtx = this.portRenderer.ctx;
|
|
809
|
+
portCtx.clearRect(0, 0, this.portRenderer.canvas.width, this.portRenderer.canvas.height);
|
|
810
|
+
|
|
811
|
+
// Sync transform
|
|
812
|
+
this.portRenderer.scale = this.renderer.scale;
|
|
813
|
+
this.portRenderer.offsetX = this.renderer.offsetX;
|
|
814
|
+
this.portRenderer.offsetY = this.renderer.offsetY;
|
|
815
|
+
|
|
816
|
+
// Draw ports for HTML overlay nodes
|
|
817
|
+
this.portRenderer._applyTransform();
|
|
818
|
+
for (const n of this.graph.nodes.values()) {
|
|
819
|
+
if (n.type !== "core/Group") {
|
|
820
|
+
const def = this.portRenderer.registry?.types?.get(n.type);
|
|
821
|
+
const hasHtmlOverlay = !!(def?.html);
|
|
822
|
+
|
|
823
|
+
if (hasHtmlOverlay) {
|
|
824
|
+
this.portRenderer._drawPorts(n);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
this.portRenderer._resetTransform();
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
renderTempEdge() {
|
|
833
|
+
if (!this.connecting) return null;
|
|
834
|
+
const a = this._portAnchorScreen(
|
|
835
|
+
this.connecting.fromNode,
|
|
836
|
+
this.connecting.fromPort
|
|
837
|
+
); // {x,y}
|
|
838
|
+
return {
|
|
839
|
+
x1: a.x,
|
|
840
|
+
y1: a.y,
|
|
841
|
+
x2: this.connecting.x,
|
|
842
|
+
y2: this.connecting.y,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
_portAnchorScreen(nodeId, portId) {
|
|
847
|
+
const n = this.graph.nodes.get(nodeId);
|
|
848
|
+
const iOut = n.outputs.findIndex((p) => p.id === portId);
|
|
849
|
+
const r = portRect(n, null, iOut, "out"); // world rect
|
|
850
|
+
return this.renderer.worldToScreen(r.x, r.y + 7); // -> screen point
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function rectHas(r, x, y) {
|
|
855
|
+
return x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
|
|
856
|
+
}
|