@elicdavis/node-flow 0.1.20 → 0.2.1

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.
Files changed (53) hide show
  1. package/dist/index.d.mts +393 -226
  2. package/dist/index.d.ts +393 -226
  3. package/dist/index.js +1905 -1058
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +1888 -1057
  6. package/dist/index.mjs.map +1 -1
  7. package/index.html +1134 -329
  8. package/package.json +1 -1
  9. package/src/connection.ts +30 -5
  10. package/src/contextMenu.ts +66 -34
  11. package/src/graph.ts +19 -14
  12. package/src/index.ts +26 -12
  13. package/src/markdown/entry.ts +11 -15
  14. package/src/markdown/syntaxParser.ts +2 -1
  15. package/src/node.ts +160 -101
  16. package/src/nodes/subsystem.ts +113 -29
  17. package/src/notes/note.ts +9 -1
  18. package/src/organize.ts +540 -111
  19. package/src/popup.ts +4 -2
  20. package/src/port.ts +83 -6
  21. package/src/styles/bevel.ts +31 -0
  22. package/src/styles/cursor.ts +1 -0
  23. package/src/styles/text.ts +4 -4
  24. package/src/theme.ts +179 -92
  25. package/src/types/text.ts +5 -1
  26. package/src/utils/color.ts +1 -1
  27. package/src/widgets/button.ts +52 -107
  28. package/src/widgets/color.ts +91 -150
  29. package/src/widgets/factory.ts +89 -46
  30. package/src/widgets/field.ts +99 -0
  31. package/src/widgets/image.ts +107 -165
  32. package/src/widgets/inplace.ts +136 -0
  33. package/src/widgets/number.ts +115 -154
  34. package/src/widgets/row.ts +138 -0
  35. package/src/widgets/slider.ts +101 -216
  36. package/src/widgets/string.ts +32 -130
  37. package/src/widgets/text.ts +81 -92
  38. package/src/widgets/toggle.ts +62 -207
  39. package/src/widgets/value.ts +127 -0
  40. package/src/widgets/widget.ts +173 -6
  41. package/test/context_menu.test.ts +82 -0
  42. package/test/cursor.test.ts +17 -0
  43. package/test/hidden_port.test.ts +125 -0
  44. package/test/markdown.test.ts +2 -2
  45. package/test/markdown_code_block.test.ts +58 -0
  46. package/test/multi_input.test.ts +124 -0
  47. package/test/organize.test.ts +117 -1
  48. package/test/support.ts +188 -0
  49. package/test/theme.test.ts +86 -0
  50. package/test/widget_click.test.ts +111 -97
  51. package/test/widget_construction.test.ts +5 -4
  52. package/test/widgets.test.ts +386 -0
  53. package/test/wire_follows_camera.test.ts +57 -0
@@ -1,14 +1,181 @@
1
- import { Box } from '../types/box';
1
+ import { Box, InBox } from '../types/box';
2
2
  import { Vector2 } from '../types/vector2';
3
+ import { CursorStyle } from '../styles/cursor';
4
+ import { requestRender } from '../render_scheduler';
5
+ import { editInPlace, InPlaceEdit } from './inplace';
3
6
 
4
7
  export const width = 150;
5
8
  export const height = 25;
6
9
 
10
+ /**
11
+ * Where the pointer is, in canvas pixels, and whether it is over the widget.
12
+ * A press that starts on a widget keeps reporting to it until it is let go,
13
+ * wherever the pointer wanders.
14
+ */
15
+ export interface WidgetPointer {
16
+ position: Vector2;
17
+ inside: boolean;
18
+ }
19
+
7
20
  export interface Widget {
8
- Size(): Vector2
9
- Draw(ctx: CanvasRenderingContext2D, position: Vector2, scale: number, mousePosition: Vector2 | undefined): Box
10
- ClickStart(): void
11
- ClickEnd(): void
21
+ /**
22
+ * The smallest room the widget needs, in graph units. The node may hand
23
+ * it a wider box.
24
+ */
25
+ Size(): Vector2;
26
+
27
+ Draw(ctx: CanvasRenderingContext2D, box: Box, scale: number, mousePosition: Vector2 | undefined): void;
28
+
29
+ PointerDown?(pointer: WidgetPointer): void;
30
+ PointerMove?(pointer: WidgetPointer): void;
31
+ PointerUp?(pointer: WidgetPointer): void;
32
+
33
+ Cursor?(): CursorStyle;
34
+ Hidden?(): boolean;
35
+ Disabled?(): boolean;
36
+ }
37
+
38
+ export abstract class WidgetBase implements Widget {
39
+
40
+ #hidden = false;
41
+
42
+ #disabled = false;
43
+
44
+ #hovered = false;
45
+
46
+ #pressed = false;
47
+
48
+ #box: Box = { Position: { x: 0, y: 0 }, Size: { x: 0, y: 0 } };
49
+
50
+ #canvas: HTMLCanvasElement | null = null;
51
+
52
+ #scale = 1;
53
+
54
+ #editing: InPlaceEdit | null = null;
55
+
56
+ abstract Size(): Vector2;
57
+
58
+ protected abstract render(ctx: CanvasRenderingContext2D, box: Box, scale: number): void;
59
+
60
+ Draw(ctx: CanvasRenderingContext2D, box: Box, scale: number, mousePosition: Vector2 | undefined): void {
61
+ this.#box = { Position: { x: box.Position.x, y: box.Position.y }, Size: { x: box.Size.x, y: box.Size.y } };
62
+ this.#canvas = ctx.canvas ?? null;
63
+ this.#scale = scale;
64
+ this.#hovered = !this.#disabled && mousePosition !== undefined && InBox(box, mousePosition);
65
+
66
+ if (this.#editing !== null) {
67
+ if (this.#editing.input.isConnected) {
68
+ this.#editing.place(this.#box, scale);
69
+ } else {
70
+ this.#editing = null;
71
+ }
72
+ }
73
+
74
+ ctx.save();
75
+ if (this.#disabled) {
76
+ ctx.globalAlpha *= 0.4;
77
+ }
78
+ this.render(ctx, box, scale);
79
+ ctx.restore();
80
+ }
81
+
82
+ PointerDown(pointer: WidgetPointer): void {
83
+ if (this.#disabled) {
84
+ return;
85
+ }
86
+ this.#pressed = true;
87
+ this.onPointerDown(pointer);
88
+ requestRender();
89
+ }
90
+
91
+ PointerMove(pointer: WidgetPointer): void {
92
+ if (!this.#pressed) {
93
+ return;
94
+ }
95
+ this.onPointerMove(pointer);
96
+ requestRender();
97
+ }
98
+
99
+ PointerUp(pointer: WidgetPointer): void {
100
+ if (!this.#pressed) {
101
+ return;
102
+ }
103
+ this.#pressed = false;
104
+ this.onPointerUp(pointer);
105
+ if (pointer.inside) {
106
+ this.onClick(pointer);
107
+ }
108
+ requestRender();
109
+ }
110
+
111
+ Cursor(): CursorStyle {
112
+ return CursorStyle.Pointer;
113
+ }
114
+
115
+ Hidden(): boolean {
116
+ return this.#hidden;
117
+ }
118
+
119
+ setHidden(hidden: boolean): void {
120
+ this.#hidden = hidden;
121
+ requestRender();
122
+ }
123
+
124
+ Disabled(): boolean {
125
+ return this.#disabled;
126
+ }
127
+
128
+ setDisabled(disabled: boolean): void {
129
+ this.#disabled = disabled;
130
+ if (disabled) {
131
+ this.#pressed = false;
132
+ }
133
+ requestRender();
134
+ }
135
+
136
+ protected hovered(): boolean {
137
+ return this.#hovered;
138
+ }
139
+
140
+ protected pressed(): boolean {
141
+ return this.#pressed;
142
+ }
143
+
144
+ /**
145
+ * Where the widget was last drawn, in canvas pixels.
146
+ */
147
+ protected box(): Box {
148
+ return this.#box;
149
+ }
150
+
151
+ protected canvas(): HTMLCanvasElement | null {
152
+ return this.#canvas;
153
+ }
154
+
155
+ protected scale(): number {
156
+ return this.#scale;
157
+ }
158
+
159
+ protected editText(value: string, onCommit: (text: string) => void): void {
160
+ this.#editing = editInPlace({
161
+ canvas: this.#canvas,
162
+ box: this.#box,
163
+ scale: this.#scale,
164
+ value,
165
+ onCommit,
166
+ });
167
+ }
168
+
169
+ protected onPointerDown(pointer: WidgetPointer): void { }
170
+
171
+ protected onPointerMove(pointer: WidgetPointer): void { }
172
+
173
+ protected onPointerUp(pointer: WidgetPointer): void { }
174
+
175
+ /**
176
+ * A press and release that both landed on the widget.
177
+ */
178
+ protected onClick(pointer: WidgetPointer): void { }
12
179
  }
13
180
 
14
181
  interface PropertyReader {
@@ -31,4 +198,4 @@ export function startingWidgetValue<T>(
31
198
  }
32
199
  }
33
200
  return fallback;
34
- }
201
+ }
@@ -0,0 +1,82 @@
1
+ import { strict as assert } from 'assert';
2
+ import { ContextMenu, ContextMenuConfig } from '../src/contextMenu';
3
+ import { setTheme } from '../src/theme';
4
+
5
+ interface Frame {
6
+ texts: Array<{ text: string; x: number; y: number; fillStyle: string }>;
7
+ }
8
+
9
+ function ctxFor(width: number, frame: Frame): CanvasRenderingContext2D {
10
+ const ctx: any = {
11
+ canvas: { clientWidth: width, clientHeight: 600 },
12
+ font: '', fillStyle: '', strokeStyle: '', lineWidth: 1,
13
+ textAlign: 'left', textBaseline: 'middle', shadowColor: '', shadowBlur: 0,
14
+ measureText: (text: string) => ({ width: text.length * 7, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }),
15
+ beginPath() { }, roundRect() { }, fill() { }, stroke() { }, lineTo() { }, moveTo() { },
16
+ fillText(text: string, x: number, y: number) { frame.texts.push({ text, x, y, fillStyle: this.fillStyle }); },
17
+ };
18
+ return ctx as CanvasRenderingContext2D;
19
+ }
20
+
21
+ function render(menu: ContextMenu, width: number, at: { x: number; y: number }, mouse: { x: number; y: number }) {
22
+ const frame: Frame = { texts: [] };
23
+ const over = menu.render(ctxFor(width, frame), at, 1, mouse, true);
24
+ return { over, frame, find: (text: string) => frame.texts.find((t) => t.text === text) };
25
+ }
26
+
27
+ const ROW = 30 * 1.25;
28
+
29
+ const config: ContextMenuConfig = {
30
+ subMenus: [
31
+ { name: 'More', items: [{ name: 'Alpha' }, { name: 'Beta' }] },
32
+ { name: 'Other', items: [{ name: 'Gamma' }] },
33
+ ],
34
+ };
35
+
36
+ describe('a context menu', () => {
37
+ it('opens a submenu on the far side of its parent when the right is out of room', () => {
38
+ const menu = new ContextMenu(config);
39
+ const at = { x: 250, y: 100 };
40
+ const onMore = { x: at.x + 10, y: at.y + ROW / 2 };
41
+
42
+ render(menu, 400, at, onMore);
43
+ const { find } = render(menu, 400, at, onMore);
44
+
45
+ const alpha = find('Alpha');
46
+ assert.ok(alpha, 'the submenu is open');
47
+ assert.ok(alpha.x < at.x, `the submenu sits left of its parent, not on top of it (at ${alpha.x})`);
48
+ });
49
+
50
+ it('lights only the row under the pointer in the frame it moves there', () => {
51
+ setTheme({ ContextMenu: { FontColor: '#000000', HighlightFontColor: '#ffffff' } });
52
+ const menu = new ContextMenu(config);
53
+ const at = { x: 10, y: 100 };
54
+
55
+ render(menu, 800, at, { x: at.x + 5, y: at.y + ROW / 2 });
56
+ const moved = render(menu, 800, at, { x: at.x + 5, y: at.y + ROW * 1.5 });
57
+ setTheme();
58
+
59
+ assert.equal(moved.find('More')?.fillStyle, '#000000');
60
+ assert.equal(moved.find('Other')?.fillStyle, '#ffffff');
61
+ assert.ok(moved.find('Gamma'), 'the newly hovered row opens its submenu in the same frame');
62
+ assert.ok(!moved.find('Alpha'), 'the old submenu is gone in the same frame');
63
+ });
64
+
65
+ it('keeps listening to an open submenu that has to overlap its parent', () => {
66
+ const menu = new ContextMenu(config);
67
+ const at = { x: 10, y: 100 };
68
+
69
+ render(menu, 120, at, { x: at.x + 5, y: at.y + ROW / 2 });
70
+ const opened = render(menu, 120, at, { x: at.x + 5, y: at.y + ROW / 2 });
71
+ const beta = opened.find('Beta');
72
+ assert.ok(beta, 'the submenu is open');
73
+
74
+ const onBeta = { x: beta.x + 5, y: beta.y };
75
+ render(menu, 120, at, onBeta);
76
+ const hovering = render(menu, 120, at, onBeta);
77
+
78
+ assert.equal(hovering.over?.text, 'Beta');
79
+ assert.ok(hovering.find('Alpha'), 'the submenu under the pointer stays open');
80
+ assert.ok(!hovering.find('Gamma'), 'the parent row underneath does not open its own submenu');
81
+ });
82
+ });
@@ -0,0 +1,17 @@
1
+ import { strict as assert } from 'assert';
2
+ import { GraphView } from '../src/graph';
3
+ import { NoteSubsystem } from '../src/notes/subsystem';
4
+ import { CursorStyle } from '../src/styles/cursor';
5
+ import { SliderWidget } from '../src/widgets/slider';
6
+ import { center, widgetScene } from './support';
7
+
8
+ describe('the cursor', () => {
9
+ it('shows the hovered widget\'s cursor once drawn through the graph', () => {
10
+ const { graph, camera, ctx, widgetBox } = widgetScene((node) => new SliderWidget(node, { value: 0.5 }));
11
+ const view = new GraphView([new NoteSubsystem(), graph]);
12
+
13
+ const results = view.render(ctx, camera, center(widgetBox()));
14
+
15
+ assert.equal(results?.cursorStyle, CursorStyle.EWResize);
16
+ });
17
+ });
@@ -0,0 +1,125 @@
1
+ import { strict as assert } from 'assert';
2
+ import { NodeSubsystem } from '../src/nodes/subsystem';
3
+ import { PassSubsystem } from '../src/pass/subsystem';
4
+ import { FlowNode } from '../src/node';
5
+ import { Camera } from '../src/camera';
6
+ import { Box } from '../src/types/box';
7
+ import { Vector2 } from '../src/types/vector2';
8
+
9
+ function stubCtx(): CanvasRenderingContext2D {
10
+ const ctx: any = {
11
+ canvas: { width: 800, height: 600 },
12
+ lineWidth: 1,
13
+ font: '',
14
+ textAlign: 'left',
15
+ textBaseline: 'top',
16
+ fillStyle: '',
17
+ strokeStyle: '',
18
+ globalAlpha: 1,
19
+ measureText: () => ({ width: 40, 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 center(box: Box): Vector2 {
32
+ return { x: box.Position.x + box.Size.x / 2, y: box.Position.y + box.Size.y / 2 };
33
+ }
34
+
35
+ function scene(hidden: boolean) {
36
+ const graph = new NodeSubsystem(new PassSubsystem());
37
+ const node = new FlowNode({
38
+ title: 'material',
39
+ position: { x: 100, y: 100 },
40
+ inputs: [
41
+ { name: 'Color', type: 'color' },
42
+ { name: 'Sheen', type: 'sheen', hidden },
43
+ { name: 'Roughness', type: 'float64' },
44
+ ],
45
+ outputs: [{ name: 'Out', type: 'material' }],
46
+ });
47
+ graph.addNode(node);
48
+ return { graph, node, camera: new Camera(), ctx: stubCtx() };
49
+ }
50
+
51
+ describe('a hidden port', () => {
52
+ it('takes no room on its node', () => {
53
+ const shown = scene(false);
54
+ const hidden = scene(true);
55
+
56
+ const shownHeight = shown.node.calculateBounds(shown.ctx, shown.camera).Size.y;
57
+ const hiddenHeight = hidden.node.calculateBounds(hidden.ctx, hidden.camera).Size.y;
58
+
59
+ assert.ok(hiddenHeight < shownHeight);
60
+ });
61
+
62
+ it('cannot be hovered where it would have been drawn', () => {
63
+ const { graph, node, camera, ctx } = scene(false);
64
+ graph.render(ctx, camera, undefined);
65
+ const where = center(node.inputPortPosition(1));
66
+
67
+ node.inputPort(1).setHidden(true);
68
+ graph.render(ctx, camera, undefined);
69
+
70
+ assert.notEqual(node.inBounds(ctx, camera, where).Port, node.inputPort(1));
71
+ });
72
+
73
+ it('keeps the ports after it lined up with their indices', () => {
74
+ const { graph, node, camera, ctx } = scene(true);
75
+ graph.render(ctx, camera, undefined);
76
+
77
+ const roughness = center(node.inputPortPosition(2));
78
+ assert.equal(node.inBounds(ctx, camera, roughness).Port, node.inputPort(2));
79
+ });
80
+
81
+ it('asks for a fresh layout when it is shown or hidden', () => {
82
+ const { graph, node, camera, ctx } = scene(true);
83
+ graph.render(ctx, camera, undefined);
84
+ assert.ok(node.hasBeenLaidOut());
85
+
86
+ node.inputPort(1).setHidden(false);
87
+
88
+ assert.ok(!node.hasBeenLaidOut());
89
+ });
90
+
91
+ it('asks for a fresh layout when a wire makes it show', () => {
92
+ const { graph, node, camera, ctx } = scene(true);
93
+ const source = new FlowNode({ title: 'sheen', outputs: [{ name: 'Out', type: 'sheen' }] });
94
+ graph.addNode(source);
95
+ graph.render(ctx, camera, undefined);
96
+
97
+ graph.connectNodes(source, 0, node, 1);
98
+
99
+ assert.ok(!node.hasBeenLaidOut());
100
+ });
101
+
102
+ it('moves the ports below it for a node drawn off screen', () => {
103
+ const { graph, node, camera, ctx } = scene(true);
104
+ const offset = () => node.inputPortPosition(2).Position.y - node.calculateBounds(ctx, camera).Position.y;
105
+ graph.render(ctx, camera, undefined);
106
+ const before = offset();
107
+
108
+ camera.position.x = -50000;
109
+ graph.render(ctx, camera, undefined);
110
+ node.inputPort(1).setHidden(false);
111
+ graph.render(ctx, camera, undefined);
112
+
113
+ assert.ok(offset() > before);
114
+ });
115
+
116
+ it('shows while something is connected to it', () => {
117
+ const { graph, node } = scene(true);
118
+ const source = new FlowNode({ title: 'sheen', outputs: [{ name: 'Out', type: 'sheen' }] });
119
+ graph.addNode(source);
120
+
121
+ graph.connectNodes(source, 0, node, 1);
122
+
123
+ assert.equal(node.inputPort(1).hidden(), false);
124
+ });
125
+ });
@@ -149,12 +149,12 @@ describe('markdown: code', () => {
149
149
  const entries = BuildMarkdown('```\nlet a = *b*;\n```');
150
150
  assert.strictEqual(entries.length, 1);
151
151
  assert.ok(entries[0] instanceof CodeBlockEntry, `got ${entries[0].constructor.name}`);
152
- assert.strictEqual(contentOf(entries[0]), 'let a = *b*;\n');
152
+ assert.strictEqual(contentOf(entries[0]), 'let a = *b*;');
153
153
  });
154
154
 
155
155
  it('keeps more than one line of a fenced block', () => {
156
156
  const entries = BuildMarkdown('```\none\ntwo\n```');
157
- assert.strictEqual(contentOf(entries[0]), 'one\ntwo\n');
157
+ assert.strictEqual(contentOf(entries[0]), 'one\ntwo');
158
158
  });
159
159
 
160
160
  it('carries on after a fenced block', () => {
@@ -0,0 +1,58 @@
1
+ import { strict as assert } from 'assert';
2
+ import { BuildMarkdown } from '../src/markdown/markdown';
3
+ import { CodeBlockEntry } from '../src/markdown/entry';
4
+ import { Theme } from '../src/theme';
5
+
6
+ interface Drawn {
7
+ box: { y: number; height: number } | null;
8
+ lines: Array<{ text: string; y: number; baseline: string }>;
9
+ }
10
+
11
+ function recordingCtx(drawn: Drawn): CanvasRenderingContext2D {
12
+ const ctx: any = {
13
+ font: '',
14
+ fillStyle: '',
15
+ textAlign: 'left',
16
+ textBaseline: 'alphabetic',
17
+ measureText: (text: string) => ({ width: text.length * 7, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }),
18
+ save() { }, restore() { },
19
+ beginPath() { }, fill() { },
20
+ roundRect(_x: number, y: number, _w: number, height: number) { drawn.box = { y, height }; },
21
+ fillText(text: string, _x: number, y: number) { drawn.lines.push({ text, y, baseline: this.textBaseline }); },
22
+ };
23
+ return ctx as CanvasRenderingContext2D;
24
+ }
25
+
26
+ function draw(markdown: string): { drawn: Drawn; size: number } {
27
+ const entry = BuildMarkdown(markdown)[0] as CodeBlockEntry;
28
+ const drawn: Drawn = { box: null, lines: [] };
29
+ entry.render(recordingCtx(drawn), { x: 0, y: 100 }, 1, 400);
30
+ return { drawn, size: entry.text().style().getSize() };
31
+ }
32
+
33
+ describe('a markdown code block', () => {
34
+ it('leaves the same room above its first line as below its last', () => {
35
+ const { drawn, size } = draw('```\nconst a = 1;\nconst b = 2;\n```');
36
+ const box = drawn.box!;
37
+ const first = drawn.lines[0];
38
+ const last = drawn.lines[drawn.lines.length - 1];
39
+
40
+ assert.equal(first.baseline, 'top', 'lines are placed by the top of the text');
41
+ const above = first.y - box.y;
42
+ const below = (box.y + box.height) - (last.y + size);
43
+ assert.equal(above, Theme.Note.CodeBlock.Padding);
44
+ assert.equal(below, Theme.Note.CodeBlock.Padding);
45
+ });
46
+
47
+ it('does not keep the newline before the closing backticks as an empty last line', () => {
48
+ const { drawn } = draw('```\nconst a = 1;\nconst b = 2;\n```');
49
+ assert.deepEqual(drawn.lines.map((l) => l.text), ['const a = 1;', 'const b = 2;']);
50
+ });
51
+
52
+ it('reports the height it drew', () => {
53
+ const entry = BuildMarkdown('```\none\n```')[0] as CodeBlockEntry;
54
+ const drawn: Drawn = { box: null, lines: [] };
55
+ const height = entry.render(recordingCtx(drawn), { x: 0, y: 0 }, 1, 400);
56
+ assert.equal(height, drawn.box!.height);
57
+ });
58
+ });
@@ -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
+ });