@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/src/input.ts CHANGED
@@ -1,20 +1,29 @@
1
- import { CopyVector2, SubVector2, Vector2, Zero } from "./types/vector2";
1
+ import { CopyVector2, Distance, SubVector2, Vector2, Zero } from "./types/vector2";
2
+
3
+ const LONG_PRESS_MS = 500;
4
+ const LONG_PRESS_SLOP = 12;
5
+ const PINCH_SENSITIVITY = 50;
6
+ const SUPPRESS_MOUSE_MS = 600;
2
7
 
3
8
  export class MouseObserver {
4
9
 
5
10
  #clicked: boolean;
6
-
7
11
  #lastTouch: Vector2;
8
-
9
- #ele: HTMLElement
10
-
12
+ #ele: HTMLElement;
11
13
  #lastMousePosition: Vector2;
12
14
 
13
- #dragCallback: (delta: Vector2) => void
14
- #moveCallback: (position: Vector2) => void
15
- #clickStart: (position: Vector2, shiftOrCtrl: boolean) => void
16
- #clickStop: () => void
17
- #contextMenu: (position: Vector2) => void
15
+ #dragCallback: (delta: Vector2) => void;
16
+ #moveCallback: (position: Vector2) => void;
17
+ #clickStart: (position: Vector2, shiftOrCtrl: boolean) => void;
18
+ #clickStop: () => void;
19
+ #contextMenu: (position: Vector2) => void;
20
+ #zoomCallback: (amount: number, anchor: Vector2) => void;
21
+
22
+ #longPressTimer: ReturnType<typeof setTimeout> | null = null;
23
+ #longPressOrigin: Vector2 = Zero();
24
+ #longPressFired: boolean = false;
25
+ #pinchDist: number = 0;
26
+ #lastTouchAt: number = 0;
18
27
 
19
28
  constructor(
20
29
  ele: HTMLElement,
@@ -23,7 +32,8 @@ export class MouseObserver {
23
32
  clickStart: (position: Vector2, shiftOrCtrl: boolean) => void,
24
33
  clickStop: () => void,
25
34
  contextMenu: (position: Vector2) => void,
26
- fileDrop: (file: File) => void
35
+ fileDrop: (file: File) => void,
36
+ zoomCallback: (amount: number, anchor: Vector2) => void,
27
37
  ) {
28
38
  this.#ele = ele;
29
39
  this.#dragCallback = dragCallback;
@@ -31,123 +41,222 @@ export class MouseObserver {
31
41
  this.#clickStart = clickStart;
32
42
  this.#clickStop = clickStop;
33
43
  this.#contextMenu = contextMenu;
44
+ this.#zoomCallback = zoomCallback;
34
45
 
35
46
  this.#clicked = false;
36
47
  this.#lastTouch = Zero();
37
48
  this.#lastMousePosition = Zero();
38
49
 
39
- // Down
40
- ele.addEventListener('mousedown', this.#down.bind(this), false);
41
- ele.addEventListener('touchstart', this.#touchDown.bind(this), false);
50
+ ele.style.touchAction = "none";
42
51
 
43
- // Up
44
- document.addEventListener('mouseup', this.#up.bind(this), false);
45
- document.addEventListener('touchend', this.#up.bind(this), false);
52
+ ele.addEventListener("mousedown", this.#down.bind(this), false);
53
+ ele.addEventListener("touchstart", this.#touchStart.bind(this), { passive: false });
46
54
 
47
- // Move
48
- ele.addEventListener('mousemove', this.#move.bind(this), false);
49
- ele.addEventListener('touchmove', this.#moveTouch.bind(this), false);
55
+ document.addEventListener("mouseup", this.#up.bind(this), false);
56
+ document.addEventListener("touchend", this.#touchEnd.bind(this), false);
57
+ document.addEventListener("touchcancel", this.#touchEnd.bind(this), false);
50
58
 
51
- ele.addEventListener('drop', (ev) => {
52
- ev.preventDefault();
53
- console.log(ev)
59
+ ele.addEventListener("mousemove", this.#move.bind(this), false);
60
+ ele.addEventListener("touchmove", this.#touchMove.bind(this), { passive: false });
61
+
62
+ ele.addEventListener("wheel", this.#wheel.bind(this), { passive: false });
54
63
 
64
+ ele.addEventListener("drop", (ev) => {
65
+ ev.preventDefault();
55
66
  if (ev.dataTransfer?.items) {
56
- // Use DataTransferItemList interface to access the file(s)
57
67
  [...ev.dataTransfer.items].forEach((item, i) => {
58
- // If dropped items aren't files, reject them
59
68
  if (item.kind === "file") {
60
69
  const file = item.getAsFile();
61
70
  if (file) {
62
71
  fileDrop(file);
63
- console.log(file)
64
- console.log(`… file[${i}].name = ${file.name}`);
65
72
  }
66
73
  }
67
74
  });
68
- } else if (ev.dataTransfer) {
69
- // Use DataTransfer interface to access the file(s)
70
- [...ev.dataTransfer.files].forEach((file, i) => {
71
- console.log(`… file[${i}].name = ${file.name}`);
72
- });
73
75
  }
74
76
  });
75
77
 
76
- ele.addEventListener('dragover', (ev) => {
78
+ ele.addEventListener("dragover", (ev) => {
77
79
  ev.preventDefault();
78
- this.#moveCallback(this.#mousePosition(ev));
79
- // console.log(ev)
80
+ this.#moveCallback(this.#mousePos(ev));
80
81
  });
81
82
 
82
- // Context
83
- ele.addEventListener('contextmenu', (evt) => {
84
- contextMenu(this.#mousePosition(evt));
85
- evt.preventDefault()
83
+ ele.addEventListener("contextmenu", (evt) => {
84
+ evt.preventDefault();
85
+ // Touch uses long-press; ignore synthetic browser contextmenu after touch.
86
+ if (Date.now() - this.#lastTouchAt < SUPPRESS_MOUSE_MS) {
87
+ return;
88
+ }
89
+ this.#contextMenu(this.#mousePos(evt));
86
90
  }, false);
87
91
  }
88
92
 
89
- #mousePosition(event: MouseEvent | DragEvent): Vector2 {
90
- var rect = this.#ele.getBoundingClientRect();
93
+ #mousePos(event: MouseEvent | DragEvent | WheelEvent): Vector2 {
94
+ const rect = this.#ele.getBoundingClientRect();
91
95
  return {
92
96
  x: event.clientX - rect.left,
93
- y: event.clientY - rect.top
97
+ y: event.clientY - rect.top,
98
+ };
99
+ }
100
+
101
+ #touchPos(touch: Touch): Vector2 {
102
+ const rect = this.#ele.getBoundingClientRect();
103
+ return {
104
+ x: touch.clientX - rect.left,
105
+ y: touch.clientY - rect.top,
106
+ };
107
+ }
108
+
109
+ #fromTouch(): boolean {
110
+ return Date.now() - this.#lastTouchAt < SUPPRESS_MOUSE_MS;
111
+ }
112
+
113
+ #clearLongPress(): void {
114
+ if (this.#longPressTimer !== null) {
115
+ clearTimeout(this.#longPressTimer);
116
+ this.#longPressTimer = null;
94
117
  }
95
118
  }
96
119
 
97
- #move(event: MouseEvent): void {
98
- const pos = this.#mousePosition(event);
120
+ #wheel(event: WheelEvent): void {
121
+ event.preventDefault();
122
+ let dy = event.deltaY;
123
+ if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
124
+ dy *= 16;
125
+ } else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
126
+ dy *= this.#ele.clientHeight;
127
+ }
128
+ // Scroll up zooms in (matches Figma / Maps).
129
+ const amount = -dy / 100;
130
+ if (amount === 0) {
131
+ return;
132
+ }
133
+ const anchor = this.#mousePos(event);
134
+ this.#moveCallback(anchor);
135
+ this.#zoomCallback(amount, anchor);
136
+ }
99
137
 
138
+ #move(event: MouseEvent): void {
139
+ if (this.#fromTouch()) {
140
+ return;
141
+ }
142
+ const pos = this.#mousePos(event);
100
143
  if (this.#clicked) {
101
144
  const delta = Zero();
102
145
  SubVector2(delta, pos, this.#lastMousePosition);
103
- this.#dragCallback(delta)
146
+ this.#dragCallback(delta);
104
147
  }
105
-
106
148
  this.#moveCallback(pos);
107
149
  CopyVector2(this.#lastMousePosition, pos);
108
150
  }
109
151
 
110
- #moveTouch(event: TouchEvent): void {
111
- const rect = this.#ele.getBoundingClientRect();
112
- const pos = {
113
- x: event.touches[0].clientX - rect.left,
114
- y: event.touches[0].clientY - rect.top
152
+ #down(event: MouseEvent): void {
153
+ if (event.button !== 0 || this.#fromTouch()) {
154
+ return;
155
+ }
156
+ this.#clicked = true;
157
+ this.#clickStart(this.#mousePos(event), event.ctrlKey || event.shiftKey);
158
+ }
159
+
160
+ #up(event: MouseEvent): void {
161
+ if (event.button !== 0 || this.#fromTouch()) {
162
+ return;
163
+ }
164
+ this.#clicked = false;
165
+ this.#clickStop();
166
+ }
167
+
168
+ #touchStart(event: TouchEvent): void {
169
+ this.#lastTouchAt = Date.now();
170
+ event.preventDefault();
171
+
172
+ if (event.touches.length >= 2) {
173
+ this.#clearLongPress();
174
+ this.#clicked = false;
175
+ this.#pinchDist = Distance(
176
+ this.#touchPos(event.touches[0]),
177
+ this.#touchPos(event.touches[1]),
178
+ );
179
+ return;
115
180
  }
181
+
182
+ if (event.touches.length !== 1) {
183
+ return;
184
+ }
185
+
186
+ const pos = this.#touchPos(event.touches[0]);
187
+ CopyVector2(this.#longPressOrigin, pos);
188
+ CopyVector2(this.#lastTouch, pos);
189
+ this.#longPressFired = false;
190
+ this.#pinchDist = 0;
191
+
192
+ this.#clearLongPress();
193
+ const x = pos.x;
194
+ const y = pos.y;
195
+ this.#longPressTimer = setTimeout(() => {
196
+ this.#longPressTimer = null;
197
+ this.#longPressFired = true;
198
+ this.#clicked = false;
199
+ this.#clickStop();
200
+ this.#contextMenu({ x, y });
201
+ }, LONG_PRESS_MS);
202
+
203
+ this.#clicked = true;
116
204
  this.#moveCallback(pos);
205
+ this.#clickStart(pos, false);
206
+ }
117
207
 
118
- if (this.#clicked) {
208
+ #touchMove(event: TouchEvent): void {
209
+ this.#lastTouchAt = Date.now();
210
+ event.preventDefault();
211
+
212
+ if (event.touches.length >= 2) {
213
+ this.#clearLongPress();
214
+ this.#clicked = false;
215
+ const a = this.#touchPos(event.touches[0]);
216
+ const b = this.#touchPos(event.touches[1]);
217
+ const dist = Distance(a, b);
218
+ const anchor = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
219
+ if (this.#pinchDist > 0) {
220
+ this.#moveCallback(anchor);
221
+ this.#zoomCallback((dist - this.#pinchDist) / PINCH_SENSITIVITY, anchor);
222
+ }
223
+ this.#pinchDist = dist;
224
+ return;
225
+ }
226
+
227
+ if (event.touches.length !== 1) {
228
+ return;
229
+ }
230
+
231
+ const pos = this.#touchPos(event.touches[0]);
232
+ if (this.#longPressTimer !== null && Distance(pos, this.#longPressOrigin) > LONG_PRESS_SLOP) {
233
+ this.#clearLongPress();
234
+ }
235
+
236
+ this.#moveCallback(pos);
237
+ if (this.#clicked && !this.#longPressFired) {
119
238
  this.#dragCallback({
120
239
  x: pos.x - this.#lastTouch.x,
121
240
  y: pos.y - this.#lastTouch.y,
122
241
  });
123
242
  }
124
-
125
243
  CopyVector2(this.#lastTouch, pos);
126
244
  }
127
245
 
128
- #down(event: MouseEvent): void {
129
- // Only register left clicks
130
- if (event.button !== 0) {
246
+ #touchEnd(event: TouchEvent): void {
247
+ this.#lastTouchAt = Date.now();
248
+ this.#clearLongPress();
249
+
250
+ if (event.touches.length >= 1) {
251
+ this.#pinchDist = 0;
131
252
  return;
132
253
  }
133
- this.#clicked = true;
134
- this.#clickStart(this.#mousePosition(event), event.ctrlKey || event.shiftKey);
135
- }
136
-
137
- #touchDown(event: TouchEvent) {
138
- this.#clicked = true;
139
- const rect = this.#ele.getBoundingClientRect();
140
- this.#lastTouch.x = event.touches[0].clientX - rect.left;
141
- this.#lastTouch.y = event.touches[0].clientY - rect.top;
142
- this.#clickStart(this.#lastTouch, false);
143
- }
144
254
 
145
- #up(event: MouseEvent): void {
146
- // Only register left clicks
147
- if (event.button !== 0) {
148
- return;
255
+ this.#pinchDist = 0;
256
+ this.#clicked = false;
257
+ if (!this.#longPressFired) {
258
+ this.#clickStop();
149
259
  }
150
- this.#clicked = false
151
- this.#clickStop();
260
+ this.#longPressFired = false;
152
261
  }
153
- }
262
+ }
package/src/node.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Port, PortConfig, PortType } from "./port";
2
2
  import { FontWeight, TextStyle, TextStyleConfig, TextStyleFallback } from "./styles/text";
3
3
  import { Box, InBox } from "./types/box";
4
- import { CopyVector2, Distance, ScaleVector, Vector2, Zero } from "./types/vector2";
4
+ import { CopyVector2, Distance, Vector2, Zero } from "./types/vector2";
5
5
  import { Widget } from './widgets/widget';
6
6
  import { List } from './types/list';
7
7
  import { BoxStyle, BoxStyleConfig, BoxStyleWithFallback } from "./styles/box";
@@ -784,55 +784,56 @@ export class FlowNode {
784
784
 
785
785
  public calculateBounds(ctx: CanvasRenderingContext2D, camera: Camera): Box {
786
786
  const tempMeasurement = Zero();
787
-
788
- const doublePadding = this.#padding * 2;
787
+ const zoom = camera.zoom;
788
+ const scaledPadding = this.#padding * zoom;
789
+ const doubleScaledPadding = scaledPadding * 2;
790
+ const scaledElementSpacing = this.#elementSpacing * zoom;
789
791
 
790
792
  const screenSpacePosition = Zero();
791
793
  camera.graphSpaceToScreenSpace(this.#position, screenSpacePosition);
792
794
 
793
795
  const size = Zero();
794
- this.#title.size(ctx, 1, size);
796
+ this.#title.size(ctx, zoom, size);
795
797
 
796
798
  const subtitleSize = Zero();
797
- this.#subTitle.size(ctx, 1, subtitleSize);
799
+ this.#subTitle.size(ctx, zoom, subtitleSize);
798
800
  size.x = Math.max(size.x, subtitleSize.x);
799
801
 
800
802
  if (this.#infoText !== "") {
801
803
  size.x += (size.y * 4);
802
804
  }
803
805
 
804
- size.x += doublePadding;
805
- size.y += doublePadding + (this.#elementSpacing * this.#input.length);
806
+ size.x += doubleScaledPadding;
807
+ size.y += doubleScaledPadding + (scaledElementSpacing * this.#input.length);
806
808
 
807
809
  for (let i = 0; i < this.#input.length; i++) {
808
810
  const port = this.#input[i];
809
- this.#portTextStyle.measure(ctx, 1, port.getDisplayName(), tempMeasurement);
811
+ this.#portTextStyle.measure(ctx, zoom, port.getDisplayName(), tempMeasurement);
810
812
  size.y += tempMeasurement.y;
811
- size.x = Math.max(size.x, tempMeasurement.x + doublePadding)
813
+ size.x = Math.max(size.x, tempMeasurement.x + doubleScaledPadding)
812
814
  }
813
815
 
814
- size.y += (this.#elementSpacing * this.#output.length);
816
+ size.y += (scaledElementSpacing * this.#output.length);
815
817
 
816
818
  for (let i = 0; i < this.#output.length; i++) {
817
819
  const port = this.#output[i];
818
- this.#portTextStyle.measure(ctx, 1, port.getDisplayName(), tempMeasurement);
820
+ this.#portTextStyle.measure(ctx, zoom, port.getDisplayName(), tempMeasurement);
819
821
  size.y += tempMeasurement.y;
820
- size.x = Math.max(size.x, tempMeasurement.x + doublePadding)
822
+ size.x = Math.max(size.x, tempMeasurement.x + doubleScaledPadding)
821
823
  }
822
824
 
823
- size.y += (this.#elementSpacing * this.#widgets.length);
825
+ size.y += (scaledElementSpacing * this.#widgets.length);
824
826
  for (let i = 0; i < this.#widgets.length; i++) {
825
827
  const element = this.#widgets[i];
826
828
  const eleSize = element.Size();
827
- size.y += eleSize.y
828
- size.x = Math.max(size.x, (eleSize.x) + doublePadding)
829
+ size.y += eleSize.y * zoom;
830
+ size.x = Math.max(size.x, (eleSize.x * zoom) + doubleScaledPadding)
829
831
  }
830
832
 
831
833
  // Add some padding at the end!
832
- size.y += this.#elementSpacing
834
+ size.y += scaledElementSpacing
833
835
 
834
- size.x = Math.max(size.x, MINIMUM_NODE_WIDTH)
835
- ScaleVector(size, camera.zoom);
836
+ size.x = Math.max(size.x, MINIMUM_NODE_WIDTH * zoom)
836
837
 
837
838
  return {
838
839
  Position: screenSpacePosition,
@@ -94,6 +94,8 @@ export class NodeSubsystem {
94
94
 
95
95
  #boxSelectStyle: BoxStyle;
96
96
 
97
+ #lastCtx: CanvasRenderingContext2D | null;
98
+
97
99
  // LISTENERS ==============================================================
98
100
 
99
101
  #nodeRemovedCallbacks: Array<NodeRemovedCallback>;
@@ -122,6 +124,7 @@ export class NodeSubsystem {
122
124
  this.#boxSelectStart_graphSpace = Zero();
123
125
  this.#boxSelectEnd_graphSpace = Zero();
124
126
  this.#boxSelectionNodes = new List<number>();
127
+ this.#lastCtx = null;
125
128
  this.#boxSelectStyle = new BoxStyle({
126
129
  border: {
127
130
  color: Theme.BoxSelect.Color,
@@ -160,6 +163,31 @@ export class NodeSubsystem {
160
163
 
161
164
  clickStart(mousePosition: Vector2, camera: Camera, ctrlKey: boolean): boolean {
162
165
  this.#boxSelect = false;
166
+ this.#nodesGrabbed.Clear();
167
+
168
+ // Touch has no continuous hover — hit-test at the click position.
169
+ if (this.#lastCtx !== null) {
170
+ this.#portHovering = null;
171
+ this.#widgetHovering = null;
172
+ this.#nodeHovering = -1;
173
+ for (let i = 0; i < this.#nodes.length; i++) {
174
+ const intersection = this.#nodes[i].inBounds(this.#lastCtx, camera, mousePosition);
175
+ if (intersection.Node !== undefined && intersection.PortIndex === undefined && intersection.Widget === undefined) {
176
+ this.#nodeHovering = i;
177
+ }
178
+ if (intersection.Widget !== undefined) {
179
+ this.#widgetHovering = intersection.Widget;
180
+ }
181
+ if (intersection.Port !== undefined && intersection.Node !== undefined && intersection.PortIndex !== undefined && intersection.PortIsInput !== undefined) {
182
+ this.#portHovering = {
183
+ Index: intersection.PortIndex,
184
+ Node: intersection.Node,
185
+ Port: intersection.Port,
186
+ InputPort: intersection.PortIsInput
187
+ };
188
+ }
189
+ }
190
+ }
163
191
 
164
192
  let hoveringSomething = false;
165
193
  if (this.#nodeHovering > -1) {
@@ -387,6 +415,20 @@ export class NodeSubsystem {
387
415
  return this.#nodes;
388
416
  }
389
417
 
418
+ /**
419
+ * Returns currently selected nodes in stable graph order (same selection
420
+ * state used by Ctrl+click and box-select styling via FlowNode.selected()).
421
+ */
422
+ getSelectedNodes(): Array<FlowNode> {
423
+ const selected = new Array<FlowNode>();
424
+ for (let i = 0; i < this.#nodes.length; i++) {
425
+ if (this.#nodes[i].selected()) {
426
+ selected.push(this.#nodes[i]);
427
+ }
428
+ }
429
+ return selected;
430
+ }
431
+
390
432
  /**
391
433
  * Returns all nodes who are connected to the inputs of the
392
434
  * node in question
@@ -747,6 +789,7 @@ export class NodeSubsystem {
747
789
  }
748
790
 
749
791
  render(ctx: CanvasRenderingContext2D, camera: Camera, mousePosition: Vector2 | undefined): RenderResults | undefined {
792
+ this.#lastCtx = ctx;
750
793
  this.#cursor = CursorStyle.Default;
751
794
  TimeExecution("Render_Connections", () => {
752
795
  this.#renderConnections(ctx, camera, mousePosition);
@@ -86,6 +86,10 @@ export class NoteSubsystem {
86
86
  }
87
87
  }
88
88
 
89
+ getNotes(): Array<FlowNote> {
90
+ return this.#notes;
91
+ }
92
+
89
93
  public addNoteAddedListener(callback: NoteAddedCallback): void {
90
94
  if (callback === null || callback === undefined) {
91
95
  return;
@@ -227,6 +231,19 @@ export class NoteSubsystem {
227
231
  }
228
232
  }
229
233
 
234
+ removeAllNotes(): void {
235
+ const notes = this.#notes;
236
+ this.#notes = [];
237
+ this.#noteHovering = null;
238
+ this.#noteSelected = null;
239
+
240
+ for (let i = 0; i < notes.length; i++) {
241
+ for (let j = 0; j < this.#onNoteRemovedCallbacks.length; j++) {
242
+ this.#onNoteRemovedCallbacks[j](notes[i]);
243
+ }
244
+ }
245
+ }
246
+
230
247
  render(ctx: CanvasRenderingContext2D, camera: Camera, mousePosition: Vector2 | undefined): RenderResults | undefined {
231
248
  this.#noteHovering = null;
232
249
  this.#hoveringHandle = DragHandle.None;
package/src/theme.ts CHANGED
@@ -57,6 +57,7 @@ export const Theme = {
57
57
  BackgroundColor: "#07212A",
58
58
  HighlightColor: "#205A6D",
59
59
  FontColor: "#afb9bb",
60
+ DisabledFontColor: "#4a585c",
60
61
  },
61
62
  Note: {
62
63
  FontColor: "#afb9bb",
@@ -23,7 +23,7 @@ export function fitString(ctx: CanvasRenderingContext2D, str: string, maxWidth:
23
23
 
24
24
  const index = binarySearch({
25
25
  max: str.length,
26
- getValue: guess => ctx.measureText(str.substring(0, guess)).width,
26
+ getValue: (guess: number) => ctx.measureText(str.substring(0, guess)).width,
27
27
  match: maxWidth - ellipsisWidth,
28
28
  });
29
29
 
@@ -38,7 +38,7 @@ export function splitString(ctx: CanvasRenderingContext2D, str: string, maxWidth
38
38
 
39
39
  let index = binarySearch({
40
40
  max: str.length,
41
- getValue: guess => ctx.measureText(str.substring(0, guess)).width,
41
+ getValue: (guess: number) => ctx.measureText(str.substring(0, guess)).width,
42
42
  match: maxWidth,
43
43
  });
44
44
 
@@ -64,7 +64,7 @@ export function splitStringIntoLines(ctx: CanvasRenderingContext2D, str: string,
64
64
  while (remaining !== "") {
65
65
  let index = binarySearch({
66
66
  max: remaining.length,
67
- getValue: guess => ctx.measureText(remaining.substring(0, guess)).width,
67
+ getValue: (guess: number) => ctx.measureText(remaining.substring(0, guess)).width,
68
68
  match: maxWidth,
69
69
  });
70
70