@yuneta/gobj-ui 7.23.12 → 7.23.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.
@@ -32,6 +32,21 @@
32
32
  * forwarded event object -- which is why the plugin's
33
33
  * `getItems(e)` receives exactly the shape it expects.
34
34
  *
35
+ * ONE finger has to serve three commands -- move the
36
+ * node, act on it, open its menu -- so the press is
37
+ * arbitrated, and arbitrated at the RELEASE:
38
+ *
39
+ * moved -> drag
40
+ * still, let go fast -> the element's own action
41
+ * still, held >= 500ms -> the context menu
42
+ *
43
+ * A menu opened on a TIMER instead cannot be arbitrated
44
+ * at all: it opens while `drag-element` is already
45
+ * carrying the node, so the same press means both things
46
+ * and the user gets a menu over a card running away
47
+ * underneath it. `classify_press` is where the three
48
+ * live, and it is pure.
49
+ *
35
50
  * Both are built on ONE per-graph record of the fingers
36
51
  * on the glass, read from the NATIVE touch events -- see
37
52
  * `touch_state_of` for why the pointer stream, which looks
@@ -50,19 +65,7 @@ import {
50
65
  register,
51
66
  } from '@antv/g6';
52
67
 
53
-
54
- /***************************************************************
55
- * Constants
56
- ***************************************************************/
57
-
58
- /*
59
- * How long a finger must stay put to mean "context menu", and how
60
- * far it may wander first. 500ms is what Android and iOS both use
61
- * for their own long press; a shorter one fires while the user is
62
- * still deciding whether to pan.
63
- */
64
- const LONG_PRESS_MS = 500;
65
- const LONG_PRESS_SLOP = 10; /* CSS px */
68
+ import {press_moved, classify_press} from "./press_arbiter.js";
66
69
 
67
70
 
68
71
  /***************************************************************
@@ -97,7 +100,11 @@ function touch_state_of(graph)
97
100
 
98
101
  state = {
99
102
  count: 0, /* fingers on the glass right now */
103
+ multi: false, /* two or more were down in THIS gesture */
100
104
  container: null,
105
+ /* A long press fired, and the click that @antv/g makes out
106
+ * of the same pointerup has not been answered yet. */
107
+ long_press_click: false,
101
108
  };
102
109
  __touch_state__.set(graph, state);
103
110
 
@@ -109,6 +116,14 @@ function touch_state_of(graph)
109
116
 
110
117
  let read = (event) => {
111
118
  state.count = event.touches? event.touches.length: 0;
119
+ if(state.count >= 2) {
120
+ /* Sticky until the LAST finger goes: a pinch releases its
121
+ * two fingers one at a time, and the release of the first
122
+ * one must not be read as the end of a press. */
123
+ state.multi = true;
124
+ } else if(state.count === 0) {
125
+ state.multi = false;
126
+ }
112
127
  };
113
128
 
114
129
  /* Passive: we never preventDefault here -- the canvas already
@@ -119,6 +134,24 @@ function touch_state_of(graph)
119
134
  container.addEventListener("touchend", read, {passive: true});
120
135
  container.addEventListener("touchcancel", read, {passive: true});
121
136
 
137
+ /*
138
+ * The browser opens its OWN menu on a long press, and it opens it
139
+ * while the finger is still down -- on top of whatever the graph
140
+ * is about to do with the same press. Refuse it for as long as
141
+ * there is a finger on the glass.
142
+ *
143
+ * This costs the graph nothing: G6 reads no `contextmenu` from
144
+ * the DOM at all (it synthesises its own from `pointerdown` with
145
+ * `button === 2`), so a real right click keeps working with its
146
+ * DOM event refused -- and it is not refused here anyway, because
147
+ * a mouse leaves `count` at zero.
148
+ */
149
+ container.addEventListener("contextmenu", (event) => {
150
+ if(state.count > 0) {
151
+ event.preventDefault();
152
+ }
153
+ }, {capture: true});
154
+
122
155
  return state;
123
156
  }
124
157
 
@@ -290,6 +323,30 @@ export function ensure_pinch_zoom_patch()
290
323
 
291
324
 
292
325
 
326
+ /************************************************************
327
+ * Is the click being dispatched right now the tail of a long
328
+ * press? Answers true ONCE, to the first asker.
329
+ *
330
+ * @antv/g does not take `click` from the DOM either: it makes
331
+ * one out of the pointerdown/pointerup pair itself, inside
332
+ * the same `onPointerUp` that just fed us the release. So
333
+ * the press that opened the menu goes on to click whatever it
334
+ * opened the menu ON -- selecting the node, or worse, dropping
335
+ * a selection of several -- unless the handler asks.
336
+ ************************************************************/
337
+ export function consume_long_press_click(graph)
338
+ {
339
+ if(!graph) {
340
+ return false;
341
+ }
342
+ let state = __touch_state__.get(graph);
343
+ if(!state || !state.long_press_click) {
344
+ return false;
345
+ }
346
+ state.long_press_click = false;
347
+ return true;
348
+ }
349
+
293
350
  /************************************************************
294
351
  * Make a long press open the context menu.
295
352
  *
@@ -300,33 +357,45 @@ export function ensure_pinch_zoom_patch()
300
357
  * the name the plugin listens for, so `getItems(e)` and
301
358
  * `enable(e)` see the same object a right-click would give
302
359
  * them -- same `target`, same `targetType`, same `client`.
360
+ *
361
+ * `options.enable(event)` decides, at the DOWN, whether this
362
+ * press is even a candidate. Pass the plugin's own predicate:
363
+ * a press on the empty canvas would otherwise arm a menu the
364
+ * plugin then refuses to show, and swallow the click that
365
+ * would have cleared the selection.
303
366
  ************************************************************/
304
- export function install_long_press_contextmenu(graph)
367
+ export function install_long_press_contextmenu(graph, options)
305
368
  {
306
369
  if(!graph) {
307
370
  return () => {};
308
371
  }
309
372
 
310
- let state = touch_state_of(graph);
311
- let pending = null; /* {timer, x, y, event} */
373
+ let enable = options?.enable;
374
+ let state = touch_state_of(graph);
375
+ let pending = null; /* {x, y, t0, event} */
312
376
 
313
- let cancel = () => {
314
- if(pending) {
315
- clearTimeout(pending.timer);
316
- pending = null;
317
- }
377
+ let disarm = () => {
378
+ pending = null;
318
379
  };
319
380
 
320
381
  let fire = (event) => {
321
- pending = null;
382
+ /*
383
+ * The click @antv/g is about to make out of this same
384
+ * pointerup belongs to the press, and the press has already
385
+ * been spent on the menu.
386
+ */
387
+ state.long_press_click = true;
388
+ setTimeout(() => {
389
+ state.long_press_click = false;
390
+ }, 0);
322
391
 
323
392
  /*
324
- * The tap that ENDS the long press would reach the plugin's
325
- * own `document` click listener and hide the menu the press
326
- * just opened -- a right click never produces a `click`, so
327
- * the plugin has no reason to guard against it. Swallow
328
- * that one click in the CAPTURE phase, where document sees
329
- * it before anything else does.
393
+ * That click also reaches the plugin's own `document`
394
+ * listener, which hides the menu the press just opened -- a
395
+ * right click never produces a `click`, so the plugin has no
396
+ * reason to guard against it. Swallow that one click in the
397
+ * CAPTURE phase, where document sees it before anything else
398
+ * does.
330
399
  */
331
400
  let swallow = (evt) => {
332
401
  evt.stopPropagation();
@@ -337,30 +406,21 @@ export function install_long_press_contextmenu(graph)
337
406
  document.removeEventListener("click", swallow, true);
338
407
  }, 1000);
339
408
 
340
- /*
341
- * And the browser's own long-press menu, which would open on
342
- * top of ours. One shot: a later right click must keep
343
- * reaching the plugin.
344
- */
345
- let container = graph.getCanvas?.()?.getContainer?.();
346
- if(container) {
347
- container.addEventListener("contextmenu", (evt) => {
348
- evt.preventDefault();
349
- }, {once: true, capture: true});
350
- }
351
-
352
409
  graph.emit(`${event.targetType}:${CommonEvent.CONTEXT_MENU}`, event);
353
410
  graph.emit(CommonEvent.CONTEXT_MENU, event);
354
411
  };
355
412
 
356
413
  let on_down = (event) => {
357
- cancel();
414
+ disarm();
358
415
  if(event.pointerType !== "touch") {
359
416
  return; /* a mouse holding its button is not a menu */
360
417
  }
361
- if(state.count > 1) {
418
+ if(state.count > 1 || state.multi) {
362
419
  return; /* two fingers: that is the pinch */
363
420
  }
421
+ if(enable && !enable(event)) {
422
+ return; /* nothing here has a menu to open */
423
+ }
364
424
  let x = event.client?.x;
365
425
  let y = event.client?.y;
366
426
  if(x === undefined || y === undefined) {
@@ -368,16 +428,15 @@ export function install_long_press_contextmenu(graph)
368
428
  }
369
429
  /*
370
430
  * The event object is reused by @antv/g between dispatches,
371
- * so what the timer must carry is a COPY -- by the time it
372
- * fires, the live object describes the pointerup.
431
+ * so what the release must answer with is a COPY -- by then
432
+ * the live object describes the pointerup, and the menu is
433
+ * about what was PRESSED.
373
434
  */
374
- let frozen = Object.assign({}, event);
375
435
  pending = {
376
- x: x,
377
- y: y,
378
- timer: setTimeout(() => {
379
- fire(frozen);
380
- }, LONG_PRESS_MS),
436
+ x: x,
437
+ y: y,
438
+ t0: Date.now(),
439
+ event: Object.assign({}, event),
381
440
  };
382
441
  };
383
442
 
@@ -385,8 +444,8 @@ export function install_long_press_contextmenu(graph)
385
444
  if(!pending) {
386
445
  return;
387
446
  }
388
- if(state.count > 1) {
389
- cancel();
447
+ if(state.count > 1 || state.multi) {
448
+ disarm();
390
449
  return;
391
450
  }
392
451
  let x = event.client?.x;
@@ -394,20 +453,51 @@ export function install_long_press_contextmenu(graph)
394
453
  if(x === undefined || y === undefined) {
395
454
  return;
396
455
  }
397
- if(Math.abs(x - pending.x) > LONG_PRESS_SLOP ||
398
- Math.abs(y - pending.y) > LONG_PRESS_SLOP) {
399
- cancel();
456
+ if(press_moved(pending, x, y)) {
457
+ disarm(); /* the finger is carrying the node */
458
+ }
459
+ };
460
+
461
+ let on_up = (event) => {
462
+ let press = pending;
463
+ pending = null;
464
+ if(!press) {
465
+ return;
466
+ }
467
+ if(state.multi) {
468
+ return; /* a finger of a pinch, not a press */
469
+ }
470
+ let x = event.client?.x;
471
+ let y = event.client?.y;
472
+ if(x === undefined) {
473
+ x = press.x;
474
+ }
475
+ if(y === undefined) {
476
+ y = press.y;
400
477
  }
478
+ if(classify_press(press, x, y, Date.now()) !== "long") {
479
+ return; /* a drag, or a tap: both are already served */
480
+ }
481
+ fire(press.event);
482
+ };
483
+
484
+ /* The gesture can also end without a release: the browser takes
485
+ * the touch away (a system gesture, a call), and the press must
486
+ * not survive it into the next one. */
487
+ let on_touch_cancel = () => {
488
+ disarm();
401
489
  };
490
+ state.container?.addEventListener("touchcancel", on_touch_cancel);
402
491
 
403
492
  graph.on(CommonEvent.POINTER_DOWN, on_down);
404
493
  graph.on(CommonEvent.POINTER_MOVE, on_move);
405
- graph.on(CommonEvent.POINTER_UP, cancel);
494
+ graph.on(CommonEvent.POINTER_UP, on_up);
406
495
 
407
496
  return () => {
408
- cancel();
497
+ disarm();
498
+ state.container?.removeEventListener("touchcancel", on_touch_cancel);
409
499
  graph.off(CommonEvent.POINTER_DOWN, on_down);
410
500
  graph.off(CommonEvent.POINTER_MOVE, on_move);
411
- graph.off(CommonEvent.POINTER_UP, cancel);
501
+ graph.off(CommonEvent.POINTER_UP, on_up);
412
502
  };
413
503
  }
package/src/lib_graph.css CHANGED
@@ -52,6 +52,27 @@
52
52
  .graph-container {
53
53
  -webkit-user-select: none;
54
54
  user-select: none;
55
+
56
+ /*
57
+ * And the finger's half of the same problem: on a touch screen
58
+ * the browser owns the gesture until something says otherwise.
59
+ *
60
+ * G6 puts `touch-action: none` on its CANVAS, so dragging from
61
+ * the background pans the graph -- and it puts nothing on the
62
+ * HTML nodes, which are ordinary DIVs layered on top of it. So
63
+ * a finger that lands on a CARD was making a page scroll:
64
+ * Chrome let two `pointermove`s through, decided the gesture
65
+ * was a scroll, and killed the pointer stream with a
66
+ * `pointercancel`. The node followed the finger for about 20px
67
+ * and then stopped dead while the page slid -- which is what
68
+ * "I only want to move a node" looked like on a phone.
69
+ *
70
+ * The whole container, not the cards: every layer G6 stacks in
71
+ * here (nodes, plugin containers, the ones it adds next) is a
72
+ * DIV over a canvas that already refuses the browser's
73
+ * gestures, and they have to agree.
74
+ */
75
+ touch-action: none;
55
76
  }
56
77
  .graph-container input,
57
78
  .graph-container textarea,
@@ -66,6 +87,19 @@
66
87
  user-select: text;
67
88
  }
68
89
 
90
+ /*
91
+ * What a finger must still be able to SCROLL: the panels, and the
92
+ * context menu, which is as long as the impact of a delete.
93
+ */
94
+ .graph-container .g6-confirm-popover,
95
+ .graph-container .g6-create-popover,
96
+ .graph-container .g6-node-popover,
97
+ .graph-container .g6-edge-popover,
98
+ .graph-container .g6-node-detail,
99
+ .graph-container .g6-contextmenu {
100
+ touch-action: auto;
101
+ }
102
+
69
103
  .graph-container:fullscreen {
70
104
  background-color: var(--bulma-background, #fff);
71
105
  }
@@ -0,0 +1,84 @@
1
+ /***********************************************************************
2
+ * press_arbiter.js
3
+ *
4
+ * What ONE finger meant.
5
+ *
6
+ * A graph gives a single finger three commands -- move the
7
+ * node, act on it, open its menu -- and nothing in the press
8
+ * says which one until the finger either moves or lets go.
9
+ * So the press is arbitrated, and arbitrated at the RELEASE:
10
+ *
11
+ * moved -> "drag" (the node followed it)
12
+ * still, let go fast -> "tap" (the element's action)
13
+ * still, held >= 500ms -> "long" (the context menu)
14
+ *
15
+ * Deciding earlier is what breaks it. A menu opened on a
16
+ * TIMER opens while `drag-element` is already carrying the
17
+ * node: the same press then means two things at once, and
18
+ * the user gets a menu over a card running away underneath
19
+ * it. A timer cannot arbitrate, because at the moment it
20
+ * fires the gesture is not over.
21
+ *
22
+ * Pure, and separate from `g6_touch_gestures.js` for that
23
+ * reason: the rule can be read and tested without a graph,
24
+ * a canvas or a finger.
25
+ *
26
+ * Copyright (c) 2026, ArtGins.
27
+ * All Rights Reserved.
28
+ ***********************************************************************/
29
+
30
+ /***************************************************************
31
+ * Constants
32
+ ***************************************************************/
33
+
34
+ /*
35
+ * How long a finger must stay put to mean "context menu", and how
36
+ * far it may wander first. 500ms is what Android and iOS both use
37
+ * for their own long press.
38
+ *
39
+ * The 10px is not a taste: it is the `dragstartDistanceThreshold`
40
+ * G6 gives the dragndrop plugin of its main canvas. Keeping the
41
+ * two equal is what makes "still" mean the same thing to both --
42
+ * a press held inside the slop has NOT moved the node, so a menu
43
+ * it opens does not open over a card that quietly shifted.
44
+ */
45
+ export const LONG_PRESS_MS = 500;
46
+ export const LONG_PRESS_SLOP = 10; /* CSS px */
47
+
48
+
49
+ /***************************************************************
50
+ * Arbitration
51
+ ***************************************************************/
52
+
53
+ /************************************************************
54
+ * Did the finger travel far enough to mean a DRAG?
55
+ *
56
+ * `press` is what the pointerdown recorded: {x, y, t0}.
57
+ ************************************************************/
58
+ export function press_moved(press, x, y)
59
+ {
60
+ if(!press) {
61
+ return false;
62
+ }
63
+ return Math.abs(x - press.x) > LONG_PRESS_SLOP ||
64
+ Math.abs(y - press.y) > LONG_PRESS_SLOP;
65
+ }
66
+
67
+ /************************************************************
68
+ * What the press meant, asked when the finger comes up.
69
+ *
70
+ * Answers "none" when there was no press to arbitrate.
71
+ ************************************************************/
72
+ export function classify_press(press, x, y, t)
73
+ {
74
+ if(!press) {
75
+ return "none";
76
+ }
77
+ if(press_moved(press, x, y)) {
78
+ return "drag";
79
+ }
80
+ if(t - press.t0 >= LONG_PRESS_MS) {
81
+ return "long";
82
+ }
83
+ return "tap";
84
+ }
@@ -0,0 +1,63 @@
1
+ /***********************************************************************
2
+ * press_arbiter.test.js
3
+ ***********************************************************************/
4
+ import {describe, it, expect} from "vitest";
5
+ import {
6
+ LONG_PRESS_MS,
7
+ LONG_PRESS_SLOP,
8
+ press_moved,
9
+ classify_press,
10
+ } from "./press_arbiter.js";
11
+
12
+ const at = (x, y, t0) => ({x: x, y: y, t0: t0});
13
+
14
+ describe("press_moved", () => {
15
+ it("a finger that stays inside the slop has not moved", () => {
16
+ let p = at(100, 100, 0);
17
+ expect(press_moved(p, 100, 100)).toBe(false);
18
+ expect(press_moved(p, 100 + LONG_PRESS_SLOP, 100)).toBe(false);
19
+ expect(press_moved(p, 100, 100 - LONG_PRESS_SLOP)).toBe(false);
20
+ });
21
+
22
+ it("one pixel past the slop, in either axis, is a move", () => {
23
+ let p = at(100, 100, 0);
24
+ expect(press_moved(p, 100 + LONG_PRESS_SLOP + 1, 100)).toBe(true);
25
+ expect(press_moved(p, 100, 100 + LONG_PRESS_SLOP + 1)).toBe(true);
26
+ });
27
+
28
+ it("no press, no move", () => {
29
+ expect(press_moved(null, 100, 100)).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe("classify_press", () => {
34
+ it("a press that moved is a drag, however long it was held", () => {
35
+ let p = at(100, 100, 0);
36
+ expect(classify_press(p, 400, 100, 10)).toBe("drag");
37
+ expect(classify_press(p, 400, 100, 10 * LONG_PRESS_MS)).toBe("drag");
38
+ });
39
+
40
+ it("still and let go quickly is a tap", () => {
41
+ let p = at(100, 100, 1000);
42
+ expect(classify_press(p, 100, 100, 1000)).toBe("tap");
43
+ expect(classify_press(p, 105, 103, 1000 + LONG_PRESS_MS - 1)).toBe("tap");
44
+ });
45
+
46
+ it("still and held to the threshold is the menu", () => {
47
+ let p = at(100, 100, 1000);
48
+ expect(classify_press(p, 100, 100, 1000 + LONG_PRESS_MS)).toBe("long");
49
+ expect(classify_press(p, 108, 100, 9000)).toBe("long");
50
+ });
51
+
52
+ it("a long hold that ends somewhere else is still a drag", () => {
53
+ /* The finger that thought about the menu and then carried the
54
+ * node is carrying the node: the release is what decides, and
55
+ * the release is far away. */
56
+ let p = at(100, 100, 0);
57
+ expect(classify_press(p, 260, 340, 3000)).toBe("drag");
58
+ });
59
+
60
+ it("no press to arbitrate", () => {
61
+ expect(classify_press(null, 0, 0, 0)).toBe("none");
62
+ });
63
+ });