@elicdavis/node-flow 0.1.17 → 0.1.19

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@elicdavis/node-flow",
3
3
  "description": "build node graphs",
4
4
  "author": "Eli C Davis",
5
- "version": "0.1.17",
5
+ "version": "0.1.19",
6
6
  "license": "MIT",
7
7
  "keywords": [
8
8
  "nodes",
package/src/graph.ts CHANGED
@@ -292,7 +292,7 @@ export class NodeFlowGraph {
292
292
  }
293
293
 
294
294
  #deleteKey(e: KeyboardEvent): boolean {
295
- return e.code === "Delete" || e.code === "Backspace" || e.key === "Delete" || e.key === "Backspace";
295
+ return e.code === "Delete" || e.key === "Delete";
296
296
  }
297
297
 
298
298
  #keyDown(e: KeyboardEvent): void {
@@ -300,6 +300,23 @@ export class NodeFlowGraph {
300
300
  if (["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) return;
301
301
  }
302
302
 
303
+ // Open menus eat input
304
+ if (this.#openQuickMenu) {
305
+ if (e.code === "Escape") {
306
+ this.#openQuickMenu = null;
307
+ return;
308
+ }
309
+
310
+ if (e.code === "Enter") {
311
+ this.#openQuickMenu.Menu.execute();
312
+ this.#openQuickMenu = null;
313
+ return;
314
+ }
315
+
316
+ this.#openQuickMenu.Menu.keyboardEvent(e);
317
+ return
318
+ }
319
+
303
320
  if (this.#deleteKey(e)) {
304
321
  if (this.#mainNodeSubsystem.getSelectedNodes().length > 0) {
305
322
  e.preventDefault();
@@ -331,22 +348,6 @@ export class NodeFlowGraph {
331
348
  }
332
349
  }
333
350
 
334
- if (this.#openQuickMenu) {
335
- if (e.code === "Escape") {
336
- this.#openQuickMenu = null;
337
- return;
338
- }
339
-
340
- if (e.code === "Enter") {
341
- this.#openQuickMenu.Menu.execute();
342
- this.#openQuickMenu = null;
343
- return;
344
- }
345
-
346
- this.#openQuickMenu.Menu.keyboardEvent(e);
347
- return
348
- }
349
-
350
351
  const spacePressed = e.key == " " || e.code == "Space";
351
352
  if (!spacePressed) {
352
353
  return;
@@ -595,6 +596,10 @@ export class NodeFlowGraph {
595
596
  return this.#mainNodeSubsystem.connectNodes(nodeOut, outPort, nodeIn, inPort);
596
597
  }
597
598
 
599
+ clearNodeInputConnection(node: FlowNode, index: number): void {
600
+ this.#mainNodeSubsystem.clearNodeInputConnection(node, index);
601
+ }
602
+
598
603
  addNode(node: FlowNode): void {
599
604
  this.#mainNodeSubsystem.addNode(node);
600
605
  }
package/src/node.ts CHANGED
@@ -1334,6 +1334,9 @@ export class FlowNode {
1334
1334
 
1335
1335
  project(this.#portOffsets.input, this.#inputPortPositions);
1336
1336
  project(this.#portOffsets.output, this.#outputPortPositions);
1337
+
1338
+ this.#widgetPositions.Clear();
1339
+
1337
1340
  return true;
1338
1341
  }
1339
1342
  }
@@ -448,7 +448,9 @@ export class NodeSubsystem {
448
448
  }
449
449
 
450
450
  if (this.#widgetCurrentlyClicking !== null) {
451
- this.#widgetCurrentlyClicking.ClickEnd();
451
+ if (this.#widgetHovering === this.#widgetCurrentlyClicking) {
452
+ this.#widgetCurrentlyClicking.ClickEnd();
453
+ }
452
454
  this.#widgetCurrentlyClicking = null;
453
455
  }
454
456
 
package/src/popup.ts CHANGED
@@ -25,6 +25,8 @@ export class Popup {
25
25
 
26
26
  #popup: HTMLDivElement | null;
27
27
 
28
+ #keyHandler: ((e: KeyboardEvent) => void) | null;
29
+
28
30
  constructor(config: PopupConfig) {
29
31
  this.#title = config.title;
30
32
  this.#options = config.options;
@@ -32,16 +34,28 @@ export class Popup {
32
34
  this.#onClose = config.onClose;
33
35
  this.#buttonCSS = config.buttonCSS === undefined ? "flex-grow: 1; margin-right: 8px;" : config.buttonCSS;
34
36
  this.#popup = null;
37
+ this.#keyHandler = null;
35
38
  }
36
39
 
37
40
  Hide(): void {
38
41
  if (this.#popup === null) {
39
42
  return;
40
43
  }
44
+ if (this.#keyHandler !== null) {
45
+ document.removeEventListener("keydown", this.#keyHandler, true);
46
+ this.#keyHandler = null;
47
+ }
41
48
  document.body.removeChild(this.#popup);
42
49
  this.#popup = null;
43
50
  }
44
51
 
52
+ #close(option: string | null): void {
53
+ this.Hide();
54
+ if (this.#onClose !== undefined) {
55
+ this.#onClose(option);
56
+ }
57
+ }
58
+
45
59
  Show(): void {
46
60
  this.#popup = document.createElement('div');
47
61
  this.#popup.style.cssText = 'position:absolute;top:0;width:100%;display:flex;height:100%;justify-content:center;align-items:center;background-color:#00000070;';
@@ -68,14 +82,21 @@ export class Popup {
68
82
  const button = document.createElement("button");
69
83
  button.style.cssText = this.#buttonCSS;
70
84
  button.textContent = this.#options[i];
71
- button.onclick = () => {
72
- this.Hide();
73
- if (this.#onClose !== undefined) {
74
- this.#onClose(this.#options[i]);
75
- }
76
- };
85
+ button.onclick = () => this.#close(this.#options[i]);
77
86
  buttonContainer.appendChild(button);
78
87
  }
88
+
89
+ this.#keyHandler = (e: KeyboardEvent): void => {
90
+ if (e.key === "Enter") {
91
+ e.preventDefault();
92
+ this.#close(this.#options.length > 0 ? this.#options[0] : null);
93
+ } else if (e.key === "Escape") {
94
+ e.preventDefault();
95
+ this.#close(null);
96
+ }
97
+ e.stopPropagation();
98
+ };
99
+ document.addEventListener("keydown", this.#keyHandler, true);
79
100
  }
80
101
 
81
102
  }
package/src/theme.ts CHANGED
@@ -60,7 +60,7 @@ export const Theme = {
60
60
  DisabledFontColor: "#4a585c",
61
61
  },
62
62
  Minimap: {
63
- BackgroundColor: "rgba(7, 33, 42, 0.85)",
63
+ BackgroundColor: "rgba(10, 16, 18, 0.85)",
64
64
  BorderColor: "#1c1c1c",
65
65
  BorderRadius: 4,
66
66
  Padding: 6,
@@ -62,9 +62,10 @@ export class NumberWidget {
62
62
  },
63
63
  text: { color: Theme.Widget.FontColor },
64
64
  }));
65
- this.#callback = config?.callback;
66
65
  this.Set(startingWidgetValue(node, this.#nodeProperty, config?.value, 0));
67
66
 
67
+ this.#callback = config?.callback;
68
+
68
69
  if (this.#nodeProperty !== undefined) {
69
70
  this.#node.addPropertyChangeListener(this.#nodeProperty, (oldVal, newVal) => {
70
71
  this.Set(newVal);
@@ -60,8 +60,10 @@ export class StringWidget {
60
60
  text: { color: Theme.Widget.FontColor },
61
61
  }));
62
62
 
63
- this.#callback = config?.callback;
64
63
  this.Set(startingWidgetValue(node, this.#nodeProperty, config?.value, ""));
64
+
65
+ this.#callback = config?.callback;
66
+
65
67
  if (this.#nodeProperty !== undefined) {
66
68
  this.#node.addPropertyChangeListener(this.#nodeProperty, (oldVal, newVal) => {
67
69
  this.Set(newVal);
@@ -159,8 +159,10 @@ export class ToggleWidget {
159
159
  lightColor: config?.disabledStyle?.lightColor === undefined ? "#004400" : config?.enabledStyle?.lightColor,
160
160
  });
161
161
 
162
- this.#callback = config?.callback;
163
162
  this.Set(startingWidgetValue(node, this.#nodeProperty, config?.value, false));
163
+
164
+ this.#callback = config?.callback;
165
+
164
166
  if (this.#nodeProperty !== undefined) {
165
167
  this.#node.addPropertyChangeListener(this.#nodeProperty, (oldVal, newVal) => {
166
168
  this.Set(newVal);
@@ -0,0 +1,104 @@
1
+ import { strict as assert } from 'assert';
2
+ import { Popup } from '../src/popup';
3
+
4
+ interface StubElement {
5
+ tagName: string;
6
+ style: { cssText: string };
7
+ textContent: string;
8
+ children: Array<StubElement>;
9
+ onclick?: () => void;
10
+ appendChild(child: StubElement): StubElement;
11
+ append(child: StubElement): void;
12
+ removeChild(child: StubElement): void;
13
+ }
14
+
15
+ function element(tagName: string): StubElement {
16
+ return {
17
+ tagName,
18
+ style: { cssText: '' },
19
+ textContent: '',
20
+ children: [],
21
+ appendChild(child) { this.children.push(child); return child; },
22
+ append(child) { this.children.push(child); },
23
+ removeChild(child) { this.children = this.children.filter((c) => c !== child); },
24
+ };
25
+ }
26
+
27
+ function withDocument(run: (fire: (key: string) => void, body: StubElement) => void): void {
28
+ const listeners: Array<(e: unknown) => void> = [];
29
+ const body = element('body');
30
+
31
+ const g = globalThis as any;
32
+ const previous = g.document;
33
+ g.document = {
34
+ body,
35
+ createElement: element,
36
+ addEventListener: (_: string, fn: (e: unknown) => void) => listeners.push(fn),
37
+ removeEventListener: (_: string, fn: (e: unknown) => void) => {
38
+ const at = listeners.indexOf(fn);
39
+ if (at >= 0) listeners.splice(at, 1);
40
+ },
41
+ };
42
+
43
+ const fire = (key: string) => {
44
+ const event = { key, preventDefault() {}, stopPropagation() {} };
45
+ [...listeners].forEach((fn) => fn(event));
46
+ };
47
+
48
+ try {
49
+ run(fire, body);
50
+ } finally {
51
+ g.document = previous;
52
+ }
53
+ }
54
+
55
+ describe('popup', () => {
56
+ it('confirms with the first option when enter is pressed', () => {
57
+ withDocument((fire) => {
58
+ let closedWith: string | null | undefined;
59
+ new Popup({
60
+ title: 'Set Number',
61
+ options: ['Set', 'Cancel'],
62
+ onClose: (button) => { closedWith = button; },
63
+ }).Show();
64
+
65
+ fire('Enter');
66
+
67
+ assert.equal(closedWith, 'Set', 'enter has to commit, not discard what was typed');
68
+ });
69
+ });
70
+
71
+ it('cancels when escape is pressed', () => {
72
+ withDocument((fire) => {
73
+ let called = false;
74
+ let closedWith: string | null | undefined;
75
+ new Popup({
76
+ title: 'Set Number',
77
+ options: ['Set', 'Cancel'],
78
+ onClose: (button) => { called = true; closedWith = button; },
79
+ }).Show();
80
+
81
+ fire('Escape');
82
+
83
+ assert.equal(called, true);
84
+ assert.equal(closedWith, null);
85
+ });
86
+ });
87
+
88
+ it('stops listening once it has closed', () => {
89
+ withDocument((fire, body) => {
90
+ let closes = 0;
91
+ new Popup({
92
+ title: 'Set Number',
93
+ options: ['Set'],
94
+ onClose: () => { closes++; },
95
+ }).Show();
96
+
97
+ fire('Enter');
98
+ fire('Enter');
99
+
100
+ assert.equal(closes, 1);
101
+ assert.equal(body.children.length, 0, 'and takes itself off the page');
102
+ });
103
+ });
104
+ });
@@ -0,0 +1,126 @@
1
+ import { strict as assert } from 'assert';
2
+ import { NodeSubsystem } from '../src/nodes/subsystem';
3
+ import { PassSubsystem } from '../src/pass/subsystem';
4
+ import { FlowNode } from '../src/node';
5
+ import { Camera } from '../src/camera';
6
+ import { Box } from '../src/types/box';
7
+ import { Vector2 } from '../src/types/vector2';
8
+
9
+ function stubCtx(): CanvasRenderingContext2D {
10
+ const ctx: any = {
11
+ canvas: { width: 800, height: 600 },
12
+ lineWidth: 1,
13
+ font: '',
14
+ textAlign: 'left',
15
+ textBaseline: 'top',
16
+ fillStyle: '',
17
+ strokeStyle: '',
18
+ globalAlpha: 1,
19
+ measureText: () => ({ width: 40 }),
20
+ save: () => { }, restore: () => { }, translate: () => { }, scale: () => { },
21
+ beginPath: () => { }, closePath: () => { }, roundRect: () => { }, rect: () => { },
22
+ moveTo: () => { }, lineTo: () => { }, arc: () => { }, ellipse: () => { },
23
+ bezierCurveTo: () => { }, quadraticCurveTo: () => { },
24
+ fill: () => { }, stroke: () => { }, fillText: () => { }, strokeText: () => { },
25
+ clip: () => { }, drawImage: () => { }, setLineDash: () => { },
26
+ createLinearGradient: () => ({ addColorStop: () => { } }),
27
+ };
28
+ return ctx as CanvasRenderingContext2D;
29
+ }
30
+
31
+ function recordingWidget() {
32
+ let clicks = 0;
33
+ let drawnAt: Box | null = null;
34
+ const widget = {
35
+ Size: (): Vector2 => ({ x: 100, y: 20 }),
36
+ Draw: (_ctx: CanvasRenderingContext2D, position: Vector2, scale: number): Box => {
37
+ drawnAt = { Position: { x: position.x, y: position.y }, Size: { x: 100 * scale, y: 20 * scale } };
38
+ return drawnAt;
39
+ },
40
+ ClickStart: (): void => { },
41
+ ClickEnd: (): void => { clicks++; },
42
+ };
43
+ return {
44
+ widget,
45
+ clicks: () => clicks,
46
+ center: (): Vector2 => {
47
+ assert.ok(drawnAt !== null, 'widget was never drawn');
48
+ const b = drawnAt as Box;
49
+ return { x: b.Position.x + b.Size.x / 2, y: b.Position.y + b.Size.y / 2 };
50
+ },
51
+ };
52
+ }
53
+
54
+ function scene() {
55
+ const graph = new NodeSubsystem(new PassSubsystem());
56
+ const node = new FlowNode({ title: 'test', position: { x: 100, y: 100 } });
57
+ const rec = recordingWidget();
58
+ node.addWidget(rec.widget as any);
59
+ graph.addNode(node);
60
+
61
+ const camera = new Camera();
62
+ const ctx = stubCtx();
63
+ return { graph, camera, ctx, rec };
64
+ }
65
+
66
+ describe('clicking a widget', () => {
67
+ it('opens it when the press and release are both on it', () => {
68
+ const { graph, camera, ctx, rec } = scene();
69
+ graph.render(ctx, camera, undefined);
70
+ const on = rec.center();
71
+
72
+ graph.render(ctx, camera, on);
73
+ graph.clickStart(on, camera, false);
74
+ graph.render(ctx, camera, on);
75
+ graph.clickEnd();
76
+
77
+ assert.equal(rec.clicks(), 1);
78
+ });
79
+
80
+ it('leaves it alone when the gesture drags away before releasing', () => {
81
+ const { graph, camera, ctx, rec } = scene();
82
+ graph.render(ctx, camera, undefined);
83
+ const on = rec.center();
84
+
85
+ graph.render(ctx, camera, on);
86
+ graph.clickStart(on, camera, false);
87
+ graph.render(ctx, camera, { x: on.x + 400, y: on.y + 300 });
88
+ graph.clickEnd();
89
+
90
+ assert.equal(rec.clicks(), 0);
91
+ });
92
+ });
93
+
94
+ describe('a node panned off screen', () => {
95
+ it('stops hit-testing as a widget where it used to be drawn', () => {
96
+ const graph = new NodeSubsystem(new PassSubsystem());
97
+ const node = new FlowNode({
98
+ title: 'test',
99
+ position: { x: 100, y: 100 },
100
+ inputs: [{ name: 'In', type: 'float64' }],
101
+ });
102
+ const rec = recordingWidget();
103
+ node.addWidget(rec.widget as any);
104
+ graph.addNode(node);
105
+
106
+ const camera = new Camera();
107
+ const ctx = stubCtx();
108
+
109
+ graph.render(ctx, camera, undefined);
110
+ const on = rec.center();
111
+
112
+ camera.position.x = -50000;
113
+ graph.render(ctx, camera, undefined);
114
+
115
+ assert.equal(
116
+ node.inBounds(ctx, camera, on).Widget,
117
+ undefined,
118
+ 'a node that was not drawn has no widget under any screen point',
119
+ );
120
+
121
+ graph.clickStart(on, camera, false);
122
+ graph.clickEnd();
123
+
124
+ assert.equal(rec.clicks(), 0);
125
+ });
126
+ });
@@ -0,0 +1,48 @@
1
+ import { strict as assert } from 'assert';
2
+ import { NumberWidget } from '../src/widgets/number';
3
+ import { StringWidget } from '../src/widgets/string';
4
+ import { ToggleWidget } from '../src/widgets/toggle';
5
+ import { SliderWidget } from '../src/widgets/slider';
6
+ import { FlowNode } from '../src/node';
7
+
8
+ function stubNode(): FlowNode {
9
+ const properties = new Map<string, unknown>();
10
+ return {
11
+ getProperty: (name: string) => properties.get(name),
12
+ setProperty: (name: string, value: unknown) => { properties.set(name, value); },
13
+ addPropertyChangeListener: () => { },
14
+ } as unknown as FlowNode;
15
+ }
16
+
17
+ describe('widget construction', () => {
18
+ it('does not fire a number callback for its initial value', () => {
19
+ let fired = 0;
20
+ new NumberWidget(stubNode(), { value: 42, callback: () => { fired++; } });
21
+ assert.equal(fired, 0);
22
+ });
23
+
24
+ it('does not fire a string callback for its initial value', () => {
25
+ let fired = 0;
26
+ new StringWidget(stubNode(), { value: 'hello', callback: () => { fired++; } });
27
+ assert.equal(fired, 0);
28
+ });
29
+
30
+ it('does not fire a toggle callback for its initial value', () => {
31
+ let fired = 0;
32
+ new ToggleWidget(stubNode(), { value: true, callback: () => { fired++; } });
33
+ assert.equal(fired, 0);
34
+ });
35
+
36
+ it('does not fire a slider callback for its initial value', () => {
37
+ let fired = 0;
38
+ new SliderWidget(stubNode(), { value: 7, min: 0, max: 10, change: () => { fired++; } });
39
+ assert.equal(fired, 0);
40
+ });
41
+
42
+ it('still fires the number callback on a real change', () => {
43
+ const values: Array<number> = [];
44
+ const widget = new NumberWidget(stubNode(), { value: 42, callback: (v) => { values.push(v); } });
45
+ widget.Set(43);
46
+ assert.deepEqual(values, [43]);
47
+ });
48
+ });