@elicdavis/node-flow 0.1.11 → 0.1.13
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/README.md +10 -0
- package/dist/index.d.mts +32 -2
- package/dist/index.d.ts +32 -2
- package/dist/index.js +355 -95
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +354 -95
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/connection.ts +33 -4
- package/src/contextMenu.ts +35 -3
- package/src/graph.ts +93 -37
- package/src/index.ts +14 -2
- package/src/input.ts +183 -74
- package/src/node.ts +19 -18
- package/src/nodes/subsystem.ts +43 -0
- package/src/notes/subsystem.ts +17 -0
- package/src/theme.ts +1 -0
- package/src/utils/string.ts +3 -3
package/package.json
CHANGED
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
|
@@ -195,9 +195,9 @@ export class NodeFlowGraph {
|
|
|
195
195
|
this.#contextMenuConfig = CombineContextMenus({
|
|
196
196
|
items: [
|
|
197
197
|
{
|
|
198
|
-
name: "
|
|
198
|
+
name: "Center on Graph",
|
|
199
199
|
group: contextMenuGroup,
|
|
200
|
-
callback: this
|
|
200
|
+
callback: this.centerOnGraph.bind(this)
|
|
201
201
|
},
|
|
202
202
|
],
|
|
203
203
|
}, config?.contextMenu);
|
|
@@ -222,23 +222,18 @@ export class NodeFlowGraph {
|
|
|
222
222
|
|
|
223
223
|
window.requestAnimationFrame(this.#render.bind(this));
|
|
224
224
|
|
|
225
|
-
this.#canvas.addEventListener('wheel', (event) => {
|
|
226
|
-
event.preventDefault();
|
|
227
|
-
this.zoom(Math.sign(event.deltaY));
|
|
228
|
-
}, false);
|
|
229
|
-
|
|
230
225
|
new MouseObserver(this.#canvas,
|
|
231
226
|
this.#mouseDragEvent.bind(this),
|
|
232
|
-
|
|
233
|
-
// Mouse move event
|
|
234
227
|
(mousePosition) => {
|
|
235
228
|
this.#mousePosition = mousePosition;
|
|
236
229
|
},
|
|
237
|
-
|
|
238
230
|
this.#clickStart.bind(this),
|
|
239
231
|
this.#clickEnd.bind(this),
|
|
240
232
|
this.#openContextMenu.bind(this),
|
|
241
|
-
this.#fileDrop.bind(this)
|
|
233
|
+
this.#fileDrop.bind(this),
|
|
234
|
+
(amount, anchor) => {
|
|
235
|
+
this.zoom(amount, anchor);
|
|
236
|
+
}
|
|
242
237
|
);
|
|
243
238
|
|
|
244
239
|
document.addEventListener(
|
|
@@ -331,20 +326,25 @@ export class NodeFlowGraph {
|
|
|
331
326
|
}
|
|
332
327
|
|
|
333
328
|
|
|
334
|
-
zoom(amount: number): void {
|
|
329
|
+
zoom(amount: number, anchor?: Vector2): void {
|
|
330
|
+
const anchorPos = anchor ?? this.#mousePosition;
|
|
335
331
|
|
|
336
332
|
let oldPos: Vector2 | undefined = undefined;
|
|
337
|
-
if (
|
|
338
|
-
oldPos = this.#sceenPositionToGraphPosition(
|
|
333
|
+
if (anchorPos !== undefined) {
|
|
334
|
+
oldPos = this.#sceenPositionToGraphPosition(anchorPos);
|
|
339
335
|
}
|
|
340
336
|
|
|
341
337
|
this.#camera.zoom += amount * this.#camera.zoom * 0.05;
|
|
342
338
|
|
|
343
|
-
if (!oldPos ||
|
|
339
|
+
if (!oldPos || anchorPos === undefined) {
|
|
344
340
|
return;
|
|
345
341
|
}
|
|
346
|
-
|
|
347
|
-
|
|
342
|
+
|
|
343
|
+
if (anchor !== undefined) {
|
|
344
|
+
this.#mousePosition = anchor;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const newPos = this.#sceenPositionToGraphPosition(anchorPos);
|
|
348
348
|
this.#camera.position.x += (newPos.x - oldPos.x) * this.#camera.zoom;
|
|
349
349
|
this.#camera.position.y += (newPos.y - oldPos.y) * this.#camera.zoom;
|
|
350
350
|
}
|
|
@@ -380,6 +380,70 @@ export class NodeFlowGraph {
|
|
|
380
380
|
return this.#mainNodeSubsystem.getNodes();
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Returns currently selected nodes in stable graph order (same selection
|
|
385
|
+
* state used by Ctrl+click and box-select styling via FlowNode.selected()).
|
|
386
|
+
*/
|
|
387
|
+
getSelectedNodes(): Array<FlowNode> {
|
|
388
|
+
return this.#mainNodeSubsystem.getSelectedNodes();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Adjusts camera position and zoom so all nodes fit in view (~10% padding).
|
|
393
|
+
* Falls back to camera.reset() when there are no nodes.
|
|
394
|
+
*/
|
|
395
|
+
centerOnGraph(): void {
|
|
396
|
+
const nodes = this.getNodes();
|
|
397
|
+
if (nodes.length === 0) {
|
|
398
|
+
this.#camera.reset();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Identity camera so calculateBounds returns graph-space sizes.
|
|
403
|
+
const measureCamera = new Camera();
|
|
404
|
+
let minX = Infinity;
|
|
405
|
+
let minY = Infinity;
|
|
406
|
+
let maxX = -Infinity;
|
|
407
|
+
let maxY = -Infinity;
|
|
408
|
+
|
|
409
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
410
|
+
const bounds = nodes[i].calculateBounds(this.#ctx, measureCamera);
|
|
411
|
+
minX = Math.min(minX, bounds.Position.x);
|
|
412
|
+
minY = Math.min(minY, bounds.Position.y);
|
|
413
|
+
maxX = Math.max(maxX, bounds.Position.x + bounds.Size.x);
|
|
414
|
+
maxY = Math.max(maxY, bounds.Position.y + bounds.Size.y);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let width = maxX - minX;
|
|
418
|
+
let height = maxY - minY;
|
|
419
|
+
if (width <= 0 || height <= 0) {
|
|
420
|
+
this.#camera.reset();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const padX = width * 0.05;
|
|
425
|
+
const padY = height * 0.05;
|
|
426
|
+
minX -= padX;
|
|
427
|
+
minY -= padY;
|
|
428
|
+
width += padX * 2;
|
|
429
|
+
height += padY * 2;
|
|
430
|
+
|
|
431
|
+
const canvasWidth = this.#canvas.clientWidth;
|
|
432
|
+
const canvasHeight = this.#canvas.clientHeight;
|
|
433
|
+
if (canvasWidth <= 0 || canvasHeight <= 0) {
|
|
434
|
+
this.#camera.reset();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const zoom = Math.min(canvasWidth / width, canvasHeight / height);
|
|
439
|
+
const centerX = minX + width / 2;
|
|
440
|
+
const centerY = minY + height / 2;
|
|
441
|
+
|
|
442
|
+
this.#camera.zoom = zoom;
|
|
443
|
+
this.#camera.position.x = canvasWidth / 2 - centerX * zoom;
|
|
444
|
+
this.#camera.position.y = canvasHeight / 2 - centerY * zoom;
|
|
445
|
+
}
|
|
446
|
+
|
|
383
447
|
connectedInputsNodeReferences(nodeIndex: number): Array<FlowNode> {
|
|
384
448
|
return this.#mainNodeSubsystem.connectedInputsNodeReferencesByIndex(nodeIndex);
|
|
385
449
|
}
|
|
@@ -404,6 +468,18 @@ export class NodeFlowGraph {
|
|
|
404
468
|
this.#mainNoteSubsystem.addNote(note);
|
|
405
469
|
}
|
|
406
470
|
|
|
471
|
+
removeNote(note: FlowNote): void {
|
|
472
|
+
this.#mainNoteSubsystem.removeNote(note);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
removeAllNotes(): void {
|
|
476
|
+
this.#mainNoteSubsystem.removeAllNotes();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
getNotes(): Array<FlowNote> {
|
|
480
|
+
return this.#mainNoteSubsystem.getNotes();
|
|
481
|
+
}
|
|
482
|
+
|
|
407
483
|
public addOnNodeCreatedListener(callback: NodeCreatedCallback): void {
|
|
408
484
|
this.#mainNodeSubsystem.addOnNodeCreatedListener(callback);
|
|
409
485
|
}
|
|
@@ -435,26 +511,6 @@ export class NodeFlowGraph {
|
|
|
435
511
|
};
|
|
436
512
|
}
|
|
437
513
|
|
|
438
|
-
|
|
439
|
-
// Somethings wrong here. Needs more testing
|
|
440
|
-
// #fitView(): void {
|
|
441
|
-
// if (this.#nodes.length === 0) {
|
|
442
|
-
// return;
|
|
443
|
-
// }
|
|
444
|
-
|
|
445
|
-
// const curBox: Box = {
|
|
446
|
-
// Position: { x: 0, y: 0 },
|
|
447
|
-
// Size: { x: 0, y: 0 }
|
|
448
|
-
// };
|
|
449
|
-
// CopyBox(curBox, this.#nodes[0].calculateBounds(this.#ctx, this.#graphState.position, this.#graphState.scale));
|
|
450
|
-
|
|
451
|
-
// for (let i = 1; i < this.#nodes.length; i++) {
|
|
452
|
-
// ExpandBox(curBox, this.#nodes[i].calculateBounds(this.#ctx, this.#graphState.position, this.#graphState.scale));
|
|
453
|
-
// }
|
|
454
|
-
|
|
455
|
-
// CopyVector2(this.#graphState.position, curBox.Position);
|
|
456
|
-
// }
|
|
457
|
-
|
|
458
514
|
#clickEnd(): void {
|
|
459
515
|
this.currentView().clickEnd();
|
|
460
516
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
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
|
|
|
7
10
|
import { Publisher, PublisherConfig } from './nodes/publisher';
|
|
8
11
|
export { Publisher, PublisherConfig };
|
|
9
12
|
|
|
10
|
-
import { FlowNote } from './notes/note';
|
|
11
|
-
export { FlowNote };
|
|
13
|
+
import { FlowNote, FlowNoteConfig, NoteContentChangeCallback, NoteWidthChangeCallback } from './notes/note';
|
|
14
|
+
export { FlowNote, FlowNoteConfig, NoteContentChangeCallback, NoteWidthChangeCallback };
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
NoteAddedCallback, NoteDragStartCallback, NoteDragStopCallback,
|
|
18
|
+
NoteRemovedCallback, NoteSubsystemConfig
|
|
19
|
+
} from './notes/subsystem';
|
|
20
|
+
export {
|
|
21
|
+
NoteAddedCallback, NoteDragStartCallback, NoteDragStopCallback,
|
|
22
|
+
NoteRemovedCallback, NoteSubsystemConfig
|
|
23
|
+
};
|
|
12
24
|
|
|
13
25
|
// Widgets
|
|
14
26
|
import { NumberWidget } from './widgets/number';
|