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.
@@ -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
+ }