@elicdavis/node-flow 0.1.19 → 0.2.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.
- package/dist/index.d.mts +386 -227
- package/dist/index.d.ts +386 -227
- package/dist/index.js +1884 -1076
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1867 -1075
- package/dist/index.mjs.map +1 -1
- package/index.html +1134 -329
- package/package.json +1 -1
- package/src/connection.ts +30 -5
- package/src/contextMenu.ts +66 -34
- package/src/graph.ts +12 -10
- package/src/index.ts +26 -12
- package/src/markdown/entry.ts +11 -15
- package/src/markdown/syntaxParser.ts +2 -1
- package/src/node.ts +162 -111
- package/src/nodes/subsystem.ts +123 -29
- package/src/notes/note.ts +9 -1
- package/src/organize.ts +540 -111
- package/src/popup.ts +4 -2
- package/src/port.ts +83 -6
- package/src/styles/bevel.ts +31 -0
- package/src/styles/cursor.ts +1 -0
- package/src/styles/text.ts +4 -4
- package/src/theme.ts +179 -92
- package/src/types/text.ts +5 -1
- package/src/utils/color.ts +1 -1
- package/src/widgets/button.ts +52 -107
- package/src/widgets/color.ts +91 -150
- package/src/widgets/factory.ts +89 -46
- package/src/widgets/field.ts +99 -0
- package/src/widgets/image.ts +107 -165
- package/src/widgets/inplace.ts +84 -0
- package/src/widgets/number.ts +122 -154
- package/src/widgets/row.ts +138 -0
- package/src/widgets/slider.ts +101 -216
- package/src/widgets/string.ts +39 -130
- package/src/widgets/text.ts +81 -92
- package/src/widgets/toggle.ts +62 -207
- package/src/widgets/value.ts +127 -0
- package/src/widgets/widget.ts +180 -34
- package/test/context_menu.test.ts +82 -0
- package/test/cursor.test.ts +17 -0
- package/test/hidden_port.test.ts +125 -0
- package/test/hover_after_removal.test.ts +72 -0
- package/test/markdown.test.ts +2 -2
- package/test/markdown_code_block.test.ts +58 -0
- package/test/multi_input.test.ts +124 -0
- package/test/organize.test.ts +117 -1
- package/test/property_binding.test.ts +49 -0
- package/test/support.ts +175 -0
- package/test/theme.test.ts +86 -0
- package/test/widget_click.test.ts +111 -97
- package/test/widget_construction.test.ts +5 -4
- package/test/widgets.test.ts +333 -0
- package/test/wire_follows_camera.test.ts +57 -0
|
@@ -0,0 +1,124 @@
|
|
|
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 { Connection } from '../src/connection';
|
|
7
|
+
import { arraySlotCenter } from '../src/port';
|
|
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, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }),
|
|
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 scene(sources: number) {
|
|
32
|
+
const graph = new NodeSubsystem(new PassSubsystem());
|
|
33
|
+
const join = new FlowNode({
|
|
34
|
+
title: 'join',
|
|
35
|
+
position: { x: 300, y: 100 },
|
|
36
|
+
inputs: [{ name: 'Items', type: 'mesh', array: true }],
|
|
37
|
+
});
|
|
38
|
+
graph.addNode(join);
|
|
39
|
+
|
|
40
|
+
const wires = new Array<Connection>();
|
|
41
|
+
for (let i = 0; i < sources; i++) {
|
|
42
|
+
const source = new FlowNode({
|
|
43
|
+
title: 'source ' + i,
|
|
44
|
+
position: { x: 0, y: 100 + i * 120 },
|
|
45
|
+
outputs: [{ name: 'Out', type: 'mesh' }],
|
|
46
|
+
});
|
|
47
|
+
graph.addNode(source);
|
|
48
|
+
wires.push(graph.connectNodes(source, 0, join, 0) as Connection);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const camera = new Camera();
|
|
52
|
+
const ctx = stubCtx();
|
|
53
|
+
graph.render(ctx, camera, undefined);
|
|
54
|
+
graph.render(ctx, camera, undefined);
|
|
55
|
+
|
|
56
|
+
const slot = (index: number) => arraySlotCenter(join.inputPortPosition(0), sources, index);
|
|
57
|
+
return { graph, join, wires, camera, ctx, slot };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('an input taking more than one wire', () => {
|
|
61
|
+
it('grows to give each wire its own slot', () => {
|
|
62
|
+
const one = scene(1);
|
|
63
|
+
const three = scene(3);
|
|
64
|
+
|
|
65
|
+
assert.ok(three.join.inputPortPosition(0).Size.y > one.join.inputPortPosition(0).Size.y);
|
|
66
|
+
assert.ok(three.slot(0).y < three.slot(1).y && three.slot(1).y < three.slot(2).y);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('picks up the wire whose slot was pressed', () => {
|
|
70
|
+
const { graph, join, wires, camera, slot } = scene(3);
|
|
71
|
+
|
|
72
|
+
graph.clickStart(slot(1), camera, false);
|
|
73
|
+
|
|
74
|
+
const remaining = join.inputPort(0).connections();
|
|
75
|
+
assert.equal(remaining.length, 2);
|
|
76
|
+
assert.ok(remaining.includes(wires[0]));
|
|
77
|
+
assert.ok(!remaining.includes(wires[1]));
|
|
78
|
+
assert.ok(remaining.includes(wires[2]));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('puts a dropped wire in the gap under the cursor', () => {
|
|
82
|
+
const { graph, join, wires, camera, ctx } = scene(2);
|
|
83
|
+
const extra = new FlowNode({ title: 'extra', position: { x: 0, y: 500 }, outputs: [{ name: 'Out', type: 'mesh' }] });
|
|
84
|
+
graph.addNode(extra);
|
|
85
|
+
graph.render(ctx, camera, undefined);
|
|
86
|
+
graph.render(ctx, camera, undefined);
|
|
87
|
+
|
|
88
|
+
const out = extra.outputPortPosition(0);
|
|
89
|
+
graph.clickStart({ x: out.Position.x + out.Size.x / 2, y: out.Position.y + out.Size.y / 2 }, camera, false);
|
|
90
|
+
const box = join.inputPortPosition(0);
|
|
91
|
+
const between = { x: box.Position.x + box.Size.x / 2, y: box.Position.y + box.Size.y / 2 };
|
|
92
|
+
graph.render(ctx, camera, between);
|
|
93
|
+
graph.render(ctx, camera, between);
|
|
94
|
+
graph.clickEnd();
|
|
95
|
+
|
|
96
|
+
const order = join.inputPort(0).connections();
|
|
97
|
+
assert.equal(order.length, 3);
|
|
98
|
+
assert.equal(order[0], wires[0]);
|
|
99
|
+
assert.equal(order[1].outNode(), extra);
|
|
100
|
+
assert.equal(order[2], wires[1]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('moves a wire picked from one slot to the gap it is dropped in', () => {
|
|
104
|
+
const { graph, join, wires, camera, ctx, slot } = scene(3);
|
|
105
|
+
|
|
106
|
+
graph.clickStart(slot(2), camera, false);
|
|
107
|
+
graph.render(ctx, camera, slot(2));
|
|
108
|
+
const box = join.inputPortPosition(0);
|
|
109
|
+
const top = { x: box.Position.x + box.Size.x / 2, y: box.Position.y + 1 };
|
|
110
|
+
graph.render(ctx, camera, top);
|
|
111
|
+
graph.render(ctx, camera, top);
|
|
112
|
+
graph.clickEnd();
|
|
113
|
+
|
|
114
|
+
assert.deepEqual(join.inputPort(0).connections().map((wire) => wires.indexOf(wire)), [2, 0, 1]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('highlights only the wire whose slot is hovered', () => {
|
|
118
|
+
const { wires, slot } = scene(3);
|
|
119
|
+
|
|
120
|
+
assert.equal(wires[0].mouseOverPort(slot(1)), null);
|
|
121
|
+
assert.notEqual(wires[1].mouseOverPort(slot(1)), null);
|
|
122
|
+
assert.equal(wires[2].mouseOverPort(slot(1)), null);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/test/organize.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as assert from 'assert';
|
|
2
|
-
import { assignLayers, orderWithinLayers, topologicalOrder } from '../src/organize';
|
|
2
|
+
import { assignLayers, findPieces, layoutGraph, orderWithinLayers, topologicalOrder } from '../src/organize';
|
|
3
|
+
import { Vector2 } from '../src/types/vector2';
|
|
3
4
|
|
|
4
5
|
/** Builds the upstream/downstream maps Organize works from. */
|
|
5
6
|
function graph(edges: Array<[number, number]>, nodes: Array<number>) {
|
|
@@ -154,3 +155,118 @@ describe('topologicalOrder', () => {
|
|
|
154
155
|
assert.ok(order.indexOf(1) < order.indexOf(2));
|
|
155
156
|
});
|
|
156
157
|
});
|
|
158
|
+
|
|
159
|
+
describe('layoutGraph', () => {
|
|
160
|
+
type Rect = { x: number, y: number, w: number, h: number };
|
|
161
|
+
|
|
162
|
+
function lay(edges: Array<[number, number]>, nodes: Array<number>, heights: Record<number, number> = {}) {
|
|
163
|
+
const { upstream, downstream } = graph(edges, nodes);
|
|
164
|
+
const sizes = new Map<number, Vector2>();
|
|
165
|
+
for (const node of nodes) {
|
|
166
|
+
sizes.set(node, { x: 100, y: heights[node] ?? 40 });
|
|
167
|
+
}
|
|
168
|
+
const positions = layoutGraph(nodes, upstream, downstream, sizes);
|
|
169
|
+
const rect = (node: number): Rect => {
|
|
170
|
+
const p = positions.get(node) as Vector2;
|
|
171
|
+
const s = sizes.get(node) as Vector2;
|
|
172
|
+
return { x: p.x, y: p.y, w: s.x, h: s.y };
|
|
173
|
+
};
|
|
174
|
+
return { positions, rect, centreY: (node: number) => rect(node).y + rect(node).h / 2 };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function around(rects: Array<Rect>): Rect {
|
|
178
|
+
const x = Math.min(...rects.map((r) => r.x));
|
|
179
|
+
const y = Math.min(...rects.map((r) => r.y));
|
|
180
|
+
return {
|
|
181
|
+
x, y,
|
|
182
|
+
w: Math.max(...rects.map((r) => r.x + r.w)) - x,
|
|
183
|
+
h: Math.max(...rects.map((r) => r.y + r.h)) - y,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function overlaps(a: Rect, b: Rect): boolean {
|
|
188
|
+
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it('lays every node out exactly once', () => {
|
|
192
|
+
const nodes = [0, 1, 2, 3, 4, 5, 6];
|
|
193
|
+
const { positions } = lay([[0, 1], [1, 2], [3, 4]], nodes);
|
|
194
|
+
assert.deepStrictEqual([...positions.keys()].sort(), nodes);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('keeps separate pieces apart', () => {
|
|
198
|
+
const { rect } = lay([[0, 1], [1, 2], [3, 4], [4, 5]], [0, 1, 2, 3, 4, 5]);
|
|
199
|
+
const first = around([0, 1, 2].map(rect));
|
|
200
|
+
const second = around([3, 4, 5].map(rect));
|
|
201
|
+
assert.ok(!overlaps(first, second), 'the pieces share space');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('gathers unconnected nodes away from the pieces', () => {
|
|
205
|
+
const { rect } = lay([[0, 1], [1, 2]], [0, 3, 1, 4, 2, 5]);
|
|
206
|
+
const piece = around([0, 1, 2].map(rect));
|
|
207
|
+
const loose = around([3, 4, 5].map(rect));
|
|
208
|
+
assert.ok(!overlaps(piece, loose), 'a loose node sits inside a piece');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('gives every branch of a tree its own band', () => {
|
|
212
|
+
const { rect } = lay([[1, 0], [2, 0], [3, 1], [4, 1], [5, 2], [6, 2]], [0, 1, 2, 3, 4, 5, 6]);
|
|
213
|
+
const first = around([1, 3, 4].map(rect));
|
|
214
|
+
const second = around([2, 5, 6].map(rect));
|
|
215
|
+
assert.ok(first.y + first.h <= second.y || second.y + second.h <= first.y, 'the branches interleave');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('lines a tree node up with the single input feeding it', () => {
|
|
219
|
+
const { centreY } = lay([[0, 1], [1, 2]], [0, 1, 2], { 0: 40, 1: 120, 2: 60 });
|
|
220
|
+
assert.strictEqual(centreY(0), centreY(1));
|
|
221
|
+
assert.strictEqual(centreY(1), centreY(2));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('puts an input in the column right before what it feeds', () => {
|
|
225
|
+
const edges: Array<[number, number]> = [[0, 1], [1, 2], [2, 3], [0, 3], [4, 3]];
|
|
226
|
+
const { rect } = lay(edges, [0, 1, 2, 3, 4]);
|
|
227
|
+
assert.strictEqual(rect(4).x, rect(2).x);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('lines a node up with the one thing it feeds', () => {
|
|
231
|
+
const edges: Array<[number, number]> = [[0, 1], [0, 2], [1, 3], [2, 3], [3, 4]];
|
|
232
|
+
const { centreY } = lay(edges, [0, 1, 2, 3, 4], { 3: 90 });
|
|
233
|
+
assert.strictEqual(centreY(3), centreY(4));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('keeps the nodes a long wire passes clear of its path', () => {
|
|
237
|
+
const edges: Array<[number, number]> = [[0, 1], [1, 2], [2, 3], [0, 3]];
|
|
238
|
+
const { rect, centreY } = lay(edges, [0, 1, 2, 3]);
|
|
239
|
+
const wire = (centreY(0) + centreY(3)) / 2;
|
|
240
|
+
for (const node of [1, 2]) {
|
|
241
|
+
const r = rect(node);
|
|
242
|
+
assert.ok(wire < r.y || wire > r.y + r.h, `the wire runs through node ${node}`);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('never overlaps two nodes', () => {
|
|
247
|
+
const edges: Array<[number, number]> = [
|
|
248
|
+
[0, 3], [1, 3], [2, 4], [3, 5], [4, 5], [0, 6], [6, 7], [5, 7], [1, 8], [8, 9], [2, 9], [7, 10], [9, 10], [0, 10],
|
|
249
|
+
];
|
|
250
|
+
const nodes = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
|
251
|
+
const heights = { 0: 80, 3: 150, 5: 30, 7: 200, 9: 60, 11: 90 };
|
|
252
|
+
const { rect } = lay(edges, nodes, heights);
|
|
253
|
+
for (let a = 0; a < nodes.length; a++) {
|
|
254
|
+
for (let b = a + 1; b < nodes.length; b++) {
|
|
255
|
+
assert.ok(!overlaps(rect(nodes[a]), rect(nodes[b])), `${nodes[a]} overlaps ${nodes[b]}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('lays out a cycle without hanging', () => {
|
|
261
|
+
const { positions } = lay([[0, 1], [1, 2], [2, 0], [2, 3]], [0, 1, 2, 3]);
|
|
262
|
+
assert.strictEqual(positions.size, 4);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('findPieces', () => {
|
|
267
|
+
it('splits nodes into the groups wires join', () => {
|
|
268
|
+
const { upstream, downstream } = graph([[0, 1], [2, 1], [3, 4]], [0, 1, 2, 3, 4, 5]);
|
|
269
|
+
const pieces = findPieces([0, 1, 2, 3, 4, 5], upstream, downstream);
|
|
270
|
+
assert.deepStrictEqual(pieces, [[0, 1, 2], [3, 4], [5]]);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { strict as assert } from 'assert';
|
|
2
|
+
import { FlowNode } from '../src/node';
|
|
3
|
+
import { NumberWidget } from '../src/widgets/number';
|
|
4
|
+
import { SliderWidget } from '../src/widgets/slider';
|
|
5
|
+
|
|
6
|
+
describe('property binding', () => {
|
|
7
|
+
it('settles when a number widget is bound to a property set to NaN', () => {
|
|
8
|
+
const node = new FlowNode({ title: 'test', position: { x: 0, y: 0 } });
|
|
9
|
+
new NumberWidget(node, { property: 'x' });
|
|
10
|
+
|
|
11
|
+
node.setProperty('x', NaN);
|
|
12
|
+
|
|
13
|
+
assert.ok(Number.isNaN(node.getProperty('x')));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('settles when a slider is bound to a property set to NaN', () => {
|
|
17
|
+
const node = new FlowNode({ title: 'test', position: { x: 0, y: 0 } });
|
|
18
|
+
new SliderWidget(node, { property: 'x', min: 0, max: 1 });
|
|
19
|
+
|
|
20
|
+
node.setProperty('x', NaN);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('does not report a NaN property as changed when it stays NaN', () => {
|
|
24
|
+
const node = new FlowNode({ title: 'test', position: { x: 0, y: 0 } });
|
|
25
|
+
let changes = 0;
|
|
26
|
+
node.addPropertyChangeListener('x', () => { changes++; });
|
|
27
|
+
|
|
28
|
+
node.setProperty('x', NaN);
|
|
29
|
+
node.setProperty('x', NaN);
|
|
30
|
+
|
|
31
|
+
assert.equal(changes, 1);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('programmatic title and info', () => {
|
|
36
|
+
it('sets the title of a node the user cannot retitle', () => {
|
|
37
|
+
const node = new FlowNode({ title: 'before', position: { x: 0, y: 0 }, canEditTitle: false });
|
|
38
|
+
node.setTitle('after');
|
|
39
|
+
assert.equal(node.title(), 'after');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('sets the info of a node the user cannot edit the info of', () => {
|
|
43
|
+
const node = new FlowNode({ title: 'test', position: { x: 0, y: 0 }, canEditInfo: false });
|
|
44
|
+
let changed = 0;
|
|
45
|
+
node.addInfoChangeListener(() => { changed++; });
|
|
46
|
+
node.setInfo('carrying a mesh');
|
|
47
|
+
assert.equal(changed, 1);
|
|
48
|
+
});
|
|
49
|
+
});
|
package/test/support.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { NodeSubsystem } from '../src/nodes/subsystem';
|
|
2
|
+
import { PassSubsystem } from '../src/pass/subsystem';
|
|
3
|
+
import { FlowNode } from '../src/node';
|
|
4
|
+
import { Camera } from '../src/camera';
|
|
5
|
+
import { Box } from '../src/types/box';
|
|
6
|
+
import { Vector2 } from '../src/types/vector2';
|
|
7
|
+
import { Widget } from '../src/widgets/widget';
|
|
8
|
+
|
|
9
|
+
export interface CanvasRecord {
|
|
10
|
+
fills: Array<string>;
|
|
11
|
+
texts: Array<{ text: string; fillStyle: string; x: number; y: number }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function stubCtx(record?: CanvasRecord): CanvasRenderingContext2D {
|
|
15
|
+
const ctx: any = {
|
|
16
|
+
canvas: {
|
|
17
|
+
width: 800,
|
|
18
|
+
height: 600,
|
|
19
|
+
getBoundingClientRect: () => ({ left: 10, top: 20, width: 800, height: 600 }),
|
|
20
|
+
},
|
|
21
|
+
lineWidth: 1,
|
|
22
|
+
font: '',
|
|
23
|
+
textAlign: 'left',
|
|
24
|
+
textBaseline: 'top',
|
|
25
|
+
fillStyle: '',
|
|
26
|
+
strokeStyle: '',
|
|
27
|
+
globalAlpha: 1,
|
|
28
|
+
shadowBlur: 0,
|
|
29
|
+
shadowColor: '',
|
|
30
|
+
measureText: (text: string) => ({ width: text.length * 7, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }),
|
|
31
|
+
save: () => { }, restore: () => { }, translate: () => { }, scale: () => { },
|
|
32
|
+
beginPath: () => { }, closePath: () => { }, roundRect: () => { }, rect: () => { },
|
|
33
|
+
moveTo: () => { }, lineTo: () => { }, arc: () => { }, ellipse: () => { },
|
|
34
|
+
bezierCurveTo: () => { }, quadraticCurveTo: () => { },
|
|
35
|
+
fill() { record?.fills.push(this.fillStyle); },
|
|
36
|
+
stroke: () => { },
|
|
37
|
+
fillText(text: string, x: number, y: number) { record?.texts.push({ text, fillStyle: this.fillStyle, x, y }); },
|
|
38
|
+
strokeText: () => { },
|
|
39
|
+
clip: () => { }, drawImage: () => { }, setLineDash: () => { },
|
|
40
|
+
createLinearGradient: () => ({ addColorStop: () => { } }),
|
|
41
|
+
};
|
|
42
|
+
return ctx as CanvasRenderingContext2D;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function center(box: Box): Vector2 {
|
|
46
|
+
return { x: box.Position.x + box.Size.x / 2, y: box.Position.y + box.Size.y / 2 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A node holding the widget, on a canvas that has been drawn once so the
|
|
51
|
+
* widget has a place.
|
|
52
|
+
*/
|
|
53
|
+
export function widgetScene(build: (node: FlowNode) => Widget, nodeConfig?: ConstructorParameters<typeof FlowNode>[0]) {
|
|
54
|
+
const graph = new NodeSubsystem(new PassSubsystem());
|
|
55
|
+
const node = new FlowNode({ title: 'test', position: { x: 100, y: 100 }, ...nodeConfig });
|
|
56
|
+
const widget = build(node);
|
|
57
|
+
node.addWidget(widget);
|
|
58
|
+
graph.addNode(node);
|
|
59
|
+
const camera = new Camera();
|
|
60
|
+
const ctx = stubCtx();
|
|
61
|
+
graph.render(ctx, camera, undefined);
|
|
62
|
+
|
|
63
|
+
const widgetBox = (): Box => {
|
|
64
|
+
const box = node.widgetPosition(node.widgets().indexOf(widget));
|
|
65
|
+
if (box === undefined) {
|
|
66
|
+
throw new Error('widget was never drawn');
|
|
67
|
+
}
|
|
68
|
+
return box;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const press = (at: Vector2) => {
|
|
72
|
+
graph.render(ctx, camera, at);
|
|
73
|
+
graph.clickStart(at, camera, false);
|
|
74
|
+
let current = { ...at };
|
|
75
|
+
return {
|
|
76
|
+
moveTo(to: Vector2) {
|
|
77
|
+
graph.mouseDragEvent({ x: to.x - current.x, y: to.y - current.y }, camera.zoom);
|
|
78
|
+
current = { ...to };
|
|
79
|
+
graph.render(ctx, camera, to);
|
|
80
|
+
},
|
|
81
|
+
release() {
|
|
82
|
+
graph.clickEnd();
|
|
83
|
+
graph.render(ctx, camera, current);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const click = (at: Vector2) => press(at).release();
|
|
89
|
+
|
|
90
|
+
return { graph, node, widget, camera, ctx, widgetBox, press, click };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface StubElement {
|
|
94
|
+
tagName: string;
|
|
95
|
+
type: string;
|
|
96
|
+
value: string;
|
|
97
|
+
style: { cssText: string };
|
|
98
|
+
children: Array<StubElement>;
|
|
99
|
+
isConnected: boolean;
|
|
100
|
+
clicked: number;
|
|
101
|
+
laidOutAt: string;
|
|
102
|
+
openedAt: Array<string>;
|
|
103
|
+
focused: boolean;
|
|
104
|
+
oninput: (() => void) | null;
|
|
105
|
+
onchange: (() => void) | null;
|
|
106
|
+
appendChild(child: StubElement): StubElement;
|
|
107
|
+
append(child: StubElement): void;
|
|
108
|
+
removeChild(child: StubElement): void;
|
|
109
|
+
remove(): void;
|
|
110
|
+
focus(): void;
|
|
111
|
+
select(): void;
|
|
112
|
+
click(): void;
|
|
113
|
+
getBoundingClientRect(): { left: number, top: number, width: number, height: number };
|
|
114
|
+
addEventListener(name: string, fn: (e: any) => void): void;
|
|
115
|
+
fire(name: string, event?: any): void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function withDocument(run: (body: StubElement, created: Array<StubElement>) => void): void {
|
|
119
|
+
const created: Array<StubElement> = [];
|
|
120
|
+
const element = (tagName: string): StubElement => {
|
|
121
|
+
const listeners = new Map<string, Array<(e: any) => void>>();
|
|
122
|
+
const el: StubElement = {
|
|
123
|
+
tagName,
|
|
124
|
+
type: '',
|
|
125
|
+
value: '',
|
|
126
|
+
style: { cssText: '' },
|
|
127
|
+
children: [],
|
|
128
|
+
isConnected: false,
|
|
129
|
+
clicked: 0,
|
|
130
|
+
laidOutAt: '',
|
|
131
|
+
openedAt: [],
|
|
132
|
+
focused: false,
|
|
133
|
+
oninput: null,
|
|
134
|
+
onchange: null,
|
|
135
|
+
appendChild(child) { this.children.push(child); child.isConnected = true; return child; },
|
|
136
|
+
append(child) { this.children.push(child); child.isConnected = true; },
|
|
137
|
+
removeChild(child) { this.children = this.children.filter((c) => c !== child); child.isConnected = false; },
|
|
138
|
+
remove() { body.removeChild(this); },
|
|
139
|
+
focus() { this.focused = true; },
|
|
140
|
+
select() { },
|
|
141
|
+
click() { this.clicked++; this.openedAt.push(this.laidOutAt); },
|
|
142
|
+
getBoundingClientRect() {
|
|
143
|
+
this.laidOutAt = this.style.cssText;
|
|
144
|
+
return { left: 0, top: 0, width: 0, height: 0 };
|
|
145
|
+
},
|
|
146
|
+
addEventListener(name, fn) {
|
|
147
|
+
listeners.set(name, [...(listeners.get(name) ?? []), fn]);
|
|
148
|
+
},
|
|
149
|
+
fire(name, event) {
|
|
150
|
+
const e = event ?? {};
|
|
151
|
+
e.preventDefault ??= () => { };
|
|
152
|
+
e.stopPropagation ??= () => { };
|
|
153
|
+
(listeners.get(name) ?? []).forEach((fn) => fn(e));
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
created.push(el);
|
|
157
|
+
return el;
|
|
158
|
+
};
|
|
159
|
+
const body = element('body');
|
|
160
|
+
|
|
161
|
+
const g = globalThis as any;
|
|
162
|
+
const previous = g.document;
|
|
163
|
+
g.document = {
|
|
164
|
+
body,
|
|
165
|
+
createElement: element,
|
|
166
|
+
addEventListener: () => { },
|
|
167
|
+
removeEventListener: () => { },
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
run(body, created);
|
|
171
|
+
} finally {
|
|
172
|
+
g.document = previous;
|
|
173
|
+
created.forEach((el) => el.isConnected = false);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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 { FlowNote } from '../src/notes/note';
|
|
6
|
+
import { Camera } from '../src/camera';
|
|
7
|
+
import { NumberWidget } from '../src/widgets/number';
|
|
8
|
+
import { DefaultTheme, Theme, onThemeChange, setTheme, themeVersion } from '../src/theme';
|
|
9
|
+
import { CanvasRecord, stubCtx } from './support';
|
|
10
|
+
|
|
11
|
+
function drawNode(node: FlowNode): CanvasRecord {
|
|
12
|
+
const record: CanvasRecord = { fills: [], texts: [] };
|
|
13
|
+
const graph = new NodeSubsystem(new PassSubsystem());
|
|
14
|
+
graph.addNode(node);
|
|
15
|
+
graph.render(stubCtx(record), new Camera(), undefined);
|
|
16
|
+
return record;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('the theme', () => {
|
|
20
|
+
afterEach(() => setTheme());
|
|
21
|
+
|
|
22
|
+
it('lays a partial theme over the defaults', () => {
|
|
23
|
+
setTheme({ Node: { BackgroundColor: '#c0c0c0' } });
|
|
24
|
+
|
|
25
|
+
assert.equal(Theme.Node.BackgroundColor, '#c0c0c0');
|
|
26
|
+
assert.equal(Theme.Node.Title.Color, DefaultTheme.Node.Title.Color);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('goes back to the defaults when set to nothing', () => {
|
|
30
|
+
setTheme({ Graph: { BackgroundColor: '#008080' } });
|
|
31
|
+
setTheme();
|
|
32
|
+
assert.equal(Theme.Graph.BackgroundColor, DefaultTheme.Graph.BackgroundColor);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('tells listeners and moves the version on', () => {
|
|
36
|
+
let heard = 0;
|
|
37
|
+
const stop = onThemeChange(() => heard++);
|
|
38
|
+
const before = themeVersion();
|
|
39
|
+
|
|
40
|
+
setTheme({ FontFamily: 'Tahoma' });
|
|
41
|
+
stop();
|
|
42
|
+
setTheme();
|
|
43
|
+
|
|
44
|
+
assert.equal(heard, 1);
|
|
45
|
+
assert.ok(themeVersion() > before);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('recolours a node that was made before the change', () => {
|
|
49
|
+
const node = new FlowNode({ title: 'made earlier' });
|
|
50
|
+
assert.ok(drawNode(node).fills.includes(DefaultTheme.Node.BackgroundColor));
|
|
51
|
+
|
|
52
|
+
setTheme({ Node: { BackgroundColor: '#c0c0c0', Title: { Color: '#000080' } } });
|
|
53
|
+
const after = drawNode(node);
|
|
54
|
+
|
|
55
|
+
assert.ok(after.fills.includes('#c0c0c0'));
|
|
56
|
+
assert.ok(after.fills.includes('#000080'));
|
|
57
|
+
assert.ok(!after.fills.includes(DefaultTheme.Node.BackgroundColor));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('keeps a colour a node was given over the theme', () => {
|
|
61
|
+
const node = new FlowNode({ title: 'styled', style: { idle: { color: '#123456' } } });
|
|
62
|
+
setTheme({ Node: { BackgroundColor: '#c0c0c0' } });
|
|
63
|
+
assert.ok(drawNode(node).fills.includes('#123456'));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('redraws widgets in the new colours', () => {
|
|
67
|
+
const record: CanvasRecord = { fills: [], texts: [] };
|
|
68
|
+
const widget = new NumberWidget(new FlowNode({ title: 't' }), { value: 1 });
|
|
69
|
+
setTheme({ Widget: { Input: { BackgroundColor: '#ffffff', FontColor: '#000000' } } });
|
|
70
|
+
|
|
71
|
+
widget.Draw(stubCtx(record), { Position: { x: 0, y: 0 }, Size: { x: 100, y: 25 } }, 1, undefined);
|
|
72
|
+
|
|
73
|
+
assert.ok(record.fills.includes('#ffffff'));
|
|
74
|
+
assert.deepEqual(record.texts.map((t) => t.fillStyle), ['#000000']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('re-reads a note in the new colours', () => {
|
|
78
|
+
const record: CanvasRecord = { fills: [], texts: [] };
|
|
79
|
+
const note = new FlowNote({ text: 'hello', position: { x: 0, y: 0 } });
|
|
80
|
+
setTheme({ Note: { FontColor: '#ffffff' } });
|
|
81
|
+
|
|
82
|
+
note.render(stubCtx(record), new Camera(), undefined);
|
|
83
|
+
|
|
84
|
+
assert.ok(record.texts.some((t) => t.text === 'hello' && t.fillStyle === '#ffffff'));
|
|
85
|
+
});
|
|
86
|
+
});
|