@elicdavis/node-flow 0.1.16 → 0.1.18

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/src/node.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Port, PortConfig, PortType } from "./port";
2
+ import { requestRender } from "./render_scheduler";
2
3
  import { FontWeight, TextStyle, TextStyleConfig, TextStyleFallback } from "./styles/text";
3
4
  import { Box, InBox } from "./types/box";
4
5
  import { CopyVector2, Distance, ScaleVector, Vector2, Zero } from "./types/vector2";
@@ -402,6 +403,7 @@ export class FlowNode {
402
403
  return;
403
404
  }
404
405
  this.#selected = true;
406
+ requestRender();
405
407
  for (let i = 0; i < this.#onSelect.length; i++) {
406
408
  this.#onSelect[i]();
407
409
  }
@@ -457,6 +459,7 @@ export class FlowNode {
457
459
  }
458
460
 
459
461
  this.#data[name] = value;
462
+ requestRender();
460
463
 
461
464
  for (let i = 0; i < this.#registeredAnyPropertyChangeCallbacks.length; i++) {
462
465
  this.#registeredAnyPropertyChangeCallbacks[i](name, oldValue, value);
@@ -735,6 +738,7 @@ export class FlowNode {
735
738
  return;
736
739
  }
737
740
  this.#selected = false;
741
+ requestRender();
738
742
  for (let i = 0; i < this.#onUnselect.length; i++) {
739
743
  this.#onUnselect[i]();
740
744
  }
@@ -772,6 +776,7 @@ export class FlowNode {
772
776
 
773
777
  public setPosition(position: Vector2): void {
774
778
  CopyVector2(this.#position, position);
779
+ requestRender();
775
780
  }
776
781
 
777
782
  public getPosition(): Vector2 {
@@ -854,11 +859,13 @@ export class FlowNode {
854
859
 
855
860
  addWidget(widget: Widget): void {
856
861
  this.#widgets.push(widget);
862
+ requestRender();
857
863
  }
858
864
 
859
865
  insertWidget(index: number, widget: Widget): void {
860
866
  const at = Math.max(0, Math.min(index, this.#widgets.length));
861
867
  this.#widgets.splice(at, 0, widget);
868
+ requestRender();
862
869
  }
863
870
 
864
871
  removeWidget(widget: Widget): void {
@@ -867,13 +874,16 @@ export class FlowNode {
867
874
  throw new Error("node does not contain widget");
868
875
  }
869
876
  this.#widgets.splice(index, 1);
877
+ requestRender();
870
878
  }
871
879
 
872
880
  removeWidgetAt(index: number): Widget | undefined {
873
881
  if (index < 0 || index >= this.#widgets.length) {
874
882
  return undefined;
875
883
  }
876
- return this.#widgets.splice(index, 1)[0];
884
+ const removed = this.#widgets.splice(index, 1)[0];
885
+ requestRender();
886
+ return removed;
877
887
  }
878
888
 
879
889
  moveWidget(from: number, to: number): void {
@@ -885,10 +895,12 @@ export class FlowNode {
885
895
  }
886
896
  const [widget] = this.#widgets.splice(from, 1);
887
897
  this.#widgets.splice(to, 0, widget);
898
+ requestRender();
888
899
  }
889
900
 
890
901
  clearWidgets(): void {
891
902
  this.#widgets.length = 0;
903
+ requestRender();
892
904
  }
893
905
 
894
906
  getWidget(index: number): Widget {
@@ -978,6 +990,7 @@ export class FlowNode {
978
990
  }
979
991
 
980
992
  this.#title.set(cleaned);
993
+ requestRender();
981
994
 
982
995
  for (let i = 0; i < this.#titleChangeCallback.length; i++) {
983
996
  const callback = this.#titleChangeCallback[i];
@@ -1001,6 +1014,7 @@ export class FlowNode {
1001
1014
 
1002
1015
  const old = this.#infoText;
1003
1016
  this.#infoText = cleaned;
1017
+ requestRender();
1004
1018
 
1005
1019
  for (let i = 0; i < this.#infoChangeCallback.length; i++) {
1006
1020
  const callback = this.#infoChangeCallback[i];
@@ -1053,7 +1067,15 @@ export class FlowNode {
1053
1067
  this.#messages = [];
1054
1068
  }
1055
1069
 
1070
+ public hasBeenLaidOut(): boolean {
1071
+ return this.#laidOut;
1072
+ }
1073
+
1074
+ #laidOut: boolean = false;
1075
+
1056
1076
  render(ctx: CanvasRenderingContext2D, camera: Camera, state: NodeState, mousePosition: Vector2 | undefined, postProcess: PassSubsystem): void {
1077
+ this.#laidOut = true;
1078
+
1057
1079
  VectorPool.run(() => {
1058
1080
  const tempMeasurement = VectorPool.get();
1059
1081
 
@@ -1255,6 +1277,66 @@ export class FlowNode {
1255
1277
  const message = this.#messages[i];
1256
1278
  messageStart.y += message.render(ctx, camera.zoom, messageStart, !(state === NodeState.Idle)) + (10 * camera.zoom);
1257
1279
  }
1280
+
1281
+ this.#rememberPortOffsets(nodeBounds, camera.zoom);
1258
1282
  })
1259
1283
  }
1284
+
1285
+ #portOffsets: { input: Array<Box>, output: Array<Box> } = { input: [], output: [] };
1286
+
1287
+ #rememberPortOffsets(bounds: Box, zoom: number): void {
1288
+ if (zoom <= 0) {
1289
+ return;
1290
+ }
1291
+
1292
+ const record = (positions: List<Box>, into: Array<Box>) => {
1293
+ into.length = 0;
1294
+ for (let i = 0; i < positions.Count(); i++) {
1295
+ const box = positions.At(i);
1296
+ into.push({
1297
+ Position: {
1298
+ x: (box.Position.x - bounds.Position.x) / zoom,
1299
+ y: (box.Position.y - bounds.Position.y) / zoom,
1300
+ },
1301
+ Size: {
1302
+ x: box.Size.x / zoom,
1303
+ y: box.Size.y / zoom,
1304
+ },
1305
+ });
1306
+ }
1307
+ };
1308
+
1309
+ record(this.#inputPortPositions, this.#portOffsets.input);
1310
+ record(this.#outputPortPositions, this.#portOffsets.output);
1311
+ }
1312
+
1313
+ public layoutPortsWithoutDrawing(bounds: Box, zoom: number): boolean {
1314
+ if (this.#portOffsets.input.length === 0 && this.#portOffsets.output.length === 0) {
1315
+ return false;
1316
+ }
1317
+
1318
+ const project = (offsets: Array<Box>, into: List<Box>) => {
1319
+ into.Clear();
1320
+ for (let i = 0; i < offsets.length; i++) {
1321
+ const offset = offsets[i];
1322
+ into.Push({
1323
+ Position: {
1324
+ x: bounds.Position.x + (offset.Position.x * zoom),
1325
+ y: bounds.Position.y + (offset.Position.y * zoom),
1326
+ },
1327
+ Size: {
1328
+ x: offset.Size.x * zoom,
1329
+ y: offset.Size.y * zoom,
1330
+ },
1331
+ });
1332
+ }
1333
+ };
1334
+
1335
+ project(this.#portOffsets.input, this.#inputPortPositions);
1336
+ project(this.#portOffsets.output, this.#outputPortPositions);
1337
+
1338
+ this.#widgetPositions.Clear();
1339
+
1340
+ return true;
1341
+ }
1260
1342
  }
@@ -1,5 +1,5 @@
1
1
  import { FlowNode } from "../node";
2
- import { Publisher, PublisherConfig } from "./publisher";
2
+ import { NodeMenuFilter, Publisher, PublisherConfig } from "./publisher";
3
3
  import { ContextMenuConfig } from "../contextMenu";
4
4
  import { Vector2 } from "../types/vector2";
5
5
  import { nodeFlowGroup as nodeFlowContextMenuGroup, NodeSubsystem } from "./subsystem";
@@ -69,19 +69,23 @@ export class NodeFactory {
69
69
  return node;
70
70
  }
71
71
 
72
- public newNodeSubmenus(graph: NodeSubsystem, position: Vector2): Array<ContextMenuConfig> {
72
+ public newNodeSubmenus(graph: NodeSubsystem, position: Vector2, filter?: NodeMenuFilter): Array<ContextMenuConfig> {
73
73
  const menus: Array<ContextMenuConfig> = [];
74
74
  for (let [_, publisher] of this.#registeredPublishers) {
75
- menus.push(publisher.contextMenu(graph, position))
75
+ const built = publisher.contextMenu(graph, position, filter);
76
+ if (filter !== undefined && built.items?.length === 0 && built.subMenus?.length === 0) {
77
+ continue;
78
+ }
79
+ menus.push(built);
76
80
  }
77
81
  return menus;
78
82
  }
79
83
 
80
- public openMenu(graph: NodeSubsystem, position: Vector2): ContextMenuConfig {
84
+ public openMenu(graph: NodeSubsystem, position: Vector2, filter?: NodeMenuFilter): ContextMenuConfig {
81
85
  return {
82
86
  name: "New Node",
83
87
  group: nodeFlowContextMenuGroup,
84
- subMenus: this.newNodeSubmenus(graph, position),
88
+ subMenus: this.newNodeSubmenus(graph, position, filter),
85
89
  };
86
90
  }
87
91
  }
@@ -1,8 +1,34 @@
1
1
  import { ContextMenuConfig, ContextMenuItemConfig } from "../contextMenu";
2
2
  import { FlowNode, FlowNodeConfig } from '../node';
3
+ import { PortConfig, PortType, portConfigTypes, typeSetsCompatible } from "../port";
3
4
  import { Vector2 } from "../types/vector2";
4
5
  import { NodeSubsystem } from "./subsystem";
5
6
 
7
+ /**
8
+ * Narrows a "new node" menu to the nodes that could actually be wired to a
9
+ * particular port.
10
+ */
11
+ export interface NodeMenuFilter {
12
+ /** Which side the new node has to offer. */
13
+ needs: PortType;
14
+
15
+ /** Types that side has to accept. An empty list takes anything. */
16
+ types: Array<string>;
17
+
18
+ onCreated?: (node: FlowNode) => void;
19
+ }
20
+
21
+ function offersMatchingPort(config: FlowNodeConfig, filter: NodeMenuFilter): boolean {
22
+ const ports: Array<PortConfig> | undefined =
23
+ filter.needs === PortType.Input ? config.inputs : config.outputs;
24
+
25
+ if (ports === undefined) {
26
+ return false;
27
+ }
28
+
29
+ return ports.some((port) => typeSetsCompatible(portConfigTypes(port), filter.types));
30
+ }
31
+
6
32
  interface PublisherNodes {
7
33
  [name: string]: FlowNodeConfig
8
34
  }
@@ -50,7 +76,7 @@ export class Publisher {
50
76
  return this.#registeredNodes.delete(nodeType);
51
77
  }
52
78
 
53
- #recurseBuildMenu(graph: NodeSubsystem, name: string, subMenu: Map<string, FlowNodeConfig>, position: Vector2): ContextMenuConfig {
79
+ #recurseBuildMenu(graph: NodeSubsystem, name: string, subMenu: Map<string, FlowNodeConfig>, position: Vector2, filter?: NodeMenuFilter): ContextMenuConfig {
54
80
  const items: Array<ContextMenuItemConfig> = [];
55
81
  const subMenus = new Map<string, Map<string, FlowNodeConfig>>();
56
82
 
@@ -59,12 +85,17 @@ export class Publisher {
59
85
  const bracketIndex = key.indexOf("[")
60
86
 
61
87
  if (slashIndex === -1 || (bracketIndex !== -1 && bracketIndex < slashIndex)) {
88
+ if (filter !== undefined && !offersMatchingPort(nodeConfig, filter)) {
89
+ continue;
90
+ }
91
+
62
92
  items.push({
63
93
  name: key,
64
94
  callback: () => {
65
95
  const node = new FlowNode(nodeConfig);
66
96
  node.setPosition(position);
67
97
  graph.addNode(node);
98
+ filter?.onCreated?.(node);
68
99
  },
69
100
  });
70
101
  } else {
@@ -81,7 +112,11 @@ export class Publisher {
81
112
 
82
113
  const menus: Array<ContextMenuConfig> = [];
83
114
  for (let [key, nodes] of subMenus) {
84
- menus.push(this.#recurseBuildMenu(graph, key, nodes, position))
115
+ const built = this.#recurseBuildMenu(graph, key, nodes, position, filter);
116
+ if (filter !== undefined && built.items?.length === 0 && built.subMenus?.length === 0) {
117
+ continue;
118
+ }
119
+ menus.push(built);
85
120
  }
86
121
 
87
122
  return {
@@ -91,8 +126,8 @@ export class Publisher {
91
126
  }
92
127
  }
93
128
 
94
- contextMenu(graph: NodeSubsystem, position: Vector2): ContextMenuConfig {
95
- return this.#recurseBuildMenu(graph, this.#name, this.#registeredNodes, position);
129
+ contextMenu(graph: NodeSubsystem, position: Vector2, filter?: NodeMenuFilter): ContextMenuConfig {
130
+ return this.#recurseBuildMenu(graph, this.#name, this.#registeredNodes, position, filter);
96
131
  }
97
132
 
98
133
  create(nodeType: string): FlowNode {
@@ -7,6 +7,7 @@ import { Organize } from "../organize";
7
7
  import { PassSubsystem } from "../pass/subsystem";
8
8
  import { TimeExecution } from "../performance";
9
9
  import { Port, PortType, portsCompatible } from "../port";
10
+ import { requestRender } from "../render_scheduler";
10
11
  import { BoxStyle } from "../styles/box";
11
12
  import { CursorStyle } from "../styles/cursor";
12
13
  import { Theme } from "../theme";
@@ -23,6 +24,33 @@ export type NodeRemovedCallback = (node: FlowNode) => void;
23
24
 
24
25
  export const nodeFlowGroup = "node-flow-graph-node-menu";
25
26
 
27
+ /**
28
+ * A connection between two nodes of a copied set, by their position in that
29
+ * set rather than by node, so it can be rebuilt against the copies.
30
+ */
31
+ export interface InternalConnection {
32
+ fromNode: number;
33
+ fromPort: number;
34
+ toNode: number;
35
+ toPort: number;
36
+ }
37
+
38
+ /**
39
+ * A connection that was dragged out and dropped on empty canvas. Describes
40
+ * what a node would have to offer to finish it, and joins the two up once
41
+ * one has been made.
42
+ */
43
+ export interface DanglingConnection {
44
+ /** The side the new node has to offer. */
45
+ needs: PortType;
46
+
47
+ /** The types that side has to accept. Empty takes anything. */
48
+ types: Array<string>;
49
+
50
+ /** Wires the new node to the port the drag started from. */
51
+ connectTo: (node: FlowNode) => void;
52
+ }
53
+
26
54
  export interface ConnectionRendererConfiguration {
27
55
  size?: number;
28
56
  color?: string;
@@ -216,6 +244,10 @@ export class NodeSubsystem {
216
244
  this.#boxSelect = true;
217
245
  }
218
246
 
247
+ if (!hoveringSomething && !ctrlKey) {
248
+ this.unselectAllNodes();
249
+ }
250
+
219
251
  return hoveringSomething || ctrlKey;
220
252
  }
221
253
 
@@ -285,6 +317,119 @@ export class NodeSubsystem {
285
317
  }
286
318
  }
287
319
 
320
+ public connectionsWithin(nodes: Array<FlowNode>): Array<InternalConnection> {
321
+ const within: Array<InternalConnection> = [];
322
+
323
+ for (const connection of this.#connections) {
324
+ const outNode = connection.outNode();
325
+ const inNode = connection.inNode();
326
+ if (outNode === null || inNode === null) {
327
+ continue;
328
+ }
329
+
330
+ const from = nodes.indexOf(outNode);
331
+ const to = nodes.indexOf(inNode);
332
+ if (from === -1 || to === -1) {
333
+ continue;
334
+ }
335
+
336
+ const outPort = connection.outPort();
337
+ const inPort = connection.inPort();
338
+ if (outPort === null || inPort === null) {
339
+ continue;
340
+ }
341
+
342
+ within.push({
343
+ fromNode: from,
344
+ fromPort: this.#outputPortIndex(outNode, outPort),
345
+ toNode: to,
346
+ toPort: this.#inputPortIndex(inNode, inPort),
347
+ });
348
+ }
349
+
350
+ return within;
351
+ }
352
+
353
+ #onConnectionReleased: ((dangling: DanglingConnection) => void) | null = null;
354
+
355
+ public setConnectionReleasedHandler(handler: (dangling: DanglingConnection) => void): void {
356
+ this.#onConnectionReleased = handler;
357
+ }
358
+
359
+ #describeDanglingConnection(conn: Connection): DanglingConnection | null {
360
+ const outNode = conn.outNode();
361
+ const inNode = conn.inNode();
362
+
363
+ // Dragged from an output, so the new node supplies the input.
364
+ if (outNode !== null && inNode === null) {
365
+ const from = conn.outPort();
366
+ if (from === null) {
367
+ return null;
368
+ }
369
+ const fromIndex = this.#outputPortIndex(outNode, from);
370
+ return {
371
+ needs: PortType.Input,
372
+ types: from.acceptedTypes(),
373
+ connectTo: (node) => {
374
+ const to = this.#firstCompatiblePort(node, PortType.Input, from);
375
+ if (to !== -1 && fromIndex !== -1) {
376
+ this.connectNodes(outNode, fromIndex, node, to);
377
+ }
378
+ },
379
+ };
380
+ }
381
+
382
+ // Dragged from an input, so the new node supplies the output.
383
+ if (inNode !== null && outNode === null) {
384
+ const to = conn.inPort();
385
+ if (to === null) {
386
+ return null;
387
+ }
388
+ const toIndex = this.#inputPortIndex(inNode, to);
389
+ return {
390
+ needs: PortType.Output,
391
+ types: to.acceptedTypes(),
392
+ connectTo: (node) => {
393
+ const from = this.#firstCompatiblePort(node, PortType.Output, to);
394
+ if (from !== -1 && toIndex !== -1) {
395
+ this.connectNodes(node, from, inNode, toIndex);
396
+ }
397
+ },
398
+ };
399
+ }
400
+
401
+ return null;
402
+ }
403
+
404
+ #outputPortIndex(node: FlowNode, port: Port): number {
405
+ for (let i = 0; i < node.outputs(); i++) {
406
+ if (node.outputPort(i) === port) {
407
+ return i;
408
+ }
409
+ }
410
+ return -1;
411
+ }
412
+
413
+ #inputPortIndex(node: FlowNode, port: Port): number {
414
+ for (let i = 0; i < node.inputs(); i++) {
415
+ if (node.inputPort(i) === port) {
416
+ return i;
417
+ }
418
+ }
419
+ return -1;
420
+ }
421
+
422
+ #firstCompatiblePort(node: FlowNode, side: PortType, against: Port): number {
423
+ const count = side === PortType.Input ? node.inputs() : node.outputs();
424
+ for (let i = 0; i < count; i++) {
425
+ const port = side === PortType.Input ? node.inputPort(i) : node.outputPort(i);
426
+ if (portsCompatible(port, against)) {
427
+ return i;
428
+ }
429
+ }
430
+ return -1;
431
+ }
432
+
288
433
  clickEnd(): void {
289
434
 
290
435
  for (let i = 0; i < this.#nodesGrabbed.Count(); i++) {
@@ -303,7 +448,9 @@ export class NodeSubsystem {
303
448
  }
304
449
 
305
450
  if (this.#widgetCurrentlyClicking !== null) {
306
- this.#widgetCurrentlyClicking.ClickEnd();
451
+ if (this.#widgetHovering === this.#widgetCurrentlyClicking) {
452
+ this.#widgetCurrentlyClicking.ClickEnd();
453
+ }
307
454
  this.#widgetCurrentlyClicking = null;
308
455
  }
309
456
 
@@ -312,7 +459,11 @@ export class NodeSubsystem {
312
459
  }
313
460
 
314
461
  if (this.#portHovering === null) {
462
+ const dangling = this.#describeDanglingConnection(this.#connectionSelected);
315
463
  this.#clearCurrentlySelectedConnection();
464
+ if (dangling !== null) {
465
+ this.#onConnectionReleased?.(dangling);
466
+ }
316
467
  return;
317
468
  }
318
469
 
@@ -408,6 +559,7 @@ export class NodeSubsystem {
408
559
  this.#idleConnectionRenderer,
409
560
  );
410
561
  this.#connections.push(connection);
562
+ requestRender();
411
563
  return connection;
412
564
  }
413
565
 
@@ -478,6 +630,7 @@ export class NodeSubsystem {
478
630
 
479
631
  addNode(node: FlowNode): void {
480
632
  this.#nodes.push(node);
633
+ requestRender();
481
634
  for (let i = 0; i < this.#registeredNodeAddedCallbacks.length; i++) {
482
635
  const callback = this.#registeredNodeAddedCallbacks[i];
483
636
  if (!callback) {
@@ -531,6 +684,7 @@ export class NodeSubsystem {
531
684
  this.#removeNodeConnections(nodeIndex);
532
685
  const node = this.#nodes[nodeIndex];
533
686
  this.#nodes.splice(nodeIndex, 1);
687
+ requestRender();
534
688
  for (let i = 0; i < this.#nodeRemovedCallbacks.length; i++) {
535
689
  this.#nodeRemovedCallbacks[i](node);
536
690
  }
@@ -541,6 +695,7 @@ export class NodeSubsystem {
541
695
  this.#connections[index].clearPorts();
542
696
  }
543
697
  this.#connections.splice(index, 1);
698
+ requestRender();
544
699
  }
545
700
 
546
701
  #removeConnection(connection: Connection, clearPorts: boolean): void {
@@ -594,7 +749,8 @@ export class NodeSubsystem {
594
749
  ]
595
750
  }
596
751
 
597
- if (this.#nodesSelected().length > 0) {
752
+ const selectedCount = this.#nodesSelected().length;
753
+ if (selectedCount > 0) {
598
754
  organizeNodesSubMenu.items?.push({
599
755
  name: "Selected Nodes",
600
756
  group: nodeFlowGroup,
@@ -602,6 +758,14 @@ export class NodeSubsystem {
602
758
  this.#organizeSelected(ctx)
603
759
  }
604
760
  })
761
+
762
+ config.items?.push({
763
+ name: selectedCount === 1 ? "Delete Selected Node" : `Delete ${selectedCount} Selected Nodes`,
764
+ group: nodeFlowGroup,
765
+ callback: () => {
766
+ this.removeSelectedNodes();
767
+ }
768
+ })
605
769
  }
606
770
 
607
771
  if (this.#nodeHovering > -1) {
@@ -684,6 +848,32 @@ export class NodeSubsystem {
684
848
  }
685
849
  }
686
850
 
851
+ /** Drops the selection entirely. */
852
+ public unselectAllNodes(): void {
853
+ let changed = false;
854
+ for (let i = 0; i < this.#nodes.length; i++) {
855
+ if (this.#nodes[i].selected()) {
856
+ this.#nodes[i].unselect();
857
+ changed = true;
858
+ }
859
+ }
860
+
861
+ if (changed) {
862
+ requestRender();
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Removes every selected node, and the connections that touched them.
868
+ */
869
+ public removeSelectedNodes(): Array<FlowNode> {
870
+ const removed = this.getSelectedNodes();
871
+ for (const node of removed) {
872
+ this.removeNode(node);
873
+ }
874
+ return removed;
875
+ }
876
+
687
877
  #selectNodeByIndex(nodeIndex: number, unselectOthers: boolean): void {
688
878
  this.#selectNode(this.#nodes[nodeIndex], unselectOthers);
689
879
  }
@@ -729,9 +919,22 @@ export class NodeSubsystem {
729
919
 
730
920
  const selectedBox_Screenspace = this.#boxSelectionScreenspaceBox(camera);
731
921
 
922
+ // Nodes outside the canvas are not drawn, but their ports are still
923
+ // placed, because wires to them stay on screen.
924
+ const canvas: Box = {
925
+ Position: { x: 0, y: 0 },
926
+ Size: { x: ctx.canvas.width, y: ctx.canvas.height },
927
+ };
928
+
732
929
  for (let i = 0; i < this.#nodes.length; i++) {
733
930
  let state = NodeState.Idle;
734
931
 
932
+ const nodeBounds = this.#nodes[i].calculateBounds(ctx, camera);
933
+ if (!BoxIntersection(nodeBounds, canvas)
934
+ && this.#nodes[i].layoutPortsWithoutDrawing(nodeBounds, camera.zoom)) {
935
+ continue;
936
+ }
937
+
735
938
  if (mousePosition !== undefined && !this.#boxSelect) {
736
939
  const intersection = this.#nodes[i].inBounds(ctx, camera, mousePosition);
737
940
 
@@ -791,6 +994,11 @@ export class NodeSubsystem {
791
994
  render(ctx: CanvasRenderingContext2D, camera: Camera, mousePosition: Vector2 | undefined): RenderResults | undefined {
792
995
  this.#lastCtx = ctx;
793
996
  this.#cursor = CursorStyle.Default;
997
+
998
+ // Connections draw first but need port positions, which only exist
999
+ // once a node has been drawn so a new node costs one extra frame.
1000
+ const awaitingLayout = this.#nodes.some((node) => !node.hasBeenLaidOut());
1001
+
794
1002
  TimeExecution("Render_Connections", () => {
795
1003
  this.#renderConnections(ctx, camera, mousePosition);
796
1004
  })
@@ -798,6 +1006,10 @@ export class NodeSubsystem {
798
1006
  this.#renderNodes(ctx, camera, mousePosition);
799
1007
  })
800
1008
 
1009
+ if (awaitingLayout) {
1010
+ requestRender();
1011
+ }
1012
+
801
1013
  if (this.#boxSelect) {
802
1014
  const box = this.#boxSelectionScreenspaceBox(camera);
803
1015
  ctx.setLineDash([Theme.BoxSelect.LineDashLength]);
@@ -1,5 +1,6 @@
1
1
  import { Camera } from "../camera";
2
2
  import { ContextMenuConfig } from "../contextMenu";
3
+ import { requestRender } from "../render_scheduler";
3
4
  import { RenderResults } from "../graphSubsystem";
4
5
  import { InBox } from "../types/box";
5
6
  import { Vector2 } from "../types/vector2";
@@ -81,6 +82,7 @@ export class NoteSubsystem {
81
82
  }
82
83
 
83
84
  this.#notes.push(note);
85
+ requestRender();
84
86
  for (let i = 0; i < this.#onNoteAddedCallbacks.length; i++) {
85
87
  this.#onNoteAddedCallbacks[i](note);
86
88
  }
@@ -222,6 +224,7 @@ export class NoteSubsystem {
222
224
  if (index > -1) {
223
225
  const noteRemoved = this.#notes[index];
224
226
  this.#notes.splice(index, 1);
227
+ requestRender();
225
228
 
226
229
  for (let i = 0; i < this.#onNoteRemovedCallbacks.length; i++) {
227
230
  this.#onNoteRemovedCallbacks[i](noteRemoved)