@yuneta/gobj-ui 7.23.8 → 7.23.9

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.
@@ -59,6 +59,7 @@ import {
59
59
  } from '@antv/g6';
60
60
 
61
61
  import { ensure_drag_canvas_patch } from "./g6_drag_canvas_touch.js";
62
+ import { ensure_pinch_zoom_patch } from "./g6_touch_gestures.js";
62
63
  import { yui_is_dark, yui_theme_now, yui_watch_theme } from "./yui_theme.js";
63
64
 
64
65
  /***************************************************************
@@ -305,6 +306,7 @@ function mt_create(gobj)
305
306
  build_ui(gobj);
306
307
 
307
308
  ensure_drag_canvas_patch();
309
+ ensure_pinch_zoom_patch();
308
310
 
309
311
  if(!__layout_registered__) {
310
312
  register(ExtensionCategory.LAYOUT, 'gobj-tree-v', GobjTreeVLayout);
@@ -67,6 +67,7 @@ import {
67
67
  } from '@antv/g6';
68
68
 
69
69
  import { ensure_drag_canvas_patch } from "./g6_drag_canvas_touch.js";
70
+ import { ensure_pinch_zoom_patch } from "./g6_touch_gestures.js";
70
71
  import { yui_is_dark, yui_theme_now, yui_watch_theme } from "./yui_theme.js";
71
72
 
72
73
  /***************************************************************
@@ -301,6 +302,7 @@ function mt_create(gobj)
301
302
  build_ui(gobj);
302
303
 
303
304
  ensure_drag_canvas_patch();
305
+ ensure_pinch_zoom_patch();
304
306
 
305
307
  if(!__layout_registered__) {
306
308
  register(ExtensionCategory.LAYOUT, 'json-tree-layout', JsonTreeLayout);
@@ -41,6 +41,12 @@
41
41
  * every graph (tree, json, treedb, editor) is fixed without
42
42
  * touching each consumer's behaviors list.
43
43
  *
44
+ * It also has to keep the ONE guard the base class has and
45
+ * this replacement dropped: G6's `onDrag` refuses to pan
46
+ * while a pinch is in progress. See `is_pinching` in
47
+ * `g6_touch_gestures.js`, which is where the pinch that
48
+ * matters is now recognised.
49
+ *
44
50
  * Copyright (c) 2026, ArtGins.
45
51
  * All Rights Reserved.
46
52
  ***********************************************************************/
@@ -51,6 +57,8 @@ import {
51
57
  register,
52
58
  } from '@antv/g6';
53
59
 
60
+ import {is_pinching} from './g6_touch_gestures.js';
61
+
54
62
  /************************************************************
55
63
  * Custom drag-canvas: same behavior, reliable delta source
56
64
  ************************************************************/
@@ -74,6 +82,19 @@ class DragCanvasTouch extends DragCanvas {
74
82
  this._lastVP = null;
75
83
  return;
76
84
  }
85
+ /*
86
+ * Two fingers on the glass is a PINCH, not a pan. G6's
87
+ * own onDrag guards on `PinchHandler.isPinching` and
88
+ * this replacement dropped it, which cost nothing while
89
+ * no graph answered the pinch -- and would have panned
90
+ * the canvas out from under the zoom the day one did.
91
+ * `null` so the next one-finger frame re-seeds instead
92
+ * of jumping the camera by the whole gesture.
93
+ */
94
+ if(is_pinching(this.context.graph)) {
95
+ this._lastVP = null;
96
+ return;
97
+ }
77
98
  let vp = event.viewport;
78
99
  if(!vp || typeof vp.x !== "number" || typeof vp.y !== "number") {
79
100
  return;
@@ -0,0 +1,413 @@
1
+ /***********************************************************************
2
+ * g6_touch_gestures.js
3
+ *
4
+ * The two gestures a G6 graph needs on a touch screen and
5
+ * does not have: PINCH to zoom, and LONG PRESS to open the
6
+ * context menu.
7
+ *
8
+ * Why this exists
9
+ * ----------------
10
+ * G6 v5 draws a graph that reads fine on a phone and cannot
11
+ * be OPERATED on one. Two gaps, both structural:
12
+ *
13
+ * 1. `zoom-canvas` binds the WHEEL and nothing else
14
+ * (`trigger: []`). There is no wheel on a phone, so the
15
+ * only zoom is the toolbar's +/- buttons. G6 does ship a
16
+ * pinch recogniser, and `zoom-canvas` will use it -- with
17
+ * `trigger: ['pinch']`, which its `bindEvents` treats as
18
+ * an ALTERNATIVE to the wheel (`if pinch ... else wheel`),
19
+ * so opting in costs the mouse. Worse, its `PinchHandler`
20
+ * keeps its instance and its callback list in STATICS: on
21
+ * a page with two graphs the second one registers its
22
+ * callback against the FIRST one's emitter, so pinching
23
+ * graph A zooms both and pinching graph B does nothing.
24
+ * We recognise the gesture ourselves, per graph.
25
+ *
26
+ * 2. The context menu is bound to `contextmenu`, and G6 does
27
+ * not take that from the DOM: its `BehaviorController`
28
+ * SYNTHESISES it from `pointerdown` with `button === 2`.
29
+ * A long press therefore never reaches the plugin on ANY
30
+ * platform, whatever the browser does with the gesture.
31
+ * So we synthesise it the same way G6 does, from the same
32
+ * forwarded event object -- which is why the plugin's
33
+ * `getItems(e)` receives exactly the shape it expects.
34
+ *
35
+ * Both are built on ONE per-graph record of the fingers
36
+ * on the glass, read from the NATIVE touch events -- see
37
+ * `touch_state_of` for why the pointer stream, which looks
38
+ * like the right source, is not. That record is also what
39
+ * tells `drag-canvas` to stop panning while two fingers are
40
+ * down (`is_pinching`), the guard G6's own drag-canvas has
41
+ * and our replacement dropped.
42
+ *
43
+ * Copyright (c) 2026, ArtGins.
44
+ * All Rights Reserved.
45
+ ***********************************************************************/
46
+ import {
47
+ ZoomCanvas,
48
+ CommonEvent,
49
+ ExtensionCategory,
50
+ register,
51
+ } from '@antv/g6';
52
+
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 */
66
+
67
+
68
+ /***************************************************************
69
+ * Per-graph record of the fingers on the glass
70
+ ***************************************************************/
71
+ const __touch_state__ = new WeakMap();
72
+
73
+ /************************************************************
74
+ * The touch record of one graph, installing its listeners
75
+ * the first time it is asked for.
76
+ *
77
+ * Read from the NATIVE touch events, not from G6's
78
+ * forwarded pointer stream.
79
+ *
80
+ * The pointer stream looks like the right source and is
81
+ * not: @antv/g re-issues pointer ids in the middle of a
82
+ * two-finger gesture (measured: a pinch that started on
83
+ * ids 2 and 3 finished on id 1), so anything that keys a
84
+ * Map on `pointerId` loses one of the two fingers halfway
85
+ * and reads the gesture as a fraction of what it was.
86
+ * G6's own `PinchHandler` is built exactly that way.
87
+ *
88
+ * `event.touches` needs no bookkeeping at all: it IS the
89
+ * list of fingers currently down, re-stated on every event.
90
+ ************************************************************/
91
+ function touch_state_of(graph)
92
+ {
93
+ let state = __touch_state__.get(graph);
94
+ if(state) {
95
+ return state;
96
+ }
97
+
98
+ state = {
99
+ count: 0, /* fingers on the glass right now */
100
+ container: null,
101
+ };
102
+ __touch_state__.set(graph, state);
103
+
104
+ let container = graph.getCanvas?.()?.getContainer?.();
105
+ if(!container) {
106
+ return state; /* no DOM yet: nothing to listen to */
107
+ }
108
+ state.container = container;
109
+
110
+ let read = (event) => {
111
+ state.count = event.touches? event.touches.length: 0;
112
+ };
113
+
114
+ /* Passive: we never preventDefault here -- the canvas already
115
+ * carries `touch-action: none`, which is what actually stops
116
+ * the browser from panning the page under the graph. */
117
+ container.addEventListener("touchstart", read, {passive: true});
118
+ container.addEventListener("touchmove", read, {passive: true});
119
+ container.addEventListener("touchend", read, {passive: true});
120
+ container.addEventListener("touchcancel", read, {passive: true});
121
+
122
+ return state;
123
+ }
124
+
125
+ /************************************************************
126
+ * Is a two-finger gesture in progress on this graph?
127
+ *
128
+ * Read by our `drag-canvas` replacement: panning and zooming
129
+ * the same drag at the same time fights the user for the
130
+ * camera.
131
+ ************************************************************/
132
+ export function is_pinching(graph)
133
+ {
134
+ if(!graph) {
135
+ return false;
136
+ }
137
+ let state = __touch_state__.get(graph);
138
+ if(!state) {
139
+ return false; /* no touch gesture was ever installed here */
140
+ }
141
+ return state.count >= 2;
142
+ }
143
+
144
+
145
+
146
+
147
+ /***************************
148
+ * Pinch to zoom
149
+ ***************************/
150
+
151
+
152
+
153
+
154
+ /************************************************************
155
+ * `zoom-canvas` that also answers the pinch.
156
+ *
157
+ * The base class keeps the wheel; the pinch is added on top
158
+ * instead of replacing it, so one build serves the mouse and
159
+ * the finger. The scale is ABSOLUTE against the zoom the
160
+ * gesture started at -- G6's own pinch feeds `zoom()` an
161
+ * increment per frame, which drifts away from the fingers
162
+ * over a long gesture.
163
+ ************************************************************/
164
+ class ZoomCanvasPinch extends ZoomCanvas
165
+ {
166
+ constructor(context, options)
167
+ {
168
+ super(context, options);
169
+
170
+ this._pinch_dist0 = 0;
171
+ this._pinch_zoom0 = 1;
172
+ this._on_touch_move = null;
173
+
174
+ let {trigger} = this.options;
175
+ if(Array.isArray(trigger) && trigger.includes(CommonEvent.PINCH)) {
176
+ return; /* the caller asked for G6's own pinch */
177
+ }
178
+
179
+ let graph = this.context.graph;
180
+ let state = touch_state_of(graph);
181
+ if(!state.container) {
182
+ return;
183
+ }
184
+
185
+ this._on_touch_move = (event) => {
186
+ let touches = event.touches;
187
+ if(!touches || touches.length !== 2) {
188
+ /* Not two fingers (yet, or any more): the next pinch
189
+ * measures itself afresh. */
190
+ this._pinch_dist0 = 0;
191
+ return;
192
+ }
193
+ let dx = touches[0].clientX - touches[1].clientX;
194
+ let dy = touches[0].clientY - touches[1].clientY;
195
+ let dist = Math.sqrt(dx * dx + dy * dy);
196
+ if(dist <= 0) {
197
+ return;
198
+ }
199
+ if(this._pinch_dist0 === 0) {
200
+ /* First frame of this gesture: remember what it is
201
+ * measured against. */
202
+ this._pinch_dist0 = dist;
203
+ this._pinch_zoom0 = graph.getZoom();
204
+ return;
205
+ }
206
+ this._pinch_to(dist / this._pinch_dist0, touches, event);
207
+ };
208
+
209
+ /*
210
+ * Bound on the CONTAINER and not through `bindEvents()`,
211
+ * which `update()` runs again on every option change: the
212
+ * wheel goes through `Shortcut`, which unbinds itself
213
+ * first, and a listener put straight on an element would
214
+ * simply pile up.
215
+ */
216
+ state.container.addEventListener(
217
+ "touchmove", this._on_touch_move, {passive: true}
218
+ );
219
+ }
220
+
221
+ /************************************************************
222
+ * Zoom to `scale` times the zoom the pinch started at,
223
+ * about the midpoint between the two fingers.
224
+ *
225
+ * ABSOLUTE against the start of the gesture, where G6's
226
+ * own pinch feeds `zoom()` an increment per frame: an
227
+ * increment drifts away from the fingers over a long
228
+ * gesture, and cannot come back when they close again.
229
+ ************************************************************/
230
+ _pinch_to(scale, touches, event)
231
+ {
232
+ if(!this.validate(event)) {
233
+ return;
234
+ }
235
+ let graph = this.context.graph;
236
+ let state = touch_state_of(graph);
237
+ let origin = null;
238
+
239
+ /* The camera's origin is measured in the canvas's own
240
+ * viewport space, which is the container box: the canvas
241
+ * fills it, so the offset IS the container's corner. */
242
+ let rect = state.container?.getBoundingClientRect();
243
+ if(rect) {
244
+ origin = [
245
+ (touches[0].clientX + touches[1].clientX) / 2 - rect.left,
246
+ (touches[0].clientY + touches[1].clientY) / 2 - rect.top,
247
+ ];
248
+ }
249
+
250
+ /* `zoomTo` clamps to the graph's own `zoomRange`; no
251
+ * animation, the fingers are the animation. */
252
+ graph.zoomTo(this._pinch_zoom0 * scale, false, origin);
253
+ this.options.onFinish?.();
254
+ }
255
+
256
+ destroy()
257
+ {
258
+ if(this._on_touch_move) {
259
+ let state = __touch_state__.get(this.context.graph);
260
+ state?.container?.removeEventListener("touchmove", this._on_touch_move);
261
+ this._on_touch_move = null;
262
+ }
263
+ super.destroy();
264
+ }
265
+ }
266
+
267
+ /************************************************************
268
+ * Register once, over the built-in `zoom-canvas`, so every
269
+ * graph (tree, json, treedb, editor) pinches without
270
+ * touching its behaviors list.
271
+ ************************************************************/
272
+ let __zoom_canvas_patched__ = false;
273
+
274
+ export function ensure_pinch_zoom_patch()
275
+ {
276
+ if(__zoom_canvas_patched__) {
277
+ return;
278
+ }
279
+ register(ExtensionCategory.BEHAVIOR, 'zoom-canvas', ZoomCanvasPinch);
280
+ __zoom_canvas_patched__ = true;
281
+ }
282
+
283
+
284
+
285
+
286
+ /***************************
287
+ * Long press
288
+ ***************************/
289
+
290
+
291
+
292
+
293
+ /************************************************************
294
+ * Make a long press open the context menu.
295
+ *
296
+ * Returns the function that uninstalls it.
297
+ *
298
+ * The menu the press opens is whatever the `contextmenu`
299
+ * plugin builds: we re-emit G6's own forwarded event under
300
+ * the name the plugin listens for, so `getItems(e)` and
301
+ * `enable(e)` see the same object a right-click would give
302
+ * them -- same `target`, same `targetType`, same `client`.
303
+ ************************************************************/
304
+ export function install_long_press_contextmenu(graph)
305
+ {
306
+ if(!graph) {
307
+ return () => {};
308
+ }
309
+
310
+ let state = touch_state_of(graph);
311
+ let pending = null; /* {timer, x, y, event} */
312
+
313
+ let cancel = () => {
314
+ if(pending) {
315
+ clearTimeout(pending.timer);
316
+ pending = null;
317
+ }
318
+ };
319
+
320
+ let fire = (event) => {
321
+ pending = null;
322
+
323
+ /*
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.
330
+ */
331
+ let swallow = (evt) => {
332
+ evt.stopPropagation();
333
+ document.removeEventListener("click", swallow, true);
334
+ };
335
+ document.addEventListener("click", swallow, true);
336
+ setTimeout(() => {
337
+ document.removeEventListener("click", swallow, true);
338
+ }, 1000);
339
+
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
+ graph.emit(`${event.targetType}:${CommonEvent.CONTEXT_MENU}`, event);
353
+ graph.emit(CommonEvent.CONTEXT_MENU, event);
354
+ };
355
+
356
+ let on_down = (event) => {
357
+ cancel();
358
+ if(event.pointerType !== "touch") {
359
+ return; /* a mouse holding its button is not a menu */
360
+ }
361
+ if(state.count > 1) {
362
+ return; /* two fingers: that is the pinch */
363
+ }
364
+ let x = event.client?.x;
365
+ let y = event.client?.y;
366
+ if(x === undefined || y === undefined) {
367
+ return;
368
+ }
369
+ /*
370
+ * 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.
373
+ */
374
+ let frozen = Object.assign({}, event);
375
+ pending = {
376
+ x: x,
377
+ y: y,
378
+ timer: setTimeout(() => {
379
+ fire(frozen);
380
+ }, LONG_PRESS_MS),
381
+ };
382
+ };
383
+
384
+ let on_move = (event) => {
385
+ if(!pending) {
386
+ return;
387
+ }
388
+ if(state.count > 1) {
389
+ cancel();
390
+ return;
391
+ }
392
+ let x = event.client?.x;
393
+ let y = event.client?.y;
394
+ if(x === undefined || y === undefined) {
395
+ return;
396
+ }
397
+ if(Math.abs(x - pending.x) > LONG_PRESS_SLOP ||
398
+ Math.abs(y - pending.y) > LONG_PRESS_SLOP) {
399
+ cancel();
400
+ }
401
+ };
402
+
403
+ graph.on(CommonEvent.POINTER_DOWN, on_down);
404
+ graph.on(CommonEvent.POINTER_MOVE, on_move);
405
+ graph.on(CommonEvent.POINTER_UP, cancel);
406
+
407
+ return () => {
408
+ cancel();
409
+ graph.off(CommonEvent.POINTER_DOWN, on_down);
410
+ graph.off(CommonEvent.POINTER_MOVE, on_move);
411
+ graph.off(CommonEvent.POINTER_UP, cancel);
412
+ };
413
+ }
package/src/lib_graph.css CHANGED
@@ -177,6 +177,25 @@
177
177
  background-color: var(--bulma-background, #f0f0f0);
178
178
  }
179
179
 
180
+ /*
181
+ * Touch: the context menu is a LIST OF COMMANDS, and G6 sizes its
182
+ * rows with `padding: 8px 12px` -- 34px, which is a mouse row. The
183
+ * menu is now reachable by a long press (g6_touch_gestures.js), so
184
+ * its rows have to be reachable by the same finger that opened it.
185
+ * `font-size` with it: 12px is a caption, not a command.
186
+ */
187
+ @media (pointer: coarse) {
188
+ .g6-contextmenu {
189
+ font-size: 14px;
190
+ }
191
+ .g6-contextmenu-li {
192
+ min-height: 44px;
193
+ display: flex;
194
+ align-items: center;
195
+ padding: 8px 16px;
196
+ }
197
+ }
198
+
180
199
  /* No transient effects: open/move popups immediately (user pref).
181
200
  * G6 injects its own .g6-contextmenu / .g6-tooltip CSS with a
182
201
  * left/top/visibility transition that makes them glide into place.
package/src/lib_icons.js CHANGED
@@ -113,6 +113,20 @@ const ICONS = `<svg xmlns="http://www.w3.org/2000/svg">
113
113
  <symbol id="g6-icon-plus" viewBox="0 0 512 512">
114
114
  <path fill="currentColor" d="M316 32 L316 196 L480 196 L480 316 L316 316 L316 480 L196 480 L196 316 L32 316 L32 196 L196 196 L196 32 Z"/>
115
115
  </symbol>
116
+ <!-- ellipsis-vertical (unfold the floating toolbar).
117
+ The strip it stands for is a vertical stack, and a vertical
118
+ ellipsis is what "there is more of this here" is drawn as
119
+ everywhere else. -->
120
+ <symbol id="g6-icon-toolbar-show" viewBox="0 0 128 512">
121
+ <path fill="currentColor" d="M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z"/>
122
+ </symbol>
123
+ <!-- xmark (fold the floating toolbar away).
124
+ Not a chevron: the two toolbars sit on OPPOSITE edges, so a
125
+ direction would point the wrong way on one of them. What the
126
+ button does is close an overlay, and that is an x. -->
127
+ <symbol id="g6-icon-toolbar-hide" viewBox="0 0 384 512">
128
+ <path fill="currentColor" d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/>
129
+ </symbol>
116
130
  <!-- floppy-disk (save) -->
117
131
  <symbol id="g6-icon-save" viewBox="0 0 448 512">
118
132
  <path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>