@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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@elicdavis/node-flow",
3
3
  "description": "build node graphs",
4
4
  "author": "Eli C Davis",
5
- "version": "0.1.11",
5
+ "version": "0.1.13",
6
6
  "license": "MIT",
7
7
  "keywords": [
8
8
  "nodes",
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
- // Draw
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(params.start.x, params.start.y);
53
- const midX = (params.start.x + params.end.x) / 2;
54
- params.ctx.bezierCurveTo(midX, params.start.y, midX, params.end.y, params.end.x, params.end.y);
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
  }
@@ -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
- this.#items.push(new ContextMenuItem(config?.items[i]));
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
- if (entryMousedOver || (this.#openSubMenu !== undefined && entry.subMenu === this.#openSubMenu)) {
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: "Reset View",
198
+ name: "Center on Graph",
199
199
  group: contextMenuGroup,
200
- callback: this.#camera.reset.bind(this.#camera)
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 (this.#mousePosition) {
338
- oldPos = this.#sceenPositionToGraphPosition(this.#mousePosition);;
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 || !this.#mousePosition) {
339
+ if (!oldPos || anchorPos === undefined) {
344
340
  return;
345
341
  }
346
- // Attempt zoom around where the current mouse is.
347
- const newPos = this.#sceenPositionToGraphPosition(this.#mousePosition);
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';