@yuneta/gobj-ui 7.23.7 → 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.
@@ -103,6 +103,10 @@ import i18next, {t} from "i18next";
103
103
 
104
104
  import {inject_svg_icons} from "./lib_icons.js";
105
105
  import {ensure_drag_canvas_patch} from "./g6_drag_canvas_touch.js";
106
+ import {
107
+ ensure_pinch_zoom_patch,
108
+ install_long_press_contextmenu,
109
+ } from "./g6_touch_gestures.js";
106
110
  import {yui_theme_now, yui_watch_theme} from "./yui_theme.js";
107
111
 
108
112
  /***************************************************************
@@ -172,6 +176,7 @@ class YuiToolbar extends Toolbar
172
176
  }
173
177
  register(ExtensionCategory.PLUGIN, 'yui-toolbar', YuiToolbar);
174
178
  ensure_drag_canvas_patch();
179
+ ensure_pinch_zoom_patch();
175
180
 
176
181
  /***************************************************************
177
182
  * Constants
@@ -336,6 +341,9 @@ let PRIVATE_DATA = {
336
341
  _focus_topic: null, // topic currently focused (EV_FOCUS_TOPIC)
337
342
  _focus_ids: [], // node ids carrying the focus 'active' state
338
343
  _on_pointerdown_focus: null, // listener keeping the keyboard on the canvas
344
+ _uninstall_long_press: null, // touch door to the context menu
345
+ toolbar_collapsed: true, // floating toolbars folded (narrow only)
346
+ _toolbars_could_collapse: null, // last answer, to notice it changed
339
347
  _on_focusout_restore: null, // ...and putting it back when it goes nowhere
340
348
  _selected_paint_ids: [], // node ids whose card is PAINTED selected
341
349
  // (G6's 'selected' state is the selection
@@ -491,6 +499,11 @@ function mt_destroy(gobj)
491
499
  priv._resize_raf = 0;
492
500
  }
493
501
 
502
+ if(priv._uninstall_long_press) {
503
+ priv._uninstall_long_press();
504
+ priv._uninstall_long_press = null;
505
+ }
506
+
494
507
  if(priv.graph) {
495
508
  priv.graph.destroy();
496
509
  priv.graph = null;
@@ -943,6 +956,19 @@ function configure_plugins(gobj)
943
956
  }
944
957
  );
945
958
 
959
+ /*
960
+ * The menu above is the way to link, unlink, delete and resize,
961
+ * and `contextmenu` is a RIGHT CLICK -- G6 synthesises it from
962
+ * `pointerdown` with `button === 2` and reads nothing from the
963
+ * DOM event of the same name. So on a touch screen those
964
+ * commands had no door at all, whatever the browser does with a
965
+ * long press. Give them one.
966
+ */
967
+ if(priv._uninstall_long_press) {
968
+ priv._uninstall_long_press();
969
+ }
970
+ priv._uninstall_long_press = install_long_press_contextmenu(priv.graph);
971
+
946
972
  if(gobj_read_bool_attr(gobj, "with_toolbar")) {
947
973
  configure_toolbar(gobj);
948
974
  configure_toolbar_edit(gobj);
@@ -1006,6 +1032,67 @@ function update_toolbar(gobj)
1006
1032
  * flipping now; the fallbacks are the old forced-light values, so
1007
1033
  * a host without Bulma gets exactly what it had.
1008
1034
  ************************************************************/
1035
+ /************************************************************
1036
+ * Is the canvas too narrow to give both floating toolbars a
1037
+ * strip of it?
1038
+ *
1039
+ * They are drawn INSIDE the canvas, one on each edge. On a
1040
+ * desktop that is a corner; on a 356px-wide phone canvas the
1041
+ * two of them take a third of the drawing area and stand on
1042
+ * top of the nodes -- and a node under a toolbar cannot be
1043
+ * read, tapped or dragged out from under it.
1044
+ *
1045
+ * Measured on the CONTAINER, not on the window: the same
1046
+ * graph is a full page in one app and a card in a column in
1047
+ * another, and it is the box it actually got that decides.
1048
+ ************************************************************/
1049
+ const TOOLBAR_COLLAPSE_WIDTH = 480;
1050
+
1051
+ function toolbars_can_collapse(gobj)
1052
+ {
1053
+ let $container = gobj.priv.$container;
1054
+
1055
+ if(!$container) {
1056
+ return false;
1057
+ }
1058
+ let width = $container.getBoundingClientRect().width;
1059
+ if(!width) {
1060
+ return false; /* not laid out yet: decided on the first resize */
1061
+ }
1062
+ return width < TOOLBAR_COLLAPSE_WIDTH;
1063
+ }
1064
+
1065
+ /************************************************************
1066
+ * Are they folded RIGHT NOW? Wide enough and the question
1067
+ * does not arise -- the desktop toolbar never grows a button
1068
+ * it does not need.
1069
+ ************************************************************/
1070
+ function toolbars_collapsed(gobj)
1071
+ {
1072
+ if(!toolbars_can_collapse(gobj)) {
1073
+ return false;
1074
+ }
1075
+ return gobj.priv.toolbar_collapsed !== false;
1076
+ }
1077
+
1078
+ /************************************************************
1079
+ * The one item a folded toolbar shows, and the one an
1080
+ * unfolded narrow toolbar adds to fold itself again.
1081
+ ************************************************************/
1082
+ function toolbar_fold_item(gobj)
1083
+ {
1084
+ if(toolbars_collapsed(gobj)) {
1085
+ return {
1086
+ id: 'g6-icon-toolbar-show', value: 'toolbar-toggle',
1087
+ className: 'EV_TOGGLE_TOOLBARS', title: t('show toolbar')
1088
+ };
1089
+ }
1090
+ return {
1091
+ id: 'g6-icon-toolbar-hide', value: 'toolbar-toggle',
1092
+ className: 'EV_TOGGLE_TOOLBARS', title: t('hide toolbar')
1093
+ };
1094
+ }
1095
+
1009
1096
  function toolbar_style(extra)
1010
1097
  {
1011
1098
  return Object.assign({
@@ -1092,6 +1179,12 @@ function configure_toolbar(gobj)
1092
1179
  marginLeft: '12px',
1093
1180
  }),
1094
1181
  getItems: () => {
1182
+ /* Folded: ONE button, and it is the one that
1183
+ * unfolds. Everything else is behind it. */
1184
+ if(toolbars_collapsed(gobj)) {
1185
+ return [toolbar_fold_item(gobj)];
1186
+ }
1187
+
1095
1188
  let items = [
1096
1189
  { id: 'g6-icon-zoom-in', value: 'zoom-in', className: 'EV_ZOOM_IN', title: t('zoom in') },
1097
1190
  { id: 'g6-icon-zoom-out', value: 'zoom-out', className: 'EV_ZOOM_OUT', title: t('zoom out') },
@@ -1143,6 +1236,14 @@ function configure_toolbar(gobj)
1143
1236
  }
1144
1237
  }
1145
1238
 
1239
+ /* Getting the canvas back is a window control too,
1240
+ * and the last thing in the strip: it is where the
1241
+ * single folded button will be. */
1242
+ if(toolbars_can_collapse(gobj)) {
1243
+ items.push({ separator: true });
1244
+ items.push(toolbar_fold_item(gobj));
1245
+ }
1246
+
1146
1247
  return items;
1147
1248
  },
1148
1249
  onClick: (value) => {
@@ -1171,6 +1272,9 @@ function configure_toolbar(gobj)
1171
1272
  case 'exit-fullscreen':
1172
1273
  gobj_send_event(gobj, "EV_EXIT_FULLSCREEN", {}, gobj);
1173
1274
  break;
1275
+ case 'toolbar-toggle':
1276
+ gobj_send_event(gobj, "EV_TOGGLE_TOOLBARS", {}, gobj);
1277
+ break;
1174
1278
  }
1175
1279
  },
1176
1280
  }
@@ -1185,7 +1289,11 @@ function configure_toolbar_edit(gobj)
1185
1289
  {
1186
1290
  let priv = gobj.priv;
1187
1291
 
1188
- if(!priv.edit_mode) {
1292
+ /* Folded with the camera strip: the two of them are the same
1293
+ * wall over the canvas, and hiding one is half a fix. Removed
1294
+ * rather than emptied -- an empty toolbar is still a card with
1295
+ * a border sitting on the graph. */
1296
+ if(!priv.edit_mode || toolbars_collapsed(gobj)) {
1189
1297
  // Remove edit toolbar when not in edit mode
1190
1298
  graph_remove_plugin(gobj, 'toolbar-edit');
1191
1299
  return;
@@ -1343,6 +1451,16 @@ function configure_behaviour(gobj)
1343
1451
  break;
1344
1452
  case "operation":
1345
1453
  priv.edit_mode = false;
1454
+ /* The camera is not an edit affordance: a mode that
1455
+ * cannot pan or zoom is a picture, and this one was one
1456
+ * -- `behaviors` was left empty here while `reading` and
1457
+ * `writing` next door both fill it. On a desktop the
1458
+ * toolbar still zooms and nothing pans; on a phone,
1459
+ * where the gestures ARE the camera, the graph froze. */
1460
+ behaviors = [
1461
+ "drag-canvas",
1462
+ "zoom-canvas",
1463
+ ];
1346
1464
  break;
1347
1465
  default:
1348
1466
  log_error(`operation mode unknown: ${operation_mode}`);
@@ -2824,6 +2942,26 @@ const RESIZE_HANDLE_SIZE = 8;
2824
2942
  const RESIZE_MIN_VP = 20;
2825
2943
  const RESIZE_MIN_WORLD = 30;
2826
2944
 
2945
+ /*
2946
+ * A handle is TWO boxes: the one you see and the one you can
2947
+ * hit. They are the same box for a mouse, and on a touch
2948
+ * screen the hit box grows to a fingertip while the mark stays
2949
+ * a mark -- a 44px white square on each corner would hide the
2950
+ * node it is there to resize.
2951
+ */
2952
+ const RESIZE_HANDLE_VISUAL_COARSE = 14;
2953
+ const RESIZE_HANDLE_HIT_COARSE = 44;
2954
+
2955
+ function resize_handle_visual()
2956
+ {
2957
+ return coarse_pointer()? RESIZE_HANDLE_VISUAL_COARSE: RESIZE_HANDLE_SIZE;
2958
+ }
2959
+
2960
+ function resize_handle_hit()
2961
+ {
2962
+ return coarse_pointer()? RESIZE_HANDLE_HIT_COARSE: RESIZE_HANDLE_SIZE;
2963
+ }
2964
+
2827
2965
  /************************************************************
2828
2966
  * SVG icons for floating action buttons (28×28 circles).
2829
2967
  * Each value is a raw SVG string sized 18×18 in a 24×24 viewBox.
@@ -2871,7 +3009,68 @@ const SVG_ICONS = {
2871
3009
  };
2872
3010
 
2873
3011
  /************************************************************
2874
- * Create a 28×28 floating circular icon button.
3012
+ * Is the primary pointer a FINGER?
3013
+ *
3014
+ * Everything this gclass draws over the canvas is sized from
3015
+ * this answer. A mouse is a pixel and can hit an 8px square;
3016
+ * a fingertip covers about 40, and the overlay it lands on
3017
+ * decides between "open the properties" and "delete the node".
3018
+ *
3019
+ * Asked per call, not cached: a tablet with a keyboard dock
3020
+ * changes the answer without reloading the page.
3021
+ ************************************************************/
3022
+ function coarse_pointer()
3023
+ {
3024
+ if(typeof window.matchMedia !== "function") {
3025
+ return false;
3026
+ }
3027
+ try {
3028
+ return window.matchMedia("(pointer: coarse)").matches === true;
3029
+ } catch(e) {
3030
+ return false;
3031
+ }
3032
+ }
3033
+
3034
+ /************************************************************
3035
+ * The size of the round floating buttons (properties,
3036
+ * delete, link), and the GAP between two of them.
3037
+ *
3038
+ * The gap matters as much as the size: `node properties` and
3039
+ * `delete node` are drawn one under the other, and at 28px
3040
+ * with 4px between them a fingertip covers both -- with the
3041
+ * destructive one underneath.
3042
+ ************************************************************/
3043
+ function floating_icon_size()
3044
+ {
3045
+ return coarse_pointer()? 44: 28;
3046
+ }
3047
+
3048
+ function floating_icon_gap()
3049
+ {
3050
+ return coarse_pointer()? 12: 4;
3051
+ }
3052
+
3053
+ /* Centre-to-top offset: the anchor names a point, the button
3054
+ * hangs centred on it. */
3055
+ function floating_icon_dy()
3056
+ {
3057
+ return -floating_icon_size() / 2;
3058
+ }
3059
+
3060
+ /* How far below the first button the second one starts. */
3061
+ function floating_icon_step()
3062
+ {
3063
+ return floating_icon_size() + floating_icon_gap();
3064
+ }
3065
+
3066
+ /* Where a popover opens: clear of the button column. */
3067
+ function floating_popover_dx()
3068
+ {
3069
+ return 4 + floating_icon_size() + 4;
3070
+ }
3071
+
3072
+ /************************************************************
3073
+ * Create a floating circular icon button.
2875
3074
  * @param {string} svgKey - key into SVG_ICONS
2876
3075
  * @param {string} color - border & text color (e.g. '#1890ff')
2877
3076
  * @param {number} left - CSS left in px
@@ -2882,19 +3081,34 @@ const SVG_ICONS = {
2882
3081
  ************************************************************/
2883
3082
  function create_floating_icon(svgKey, color, left, top, title, onClick)
2884
3083
  {
3084
+ const size = floating_icon_size();
2885
3085
  const el = document.createElement('div');
2886
3086
  el.title = title;
3087
+ el.setAttribute('aria-label', title);
2887
3088
  el.innerHTML = SVG_ICONS[svgKey] || '';
2888
3089
  el.style.cssText =
2889
3090
  'position:absolute;' +
2890
3091
  'left:' + left + 'px;' +
2891
3092
  'top:' + top + 'px;' +
2892
- 'width:28px;height:28px;' +
3093
+ 'width:' + size + 'px;height:' + size + 'px;' +
2893
3094
  'display:flex;align-items:center;justify-content:center;' +
2894
3095
  'background:#fff;border:1px solid ' + color + ';border-radius:50%;' +
2895
3096
  'cursor:pointer;pointer-events:all;z-index:11;' +
3097
+ /* The button is a drag handle in one case (the link icon)
3098
+ * and the browser would claim that gesture as a scroll. */
3099
+ 'touch-action:none;' +
2896
3100
  'box-shadow:0 2px 6px rgba(0,0,0,0.15);' +
2897
3101
  'color:' + color + ';';
3102
+
3103
+ /* The glyphs are authored 18×18; on the bigger button that
3104
+ * leaves a ring of empty white, so scale with the button. */
3105
+ const $svg = el.querySelector('svg');
3106
+ if($svg) {
3107
+ const glyph = Math.round(size * 0.5);
3108
+ $svg.setAttribute('width', String(glyph));
3109
+ $svg.setAttribute('height', String(glyph));
3110
+ }
3111
+
2898
3112
  el.addEventListener('click', (e) => {
2899
3113
  e.stopPropagation();
2900
3114
  onClick();
@@ -2954,7 +3168,7 @@ function show_dual_icons(gobj, x, y, gear_title, gear_click, trash_title, trash_
2954
3168
  priv.$container.appendChild(icon_el);
2955
3169
 
2956
3170
  const delete_el = create_floating_icon(
2957
- 'trash', '#ff4d4f', x, y + 32, trash_title, trash_click
3171
+ 'trash', '#ff4d4f', x, y + floating_icon_step(), trash_title, trash_click
2958
3172
  );
2959
3173
  priv.$container.appendChild(delete_el);
2960
3174
 
@@ -2987,6 +3201,32 @@ function create_popover_base(left, top, className, borderColor, minWidth)
2987
3201
  return popover;
2988
3202
  }
2989
3203
 
3204
+ /************************************************************
3205
+ * Extra CSS every control inside a popover carries on a
3206
+ * touch screen.
3207
+ *
3208
+ * A popover is where the graph is TYPED into -- a colour, a
3209
+ * radius, an id -- and its controls were sized by their
3210
+ * padding alone, which lands them at 25-30px. A finger needs
3211
+ * 44, and the number is not ours: it is the floor both Apple
3212
+ * and Google publish, and the one every native control on the
3213
+ * device already meets.
3214
+ ************************************************************/
3215
+ function touch_control_css()
3216
+ {
3217
+ if(!coarse_pointer()) {
3218
+ return '';
3219
+ }
3220
+ return 'min-height:44px;font-size:16px;';
3221
+ }
3222
+
3223
+ /*
3224
+ * 16px is not a taste: iOS Safari ZOOMS the whole page when a
3225
+ * text field smaller than that takes focus, and the page never
3226
+ * zooms back -- the graph is left half off screen after typing
3227
+ * one id.
3228
+ */
3229
+
2990
3230
  function create_form_label(parent, text)
2991
3231
  {
2992
3232
  let label = document.createElement('label');
@@ -3004,7 +3244,7 @@ function create_form_color_input(parent, value, onInput)
3004
3244
  input.style.cssText =
3005
3245
  'width:100%;height:30px;padding:0;' +
3006
3246
  'border:1px solid var(--bulma-border-weak, #d9d9d9);border-radius:4px;' +
3007
- 'cursor:pointer;margin-bottom:10px;';
3247
+ 'cursor:pointer;margin-bottom:10px;' + touch_control_css();
3008
3248
  if(onInput) {
3009
3249
  input.addEventListener('input', onInput);
3010
3250
  }
@@ -3028,7 +3268,7 @@ function create_form_number_input(parent, value, min, max, onInput)
3028
3268
  'background:var(--bulma-scheme-main-bis, #fff);' +
3029
3269
  'color:var(--bulma-text-strong, #333);' +
3030
3270
  'border:1px solid var(--bulma-border-weak, #d9d9d9);border-radius:4px;' +
3031
- 'box-sizing:border-box;margin-bottom:10px;';
3271
+ 'box-sizing:border-box;margin-bottom:10px;' + touch_control_css();
3032
3272
  if(onInput) {
3033
3273
  input.addEventListener('input', onInput);
3034
3274
  }
@@ -3044,7 +3284,8 @@ function create_form_select(parent, options, marginBottom)
3044
3284
  'background:var(--bulma-scheme-main-bis, #fff);' +
3045
3285
  'color:var(--bulma-text-strong, #333);' +
3046
3286
  'border:1px solid var(--bulma-border-weak, #d9d9d9);border-radius:4px;' +
3047
- 'box-sizing:border-box;margin-bottom:' + (marginBottom || '12px') + ';';
3287
+ 'box-sizing:border-box;margin-bottom:' + (marginBottom || '12px') + ';' +
3288
+ touch_control_css();
3048
3289
  for(let opt of options) {
3049
3290
  let o = document.createElement('option');
3050
3291
  o.value = opt.value;
@@ -3068,7 +3309,7 @@ function create_form_button_row(parent, buttons)
3068
3309
  for(let {text, style, onClick} of buttons) {
3069
3310
  let btn = document.createElement('button');
3070
3311
  btn.textContent = t(text);
3071
- btn.style.cssText = style;
3312
+ btn.style.cssText = style + touch_control_css();
3072
3313
  btn.addEventListener('click', (e) => {
3073
3314
  e.stopPropagation();
3074
3315
  onClick();
@@ -3448,7 +3689,7 @@ function show_resize_handles(gobj)
3448
3689
  container.appendChild(selRect);
3449
3690
 
3450
3691
  // 8 resize handles: nw, n, ne, w, e, sw, s, se
3451
- const handle_defs = [
3692
+ let handle_defs = [
3452
3693
  { cursor: 'nw-resize', mx: -1, my: -1 },
3453
3694
  { cursor: 'n-resize', mx: 0, my: -1 },
3454
3695
  { cursor: 'ne-resize', mx: 1, my: -1 },
@@ -3459,18 +3700,46 @@ function show_resize_handles(gobj)
3459
3700
  { cursor: 'se-resize', mx: 1, my: 1 },
3460
3701
  ];
3461
3702
 
3703
+ /*
3704
+ * On a touch screen, the CORNERS only. Eight fingertip-sized
3705
+ * hit boxes around a node 90px wide overlap into one blob, and
3706
+ * the edge handles are the ones that lose -- a corner resizes
3707
+ * both axes anyway, so nothing is out of reach.
3708
+ */
3709
+ const hit = resize_handle_hit();
3710
+ const visual = resize_handle_visual();
3711
+ if(coarse_pointer()) {
3712
+ handle_defs = handle_defs.filter((def) => def.mx !== 0 && def.my !== 0);
3713
+ }
3714
+
3462
3715
  const handles = [];
3463
3716
  for(const def of handle_defs) {
3717
+ /* The box that catches the finger: transparent, and never
3718
+ * in front of the mark of a NEIGHBOURING handle. */
3464
3719
  const el = document.createElement('div');
3465
3720
  el.style.cssText =
3466
3721
  'position:absolute;' +
3467
- 'width:' + RESIZE_HANDLE_SIZE + 'px;' +
3468
- 'height:' + RESIZE_HANDLE_SIZE + 'px;' +
3469
- 'background:#fff;' +
3470
- 'border:1px solid #1890ff;' +
3722
+ 'width:' + hit + 'px;' +
3723
+ 'height:' + hit + 'px;' +
3724
+ 'display:flex;align-items:center;justify-content:center;' +
3471
3725
  'cursor:' + def.cursor + ';' +
3472
3726
  'pointer-events:all;' +
3727
+ /* Without this the browser reads the drag as a scroll
3728
+ * and cancels the pointer mid-resize. */
3729
+ 'touch-action:none;' +
3730
+ 'box-sizing:border-box;';
3731
+
3732
+ /* The box you see. */
3733
+ const mark = document.createElement('div');
3734
+ mark.style.cssText =
3735
+ 'width:' + visual + 'px;' +
3736
+ 'height:' + visual + 'px;' +
3737
+ 'background:#fff;' +
3738
+ 'border:1px solid #1890ff;' +
3739
+ 'pointer-events:none;' +
3473
3740
  'box-sizing:border-box;';
3741
+ el.appendChild(mark);
3742
+
3474
3743
  el.addEventListener('pointerdown', (e) => {
3475
3744
  start_node_resize(gobj, e, def.mx, def.my);
3476
3745
  });
@@ -3523,7 +3792,7 @@ function update_resize_handles_position(gobj)
3523
3792
  function apply_handles_to_rect(gobj, rect)
3524
3793
  {
3525
3794
  let priv = gobj.priv;
3526
- const HALF = RESIZE_HANDLE_SIZE / 2;
3795
+ const HALF = resize_handle_hit() / 2;
3527
3796
 
3528
3797
  const left = rect.left;
3529
3798
  const top = rect.top;
@@ -3544,23 +3813,15 @@ function apply_handles_to_rect(gobj, rect)
3544
3813
  'box-sizing:border-box;';
3545
3814
  }
3546
3815
 
3547
- // Handle positions: nw, n, ne, w, e, sw, s, se
3548
- const positions = [
3549
- { x: left, y: top },
3550
- { x: left + w/2, y: top },
3551
- { x: left + w, y: top },
3552
- { x: left, y: top + h/2 },
3553
- { x: left + w, y: top + h/2 },
3554
- { x: left, y: top + h },
3555
- { x: left + w/2, y: top + h },
3556
- { x: left + w, y: top + h },
3557
- ];
3558
-
3559
- for(let i = 0; i < priv._resize_handles.length; i++) {
3560
- const handle = priv._resize_handles[i];
3561
- const p = positions[i];
3562
- handle.el.style.left = (p.x - HALF) + 'px';
3563
- handle.el.style.top = (p.y - HALF) + 'px';
3816
+ /* Each handle carries the corner it is: -1 / 0 / +1 maps to
3817
+ * the near edge, the middle and the far edge. Read from the
3818
+ * handle instead of a parallel table, because the table is
3819
+ * not the same length on a touch screen. */
3820
+ for(const handle of priv._resize_handles) {
3821
+ const x = left + ((handle.mx + 1) / 2) * w;
3822
+ const y = top + ((handle.my + 1) / 2) * h;
3823
+ handle.el.style.left = (x - HALF) + 'px';
3824
+ handle.el.style.top = (y - HALF) + 'px';
3564
3825
  }
3565
3826
  }
3566
3827
 
@@ -3680,6 +3941,26 @@ function start_node_resize(gobj, e, mx, my)
3680
3941
  ************************************************************/
3681
3942
  const PORT_HANDLE_SIZE = 8;
3682
3943
 
3944
+ /*
3945
+ * Same two-box idea as the node handles. Kept smaller than a
3946
+ * full 44: these four sit on the RIM of a port that is often a
3947
+ * 6px dot, so a fingertip-sized box on each would cover the
3948
+ * neighbouring ports as well -- and picking a port is how a
3949
+ * link is started.
3950
+ */
3951
+ const PORT_HANDLE_VISUAL_COARSE = 12;
3952
+ const PORT_HANDLE_HIT_COARSE = 32;
3953
+
3954
+ function port_handle_visual()
3955
+ {
3956
+ return coarse_pointer()? PORT_HANDLE_VISUAL_COARSE: PORT_HANDLE_SIZE;
3957
+ }
3958
+
3959
+ function port_handle_hit()
3960
+ {
3961
+ return coarse_pointer()? PORT_HANDLE_HIT_COARSE: PORT_HANDLE_SIZE;
3962
+ }
3963
+
3683
3964
  /************************************************************
3684
3965
  * Compute the canvas (world) position of a port on a node.
3685
3966
  * Returns {x, y} in world coordinates.
@@ -3731,6 +4012,28 @@ function get_port_radius(gobj, node_id, port_key)
3731
4012
  return style.portR || 6;
3732
4013
  }
3733
4014
 
4015
+ /************************************************************
4016
+ * How much wider than the port itself its hit area is,
4017
+ * measured on the SCREEN and converted to world units.
4018
+ *
4019
+ * It used to be a flat `+4` in WORLD units, which is a
4020
+ * different target on every zoom: at the 50% a phone lands on
4021
+ * after fit, a 6-unit port plus 4 is a 5px radius -- one
4022
+ * fifth of a fingertip, and picking a port is the first step
4023
+ * of every link.
4024
+ ************************************************************/
4025
+ function port_hit_slop_world(graph)
4026
+ {
4027
+ let slop = coarse_pointer()? 22: 6;
4028
+ let zoom = 1;
4029
+ try {
4030
+ zoom = graph.getZoom() || 1;
4031
+ } catch(e) {
4032
+ zoom = 1;
4033
+ }
4034
+ return slop / zoom;
4035
+ }
4036
+
3734
4037
  /************************************************************
3735
4038
  * Detect if a click in canvas coordinates hits a port.
3736
4039
  * Returns the port key string, or null if no port hit.
@@ -3749,6 +4052,7 @@ function detect_port_click(gobj, node_id, canvasX, canvasY)
3749
4052
 
3750
4053
  let best_key = null;
3751
4054
  let best_dist = Infinity;
4055
+ let slop = port_hit_slop_world(graph);
3752
4056
 
3753
4057
  for(let i = 0; i < ports.length; i++) {
3754
4058
  let pl = ports[i].placement || [0.5, 0.5];
@@ -3760,8 +4064,9 @@ function detect_port_click(gobj, node_id, canvasX, canvasY)
3760
4064
  let dy = canvasY - py;
3761
4065
  let dist = Math.sqrt(dx * dx + dy * dy);
3762
4066
 
3763
- // Hit area is the port radius + a tolerance of 4 world units
3764
- if(dist <= r + 4 && dist < best_dist) {
4067
+ /* Nearest wins, so the enlarged areas of two ports
4068
+ * overlapping is not a tie -- it is the closer one. */
4069
+ if(dist <= r + slop && dist < best_dist) {
3765
4070
  best_dist = dist;
3766
4071
  best_key = ports[i].key;
3767
4072
  }
@@ -3838,7 +4143,8 @@ function show_link_icon_if_fkey(gobj)
3838
4143
  let vp = graph.getViewportByCanvas([canvasPos.x, canvasPos.y]);
3839
4144
 
3840
4145
  let icon = create_floating_icon(
3841
- 'link', '#52c41a', vp[0] + 18, vp[1] - 14,
4146
+ 'link', '#52c41a',
4147
+ vp[0] + floating_icon_size() / 2 + 4, vp[1] + floating_icon_dy(),
3842
4148
  t('link to hook'), () => {
3843
4149
  // Click (no drag) toggles linking mode
3844
4150
  if(!priv._linking_mode) {
@@ -3877,8 +4183,9 @@ function update_link_icon_position(gobj)
3877
4183
  return;
3878
4184
  }
3879
4185
  let vp = graph.getViewportByCanvas([canvasPos.x, canvasPos.y]);
3880
- priv._link_icon_el.style.left = (vp[0] + 18) + 'px';
3881
- priv._link_icon_el.style.top = (vp[1] - 14) + 'px';
4186
+ priv._link_icon_el.style.left =
4187
+ (vp[0] + floating_icon_size() / 2 + 4) + 'px';
4188
+ priv._link_icon_el.style.top = (vp[1] + floating_icon_dy()) + 'px';
3882
4189
  } catch(e) {
3883
4190
  hide_link_icon(gobj);
3884
4191
  }
@@ -4262,19 +4569,33 @@ function show_port_resize_handles(gobj)
4262
4569
  { cursor: 'w-resize', dx: -1, dy: 0 },
4263
4570
  ];
4264
4571
 
4572
+ const hit = port_handle_hit();
4573
+ const visual = port_handle_visual();
4574
+
4265
4575
  const handles = [];
4266
4576
  for(const def of handle_defs) {
4267
4577
  const el = document.createElement('div');
4268
4578
  el.style.cssText =
4269
4579
  'position:absolute;' +
4270
- 'width:' + PORT_HANDLE_SIZE + 'px;' +
4271
- 'height:' + PORT_HANDLE_SIZE + 'px;' +
4580
+ 'width:' + hit + 'px;' +
4581
+ 'height:' + hit + 'px;' +
4582
+ 'display:flex;align-items:center;justify-content:center;' +
4583
+ 'cursor:' + def.cursor + ';' +
4584
+ 'pointer-events:all;' +
4585
+ 'touch-action:none;' +
4586
+ 'box-sizing:border-box;';
4587
+
4588
+ const mark = document.createElement('div');
4589
+ mark.style.cssText =
4590
+ 'width:' + visual + 'px;' +
4591
+ 'height:' + visual + 'px;' +
4272
4592
  'background:#fff;' +
4273
4593
  'border:1px solid #fa8c16;' +
4274
4594
  'border-radius:50%;' +
4275
- 'cursor:' + def.cursor + ';' +
4276
- 'pointer-events:all;' +
4595
+ 'pointer-events:none;' +
4277
4596
  'box-sizing:border-box;';
4597
+ el.appendChild(mark);
4598
+
4278
4599
  el.addEventListener('pointerdown', (e) => {
4279
4600
  start_port_resize(gobj, e);
4280
4601
  });
@@ -4342,7 +4663,7 @@ function update_port_resize_handles_position(gobj)
4342
4663
  function apply_port_handles(gobj, cx, cy, vpR)
4343
4664
  {
4344
4665
  let priv = gobj.priv;
4345
- const HALF = PORT_HANDLE_SIZE / 2;
4666
+ const HALF = port_handle_hit() / 2;
4346
4667
 
4347
4668
  // Dashed ring
4348
4669
  const ring = priv._port_ring;
@@ -4534,7 +4855,7 @@ function show_edge_icon(gobj)
4534
4855
  return;
4535
4856
  }
4536
4857
 
4537
- let icons = show_dual_icons(gobj, mid.x + 4, mid.y - 14,
4858
+ let icons = show_dual_icons(gobj, mid.x + 4, mid.y + floating_icon_dy(),
4538
4859
  t('edge properties'), () => toggle_edge_popover(gobj),
4539
4860
  t('unlink'), () => request_unlink_edge(gobj)
4540
4861
  );
@@ -4562,16 +4883,18 @@ function update_edge_icon_position(gobj)
4562
4883
  return;
4563
4884
  }
4564
4885
  priv._edge_icon_el.style.left = (mid.x + 4) + 'px';
4565
- priv._edge_icon_el.style.top = (mid.y - 14) + 'px';
4886
+ priv._edge_icon_el.style.top = (mid.y + floating_icon_dy()) + 'px';
4566
4887
  if(priv._edge_delete_el) {
4567
4888
  priv._edge_delete_el.style.left = (mid.x + 4) + 'px';
4568
- priv._edge_delete_el.style.top = (mid.y + 18) + 'px';
4889
+ priv._edge_delete_el.style.top =
4890
+ (mid.y + floating_icon_dy() + floating_icon_step()) + 'px';
4569
4891
  }
4570
4892
 
4571
4893
  // Reposition popover if open
4572
4894
  if(priv._edge_popover_el) {
4573
- priv._edge_popover_el.style.left = (mid.x + 36) + 'px';
4574
- priv._edge_popover_el.style.top = (mid.y - 14) + 'px';
4895
+ priv._edge_popover_el.style.left =
4896
+ (mid.x + floating_popover_dx()) + 'px';
4897
+ priv._edge_popover_el.style.top = (mid.y + floating_icon_dy()) + 'px';
4575
4898
  clamp_popover_position(gobj, priv._edge_popover_el);
4576
4899
  }
4577
4900
  } catch(e) {
@@ -4631,7 +4954,10 @@ function show_edge_popover(gobj)
4631
4954
  return;
4632
4955
  }
4633
4956
 
4634
- const popover = create_popover_base(mid.x + 36, mid.y - 14, 'g6-edge-popover', '#d9d9d9', 180);
4957
+ const popover = create_popover_base(
4958
+ mid.x + floating_popover_dx(), mid.y + floating_icon_dy(),
4959
+ 'g6-edge-popover', '#d9d9d9', 180
4960
+ );
4635
4961
 
4636
4962
  // Live preview: update the selected edge in real time
4637
4963
  function preview_edge() {
@@ -4747,7 +5073,7 @@ function show_node_icon(gobj)
4747
5073
  return;
4748
5074
  }
4749
5075
 
4750
- let icons = show_dual_icons(gobj, rect.right + 4, rect.top - 14,
5076
+ let icons = show_dual_icons(gobj, rect.right + 4, rect.top + floating_icon_dy(),
4751
5077
  t('node properties'), () => toggle_node_popover(gobj),
4752
5078
  t('delete node'), () => request_delete_node(gobj)
4753
5079
  );
@@ -4781,16 +5107,18 @@ function update_node_icon_position(gobj)
4781
5107
  return;
4782
5108
  }
4783
5109
  priv._node_icon_el.style.left = (rect.right + 4) + 'px';
4784
- priv._node_icon_el.style.top = (rect.top - 14) + 'px';
5110
+ priv._node_icon_el.style.top = (rect.top + floating_icon_dy()) + 'px';
4785
5111
  if(priv._node_delete_el) {
4786
5112
  priv._node_delete_el.style.left = (rect.right + 4) + 'px';
4787
- priv._node_delete_el.style.top = (rect.top + 18) + 'px';
5113
+ priv._node_delete_el.style.top =
5114
+ (rect.top + floating_icon_dy() + floating_icon_step()) + 'px';
4788
5115
  }
4789
5116
 
4790
5117
  // Reposition popover if open
4791
5118
  if(priv._node_popover_el) {
4792
- priv._node_popover_el.style.left = (rect.right + 36) + 'px';
4793
- priv._node_popover_el.style.top = (rect.top - 14) + 'px';
5119
+ priv._node_popover_el.style.left =
5120
+ (rect.right + floating_popover_dx()) + 'px';
5121
+ priv._node_popover_el.style.top = (rect.top + floating_icon_dy()) + 'px';
4794
5122
  clamp_popover_position(gobj, priv._node_popover_el);
4795
5123
  }
4796
5124
  } catch(e) {
@@ -4843,7 +5171,10 @@ function show_node_popover(gobj)
4843
5171
  return;
4844
5172
  }
4845
5173
 
4846
- const popover = create_popover_base(rect.right + 36, rect.top - 14, 'g6-node-popover', '#d9d9d9', 180);
5174
+ const popover = create_popover_base(
5175
+ rect.right + floating_popover_dx(), rect.top + floating_icon_dy(),
5176
+ 'g6-node-popover', '#d9d9d9', 180
5177
+ );
4847
5178
 
4848
5179
  let node_graph_type = nodeData.data && nodeData.data.desc ?
4849
5180
  nodeData.data.desc.node_treedb_type : null;
@@ -5831,7 +6162,7 @@ function show_create_popover(gobj)
5831
6162
  let topicSelect = document.createElement('select');
5832
6163
  topicSelect.style.cssText =
5833
6164
  'width:100%;padding:6px;border:1px solid #d9d9d9;border-radius:4px;' +
5834
- 'font-size:13px;margin-bottom:8px;';
6165
+ 'font-size:13px;margin-bottom:8px;' + touch_control_css();
5835
6166
  for(let i = 0; i < topics.length; i++) {
5836
6167
  let opt = document.createElement('option');
5837
6168
  opt.value = topics[i].topic_name;
@@ -5874,7 +6205,8 @@ function show_create_popover(gobj)
5874
6205
  idInput.placeholder = t('node id');
5875
6206
  idInput.style.cssText =
5876
6207
  'width:100%;padding:6px;border:1px solid #d9d9d9;border-radius:4px;' +
5877
- 'font-size:13px;margin-bottom:10px;box-sizing:border-box;';
6208
+ 'font-size:13px;margin-bottom:10px;box-sizing:border-box;' +
6209
+ touch_control_css();
5878
6210
  popover.appendChild(idInput);
5879
6211
 
5880
6212
  // Error message area
@@ -6960,6 +7292,16 @@ function ac_resize(gobj, event, kw, src)
6960
7292
  }
6961
7293
  }
6962
7294
 
7295
+ /* Crossing the width where the toolbars start folding changes
7296
+ * what they contain -- a fold button appears or goes away --
7297
+ * and nothing else would ever rebuild them. Only on the
7298
+ * CROSSING: a resize fires on every frame of a drag. */
7299
+ let can_collapse = toolbars_can_collapse(gobj);
7300
+ if(can_collapse !== priv._toolbars_could_collapse) {
7301
+ priv._toolbars_could_collapse = can_collapse;
7302
+ update_toolbar(gobj);
7303
+ }
7304
+
6963
7305
  return 0;
6964
7306
  }
6965
7307
 
@@ -7018,6 +7360,22 @@ function ac_zoom_reset(gobj, event, kw, src)
7018
7360
  return 0;
7019
7361
  }
7020
7362
 
7363
+ /************************************************************
7364
+ * Fold / unfold the floating toolbars.
7365
+ *
7366
+ * One flag for BOTH: they are one wall over the canvas, and
7367
+ * the button that unfolds them has to be the same button
7368
+ * whichever edge the user reaches for.
7369
+ ************************************************************/
7370
+ function ac_toggle_toolbars(gobj, event, kw, src)
7371
+ {
7372
+ let priv = gobj.priv;
7373
+
7374
+ priv.toolbar_collapsed = !toolbars_collapsed(gobj);
7375
+ update_toolbar(gobj);
7376
+ return 0;
7377
+ }
7378
+
7021
7379
  function ac_auto_fit(gobj, event, kw, src)
7022
7380
  {
7023
7381
  graph_fitview(gobj);
@@ -7506,6 +7864,7 @@ function create_gclass(gclass_name)
7506
7864
  ["EV_ZOOM_IN", ac_zoom_in, null],
7507
7865
  ["EV_ZOOM_OUT", ac_zoom_out, null],
7508
7866
  ["EV_ZOOM_RESET", ac_zoom_reset, null],
7867
+ ["EV_TOGGLE_TOOLBARS", ac_toggle_toolbars, null],
7509
7868
  ["EV_AUTO_FIT", ac_auto_fit, null],
7510
7869
  ["EV_ZOOM_SELECTION", ac_zoom_selection, null],
7511
7870
  ["EV_FOCUS_TOPIC", ac_focus_topic, null],
@@ -7553,6 +7912,7 @@ function create_gclass(gclass_name)
7553
7912
  ["EV_ZOOM_IN", 0],
7554
7913
  ["EV_ZOOM_OUT", 0],
7555
7914
  ["EV_ZOOM_RESET", 0],
7915
+ ["EV_TOGGLE_TOOLBARS", 0],
7556
7916
  ["EV_AUTO_FIT", 0],
7557
7917
  ["EV_ZOOM_SELECTION", 0],
7558
7918
  ["EV_FOCUS_TOPIC", 0],