@elicdavis/node-flow 0.1.20 → 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 +1866 -1059
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1849 -1058
- 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 +160 -101
- package/src/nodes/subsystem.ts +113 -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/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/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
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
|
+
});
|
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
|
+
});
|
|
@@ -1,126 +1,140 @@
|
|
|
1
1
|
import { strict as assert } from 'assert';
|
|
2
|
-
import { NodeSubsystem } from '../src/nodes/subsystem';
|
|
3
|
-
import { PassSubsystem } from '../src/pass/subsystem';
|
|
4
2
|
import { FlowNode } from '../src/node';
|
|
5
|
-
import { Camera } from '../src/camera';
|
|
6
3
|
import { Box } from '../src/types/box';
|
|
7
4
|
import { Vector2 } from '../src/types/vector2';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
5
|
+
import { WidgetBase, WidgetPointer } from '../src/widgets/widget';
|
|
6
|
+
import { center, widgetScene } from './support';
|
|
7
|
+
|
|
8
|
+
class Recording extends WidgetBase {
|
|
9
|
+
clicks = 0;
|
|
10
|
+
downs: Array<WidgetPointer> = [];
|
|
11
|
+
moves: Array<WidgetPointer> = [];
|
|
12
|
+
ups: Array<WidgetPointer> = [];
|
|
13
|
+
drawnIn: Box | null = null;
|
|
14
|
+
|
|
15
|
+
Size(): Vector2 {
|
|
16
|
+
return { x: 100, y: 20 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
protected render(_ctx: CanvasRenderingContext2D, box: Box): void {
|
|
20
|
+
this.drawnIn = box;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
protected onPointerDown(p: WidgetPointer): void { this.downs.push(p); }
|
|
24
|
+
protected onPointerMove(p: WidgetPointer): void { this.moves.push(p); }
|
|
25
|
+
protected onPointerUp(p: WidgetPointer): void { this.ups.push(p); }
|
|
26
|
+
protected onClick(): void { this.clicks++; }
|
|
29
27
|
}
|
|
30
28
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
}
|
|
29
|
+
describe('clicking a widget', () => {
|
|
30
|
+
it('clicks it when the press and release are both on it', () => {
|
|
31
|
+
const { widget, widgetBox, click } = widgetScene(() => new Recording());
|
|
32
|
+
click(center(widgetBox()));
|
|
33
|
+
assert.equal((widget as Recording).clicks, 1);
|
|
34
|
+
});
|
|
53
35
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const rec = recordingWidget();
|
|
58
|
-
node.addWidget(rec.widget as any);
|
|
59
|
-
graph.addNode(node);
|
|
36
|
+
it('does not click it when the gesture drags away before releasing', () => {
|
|
37
|
+
const { widget, widgetBox, press } = widgetScene(() => new Recording());
|
|
38
|
+
const on = center(widgetBox());
|
|
60
39
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
40
|
+
const gesture = press(on);
|
|
41
|
+
gesture.moveTo({ x: on.x + 400, y: on.y + 300 });
|
|
42
|
+
gesture.release();
|
|
65
43
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
44
|
+
const recording = widget as Recording;
|
|
45
|
+
assert.equal(recording.clicks, 0);
|
|
46
|
+
assert.equal(recording.ups.length, 1, 'it still hears the release');
|
|
47
|
+
assert.equal(recording.ups[0].inside, false);
|
|
48
|
+
});
|
|
71
49
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
50
|
+
it('keeps reporting the drag to the widget once the pointer has left it', () => {
|
|
51
|
+
const { widget, widgetBox, press } = widgetScene(() => new Recording());
|
|
52
|
+
const on = center(widgetBox());
|
|
53
|
+
|
|
54
|
+
const gesture = press(on);
|
|
55
|
+
gesture.moveTo({ x: on.x + 300, y: on.y });
|
|
56
|
+
gesture.release();
|
|
76
57
|
|
|
77
|
-
|
|
58
|
+
const recording = widget as Recording;
|
|
59
|
+
assert.deepEqual(recording.moves.map((m) => m.position), [{ x: on.x + 300, y: on.y }]);
|
|
60
|
+
assert.equal(recording.moves[0].inside, false);
|
|
78
61
|
});
|
|
79
62
|
|
|
80
|
-
it('
|
|
81
|
-
const {
|
|
82
|
-
|
|
83
|
-
|
|
63
|
+
it('ignores a disabled widget', () => {
|
|
64
|
+
const { widget, widgetBox, click } = widgetScene(() => {
|
|
65
|
+
const w = new Recording();
|
|
66
|
+
w.setDisabled(true);
|
|
67
|
+
return w;
|
|
68
|
+
});
|
|
69
|
+
click(center(widgetBox()));
|
|
70
|
+
const recording = widget as Recording;
|
|
71
|
+
assert.equal(recording.downs.length, 0);
|
|
72
|
+
assert.equal(recording.clicks, 0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
84
75
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
76
|
+
describe('laying out widgets', () => {
|
|
77
|
+
it('stretches a widget across a node made wide by its ports', () => {
|
|
78
|
+
const { node, widget } = widgetScene(
|
|
79
|
+
() => new Recording(),
|
|
80
|
+
{ inputs: [{ name: 'A port name long enough to widen the whole node', type: 'float64' }] },
|
|
81
|
+
);
|
|
82
|
+
const drawnIn = (widget as Recording).drawnIn as Box;
|
|
83
|
+
assert.ok(drawnIn.Size.x > 100 * 2);
|
|
84
|
+
assert.ok(node.widgets().length === 1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('leaves a hidden widget out of the node and out of reach', () => {
|
|
88
|
+
const shown = widgetScene(() => new Recording());
|
|
89
|
+
const hidden = widgetScene(() => {
|
|
90
|
+
const w = new Recording();
|
|
91
|
+
w.setHidden(true);
|
|
92
|
+
return w;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const shownHeight = shown.node.calculateBounds(shown.ctx, shown.camera).Size.y;
|
|
96
|
+
const hiddenHeight = hidden.node.calculateBounds(hidden.ctx, hidden.camera).Size.y;
|
|
97
|
+
assert.ok(hiddenHeight < shownHeight);
|
|
89
98
|
|
|
90
|
-
|
|
99
|
+
hidden.click(center(shown.widgetBox()));
|
|
100
|
+
assert.equal((hidden.widget as Recording).clicks, 0);
|
|
91
101
|
});
|
|
92
102
|
});
|
|
93
103
|
|
|
94
104
|
describe('a node panned off screen', () => {
|
|
95
105
|
it('stops hit-testing as a widget where it used to be drawn', () => {
|
|
96
|
-
const graph
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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();
|
|
106
|
+
const { node, widget, camera, ctx, graph, widgetBox } = widgetScene(
|
|
107
|
+
() => new Recording(),
|
|
108
|
+
{ inputs: [{ name: 'In', type: 'float64' }] },
|
|
109
|
+
);
|
|
110
|
+
const on = center(widgetBox());
|
|
111
111
|
|
|
112
112
|
camera.position.x = -50000;
|
|
113
113
|
graph.render(ctx, camera, undefined);
|
|
114
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
|
-
|
|
115
|
+
assert.equal(node.inBounds(ctx, camera, on).Widget, undefined);
|
|
121
116
|
graph.clickStart(on, camera, false);
|
|
122
117
|
graph.clickEnd();
|
|
118
|
+
assert.equal((widget as Recording).clicks, 0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
123
121
|
|
|
124
|
-
|
|
122
|
+
describe('a node built from config', () => {
|
|
123
|
+
it('builds a row of labelled numbers bound to properties', () => {
|
|
124
|
+
const node = new FlowNode({
|
|
125
|
+
title: 'vector',
|
|
126
|
+
widgets: [{
|
|
127
|
+
type: 'row',
|
|
128
|
+
config: {
|
|
129
|
+
widgets: [
|
|
130
|
+
{ type: 'number', config: { property: 'x', label: 'X', width: 60 } },
|
|
131
|
+
{ type: 'number', config: { property: 'y', label: 'Y', width: 60 } },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
}],
|
|
135
|
+
});
|
|
136
|
+
node.setProperty('y', 4);
|
|
137
|
+
assert.equal(node.widgets().length, 1);
|
|
138
|
+
assert.equal(node.getProperty('x'), 0);
|
|
125
139
|
});
|
|
126
140
|
});
|
|
@@ -35,14 +35,15 @@ describe('widget construction', () => {
|
|
|
35
35
|
|
|
36
36
|
it('does not fire a slider callback for its initial value', () => {
|
|
37
37
|
let fired = 0;
|
|
38
|
-
new SliderWidget(stubNode(), { value: 7, min: 0, max: 10,
|
|
38
|
+
new SliderWidget(stubNode(), { value: 7, min: 0, max: 10, callback: () => { fired++; }, onInput: () => { fired++; } });
|
|
39
39
|
assert.equal(fired, 0);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
it('
|
|
42
|
+
it('does not fire the number callback for a value set from code', () => {
|
|
43
43
|
const values: Array<number> = [];
|
|
44
44
|
const widget = new NumberWidget(stubNode(), { value: 42, callback: (v) => { values.push(v); } });
|
|
45
|
-
widget.
|
|
46
|
-
assert.
|
|
45
|
+
widget.setValue(43);
|
|
46
|
+
assert.equal(widget.value(), 43);
|
|
47
|
+
assert.deepEqual(values, []);
|
|
47
48
|
});
|
|
48
49
|
});
|