@elicdavis/node-flow 0.1.12 → 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.12",
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
  }
@@ -447,26 +511,6 @@ export class NodeFlowGraph {
447
511
  };
448
512
  }
449
513
 
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
514
  #clickEnd(): void {
471
515
  this.currentView().clickEnd();
472
516
  }
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
 
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
+ }