@elicdavis/node-flow 0.1.16 → 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.
- package/dist/index.d.mts +97 -4
- package/dist/index.d.ts +97 -4
- package/dist/index.js +643 -69
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +637 -68
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/graph.ts +234 -41
- package/src/index.ts +16 -0
- package/src/input.ts +22 -14
- package/src/markdown/entry.ts +6 -2
- package/src/minimap.ts +178 -0
- package/src/node.ts +80 -1
- package/src/nodes/factory.ts +9 -5
- package/src/nodes/publisher.ts +39 -4
- package/src/nodes/subsystem.ts +211 -1
- package/src/notes/subsystem.ts +3 -0
- package/src/port.ts +17 -4
- package/src/render_scheduler.ts +49 -0
- package/src/theme.ts +8 -0
- package/src/types/text.ts +2 -0
- package/src/widgets/image.ts +2 -0
- package/test/minimap.test.ts +103 -0
- package/test/node_menu_filter.test.ts +119 -0
- package/test/render_scheduler.test.ts +119 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
type Redraw = () => void;
|
|
2
|
+
|
|
3
|
+
const subscribers = new Set<Redraw>();
|
|
4
|
+
let frameQueued = false;
|
|
5
|
+
let continuous = 0;
|
|
6
|
+
|
|
7
|
+
const canSchedule = (): boolean =>
|
|
8
|
+
typeof requestAnimationFrame === "function";
|
|
9
|
+
|
|
10
|
+
export function onRenderRequested(redraw: Redraw): () => void {
|
|
11
|
+
subscribers.add(redraw);
|
|
12
|
+
return () => {
|
|
13
|
+
subscribers.delete(redraw);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
export function requestRender(): void {
|
|
19
|
+
if (frameQueued || !canSchedule()) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
frameQueued = true;
|
|
24
|
+
requestAnimationFrame(() => {
|
|
25
|
+
frameQueued = false;
|
|
26
|
+
subscribers.forEach((redraw) => redraw());
|
|
27
|
+
|
|
28
|
+
// Something asked to be redrawn every frame, so keep the loop
|
|
29
|
+
// alive by asking again on its behalf.
|
|
30
|
+
if (continuous > 0) {
|
|
31
|
+
requestRender();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
export function renderContinuously(): () => void {
|
|
38
|
+
continuous++;
|
|
39
|
+
requestRender();
|
|
40
|
+
|
|
41
|
+
let released = false;
|
|
42
|
+
return () => {
|
|
43
|
+
if (released) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
released = true;
|
|
47
|
+
continuous--;
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/theme.ts
CHANGED
|
@@ -59,6 +59,14 @@ export const Theme = {
|
|
|
59
59
|
FontColor: "#afb9bb",
|
|
60
60
|
DisabledFontColor: "#4a585c",
|
|
61
61
|
},
|
|
62
|
+
Minimap: {
|
|
63
|
+
BackgroundColor: "rgba(7, 33, 42, 0.85)",
|
|
64
|
+
BorderColor: "#1c1c1c",
|
|
65
|
+
BorderRadius: 4,
|
|
66
|
+
Padding: 6,
|
|
67
|
+
NodeColor: "#6b7f85",
|
|
68
|
+
ViewportColor: "rgba(175, 185, 187, 0.25)",
|
|
69
|
+
},
|
|
62
70
|
Note: {
|
|
63
71
|
FontColor: "#afb9bb",
|
|
64
72
|
FontSize: 16,
|
package/src/types/text.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requestRender } from "../render_scheduler";
|
|
1
2
|
import { FontWeight, TextStyle, TextStyleConfig } from "../styles/text";
|
|
2
3
|
import { splitString, splitStringIntoLines } from "../utils/string";
|
|
3
4
|
import { CopyVector2, ScaleVector, Vector2, Zero } from "./vector2";
|
|
@@ -36,6 +37,7 @@ export class Text {
|
|
|
36
37
|
if (!document.fonts.check(`16px "${this.#style.getFont()}"`)) {
|
|
37
38
|
document.fonts.addEventListener("loadingdone", () => {
|
|
38
39
|
this.#measured = false;
|
|
40
|
+
requestRender();
|
|
39
41
|
});
|
|
40
42
|
}
|
|
41
43
|
}
|
package/src/widgets/image.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requestRender } from "../render_scheduler";
|
|
1
2
|
import { Box } from "../types/box";
|
|
2
3
|
import { CopyVector2, Vector2 } from "../types/vector2";
|
|
3
4
|
|
|
@@ -101,6 +102,7 @@ export class ImageWidget {
|
|
|
101
102
|
img.src = url;
|
|
102
103
|
img.onload = () => {
|
|
103
104
|
this.#image = img;
|
|
105
|
+
requestRender();
|
|
104
106
|
};
|
|
105
107
|
img.onerror = (event) => {
|
|
106
108
|
console.log("error loading image:", url, event);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as assert from 'assert';
|
|
2
|
+
import { Camera } from '../src/camera';
|
|
3
|
+
import { Minimap } from '../src/minimap';
|
|
4
|
+
import { FlowNode } from '../src/node';
|
|
5
|
+
import { Theme } from '../src/theme';
|
|
6
|
+
|
|
7
|
+
/** Records the fill and stroke colors a render asked for, in order. */
|
|
8
|
+
function recordingContext(): { ctx: any, fills: Array<string>, strokes: Array<string> } {
|
|
9
|
+
const fills: Array<string> = [];
|
|
10
|
+
const strokes: Array<string> = [];
|
|
11
|
+
|
|
12
|
+
const ctx: any = {
|
|
13
|
+
lineWidth: 1,
|
|
14
|
+
font: "",
|
|
15
|
+
textAlign: "left",
|
|
16
|
+
textBaseline: "top",
|
|
17
|
+
set fillStyle(value: string) { fills.push(value); },
|
|
18
|
+
get fillStyle() { return fills[fills.length - 1]; },
|
|
19
|
+
set strokeStyle(value: string) { strokes.push(value); },
|
|
20
|
+
get strokeStyle() { return strokes[strokes.length - 1]; },
|
|
21
|
+
measureText: () => ({ width: 40 }),
|
|
22
|
+
beginPath: () => { },
|
|
23
|
+
closePath: () => { },
|
|
24
|
+
roundRect: () => { },
|
|
25
|
+
rect: () => { },
|
|
26
|
+
moveTo: () => { },
|
|
27
|
+
lineTo: () => { },
|
|
28
|
+
arc: () => { },
|
|
29
|
+
bezierCurveTo: () => { },
|
|
30
|
+
quadraticCurveTo: () => { },
|
|
31
|
+
fill: () => { },
|
|
32
|
+
stroke: () => { },
|
|
33
|
+
fillRect: () => { },
|
|
34
|
+
strokeRect: () => { },
|
|
35
|
+
fillText: () => { },
|
|
36
|
+
save: () => { },
|
|
37
|
+
restore: () => { },
|
|
38
|
+
translate: () => { },
|
|
39
|
+
scale: () => { },
|
|
40
|
+
setLineDash: () => { },
|
|
41
|
+
clip: () => { },
|
|
42
|
+
createLinearGradient: () => ({ addColorStop: () => { } }),
|
|
43
|
+
drawImage: () => { },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return { ctx, fills, strokes };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function render(minimap: Minimap) {
|
|
50
|
+
const { ctx, fills, strokes } = recordingContext();
|
|
51
|
+
const canvas: any = { width: 800, height: 600 };
|
|
52
|
+
const node = new FlowNode({ title: "node", position: { x: 0, y: 0 } });
|
|
53
|
+
minimap.render(ctx, canvas, new Camera(), [node]);
|
|
54
|
+
return { fills, strokes };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('Minimap theming', () => {
|
|
58
|
+
const original = { ...Theme.Minimap };
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
Object.assign(Theme.Minimap, original);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('draws with the colors the theme carries', () => {
|
|
65
|
+
const minimap = new Minimap({ enabled: true });
|
|
66
|
+
|
|
67
|
+
const { fills, strokes } = render(minimap);
|
|
68
|
+
|
|
69
|
+
assert.ok(fills.includes(Theme.Minimap.BackgroundColor), "panel uses the theme background");
|
|
70
|
+
assert.ok(fills.includes(Theme.Minimap.NodeColor), "nodes use the theme node color");
|
|
71
|
+
assert.ok(fills.includes(Theme.Minimap.ViewportColor), "viewport uses the theme viewport color");
|
|
72
|
+
assert.ok(strokes.includes(Theme.Minimap.BorderColor), "border uses the theme border color");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// The whole point of moving these onto Theme: retheming the app restyles a
|
|
76
|
+
// minimap that is already on screen, without rebuilding it.
|
|
77
|
+
it('follows a theme change made after it was built', () => {
|
|
78
|
+
const minimap = new Minimap({ enabled: true });
|
|
79
|
+
render(minimap);
|
|
80
|
+
|
|
81
|
+
Theme.Minimap.BackgroundColor = "#ff0000";
|
|
82
|
+
Theme.Minimap.NodeColor = "#00ff00";
|
|
83
|
+
Theme.Minimap.ViewportColor = "#0000ff";
|
|
84
|
+
Theme.Minimap.BorderColor = "#ffff00";
|
|
85
|
+
|
|
86
|
+
const { fills, strokes } = render(minimap);
|
|
87
|
+
|
|
88
|
+
assert.ok(fills.includes("#ff0000"));
|
|
89
|
+
assert.ok(fills.includes("#00ff00"));
|
|
90
|
+
assert.ok(fills.includes("#0000ff"));
|
|
91
|
+
assert.ok(strokes.includes("#ffff00"));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('lets one minimap override the theme without changing it for the rest', () => {
|
|
95
|
+
const minimap = new Minimap({ enabled: true, backgroundColor: "#123456" });
|
|
96
|
+
|
|
97
|
+
const { fills } = render(minimap);
|
|
98
|
+
|
|
99
|
+
assert.ok(fills.includes("#123456"), "the override wins");
|
|
100
|
+
assert.ok(!fills.includes(Theme.Minimap.BackgroundColor), "the theme value is not also drawn");
|
|
101
|
+
assert.ok(fills.includes(Theme.Minimap.NodeColor), "ports left unset still follow the theme");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as assert from 'assert';
|
|
2
|
+
import { PortType, portConfigTypes, typeSetsCompatible } from '../src/port';
|
|
3
|
+
import { Publisher } from '../src/nodes/publisher';
|
|
4
|
+
import { ContextMenuConfig, ContextMenuItemConfig } from '../src/contextMenu';
|
|
5
|
+
|
|
6
|
+
function itemNames(menu: ContextMenuConfig): Array<string> {
|
|
7
|
+
const names: Array<string> = [];
|
|
8
|
+
|
|
9
|
+
const walk = (m: ContextMenuConfig) => {
|
|
10
|
+
(m.items ?? []).forEach((i: ContextMenuItemConfig) => names.push(i.name ?? ''));
|
|
11
|
+
(m.subMenus ?? []).forEach(walk);
|
|
12
|
+
};
|
|
13
|
+
walk(menu);
|
|
14
|
+
|
|
15
|
+
return names.sort();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const publisher = () => new Publisher({
|
|
19
|
+
name: 'test',
|
|
20
|
+
nodes: {
|
|
21
|
+
'Math/Add': {
|
|
22
|
+
inputs: [{ name: 'a', type: 'float64' }, { name: 'b', type: 'float64' }],
|
|
23
|
+
outputs: [{ name: 'sum', type: 'float64' }],
|
|
24
|
+
},
|
|
25
|
+
'Mesh/Cube': {
|
|
26
|
+
outputs: [{ name: 'out', type: 'mesh' }],
|
|
27
|
+
},
|
|
28
|
+
'Mesh/Translate': {
|
|
29
|
+
inputs: [{ name: 'mesh', type: 'mesh' }],
|
|
30
|
+
outputs: [{ name: 'out', type: 'mesh' }],
|
|
31
|
+
},
|
|
32
|
+
'Logic/Select': {
|
|
33
|
+
inputs: [{ name: 'a', type: 'T', anyType: true }],
|
|
34
|
+
outputs: [{ name: 'out', type: 'T', anyType: true }],
|
|
35
|
+
},
|
|
36
|
+
'Lifted/Negate': {
|
|
37
|
+
inputs: [{ name: 'in', type: 'float64', acceptedTypes: ['float64', '[]float64'] }],
|
|
38
|
+
outputs: [{ name: 'out', type: 'float64' }],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('type set matching', () => {
|
|
44
|
+
it('overlapping sets match', () => {
|
|
45
|
+
assert.ok(typeSetsCompatible(['mesh'], ['mesh']));
|
|
46
|
+
assert.ok(typeSetsCompatible(['float64', '[]float64'], ['[]float64']));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('disjoint sets do not', () => {
|
|
50
|
+
assert.ok(!typeSetsCompatible(['mesh'], ['float64']));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('an empty set takes anything, from either side', () => {
|
|
54
|
+
assert.ok(typeSetsCompatible([], ['mesh']));
|
|
55
|
+
assert.ok(typeSetsCompatible(['mesh'], []));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('reads a port config the same way a built port reports itself', () => {
|
|
59
|
+
assert.deepStrictEqual(portConfigTypes({ type: 'mesh' }), ['mesh']);
|
|
60
|
+
assert.deepStrictEqual(portConfigTypes({ type: 'T', anyType: true }), []);
|
|
61
|
+
assert.deepStrictEqual(
|
|
62
|
+
portConfigTypes({ type: 'float64', acceptedTypes: ['float64', '[]float64'] }),
|
|
63
|
+
['float64', '[]float64'],
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('filtering the new node menu to what could finish a wire', () => {
|
|
69
|
+
const graph = {} as any;
|
|
70
|
+
const at = { x: 0, y: 0 };
|
|
71
|
+
|
|
72
|
+
it('offers everything when nothing is being dragged', () => {
|
|
73
|
+
const names = itemNames(publisher().contextMenu(graph, at));
|
|
74
|
+
assert.deepStrictEqual(names, ['Add', 'Cube', 'Negate', 'Select', 'Translate']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('dragging a mesh output offers only nodes taking a mesh', () => {
|
|
78
|
+
const names = itemNames(publisher().contextMenu(graph, at, {
|
|
79
|
+
needs: PortType.Input,
|
|
80
|
+
types: ['mesh'],
|
|
81
|
+
}));
|
|
82
|
+
// Select takes anything, so it fits too. Cube has no inputs at all.
|
|
83
|
+
assert.deepStrictEqual(names, ['Select', 'Translate']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('dragging a mesh input offers only nodes producing a mesh', () => {
|
|
87
|
+
const names = itemNames(publisher().contextMenu(graph, at, {
|
|
88
|
+
needs: PortType.Output,
|
|
89
|
+
types: ['mesh'],
|
|
90
|
+
}));
|
|
91
|
+
assert.deepStrictEqual(names, ['Cube', 'Select', 'Translate']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('matches a lifted port on either of the types it takes', () => {
|
|
95
|
+
const names = itemNames(publisher().contextMenu(graph, at, {
|
|
96
|
+
needs: PortType.Input,
|
|
97
|
+
types: ['[]float64'],
|
|
98
|
+
}));
|
|
99
|
+
assert.deepStrictEqual(names, ['Negate', 'Select'],
|
|
100
|
+
'Add only takes a single float64, so it is left out');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('an any-type drag offers everything with a port on that side', () => {
|
|
104
|
+
const names = itemNames(publisher().contextMenu(graph, at, {
|
|
105
|
+
needs: PortType.Input,
|
|
106
|
+
types: [],
|
|
107
|
+
}));
|
|
108
|
+
assert.deepStrictEqual(names, ['Add', 'Negate', 'Select', 'Translate']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('drops submenus that matched nothing', () => {
|
|
112
|
+
const menu = publisher().contextMenu(graph, at, {
|
|
113
|
+
needs: PortType.Output,
|
|
114
|
+
types: ['mesh'],
|
|
115
|
+
});
|
|
116
|
+
const groups = (menu.subMenus ?? []).map((m) => m.name).sort();
|
|
117
|
+
assert.ok(!groups.includes('Math'), `Math should be gone, got ${groups}`);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as assert from 'assert';
|
|
2
|
+
import { onRenderRequested, renderContinuously, requestRender } from '../src/render_scheduler';
|
|
3
|
+
|
|
4
|
+
// The scheduler reaches for the global requestAnimationFrame, so the test
|
|
5
|
+
// supplies one it can step by hand.
|
|
6
|
+
let pending: Array<() => void> = [];
|
|
7
|
+
|
|
8
|
+
function flush(): number {
|
|
9
|
+
const frame = pending;
|
|
10
|
+
pending = [];
|
|
11
|
+
frame.forEach((cb) => cb());
|
|
12
|
+
return frame.length;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('render scheduler', () => {
|
|
16
|
+
let unsubscribes: Array<() => void> = [];
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
pending = [];
|
|
20
|
+
(globalThis as any).requestAnimationFrame = (cb: () => void) => {
|
|
21
|
+
pending.push(cb);
|
|
22
|
+
return pending.length;
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
unsubscribes.forEach((stop) => stop());
|
|
28
|
+
unsubscribes = [];
|
|
29
|
+
pending = [];
|
|
30
|
+
delete (globalThis as any).requestAnimationFrame;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function subscribe(redraw: () => void): void {
|
|
34
|
+
unsubscribes.push(onRenderRequested(redraw));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it('redraws once however many times it was asked', () => {
|
|
38
|
+
let draws = 0;
|
|
39
|
+
subscribe(() => { draws++; });
|
|
40
|
+
|
|
41
|
+
requestRender();
|
|
42
|
+
requestRender();
|
|
43
|
+
requestRender();
|
|
44
|
+
|
|
45
|
+
assert.strictEqual(pending.length, 1, 'only one frame is ever queued');
|
|
46
|
+
flush();
|
|
47
|
+
assert.strictEqual(draws, 1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does not redraw when nothing asked', () => {
|
|
51
|
+
let draws = 0;
|
|
52
|
+
subscribe(() => { draws++; });
|
|
53
|
+
|
|
54
|
+
flush();
|
|
55
|
+
assert.strictEqual(draws, 0, 'this is the whole point: idle costs nothing');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('can be asked again after a frame has gone out', () => {
|
|
59
|
+
let draws = 0;
|
|
60
|
+
subscribe(() => { draws++; });
|
|
61
|
+
|
|
62
|
+
requestRender();
|
|
63
|
+
flush();
|
|
64
|
+
requestRender();
|
|
65
|
+
flush();
|
|
66
|
+
|
|
67
|
+
assert.strictEqual(draws, 2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('redraws every canvas that is listening', () => {
|
|
71
|
+
let a = 0, b = 0;
|
|
72
|
+
subscribe(() => { a++; });
|
|
73
|
+
subscribe(() => { b++; });
|
|
74
|
+
|
|
75
|
+
requestRender();
|
|
76
|
+
flush();
|
|
77
|
+
|
|
78
|
+
assert.strictEqual(a, 1);
|
|
79
|
+
assert.strictEqual(b, 1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('stops redrawing one that unsubscribed', () => {
|
|
83
|
+
let draws = 0;
|
|
84
|
+
const stop = onRenderRequested(() => { draws++; });
|
|
85
|
+
stop();
|
|
86
|
+
|
|
87
|
+
requestRender();
|
|
88
|
+
flush();
|
|
89
|
+
|
|
90
|
+
assert.strictEqual(draws, 0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps asking for frames while held continuous, and stops after', () => {
|
|
94
|
+
let draws = 0;
|
|
95
|
+
subscribe(() => { draws++; });
|
|
96
|
+
|
|
97
|
+
const release = renderContinuously();
|
|
98
|
+
flush();
|
|
99
|
+
flush();
|
|
100
|
+
flush();
|
|
101
|
+
assert.ok(draws >= 3, `expected a frame every flush, got ${draws}`);
|
|
102
|
+
|
|
103
|
+
release();
|
|
104
|
+
|
|
105
|
+
// The frame queued before releasing still lands, which is what
|
|
106
|
+
// draws the final state. Nothing is queued behind it.
|
|
107
|
+
const after = draws;
|
|
108
|
+
flush();
|
|
109
|
+
assert.strictEqual(draws, after + 1, 'the already queued frame still lands');
|
|
110
|
+
flush();
|
|
111
|
+
flush();
|
|
112
|
+
assert.strictEqual(draws, after + 1, 'and then it goes quiet again');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('survives having no requestAnimationFrame at all', () => {
|
|
116
|
+
delete (globalThis as any).requestAnimationFrame;
|
|
117
|
+
assert.doesNotThrow(() => requestRender());
|
|
118
|
+
});
|
|
119
|
+
});
|