@elicdavis/node-flow 0.1.15 → 0.1.17

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.
Files changed (43) hide show
  1. package/.mocharc.json +5 -0
  2. package/dist/index.d.mts +139 -4
  3. package/dist/index.d.ts +139 -4
  4. package/dist/index.js +3754 -3352
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +3741 -3352
  7. package/dist/index.mjs.map +1 -1
  8. package/index.html +0 -30
  9. package/package.json +4 -2
  10. package/src/graph.ts +234 -41
  11. package/src/index.ts +82 -40
  12. package/src/input.ts +22 -14
  13. package/src/markdown/entry.ts +26 -3
  14. package/src/markdown/lexicalParser.ts +22 -32
  15. package/src/markdown/syntaxParser.ts +93 -68
  16. package/src/minimap.ts +178 -0
  17. package/src/node.ts +114 -0
  18. package/src/nodes/factory.ts +9 -5
  19. package/src/nodes/publisher.ts +39 -4
  20. package/src/nodes/subsystem.ts +218 -8
  21. package/src/notes/subsystem.ts +3 -0
  22. package/src/organize.ts +276 -181
  23. package/src/port.ts +143 -7
  24. package/src/render_scheduler.ts +49 -0
  25. package/src/styles/text.ts +8 -0
  26. package/src/theme.ts +8 -0
  27. package/src/types/text.ts +8 -4
  28. package/src/utils/string.ts +7 -1
  29. package/src/widgets/color.ts +5 -6
  30. package/src/widgets/factory.ts +1 -1
  31. package/src/widgets/image.ts +2 -0
  32. package/src/widgets/number.ts +2 -2
  33. package/src/widgets/slider.ts +2 -2
  34. package/src/widgets/string.ts +2 -2
  35. package/src/widgets/toggle.ts +2 -2
  36. package/src/widgets/widget.ts +22 -0
  37. package/test/markdown.test.ts +165 -0
  38. package/test/minimap.test.ts +103 -0
  39. package/test/node_menu_filter.test.ts +119 -0
  40. package/test/organize.test.ts +156 -0
  41. package/test/port.test.ts +125 -0
  42. package/test/render_scheduler.test.ts +119 -0
  43. package/tsconfig.json +6 -2
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,6 +859,13 @@ export class FlowNode {
854
859
 
855
860
  addWidget(widget: Widget): void {
856
861
  this.#widgets.push(widget);
862
+ requestRender();
863
+ }
864
+
865
+ insertWidget(index: number, widget: Widget): void {
866
+ const at = Math.max(0, Math.min(index, this.#widgets.length));
867
+ this.#widgets.splice(at, 0, widget);
868
+ requestRender();
857
869
  }
858
870
 
859
871
  removeWidget(widget: Widget): void {
@@ -862,12 +874,47 @@ export class FlowNode {
862
874
  throw new Error("node does not contain widget");
863
875
  }
864
876
  this.#widgets.splice(index, 1);
877
+ requestRender();
878
+ }
879
+
880
+ removeWidgetAt(index: number): Widget | undefined {
881
+ if (index < 0 || index >= this.#widgets.length) {
882
+ return undefined;
883
+ }
884
+ const removed = this.#widgets.splice(index, 1)[0];
885
+ requestRender();
886
+ return removed;
887
+ }
888
+
889
+ moveWidget(from: number, to: number): void {
890
+ if (from < 0 || from >= this.#widgets.length) {
891
+ return;
892
+ }
893
+ if (to < 0 || to >= this.#widgets.length || from === to) {
894
+ return;
895
+ }
896
+ const [widget] = this.#widgets.splice(from, 1);
897
+ this.#widgets.splice(to, 0, widget);
898
+ requestRender();
899
+ }
900
+
901
+ clearWidgets(): void {
902
+ this.#widgets.length = 0;
903
+ requestRender();
865
904
  }
866
905
 
867
906
  getWidget(index: number): Widget {
868
907
  return this.#widgets[index];
869
908
  }
870
909
 
910
+ widgets(): ReadonlyArray<Widget> {
911
+ return this.#widgets;
912
+ }
913
+
914
+ indexOfWidget(widget: Widget): number {
915
+ return this.#widgets.indexOf(widget, 0);
916
+ }
917
+
871
918
  widgetCount(): number {
872
919
  return this.#widgets.length;
873
920
  }
@@ -943,6 +990,7 @@ export class FlowNode {
943
990
  }
944
991
 
945
992
  this.#title.set(cleaned);
993
+ requestRender();
946
994
 
947
995
  for (let i = 0; i < this.#titleChangeCallback.length; i++) {
948
996
  const callback = this.#titleChangeCallback[i];
@@ -966,6 +1014,7 @@ export class FlowNode {
966
1014
 
967
1015
  const old = this.#infoText;
968
1016
  this.#infoText = cleaned;
1017
+ requestRender();
969
1018
 
970
1019
  for (let i = 0; i < this.#infoChangeCallback.length; i++) {
971
1020
  const callback = this.#infoChangeCallback[i];
@@ -1018,7 +1067,15 @@ export class FlowNode {
1018
1067
  this.#messages = [];
1019
1068
  }
1020
1069
 
1070
+ public hasBeenLaidOut(): boolean {
1071
+ return this.#laidOut;
1072
+ }
1073
+
1074
+ #laidOut: boolean = false;
1075
+
1021
1076
  render(ctx: CanvasRenderingContext2D, camera: Camera, state: NodeState, mousePosition: Vector2 | undefined, postProcess: PassSubsystem): void {
1077
+ this.#laidOut = true;
1078
+
1022
1079
  VectorPool.run(() => {
1023
1080
  const tempMeasurement = VectorPool.get();
1024
1081
 
@@ -1220,6 +1277,63 @@ export class FlowNode {
1220
1277
  const message = this.#messages[i];
1221
1278
  messageStart.y += message.render(ctx, camera.zoom, messageStart, !(state === NodeState.Idle)) + (10 * camera.zoom);
1222
1279
  }
1280
+
1281
+ this.#rememberPortOffsets(nodeBounds, camera.zoom);
1223
1282
  })
1224
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
+ return true;
1338
+ }
1225
1339
  }
@@ -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 {
@@ -6,7 +6,8 @@ import { FlowNode, NodeState } from "../node";
6
6
  import { Organize } from "../organize";
7
7
  import { PassSubsystem } from "../pass/subsystem";
8
8
  import { TimeExecution } from "../performance";
9
- import { Port, PortType } from "../port";
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++) {
@@ -312,7 +457,11 @@ export class NodeSubsystem {
312
457
  }
313
458
 
314
459
  if (this.#portHovering === null) {
460
+ const dangling = this.#describeDanglingConnection(this.#connectionSelected);
315
461
  this.#clearCurrentlySelectedConnection();
462
+ if (dangling !== null) {
463
+ this.#onConnectionReleased?.(dangling);
464
+ }
316
465
  return;
317
466
  }
318
467
 
@@ -341,13 +490,13 @@ export class NodeSubsystem {
341
490
  }
342
491
 
343
492
  // Different types, can't connect
344
- if (this.#portHovering.InputPort && this.#portHovering.Port.getDataType() !== conn.outPort()?.getDataType()) {
493
+ if (this.#portHovering.InputPort && !portsCompatible(this.#portHovering.Port, conn.outPort())) {
345
494
  this.#clearCurrentlySelectedConnection();
346
495
  return;
347
496
  }
348
497
 
349
498
  // Different types, can't connect
350
- if (!this.#portHovering.InputPort && this.#portHovering.Port.getDataType() !== conn.inPort()?.getDataType()) {
499
+ if (!this.#portHovering.InputPort && !portsCompatible(this.#portHovering.Port, conn.inPort())) {
351
500
  this.#clearCurrentlySelectedConnection();
352
501
  return;
353
502
  }
@@ -377,10 +526,10 @@ export class NodeSubsystem {
377
526
  }
378
527
 
379
528
  connectNodes(nodeOut: FlowNode, outPort: number, nodeIn: FlowNode, inPort: number): Connection | undefined {
380
- const outType = nodeOut.outputPort(outPort).getDataType();
381
- const inType = nodeIn.inputPort(inPort).getDataType();
382
- if (outType !== inType) {
383
- console.error("can't connect nodes of different types", outType, inType);
529
+ const from = nodeOut.outputPort(outPort);
530
+ const to = nodeIn.inputPort(inPort);
531
+ if (!portsCompatible(from, to)) {
532
+ console.error("can't connect nodes of different types", from.getDataType(), to.getDataType());
384
533
  return;
385
534
  }
386
535
 
@@ -408,6 +557,7 @@ export class NodeSubsystem {
408
557
  this.#idleConnectionRenderer,
409
558
  );
410
559
  this.#connections.push(connection);
560
+ requestRender();
411
561
  return connection;
412
562
  }
413
563
 
@@ -478,6 +628,7 @@ export class NodeSubsystem {
478
628
 
479
629
  addNode(node: FlowNode): void {
480
630
  this.#nodes.push(node);
631
+ requestRender();
481
632
  for (let i = 0; i < this.#registeredNodeAddedCallbacks.length; i++) {
482
633
  const callback = this.#registeredNodeAddedCallbacks[i];
483
634
  if (!callback) {
@@ -531,6 +682,7 @@ export class NodeSubsystem {
531
682
  this.#removeNodeConnections(nodeIndex);
532
683
  const node = this.#nodes[nodeIndex];
533
684
  this.#nodes.splice(nodeIndex, 1);
685
+ requestRender();
534
686
  for (let i = 0; i < this.#nodeRemovedCallbacks.length; i++) {
535
687
  this.#nodeRemovedCallbacks[i](node);
536
688
  }
@@ -541,6 +693,7 @@ export class NodeSubsystem {
541
693
  this.#connections[index].clearPorts();
542
694
  }
543
695
  this.#connections.splice(index, 1);
696
+ requestRender();
544
697
  }
545
698
 
546
699
  #removeConnection(connection: Connection, clearPorts: boolean): void {
@@ -594,7 +747,8 @@ export class NodeSubsystem {
594
747
  ]
595
748
  }
596
749
 
597
- if (this.#nodesSelected().length > 0) {
750
+ const selectedCount = this.#nodesSelected().length;
751
+ if (selectedCount > 0) {
598
752
  organizeNodesSubMenu.items?.push({
599
753
  name: "Selected Nodes",
600
754
  group: nodeFlowGroup,
@@ -602,6 +756,14 @@ export class NodeSubsystem {
602
756
  this.#organizeSelected(ctx)
603
757
  }
604
758
  })
759
+
760
+ config.items?.push({
761
+ name: selectedCount === 1 ? "Delete Selected Node" : `Delete ${selectedCount} Selected Nodes`,
762
+ group: nodeFlowGroup,
763
+ callback: () => {
764
+ this.removeSelectedNodes();
765
+ }
766
+ })
605
767
  }
606
768
 
607
769
  if (this.#nodeHovering > -1) {
@@ -684,6 +846,32 @@ export class NodeSubsystem {
684
846
  }
685
847
  }
686
848
 
849
+ /** Drops the selection entirely. */
850
+ public unselectAllNodes(): void {
851
+ let changed = false;
852
+ for (let i = 0; i < this.#nodes.length; i++) {
853
+ if (this.#nodes[i].selected()) {
854
+ this.#nodes[i].unselect();
855
+ changed = true;
856
+ }
857
+ }
858
+
859
+ if (changed) {
860
+ requestRender();
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Removes every selected node, and the connections that touched them.
866
+ */
867
+ public removeSelectedNodes(): Array<FlowNode> {
868
+ const removed = this.getSelectedNodes();
869
+ for (const node of removed) {
870
+ this.removeNode(node);
871
+ }
872
+ return removed;
873
+ }
874
+
687
875
  #selectNodeByIndex(nodeIndex: number, unselectOthers: boolean): void {
688
876
  this.#selectNode(this.#nodes[nodeIndex], unselectOthers);
689
877
  }
@@ -729,9 +917,22 @@ export class NodeSubsystem {
729
917
 
730
918
  const selectedBox_Screenspace = this.#boxSelectionScreenspaceBox(camera);
731
919
 
920
+ // Nodes outside the canvas are not drawn, but their ports are still
921
+ // placed, because wires to them stay on screen.
922
+ const canvas: Box = {
923
+ Position: { x: 0, y: 0 },
924
+ Size: { x: ctx.canvas.width, y: ctx.canvas.height },
925
+ };
926
+
732
927
  for (let i = 0; i < this.#nodes.length; i++) {
733
928
  let state = NodeState.Idle;
734
929
 
930
+ const nodeBounds = this.#nodes[i].calculateBounds(ctx, camera);
931
+ if (!BoxIntersection(nodeBounds, canvas)
932
+ && this.#nodes[i].layoutPortsWithoutDrawing(nodeBounds, camera.zoom)) {
933
+ continue;
934
+ }
935
+
735
936
  if (mousePosition !== undefined && !this.#boxSelect) {
736
937
  const intersection = this.#nodes[i].inBounds(ctx, camera, mousePosition);
737
938
 
@@ -791,6 +992,11 @@ export class NodeSubsystem {
791
992
  render(ctx: CanvasRenderingContext2D, camera: Camera, mousePosition: Vector2 | undefined): RenderResults | undefined {
792
993
  this.#lastCtx = ctx;
793
994
  this.#cursor = CursorStyle.Default;
995
+
996
+ // Connections draw first but need port positions, which only exist
997
+ // once a node has been drawn so a new node costs one extra frame.
998
+ const awaitingLayout = this.#nodes.some((node) => !node.hasBeenLaidOut());
999
+
794
1000
  TimeExecution("Render_Connections", () => {
795
1001
  this.#renderConnections(ctx, camera, mousePosition);
796
1002
  })
@@ -798,6 +1004,10 @@ export class NodeSubsystem {
798
1004
  this.#renderNodes(ctx, camera, mousePosition);
799
1005
  })
800
1006
 
1007
+ if (awaitingLayout) {
1008
+ requestRender();
1009
+ }
1010
+
801
1011
  if (this.#boxSelect) {
802
1012
  const box = this.#boxSelectionScreenspaceBox(camera);
803
1013
  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)