@elicdavis/node-flow 0.2.2 → 0.2.4
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 +142 -103
- package/dist/index.d.ts +142 -103
- package/dist/index.js +201 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +198 -22
- package/dist/index.mjs.map +1 -1
- package/dist/web/NodeFlow.js +8 -0
- package/dist/web/NodeFlow.js.map +7 -0
- package/index.html +48 -50
- package/package.json +1 -1
- package/src/contextMenu.ts +3 -3
- package/src/graph.ts +39 -5
- package/src/index.ts +6 -6
- package/src/input.ts +16 -0
- package/src/markdown/syntaxParser.ts +2 -2
- package/src/node.ts +74 -2
- package/src/nodes/publisher.ts +6 -8
- package/src/nodes/subsystem.ts +43 -5
- package/src/port.ts +62 -1
- package/src/quickMenu.ts +11 -0
- package/src/theme.ts +3 -1
- package/test/markdown.test.ts +11 -0
- package/test/markdown_code_block.test.ts +21 -1
- package/test/mouse_leave.test.ts +70 -0
- package/test/node_hover.test.ts +69 -0
- package/test/node_menu_filter.test.ts +24 -0
- package/test/port.test.ts +49 -1
package/src/nodes/subsystem.ts
CHANGED
|
@@ -6,7 +6,7 @@ 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 { arrayInsertIndex, Port, PortType,
|
|
9
|
+
import { arrayInsertIndex, Cast, Port, PortType, portsConnect } from "../port";
|
|
10
10
|
import { requestRender } from "../render_scheduler";
|
|
11
11
|
import { BoxStyle } from "../styles/box";
|
|
12
12
|
import { CursorStyle } from "../styles/cursor";
|
|
@@ -21,6 +21,7 @@ import { Publisher } from "./publisher";
|
|
|
21
21
|
|
|
22
22
|
export type NodeAddedCallback = (node: FlowNode) => void;
|
|
23
23
|
export type NodeRemovedCallback = (node: FlowNode) => void;
|
|
24
|
+
export type NodeHoverCallback = (node: FlowNode | null) => void;
|
|
24
25
|
|
|
25
26
|
export const nodeFlowGroup = "node-flow-graph-node-menu";
|
|
26
27
|
|
|
@@ -84,6 +85,7 @@ interface PortIntersection {
|
|
|
84
85
|
interface NodeSubsystemConfig {
|
|
85
86
|
idleConnection?: ConnectionRendererConfiguration
|
|
86
87
|
nodes?: NodeFactoryConfig
|
|
88
|
+
casts?: Array<Cast>
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
export class NodeSubsystem {
|
|
@@ -126,6 +128,7 @@ export class NodeSubsystem {
|
|
|
126
128
|
|
|
127
129
|
#boxSelectionNodes: List<number>;
|
|
128
130
|
|
|
131
|
+
#casts: Array<Cast>;
|
|
129
132
|
|
|
130
133
|
#lastCtx: CanvasRenderingContext2D | null;
|
|
131
134
|
|
|
@@ -137,6 +140,10 @@ export class NodeSubsystem {
|
|
|
137
140
|
|
|
138
141
|
#registeredNodeAddedCallbacks: Array<NodeAddedCallback>;
|
|
139
142
|
|
|
143
|
+
#nodeHoverCallbacks = new Array<NodeHoverCallback>();
|
|
144
|
+
|
|
145
|
+
#hoveredNode: FlowNode | null = null;
|
|
146
|
+
|
|
140
147
|
// ========================================================================
|
|
141
148
|
|
|
142
149
|
constructor(postProcessPass: PassSubsystem, config?: NodeSubsystemConfig) {
|
|
@@ -160,6 +167,15 @@ export class NodeSubsystem {
|
|
|
160
167
|
this.#boxSelectEnd_graphSpace = Zero();
|
|
161
168
|
this.#boxSelectionNodes = new List<number>();
|
|
162
169
|
this.#lastCtx = null;
|
|
170
|
+
this.#casts = config?.casts ?? [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
public setCasts(casts: Array<Cast>): void {
|
|
174
|
+
this.#casts = casts;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
public casts(): Array<Cast> {
|
|
178
|
+
return this.#casts;
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
public addPublisher(identifier: string, publisher: Publisher): void {
|
|
@@ -188,6 +204,20 @@ export class NodeSubsystem {
|
|
|
188
204
|
this.#nodeRemovedCallbacks.push(callback);
|
|
189
205
|
}
|
|
190
206
|
|
|
207
|
+
public addNodeHoverListener(callback: NodeHoverCallback): void {
|
|
208
|
+
this.#nodeHoverCallbacks.push(callback);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#setHoveredNode(node: FlowNode | null): void {
|
|
212
|
+
if (this.#hoveredNode === node) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
this.#hoveredNode = node;
|
|
216
|
+
for (let i = 0; i < this.#nodeHoverCallbacks.length; i++) {
|
|
217
|
+
this.#nodeHoverCallbacks[i](node);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
191
221
|
clickStart(mousePosition: Vector2, camera: Camera, ctrlKey: boolean): boolean {
|
|
192
222
|
this.#boxSelect = false;
|
|
193
223
|
this.#nodesGrabbed.Clear();
|
|
@@ -445,7 +475,10 @@ export class NodeSubsystem {
|
|
|
445
475
|
let firstHidden = -1;
|
|
446
476
|
for (let i = 0; i < count; i++) {
|
|
447
477
|
const port = side === PortType.Input ? node.inputPort(i) : node.outputPort(i);
|
|
448
|
-
|
|
478
|
+
const compatible = side === PortType.Input
|
|
479
|
+
? portsConnect(against, port, this.#casts)
|
|
480
|
+
: portsConnect(port, against, this.#casts);
|
|
481
|
+
if (!compatible) {
|
|
449
482
|
continue;
|
|
450
483
|
}
|
|
451
484
|
if (!port.hidden()) {
|
|
@@ -521,13 +554,13 @@ export class NodeSubsystem {
|
|
|
521
554
|
}
|
|
522
555
|
|
|
523
556
|
// Different types, can't connect
|
|
524
|
-
if (this.#portHovering.InputPort && !
|
|
557
|
+
if (this.#portHovering.InputPort && !portsConnect(conn.outPort(), this.#portHovering.Port, this.#casts)) {
|
|
525
558
|
this.#clearCurrentlySelectedConnection();
|
|
526
559
|
return;
|
|
527
560
|
}
|
|
528
561
|
|
|
529
562
|
// Different types, can't connect
|
|
530
|
-
if (!this.#portHovering.InputPort && !
|
|
563
|
+
if (!this.#portHovering.InputPort && !portsConnect(this.#portHovering.Port, conn.inPort(), this.#casts)) {
|
|
531
564
|
this.#clearCurrentlySelectedConnection();
|
|
532
565
|
return;
|
|
533
566
|
}
|
|
@@ -559,7 +592,7 @@ export class NodeSubsystem {
|
|
|
559
592
|
connectNodes(nodeOut: FlowNode, outPort: number, nodeIn: FlowNode, inPort: number): Connection | undefined {
|
|
560
593
|
const from = nodeOut.outputPort(outPort);
|
|
561
594
|
const to = nodeIn.inputPort(inPort);
|
|
562
|
-
if (!
|
|
595
|
+
if (!portsConnect(from, to, this.#casts)) {
|
|
563
596
|
console.error("can't connect nodes of different types", from.getDataType(), to.getDataType());
|
|
564
597
|
return;
|
|
565
598
|
}
|
|
@@ -955,6 +988,7 @@ export class NodeSubsystem {
|
|
|
955
988
|
this.#widgetHovering = null;
|
|
956
989
|
this.#nodeHovering = -1;
|
|
957
990
|
this.#boxSelectionNodes.Clear();
|
|
991
|
+
let hovered: FlowNode | null = null;
|
|
958
992
|
|
|
959
993
|
const selectedBox_Screenspace = this.#boxSelectionScreenspaceBox(camera);
|
|
960
994
|
|
|
@@ -976,6 +1010,9 @@ export class NodeSubsystem {
|
|
|
976
1010
|
|
|
977
1011
|
if (mousePosition !== undefined && !this.#boxSelect) {
|
|
978
1012
|
const intersection = this.#nodes[i].inBounds(ctx, camera, mousePosition);
|
|
1013
|
+
if (intersection.Node !== undefined) {
|
|
1014
|
+
hovered = intersection.Node;
|
|
1015
|
+
}
|
|
979
1016
|
|
|
980
1017
|
if (intersection.Node !== undefined && intersection.PortIndex === undefined && intersection.Widget === undefined) {
|
|
981
1018
|
state = NodeState.MouseOver;
|
|
@@ -1019,6 +1056,7 @@ export class NodeSubsystem {
|
|
|
1019
1056
|
|
|
1020
1057
|
this.#nodes[i].render(ctx, camera, state, mousePosition, this.#postProcessPass);
|
|
1021
1058
|
}
|
|
1059
|
+
this.#setHoveredNode(hovered);
|
|
1022
1060
|
|
|
1023
1061
|
if (this.#widgetCurrentlyClicking !== null) {
|
|
1024
1062
|
this.#cursor = this.#widgetCurrentlyClicking.Cursor?.() ?? CursorStyle.Pointer;
|
package/src/port.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { Box, InBox } from "./types/box";
|
|
|
11
11
|
import { Text } from "./types/text";
|
|
12
12
|
import { Vector2 } from "./types/vector2";
|
|
13
13
|
import { Color, HSV, HSV2RGB, RgbToHex } from './utils/color';
|
|
14
|
+
import type { Widget } from "./widgets/widget";
|
|
15
|
+
import type { WidgetConfig } from "./widgets/factory";
|
|
14
16
|
|
|
15
17
|
export enum PortType {
|
|
16
18
|
Input = "INPUT",
|
|
@@ -56,6 +58,12 @@ export interface PortConfig {
|
|
|
56
58
|
*/
|
|
57
59
|
hidden?: boolean;
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* A widget drawn on the port's own row while nothing is connected to
|
|
63
|
+
* it, for a value the port holds on its own. Input ports only.
|
|
64
|
+
*/
|
|
65
|
+
widget?: WidgetConfig;
|
|
66
|
+
|
|
59
67
|
emptyStyle?: PortStyle;
|
|
60
68
|
filledStyle?: PortStyle;
|
|
61
69
|
onConnectionAdded?: ConnectionChangeCallback;
|
|
@@ -83,6 +91,33 @@ export function typeSetsCompatible(a: Array<string>, b: Array<string>): boolean
|
|
|
83
91
|
return a.some((type) => b.includes(type));
|
|
84
92
|
}
|
|
85
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Lets an output of type `from` feed an input of type `to`
|
|
96
|
+
*/
|
|
97
|
+
export interface Cast {
|
|
98
|
+
from: string;
|
|
99
|
+
to: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether an output may feed an input: their types overlap, or a cast takes
|
|
104
|
+
* one of the output's types to one of the input's.
|
|
105
|
+
*/
|
|
106
|
+
export function portsConnect(output: Port | undefined | null, input: Port | undefined | null, casts?: Array<Cast>): boolean {
|
|
107
|
+
if (!output || !input) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return typeSetsConnect(output.acceptedTypes(), input.acceptedTypes(), casts);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function typeSetsConnect(output: Array<string>, input: Array<string>, casts?: Array<Cast>): boolean {
|
|
115
|
+
if (typeSetsCompatible(output, input)) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
return (casts ?? []).some((cast) => output.includes(cast.from) && input.includes(cast.to));
|
|
119
|
+
}
|
|
120
|
+
|
|
86
121
|
export function portConfigTypes(config: PortConfig): Array<string> {
|
|
87
122
|
if (config.anyType === true) {
|
|
88
123
|
return [];
|
|
@@ -131,6 +166,10 @@ function fallbackColor(type: string, s: number): string {
|
|
|
131
166
|
return RgbToHex(color);
|
|
132
167
|
}
|
|
133
168
|
|
|
169
|
+
export function portTypeColor(type: string): string {
|
|
170
|
+
return fallbackColor(type, 1);
|
|
171
|
+
}
|
|
172
|
+
|
|
134
173
|
export class Port {
|
|
135
174
|
|
|
136
175
|
#node: FlowNode;
|
|
@@ -167,6 +206,8 @@ export class Port {
|
|
|
167
206
|
|
|
168
207
|
#onConnectionRemoved: Array<ConnectionChangeCallback>;
|
|
169
208
|
|
|
209
|
+
#widget: Widget | null = null;
|
|
210
|
+
|
|
170
211
|
constructor(node: FlowNode, portType: PortType, config?: PortConfig) {
|
|
171
212
|
this.#node = node;
|
|
172
213
|
this.#connections = new Array<Connection>();
|
|
@@ -339,11 +380,31 @@ export class Port {
|
|
|
339
380
|
}
|
|
340
381
|
|
|
341
382
|
#visibilityMayHaveChanged(was: boolean): void {
|
|
342
|
-
if (was !== this.hidden() || this.#portType === PortType.InputArray) {
|
|
383
|
+
if (was !== this.hidden() || this.#portType === PortType.InputArray || this.#widget !== null) {
|
|
343
384
|
this.#node?.invalidateLayout();
|
|
344
385
|
}
|
|
345
386
|
}
|
|
346
387
|
|
|
388
|
+
setWidget(widget: Widget | null): void {
|
|
389
|
+
this.#widget = widget;
|
|
390
|
+
this.#node?.invalidateLayout();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
widget(): Widget | null {
|
|
394
|
+
return this.#widget;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The widget stands in for a wire, so it leaves once one is connected.
|
|
399
|
+
*/
|
|
400
|
+
widgetShown(): boolean {
|
|
401
|
+
return this.#widget !== null
|
|
402
|
+
&& this.#portType !== PortType.Output
|
|
403
|
+
&& this.#connections.length === 0
|
|
404
|
+
&& !this.hidden()
|
|
405
|
+
&& !(this.#widget.Hidden?.() ?? false);
|
|
406
|
+
}
|
|
407
|
+
|
|
347
408
|
/**
|
|
348
409
|
* A port asked to hide still shows while anything is connected to it,
|
|
349
410
|
* and while its node is showing its hidden ports.
|
package/src/quickMenu.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { FontWeight } from "./styles/text";
|
|
|
6
6
|
import { Theme } from "./theme";
|
|
7
7
|
import { Text } from './types/text';
|
|
8
8
|
import { ScaleVector, Vector2 } from './types/vector2';
|
|
9
|
+
import { Box, InBox } from './types/box';
|
|
9
10
|
import { Display } from './elements/base';
|
|
10
11
|
|
|
11
12
|
class QuickMenuGroup {
|
|
@@ -325,6 +326,16 @@ export class QuickMenu {
|
|
|
325
326
|
}
|
|
326
327
|
|
|
327
328
|
this.#container.render(ctx, pos, 1, this.renderSize);
|
|
329
|
+
this.#bounds = {
|
|
330
|
+
Position: { x: pos.x, y: pos.y },
|
|
331
|
+
Size: { x: this.renderSize.x, y: this.renderSize.y },
|
|
332
|
+
};
|
|
328
333
|
return null;
|
|
329
334
|
}
|
|
335
|
+
|
|
336
|
+
#bounds: Box | null = null;
|
|
337
|
+
|
|
338
|
+
public contains(point: Vector2): boolean {
|
|
339
|
+
return this.#bounds !== null && InBox(this.#bounds, point);
|
|
340
|
+
}
|
|
330
341
|
}
|
package/src/theme.ts
CHANGED
|
@@ -24,7 +24,8 @@ const defaults = {
|
|
|
24
24
|
Idle: "#1c1c1c",
|
|
25
25
|
MouseOver: "#8e8e8e",
|
|
26
26
|
Grabbed: "white",
|
|
27
|
-
Selected: "#6e6e6e"
|
|
27
|
+
Selected: "#6e6e6e",
|
|
28
|
+
Highlighted: "#ffc95c"
|
|
28
29
|
},
|
|
29
30
|
Bevel: null as BevelColors | null,
|
|
30
31
|
Port: {
|
|
@@ -111,6 +112,7 @@ const defaults = {
|
|
|
111
112
|
H3: {
|
|
112
113
|
FontSize: 16,
|
|
113
114
|
},
|
|
115
|
+
CodeFontFamily: null as string | null,
|
|
114
116
|
CodeBlock: {
|
|
115
117
|
BackgroundColor: "#0c2b35",
|
|
116
118
|
FontColor: "#afb9bb",
|
package/test/markdown.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { BuildMarkdown } from '../src/markdown/markdown';
|
|
|
3
3
|
import { BasicMarkdownEntry, CodeBlockEntry, UnorderedListMarkdownEntry } from '../src/markdown/entry';
|
|
4
4
|
import { FontStyle, FontWeight } from '../src/styles/text';
|
|
5
5
|
import { Text } from '../src/types/text';
|
|
6
|
+
import { setTheme } from '../src/theme';
|
|
6
7
|
|
|
7
8
|
/** The runs of text in an entry, as [content, weight, style] triples. */
|
|
8
9
|
function runs(entry: any): Array<[string, FontWeight, FontStyle]> {
|
|
@@ -141,6 +142,16 @@ describe('markdown: code', () => {
|
|
|
141
142
|
assert.notStrictEqual(entry.lines()[0].style().getFont(), 'monospace');
|
|
142
143
|
});
|
|
143
144
|
|
|
145
|
+
it('sets inline code in the theme code font', () => {
|
|
146
|
+
setTheme({ Note: { CodeFontFamily: 'VT323' } });
|
|
147
|
+
try {
|
|
148
|
+
const entry = BuildMarkdown('a `b` c')[0] as any;
|
|
149
|
+
assert.strictEqual(entry.lines()[1].style().getFont(), 'VT323');
|
|
150
|
+
} finally {
|
|
151
|
+
setTheme();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
144
155
|
it('keeps a lone backtick as text', () => {
|
|
145
156
|
assert.strictEqual(contentOf(BuildMarkdown('a ` b')[0]), 'a ` b');
|
|
146
157
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { strict as assert } from 'assert';
|
|
2
2
|
import { BuildMarkdown } from '../src/markdown/markdown';
|
|
3
3
|
import { CodeBlockEntry } from '../src/markdown/entry';
|
|
4
|
-
import { Theme } from '../src/theme';
|
|
4
|
+
import { Theme, setTheme } from '../src/theme';
|
|
5
5
|
|
|
6
6
|
interface Drawn {
|
|
7
7
|
box: { y: number; height: number } | null;
|
|
@@ -49,6 +49,26 @@ describe('a markdown code block', () => {
|
|
|
49
49
|
assert.deepEqual(drawn.lines.map((l) => l.text), ['const a = 1;', 'const b = 2;']);
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
it('draws in the theme code font, apart from the note text', () => {
|
|
53
|
+
setTheme({ FontFamily: 'Tahoma', Note: { CodeFontFamily: 'VT323' } });
|
|
54
|
+
try {
|
|
55
|
+
const entry = BuildMarkdown('```\none\n```')[0] as CodeBlockEntry;
|
|
56
|
+
assert.equal(entry.text().style().getFont(), 'VT323');
|
|
57
|
+
} finally {
|
|
58
|
+
setTheme();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('draws in the theme font when no code font is set', () => {
|
|
63
|
+
setTheme({ FontFamily: 'Tahoma' });
|
|
64
|
+
try {
|
|
65
|
+
const entry = BuildMarkdown('```\none\n```')[0] as CodeBlockEntry;
|
|
66
|
+
assert.equal(entry.text().style().getFont(), 'Tahoma');
|
|
67
|
+
} finally {
|
|
68
|
+
setTheme();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
52
72
|
it('reports the height it drew', () => {
|
|
53
73
|
const entry = BuildMarkdown('```\none\n```')[0] as CodeBlockEntry;
|
|
54
74
|
const drawn: Drawn = { box: null, lines: [] };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { strict as assert } from 'assert';
|
|
2
|
+
import { MouseObserver } from '../src/input';
|
|
3
|
+
|
|
4
|
+
function observe(run: (fire: (name: string, event?: any) => void, fireDocument: (name: string, event?: any) => void, left: () => number) => void) {
|
|
5
|
+
const listeners = new Map<string, Array<(e: any) => void>>();
|
|
6
|
+
const documentListeners = new Map<string, Array<(e: any) => void>>();
|
|
7
|
+
const ele: any = {
|
|
8
|
+
style: {},
|
|
9
|
+
addEventListener: (name: string, fn: (e: any) => void) => {
|
|
10
|
+
listeners.set(name, [...(listeners.get(name) ?? []), fn]);
|
|
11
|
+
},
|
|
12
|
+
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
|
13
|
+
};
|
|
14
|
+
const dispatch = (from: Map<string, Array<(e: any) => void>>) => (name: string, event: any = {}) => {
|
|
15
|
+
event.preventDefault = () => { };
|
|
16
|
+
event.button ??= 0;
|
|
17
|
+
event.clientX ??= 10;
|
|
18
|
+
event.clientY ??= 10;
|
|
19
|
+
(from.get(name) ?? []).forEach((fn) => fn(event));
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let leaves = 0;
|
|
23
|
+
const g = globalThis as any;
|
|
24
|
+
const previous = g.document;
|
|
25
|
+
g.document = {
|
|
26
|
+
addEventListener: (name: string, fn: (e: any) => void) => {
|
|
27
|
+
documentListeners.set(name, [...(documentListeners.get(name) ?? []), fn]);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
try {
|
|
31
|
+
new MouseObserver(ele, () => { }, () => { }, () => { }, () => { }, () => { }, () => { }, () => { },
|
|
32
|
+
() => { leaves++; });
|
|
33
|
+
run(dispatch(listeners), dispatch(documentListeners), () => leaves);
|
|
34
|
+
} finally {
|
|
35
|
+
g.document = previous;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('the mouse leaving the canvas', () => {
|
|
40
|
+
it('is reported when nothing is held', () => {
|
|
41
|
+
observe((fire, _, left) => {
|
|
42
|
+
fire('mousemove');
|
|
43
|
+
fire('mouseleave');
|
|
44
|
+
assert.equal(left(), 1);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('is reported once a drag that left the canvas is let go', () => {
|
|
49
|
+
observe((fire, fireDocument, left) => {
|
|
50
|
+
fire('mousemove');
|
|
51
|
+
fire('mousedown');
|
|
52
|
+
fire('mouseleave');
|
|
53
|
+
assert.equal(left(), 0, 'still dragging');
|
|
54
|
+
|
|
55
|
+
fireDocument('mouseup');
|
|
56
|
+
assert.equal(left(), 1);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('is not reported when a drag comes back before it is let go', () => {
|
|
61
|
+
observe((fire, fireDocument, left) => {
|
|
62
|
+
fire('mousemove');
|
|
63
|
+
fire('mousedown');
|
|
64
|
+
fire('mouseleave');
|
|
65
|
+
fire('mousemove');
|
|
66
|
+
fireDocument('mouseup');
|
|
67
|
+
assert.equal(left(), 0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
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 { Theme } from '../src/theme';
|
|
7
|
+
import { center, stubCtx } from './support';
|
|
8
|
+
|
|
9
|
+
describe('the node hover listener', () => {
|
|
10
|
+
it('reports the node under the mouse once, then null when the mouse leaves', () => {
|
|
11
|
+
const graph = new NodeSubsystem(new PassSubsystem());
|
|
12
|
+
const node = new FlowNode({ title: 'hovered', position: { x: 100, y: 100 } });
|
|
13
|
+
graph.addNode(node);
|
|
14
|
+
const ctx = stubCtx();
|
|
15
|
+
const camera = new Camera();
|
|
16
|
+
const seen: Array<FlowNode | null> = [];
|
|
17
|
+
graph.addNodeHoverListener((n) => seen.push(n));
|
|
18
|
+
|
|
19
|
+
graph.render(ctx, camera, undefined);
|
|
20
|
+
const over = center(node.calculateBounds(ctx, camera));
|
|
21
|
+
graph.render(ctx, camera, over);
|
|
22
|
+
graph.render(ctx, camera, over);
|
|
23
|
+
graph.render(ctx, camera, undefined);
|
|
24
|
+
|
|
25
|
+
assert.deepEqual(seen, [node, null]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('a highlighted node', () => {
|
|
30
|
+
it('keeps a screen-width outline in the highlight colour when zoomed far out', () => {
|
|
31
|
+
const node = new FlowNode({ title: 'lit', position: { x: 100, y: 100 } });
|
|
32
|
+
const graph = new NodeSubsystem(new PassSubsystem());
|
|
33
|
+
graph.addNode(node);
|
|
34
|
+
const strokes: Array<{ style: string; width: number }> = [];
|
|
35
|
+
const ctx = stubCtx();
|
|
36
|
+
(ctx as any).stroke = function () { strokes.push({ style: this.strokeStyle, width: this.lineWidth }); };
|
|
37
|
+
const camera = new Camera();
|
|
38
|
+
camera.zoom = 0.1;
|
|
39
|
+
|
|
40
|
+
node.setHighlighted(true);
|
|
41
|
+
graph.render(ctx, camera, undefined);
|
|
42
|
+
|
|
43
|
+
assert.ok(strokes.some((s) => s.style === Theme.Node.Border.Highlighted && s.width === 2));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('draws its ports with the same line width as when it is not highlighted', () => {
|
|
47
|
+
const widths = (highlighted: boolean) => {
|
|
48
|
+
const node = new FlowNode({ title: 'lit', position: { x: 100, y: 100 }, inputs: [{ name: 'In', type: 'float64' }] });
|
|
49
|
+
const graph = new NodeSubsystem(new PassSubsystem());
|
|
50
|
+
graph.addNode(node);
|
|
51
|
+
const strokes: Array<{ style: string; width: number }> = [];
|
|
52
|
+
const ctx = stubCtx();
|
|
53
|
+
let width = 1;
|
|
54
|
+
Object.defineProperty(ctx, 'lineWidth', { get: () => width, set: (w: number) => { width = w; } });
|
|
55
|
+
const saved: Array<number> = [];
|
|
56
|
+
(ctx as any).save = () => saved.push(width);
|
|
57
|
+
(ctx as any).restore = () => { width = saved.pop() ?? width; };
|
|
58
|
+
(ctx as any).stroke = function () { strokes.push({ style: this.strokeStyle, width }); };
|
|
59
|
+
const camera = new Camera();
|
|
60
|
+
camera.zoom = 0.1;
|
|
61
|
+
|
|
62
|
+
node.setHighlighted(highlighted);
|
|
63
|
+
graph.render(ctx, camera, undefined);
|
|
64
|
+
return strokes.filter((s) => s.style !== Theme.Node.Border.Highlighted).map((s) => s.width);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
assert.deepEqual(widths(true), widths(false));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -117,3 +117,27 @@ describe('filtering the new node menu to what could finish a wire', () => {
|
|
|
117
117
|
assert.ok(!groups.includes('Math'), `Math should be gone, got ${groups}`);
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
describe('filtering the new node menu through casts', () => {
|
|
122
|
+
const graph = {} as any;
|
|
123
|
+
const at = { x: 0, y: 0 };
|
|
124
|
+
const casts = [{ from: 'int', to: 'float64' }];
|
|
125
|
+
|
|
126
|
+
it('dragging an int output offers nodes taking a float64', () => {
|
|
127
|
+
const names = itemNames(publisher().contextMenu(graph, at, {
|
|
128
|
+
needs: PortType.Input,
|
|
129
|
+
types: ['int'],
|
|
130
|
+
casts,
|
|
131
|
+
}));
|
|
132
|
+
assert.deepStrictEqual(names, ['Add', 'Negate', 'Select']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('dragging an int input does not offer float64 producers', () => {
|
|
136
|
+
const names = itemNames(publisher().contextMenu(graph, at, {
|
|
137
|
+
needs: PortType.Output,
|
|
138
|
+
types: ['int'],
|
|
139
|
+
casts,
|
|
140
|
+
}));
|
|
141
|
+
assert.deepStrictEqual(names, ['Select']);
|
|
142
|
+
});
|
|
143
|
+
});
|
package/test/port.test.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import * as assert from 'assert';
|
|
2
|
-
import { Port, PortConfig, PortType, portsCompatible } from '../src/port';
|
|
2
|
+
import { Port, PortConfig, PortType, portsCompatible, portsConnect } from '../src/port';
|
|
3
3
|
import { FlowNode } from '../src/node';
|
|
4
|
+
import { DanglingConnection, NodeSubsystem } from '../src/nodes/subsystem';
|
|
5
|
+
import { PassSubsystem } from '../src/pass/subsystem';
|
|
6
|
+
import { Camera } from '../src/camera';
|
|
7
|
+
import { center, stubCtx } from './support';
|
|
4
8
|
|
|
5
9
|
function port(config: PortConfig): Port {
|
|
6
10
|
// A port only reaches back to its node when it is drawn or connected,
|
|
@@ -123,3 +127,47 @@ describe('settling a port on a type', () => {
|
|
|
123
127
|
assert.ok(portsCompatible(settled, port({ type: 'float64' })));
|
|
124
128
|
});
|
|
125
129
|
});
|
|
130
|
+
|
|
131
|
+
describe('casts', () => {
|
|
132
|
+
const casts = [{ from: 'int', to: 'float64' }];
|
|
133
|
+
|
|
134
|
+
it('lets an output feed an input it casts to', () => {
|
|
135
|
+
assert.ok(portsConnect(port({ type: 'int' }), port({ type: 'float64' }), casts));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('only goes the way it was registered', () => {
|
|
139
|
+
assert.ok(!portsConnect(port({ type: 'float64' }), port({ type: 'int' }), casts));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('reaches any of the types an input takes', () => {
|
|
143
|
+
const lifted = port({ type: 'float64', acceptedTypes: ['float64', '[]float64'] });
|
|
144
|
+
assert.ok(portsConnect(port({ type: 'int' }), lifted, casts));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('refuses mismatched types without one', () => {
|
|
148
|
+
assert.ok(!portsConnect(port({ type: 'int' }), port({ type: 'float64' })));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('wires a node made from a released wire through a cast', () => {
|
|
152
|
+
const graph = new NodeSubsystem(new PassSubsystem(), { casts });
|
|
153
|
+
const source = new FlowNode({ title: 'source', position: { x: 0, y: 0 }, outputs: [{ name: 'Out', type: 'int' }] });
|
|
154
|
+
graph.addNode(source);
|
|
155
|
+
const camera = new Camera();
|
|
156
|
+
const ctx = stubCtx();
|
|
157
|
+
graph.render(ctx, camera, undefined);
|
|
158
|
+
|
|
159
|
+
let dangling: DanglingConnection | null = null;
|
|
160
|
+
graph.setConnectionReleasedHandler((d) => dangling = d);
|
|
161
|
+
const out = center(source.outputPortPosition(0));
|
|
162
|
+
graph.render(ctx, camera, out);
|
|
163
|
+
graph.clickStart(out, camera, false);
|
|
164
|
+
graph.render(ctx, camera, { x: 600, y: 500 });
|
|
165
|
+
graph.clickEnd();
|
|
166
|
+
|
|
167
|
+
const made = new FlowNode({ title: 'made', position: { x: 300, y: 0 }, inputs: [{ name: 'In', type: 'float64' }] });
|
|
168
|
+
graph.addNode(made);
|
|
169
|
+
dangling!.connectTo(made);
|
|
170
|
+
|
|
171
|
+
assert.equal(made.inputPort(0).connections().length, 1);
|
|
172
|
+
});
|
|
173
|
+
});
|