@elicdavis/node-flow 0.1.12 → 0.1.14
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 +29 -2
- package/dist/index.d.ts +29 -2
- package/dist/index.js +332 -95
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +331 -95
- package/dist/index.mjs.map +1 -1
- package/dist/web/NodeFlow.js +4 -4
- package/dist/web/NodeFlow.js.map +3 -3
- package/package.json +1 -1
- package/src/camera.ts +23 -1
- package/src/connection.ts +33 -4
- package/src/contextMenu.ts +35 -3
- package/src/graph.ts +97 -38
- package/src/index.ts +3 -0
- package/src/input.ts +183 -74
- package/src/node.ts +19 -18
- package/src/nodes/subsystem.ts +43 -0
- package/src/theme.ts +1 -0
package/package.json
CHANGED
package/src/camera.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { Vector2, Zero } from "./types/vector2";
|
|
2
2
|
|
|
3
|
+
/** Serializable camera orientation for save/restore. */
|
|
4
|
+
export interface CameraOrientation {
|
|
5
|
+
position: Vector2;
|
|
6
|
+
zoom: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
export class Camera {
|
|
4
10
|
|
|
5
11
|
zoom: number;
|
|
@@ -22,9 +28,25 @@ export class Camera {
|
|
|
22
28
|
out.y = this.position.y + (graphPosition.y * this.zoom);
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
getOrientation(): CameraOrientation {
|
|
32
|
+
return {
|
|
33
|
+
position: {
|
|
34
|
+
x: this.position.x,
|
|
35
|
+
y: this.position.y,
|
|
36
|
+
},
|
|
37
|
+
zoom: this.zoom,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setOrientation(orientation: CameraOrientation): void {
|
|
42
|
+
this.position.x = orientation.position.x;
|
|
43
|
+
this.position.y = orientation.position.y;
|
|
44
|
+
this.zoom = orientation.zoom;
|
|
45
|
+
}
|
|
46
|
+
|
|
25
47
|
reset(): void {
|
|
26
48
|
this.zoom = 1;
|
|
27
49
|
this.position.x = 0;
|
|
28
50
|
this.position.y = 0;
|
|
29
51
|
}
|
|
30
|
-
}
|
|
52
|
+
}
|
package/src/connection.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { FlowNode } from "./node";
|
|
|
2
2
|
import { Port } from "./port";
|
|
3
3
|
import { BoxCenter, InBox } from "./types/box";
|
|
4
4
|
import { Vector2, Zero } from "./types/vector2";
|
|
5
|
+
import { Clamp01 } from "./utils/math";
|
|
5
6
|
|
|
6
7
|
export interface ConnectionRendererParams {
|
|
7
8
|
ctx: CanvasRenderingContext2D;
|
|
@@ -18,6 +19,16 @@ export interface ConnectionRendererParams {
|
|
|
18
19
|
|
|
19
20
|
export type ConnectionRenderer = (params: ConnectionRendererParams) => void
|
|
20
21
|
|
|
22
|
+
/** Horizontal stub (screen px) at full strength on each port when output is right of input. */
|
|
23
|
+
const BACKWARD_PORT_STUB = 30;
|
|
24
|
+
/** Screen px of output-over-input overlap over which stubs ramp from 0 to full. */
|
|
25
|
+
const BACKWARD_STUB_RAMP = 80;
|
|
26
|
+
|
|
27
|
+
function smoothstep(t: number): number {
|
|
28
|
+
const x = Clamp01(t);
|
|
29
|
+
return x * x * (3 - 2 * x);
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
export function DefaultConnectionRenderer(connectionSize: number, connectionColor: string | undefined, mouseOverSize: number, mouseOverColor: string | undefined): ConnectionRenderer {
|
|
22
33
|
return (params: ConnectionRendererParams) => {
|
|
23
34
|
|
|
@@ -47,11 +58,29 @@ export function DefaultConnectionRenderer(connectionSize: number, connectionColo
|
|
|
47
58
|
params.ctx.strokeStyle = color;
|
|
48
59
|
params.ctx.lineWidth = lineSize;
|
|
49
60
|
|
|
50
|
-
//
|
|
61
|
+
// start = input port, end = output port
|
|
62
|
+
const inX = params.start.x;
|
|
63
|
+
const inY = params.start.y;
|
|
64
|
+
const outX = params.end.x;
|
|
65
|
+
const outY = params.end.y;
|
|
66
|
+
|
|
67
|
+
const overlap = outX - inX;
|
|
68
|
+
const fullStub = BACKWARD_PORT_STUB * params.graphScale;
|
|
69
|
+
const ramp = BACKWARD_STUB_RAMP * params.graphScale;
|
|
70
|
+
const stub = overlap <= 0 ? 0 : fullStub * smoothstep(overlap / ramp);
|
|
71
|
+
const curveStartX = inX - stub;
|
|
72
|
+
const curveEndX = outX + stub;
|
|
73
|
+
|
|
51
74
|
params.ctx.beginPath();
|
|
52
|
-
params.ctx.moveTo(
|
|
53
|
-
|
|
54
|
-
|
|
75
|
+
params.ctx.moveTo(inX, inY);
|
|
76
|
+
if (stub > 0) {
|
|
77
|
+
params.ctx.lineTo(curveStartX, inY);
|
|
78
|
+
}
|
|
79
|
+
const midX = (curveStartX + curveEndX) / 2;
|
|
80
|
+
params.ctx.bezierCurveTo(midX, inY, midX, outY, curveEndX, outY);
|
|
81
|
+
if (stub > 0) {
|
|
82
|
+
params.ctx.lineTo(outX, outY);
|
|
83
|
+
}
|
|
55
84
|
params.ctx.stroke();
|
|
56
85
|
params.ctx.shadowBlur = 0;
|
|
57
86
|
}
|
package/src/contextMenu.ts
CHANGED
|
@@ -8,11 +8,24 @@ import { CopyVector2, Vector2, Zero } from './types/vector2';
|
|
|
8
8
|
const contextEntryHeight = 30;
|
|
9
9
|
const contextEntryWidth = 250;
|
|
10
10
|
|
|
11
|
+
export enum ContextMenuItemState {
|
|
12
|
+
Enabled = 0,
|
|
13
|
+
Disabled = 1,
|
|
14
|
+
Hidden = 2,
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
export interface ContextMenuItemConfig {
|
|
12
18
|
name?: string;
|
|
13
19
|
textStyle?: TextStyleConfig;
|
|
14
20
|
group?: string;
|
|
15
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Evaluated when the menu is opened. Defaults to Enabled when omitted.
|
|
24
|
+
* Hidden items are omitted from the menu; Disabled items are shown muted
|
|
25
|
+
* and are not clickable.
|
|
26
|
+
*/
|
|
27
|
+
enabled?: () => ContextMenuItemState;
|
|
28
|
+
|
|
16
29
|
callback?: () => void;
|
|
17
30
|
}
|
|
18
31
|
|
|
@@ -33,6 +46,8 @@ export class ContextMenuItem {
|
|
|
33
46
|
|
|
34
47
|
#textStyle: TextStyle;
|
|
35
48
|
|
|
49
|
+
#state: ContextMenuItemState;
|
|
50
|
+
|
|
36
51
|
public group?: string;
|
|
37
52
|
|
|
38
53
|
constructor(config?: ContextMenuItemConfig) {
|
|
@@ -40,14 +55,21 @@ export class ContextMenuItem {
|
|
|
40
55
|
this.#callback = config?.callback;
|
|
41
56
|
this.#textStyle = new TextStyle(config?.textStyle);
|
|
42
57
|
this.group = config?.group;
|
|
58
|
+
this.#state = config?.enabled === undefined
|
|
59
|
+
? ContextMenuItemState.Enabled
|
|
60
|
+
: config.enabled();
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
getName(): string {
|
|
46
64
|
return this.#name;
|
|
47
65
|
}
|
|
48
66
|
|
|
67
|
+
state(): ContextMenuItemState {
|
|
68
|
+
return this.#state;
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
execute(): void {
|
|
50
|
-
if (this.#callback === undefined) {
|
|
72
|
+
if (this.#state !== ContextMenuItemState.Enabled || this.#callback === undefined) {
|
|
51
73
|
return;
|
|
52
74
|
}
|
|
53
75
|
this.#callback();
|
|
@@ -136,7 +158,11 @@ export class ContextMenu {
|
|
|
136
158
|
|
|
137
159
|
if (config?.items !== undefined) {
|
|
138
160
|
for (let i = 0; i < config?.items.length; i++) {
|
|
139
|
-
|
|
161
|
+
const item = new ContextMenuItem(config?.items[i]);
|
|
162
|
+
if (item.state() === ContextMenuItemState.Hidden) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
this.#items.push(item);
|
|
140
166
|
}
|
|
141
167
|
}
|
|
142
168
|
|
|
@@ -312,7 +338,10 @@ export class ContextMenu {
|
|
|
312
338
|
}
|
|
313
339
|
}
|
|
314
340
|
|
|
315
|
-
|
|
341
|
+
const itemDisabled = entry.item !== undefined
|
|
342
|
+
&& entry.item.state() === ContextMenuItemState.Disabled;
|
|
343
|
+
|
|
344
|
+
if (!itemDisabled && (entryMousedOver || (this.#openSubMenu !== undefined && entry.subMenu === this.#openSubMenu))) {
|
|
316
345
|
ctx.fillStyle = Theme.ContextMenu.HighlightColor;
|
|
317
346
|
ctx.beginPath();
|
|
318
347
|
ctx.roundRect(
|
|
@@ -326,6 +355,9 @@ export class ContextMenu {
|
|
|
326
355
|
}
|
|
327
356
|
|
|
328
357
|
this.#textStyle.setupStyle(ctx, menuScale);
|
|
358
|
+
if (itemDisabled) {
|
|
359
|
+
ctx.fillStyle = Theme.ContextMenu.DisabledFontColor;
|
|
360
|
+
}
|
|
329
361
|
ctx.fillText(entry.text, position.x + (scaledEntryHeight / 5), this.#tempBox.Position.y + (scaledEntryHeight / 2))
|
|
330
362
|
|
|
331
363
|
// Render arrows
|
package/src/graph.ts
CHANGED
|
@@ -14,7 +14,8 @@ import { ConnectionRendererConfiguration, NodeAddedCallback, NodeRemovedCallback
|
|
|
14
14
|
import { Connection } from './connection';
|
|
15
15
|
import { Publisher } from './nodes/publisher';
|
|
16
16
|
import { VectorPool } from './types/pool';
|
|
17
|
-
import { Camera } from './camera';
|
|
17
|
+
import { Camera, CameraOrientation } from './camera';
|
|
18
|
+
export { CameraOrientation };
|
|
18
19
|
import { PassSubsystem } from './pass/subsystem';
|
|
19
20
|
import { QuickMenu } from './quickMenu';
|
|
20
21
|
|
|
@@ -195,9 +196,9 @@ export class NodeFlowGraph {
|
|
|
195
196
|
this.#contextMenuConfig = CombineContextMenus({
|
|
196
197
|
items: [
|
|
197
198
|
{
|
|
198
|
-
name: "
|
|
199
|
+
name: "Center on Graph",
|
|
199
200
|
group: contextMenuGroup,
|
|
200
|
-
callback: this
|
|
201
|
+
callback: this.centerOnGraph.bind(this)
|
|
201
202
|
},
|
|
202
203
|
],
|
|
203
204
|
}, config?.contextMenu);
|
|
@@ -222,23 +223,18 @@ export class NodeFlowGraph {
|
|
|
222
223
|
|
|
223
224
|
window.requestAnimationFrame(this.#render.bind(this));
|
|
224
225
|
|
|
225
|
-
this.#canvas.addEventListener('wheel', (event) => {
|
|
226
|
-
event.preventDefault();
|
|
227
|
-
this.zoom(Math.sign(event.deltaY));
|
|
228
|
-
}, false);
|
|
229
|
-
|
|
230
226
|
new MouseObserver(this.#canvas,
|
|
231
227
|
this.#mouseDragEvent.bind(this),
|
|
232
|
-
|
|
233
|
-
// Mouse move event
|
|
234
228
|
(mousePosition) => {
|
|
235
229
|
this.#mousePosition = mousePosition;
|
|
236
230
|
},
|
|
237
|
-
|
|
238
231
|
this.#clickStart.bind(this),
|
|
239
232
|
this.#clickEnd.bind(this),
|
|
240
233
|
this.#openContextMenu.bind(this),
|
|
241
|
-
this.#fileDrop.bind(this)
|
|
234
|
+
this.#fileDrop.bind(this),
|
|
235
|
+
(amount, anchor) => {
|
|
236
|
+
this.zoom(amount, anchor);
|
|
237
|
+
}
|
|
242
238
|
);
|
|
243
239
|
|
|
244
240
|
document.addEventListener(
|
|
@@ -331,20 +327,25 @@ export class NodeFlowGraph {
|
|
|
331
327
|
}
|
|
332
328
|
|
|
333
329
|
|
|
334
|
-
zoom(amount: number): void {
|
|
330
|
+
zoom(amount: number, anchor?: Vector2): void {
|
|
331
|
+
const anchorPos = anchor ?? this.#mousePosition;
|
|
335
332
|
|
|
336
333
|
let oldPos: Vector2 | undefined = undefined;
|
|
337
|
-
if (
|
|
338
|
-
oldPos = this.#sceenPositionToGraphPosition(
|
|
334
|
+
if (anchorPos !== undefined) {
|
|
335
|
+
oldPos = this.#sceenPositionToGraphPosition(anchorPos);
|
|
339
336
|
}
|
|
340
337
|
|
|
341
338
|
this.#camera.zoom += amount * this.#camera.zoom * 0.05;
|
|
342
339
|
|
|
343
|
-
if (!oldPos ||
|
|
340
|
+
if (!oldPos || anchorPos === undefined) {
|
|
344
341
|
return;
|
|
345
342
|
}
|
|
346
|
-
|
|
347
|
-
|
|
343
|
+
|
|
344
|
+
if (anchor !== undefined) {
|
|
345
|
+
this.#mousePosition = anchor;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const newPos = this.#sceenPositionToGraphPosition(anchorPos);
|
|
348
349
|
this.#camera.position.x += (newPos.x - oldPos.x) * this.#camera.zoom;
|
|
349
350
|
this.#camera.position.y += (newPos.y - oldPos.y) * this.#camera.zoom;
|
|
350
351
|
}
|
|
@@ -380,6 +381,84 @@ export class NodeFlowGraph {
|
|
|
380
381
|
return this.#mainNodeSubsystem.getNodes();
|
|
381
382
|
}
|
|
382
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Returns currently selected nodes in stable graph order (same selection
|
|
386
|
+
* state used by Ctrl+click and box-select styling via FlowNode.selected()).
|
|
387
|
+
*/
|
|
388
|
+
getSelectedNodes(): Array<FlowNode> {
|
|
389
|
+
return this.#mainNodeSubsystem.getSelectedNodes();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Returns a copy of the current camera orientation (screen-space pan + zoom).
|
|
394
|
+
*/
|
|
395
|
+
getCamera(): CameraOrientation {
|
|
396
|
+
return this.#camera.getOrientation();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Restores a previously saved camera orientation.
|
|
401
|
+
*/
|
|
402
|
+
setCamera(orientation: CameraOrientation): void {
|
|
403
|
+
this.#camera.setOrientation(orientation);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Adjusts camera position and zoom so all nodes fit in view (~10% padding).
|
|
408
|
+
* Falls back to camera.reset() when there are no nodes.
|
|
409
|
+
*/
|
|
410
|
+
centerOnGraph(): void {
|
|
411
|
+
const nodes = this.getNodes();
|
|
412
|
+
if (nodes.length === 0) {
|
|
413
|
+
this.#camera.reset();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Identity camera so calculateBounds returns graph-space sizes.
|
|
418
|
+
const measureCamera = new Camera();
|
|
419
|
+
let minX = Infinity;
|
|
420
|
+
let minY = Infinity;
|
|
421
|
+
let maxX = -Infinity;
|
|
422
|
+
let maxY = -Infinity;
|
|
423
|
+
|
|
424
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
425
|
+
const bounds = nodes[i].calculateBounds(this.#ctx, measureCamera);
|
|
426
|
+
minX = Math.min(minX, bounds.Position.x);
|
|
427
|
+
minY = Math.min(minY, bounds.Position.y);
|
|
428
|
+
maxX = Math.max(maxX, bounds.Position.x + bounds.Size.x);
|
|
429
|
+
maxY = Math.max(maxY, bounds.Position.y + bounds.Size.y);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let width = maxX - minX;
|
|
433
|
+
let height = maxY - minY;
|
|
434
|
+
if (width <= 0 || height <= 0) {
|
|
435
|
+
this.#camera.reset();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const padX = width * 0.05;
|
|
440
|
+
const padY = height * 0.05;
|
|
441
|
+
minX -= padX;
|
|
442
|
+
minY -= padY;
|
|
443
|
+
width += padX * 2;
|
|
444
|
+
height += padY * 2;
|
|
445
|
+
|
|
446
|
+
const canvasWidth = this.#canvas.clientWidth;
|
|
447
|
+
const canvasHeight = this.#canvas.clientHeight;
|
|
448
|
+
if (canvasWidth <= 0 || canvasHeight <= 0) {
|
|
449
|
+
this.#camera.reset();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const zoom = Math.min(canvasWidth / width, canvasHeight / height);
|
|
454
|
+
const centerX = minX + width / 2;
|
|
455
|
+
const centerY = minY + height / 2;
|
|
456
|
+
|
|
457
|
+
this.#camera.zoom = zoom;
|
|
458
|
+
this.#camera.position.x = canvasWidth / 2 - centerX * zoom;
|
|
459
|
+
this.#camera.position.y = canvasHeight / 2 - centerY * zoom;
|
|
460
|
+
}
|
|
461
|
+
|
|
383
462
|
connectedInputsNodeReferences(nodeIndex: number): Array<FlowNode> {
|
|
384
463
|
return this.#mainNodeSubsystem.connectedInputsNodeReferencesByIndex(nodeIndex);
|
|
385
464
|
}
|
|
@@ -447,26 +526,6 @@ export class NodeFlowGraph {
|
|
|
447
526
|
};
|
|
448
527
|
}
|
|
449
528
|
|
|
450
|
-
|
|
451
|
-
// Somethings wrong here. Needs more testing
|
|
452
|
-
// #fitView(): void {
|
|
453
|
-
// if (this.#nodes.length === 0) {
|
|
454
|
-
// return;
|
|
455
|
-
// }
|
|
456
|
-
|
|
457
|
-
// const curBox: Box = {
|
|
458
|
-
// Position: { x: 0, y: 0 },
|
|
459
|
-
// Size: { x: 0, y: 0 }
|
|
460
|
-
// };
|
|
461
|
-
// CopyBox(curBox, this.#nodes[0].calculateBounds(this.#ctx, this.#graphState.position, this.#graphState.scale));
|
|
462
|
-
|
|
463
|
-
// for (let i = 1; i < this.#nodes.length; i++) {
|
|
464
|
-
// ExpandBox(curBox, this.#nodes[i].calculateBounds(this.#ctx, this.#graphState.position, this.#graphState.scale));
|
|
465
|
-
// }
|
|
466
|
-
|
|
467
|
-
// CopyVector2(this.#graphState.position, curBox.Position);
|
|
468
|
-
// }
|
|
469
|
-
|
|
470
529
|
#clickEnd(): void {
|
|
471
530
|
this.currentView().clickEnd();
|
|
472
531
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export * from './graph';
|
|
2
2
|
export * from './node';
|
|
3
3
|
|
|
4
|
+
import { ContextMenuConfig, ContextMenuItemConfig, ContextMenuItemState } from './contextMenu';
|
|
5
|
+
export { ContextMenuConfig, ContextMenuItemConfig, ContextMenuItemState };
|
|
6
|
+
|
|
4
7
|
import { Theme } from './theme';
|
|
5
8
|
export { Theme };
|
|
6
9
|
|