@yuneta/gobj-ui 7.23.8 → 7.23.10

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,14 @@ 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_brush_select_owned,
108
+ BRUSH_SELECT_OWNED,
109
+ } from "./g6_brush_select_owned.js";
110
+ import {
111
+ ensure_pinch_zoom_patch,
112
+ install_long_press_contextmenu,
113
+ } from "./g6_touch_gestures.js";
106
114
  import {yui_theme_now, yui_watch_theme} from "./yui_theme.js";
107
115
 
108
116
  /***************************************************************
@@ -172,6 +180,8 @@ class YuiToolbar extends Toolbar
172
180
  }
173
181
  register(ExtensionCategory.PLUGIN, 'yui-toolbar', YuiToolbar);
174
182
  ensure_drag_canvas_patch();
183
+ ensure_brush_select_owned();
184
+ ensure_pinch_zoom_patch();
175
185
 
176
186
  /***************************************************************
177
187
  * Constants
@@ -336,6 +346,9 @@ let PRIVATE_DATA = {
336
346
  _focus_topic: null, // topic currently focused (EV_FOCUS_TOPIC)
337
347
  _focus_ids: [], // node ids carrying the focus 'active' state
338
348
  _on_pointerdown_focus: null, // listener keeping the keyboard on the canvas
349
+ _uninstall_long_press: null, // touch door to the context menu
350
+ toolbar_collapsed: true, // floating toolbars folded (narrow only)
351
+ _toolbars_could_collapse: null, // last answer, to notice it changed
339
352
  _on_focusout_restore: null, // ...and putting it back when it goes nowhere
340
353
  _selected_paint_ids: [], // node ids whose card is PAINTED selected
341
354
  // (G6's 'selected' state is the selection
@@ -491,6 +504,11 @@ function mt_destroy(gobj)
491
504
  priv._resize_raf = 0;
492
505
  }
493
506
 
507
+ if(priv._uninstall_long_press) {
508
+ priv._uninstall_long_press();
509
+ priv._uninstall_long_press = null;
510
+ }
511
+
494
512
  if(priv.graph) {
495
513
  priv.graph.destroy();
496
514
  priv.graph = null;
@@ -943,6 +961,19 @@ function configure_plugins(gobj)
943
961
  }
944
962
  );
945
963
 
964
+ /*
965
+ * The menu above is the way to link, unlink, delete and resize,
966
+ * and `contextmenu` is a RIGHT CLICK -- G6 synthesises it from
967
+ * `pointerdown` with `button === 2` and reads nothing from the
968
+ * DOM event of the same name. So on a touch screen those
969
+ * commands had no door at all, whatever the browser does with a
970
+ * long press. Give them one.
971
+ */
972
+ if(priv._uninstall_long_press) {
973
+ priv._uninstall_long_press();
974
+ }
975
+ priv._uninstall_long_press = install_long_press_contextmenu(priv.graph);
976
+
946
977
  if(gobj_read_bool_attr(gobj, "with_toolbar")) {
947
978
  configure_toolbar(gobj);
948
979
  configure_toolbar_edit(gobj);
@@ -1006,6 +1037,67 @@ function update_toolbar(gobj)
1006
1037
  * flipping now; the fallbacks are the old forced-light values, so
1007
1038
  * a host without Bulma gets exactly what it had.
1008
1039
  ************************************************************/
1040
+ /************************************************************
1041
+ * Is the canvas too narrow to give both floating toolbars a
1042
+ * strip of it?
1043
+ *
1044
+ * They are drawn INSIDE the canvas, one on each edge. On a
1045
+ * desktop that is a corner; on a 356px-wide phone canvas the
1046
+ * two of them take a third of the drawing area and stand on
1047
+ * top of the nodes -- and a node under a toolbar cannot be
1048
+ * read, tapped or dragged out from under it.
1049
+ *
1050
+ * Measured on the CONTAINER, not on the window: the same
1051
+ * graph is a full page in one app and a card in a column in
1052
+ * another, and it is the box it actually got that decides.
1053
+ ************************************************************/
1054
+ const TOOLBAR_COLLAPSE_WIDTH = 480;
1055
+
1056
+ function toolbars_can_collapse(gobj)
1057
+ {
1058
+ let $container = gobj.priv.$container;
1059
+
1060
+ if(!$container) {
1061
+ return false;
1062
+ }
1063
+ let width = $container.getBoundingClientRect().width;
1064
+ if(!width) {
1065
+ return false; /* not laid out yet: decided on the first resize */
1066
+ }
1067
+ return width < TOOLBAR_COLLAPSE_WIDTH;
1068
+ }
1069
+
1070
+ /************************************************************
1071
+ * Are they folded RIGHT NOW? Wide enough and the question
1072
+ * does not arise -- the desktop toolbar never grows a button
1073
+ * it does not need.
1074
+ ************************************************************/
1075
+ function toolbars_collapsed(gobj)
1076
+ {
1077
+ if(!toolbars_can_collapse(gobj)) {
1078
+ return false;
1079
+ }
1080
+ return gobj.priv.toolbar_collapsed !== false;
1081
+ }
1082
+
1083
+ /************************************************************
1084
+ * The one item a folded toolbar shows, and the one an
1085
+ * unfolded narrow toolbar adds to fold itself again.
1086
+ ************************************************************/
1087
+ function toolbar_fold_item(gobj)
1088
+ {
1089
+ if(toolbars_collapsed(gobj)) {
1090
+ return {
1091
+ id: 'g6-icon-toolbar-show', value: 'toolbar-toggle',
1092
+ className: 'EV_TOGGLE_TOOLBARS', title: t('show toolbar')
1093
+ };
1094
+ }
1095
+ return {
1096
+ id: 'g6-icon-toolbar-hide', value: 'toolbar-toggle',
1097
+ className: 'EV_TOGGLE_TOOLBARS', title: t('hide toolbar')
1098
+ };
1099
+ }
1100
+
1009
1101
  function toolbar_style(extra)
1010
1102
  {
1011
1103
  return Object.assign({
@@ -1092,6 +1184,12 @@ function configure_toolbar(gobj)
1092
1184
  marginLeft: '12px',
1093
1185
  }),
1094
1186
  getItems: () => {
1187
+ /* Folded: ONE button, and it is the one that
1188
+ * unfolds. Everything else is behind it. */
1189
+ if(toolbars_collapsed(gobj)) {
1190
+ return [toolbar_fold_item(gobj)];
1191
+ }
1192
+
1095
1193
  let items = [
1096
1194
  { id: 'g6-icon-zoom-in', value: 'zoom-in', className: 'EV_ZOOM_IN', title: t('zoom in') },
1097
1195
  { id: 'g6-icon-zoom-out', value: 'zoom-out', className: 'EV_ZOOM_OUT', title: t('zoom out') },
@@ -1143,6 +1241,14 @@ function configure_toolbar(gobj)
1143
1241
  }
1144
1242
  }
1145
1243
 
1244
+ /* Getting the canvas back is a window control too,
1245
+ * and the last thing in the strip: it is where the
1246
+ * single folded button will be. */
1247
+ if(toolbars_can_collapse(gobj)) {
1248
+ items.push({ separator: true });
1249
+ items.push(toolbar_fold_item(gobj));
1250
+ }
1251
+
1146
1252
  return items;
1147
1253
  },
1148
1254
  onClick: (value) => {
@@ -1171,6 +1277,9 @@ function configure_toolbar(gobj)
1171
1277
  case 'exit-fullscreen':
1172
1278
  gobj_send_event(gobj, "EV_EXIT_FULLSCREEN", {}, gobj);
1173
1279
  break;
1280
+ case 'toolbar-toggle':
1281
+ gobj_send_event(gobj, "EV_TOGGLE_TOOLBARS", {}, gobj);
1282
+ break;
1174
1283
  }
1175
1284
  },
1176
1285
  }
@@ -1185,7 +1294,11 @@ function configure_toolbar_edit(gobj)
1185
1294
  {
1186
1295
  let priv = gobj.priv;
1187
1296
 
1188
- if(!priv.edit_mode) {
1297
+ /* Folded with the camera strip: the two of them are the same
1298
+ * wall over the canvas, and hiding one is half a fix. Removed
1299
+ * rather than emptied -- an empty toolbar is still a card with
1300
+ * a border sitting on the graph. */
1301
+ if(!priv.edit_mode || toolbars_collapsed(gobj)) {
1189
1302
  // Remove edit toolbar when not in edit mode
1190
1303
  graph_remove_plugin(gobj, 'toolbar-edit');
1191
1304
  return;
@@ -1302,11 +1415,28 @@ function configure_behaviour(gobj)
1302
1415
  /* Panning gives way while Shift is held: that is the
1303
1416
  * marquee's gesture, and G6 binds drag-canvas straight
1304
1417
  * to the drag events, so without this the canvas pans
1305
- * under the rubber band. */
1418
+ * under the rubber band.
1419
+ *
1420
+ * And it gives way to a NODE drag, which is what G6's
1421
+ * own default `enable` says and what this one has to
1422
+ * repeat: `enable` REPLACES it, it does not add to it.
1423
+ * Dropping the `targetType` half made every drag of a
1424
+ * card pan the canvas as well as move the card -- the
1425
+ * card ran at twice the pointer and the whole graph
1426
+ * slid underneath it, on a move whose RESULT was
1427
+ * right, because a pan writes nothing. */
1306
1428
  {
1307
1429
  type: "drag-canvas",
1308
1430
  key: "drag-canvas",
1309
- enable: (event) => !event.shiftKey,
1431
+ enable: (event) => {
1432
+ if(event.shiftKey) {
1433
+ return false;
1434
+ }
1435
+ if('targetType' in event) {
1436
+ return event.targetType === 'canvas';
1437
+ }
1438
+ return true; /* a key, not a pointer */
1439
+ },
1310
1440
  },
1311
1441
  "zoom-canvas",
1312
1442
  /* Moves EVERY node in the `selected` state, not just
@@ -1321,7 +1451,12 @@ function configure_behaviour(gobj)
1321
1451
  * ids travel with the event and the action does not
1322
1452
  * have to race it. */
1323
1453
  {
1324
- type: "brush-select",
1454
+ /* Ours: the built-in also rewrites the state of
1455
+ * EVERY element on a canvas click, behind the
1456
+ * gclass's back and outside its history pause --
1457
+ * a command that changed nothing, and a Save
1458
+ * button lit on a graph nobody had touched. */
1459
+ type: BRUSH_SELECT_OWNED,
1325
1460
  key: "brush-select",
1326
1461
  trigger: ["shift"],
1327
1462
  enableElements: ["node"],
@@ -1343,6 +1478,16 @@ function configure_behaviour(gobj)
1343
1478
  break;
1344
1479
  case "operation":
1345
1480
  priv.edit_mode = false;
1481
+ /* The camera is not an edit affordance: a mode that
1482
+ * cannot pan or zoom is a picture, and this one was one
1483
+ * -- `behaviors` was left empty here while `reading` and
1484
+ * `writing` next door both fill it. On a desktop the
1485
+ * toolbar still zooms and nothing pans; on a phone,
1486
+ * where the gestures ARE the camera, the graph froze. */
1487
+ behaviors = [
1488
+ "drag-canvas",
1489
+ "zoom-canvas",
1490
+ ];
1346
1491
  break;
1347
1492
  default:
1348
1493
  log_error(`operation mode unknown: ${operation_mode}`);
@@ -2252,6 +2397,42 @@ function history_resume(gobj)
2252
2397
  gobj.priv._history_paused = false;
2253
2398
  }
2254
2399
 
2400
+ /************************************************************
2401
+ * The history plugin exists while the graph is in EDITION,
2402
+ * and only there.
2403
+ *
2404
+ * It used to be installed at one MOMENT -- the arrival of the
2405
+ * last topic of the load -- and only if the graph happened to
2406
+ * be in edition right then. Entering edition afterwards, which
2407
+ * is the ordinary way in (the graph opens in reading and the
2408
+ * mode selector is next to it), left no history at all: Undo
2409
+ * and Redo were dead buttons that never even lit, `history_
2410
+ * pause()` had no `beforeAddCommand` to answer, and the Save
2411
+ * button was told the graph was dirty by `mark_graph_dirty()`
2412
+ * alone -- which is why a move offered Save without Undo.
2413
+ *
2414
+ * So it follows the MODE, and every place that changes the
2415
+ * mode calls this.
2416
+ ************************************************************/
2417
+ function sync_history_plugin(gobj)
2418
+ {
2419
+ let priv = gobj.priv;
2420
+
2421
+ if(!priv.graph || !priv.graph_rendered) {
2422
+ /* Nothing to hang a plugin on yet: the load path calls
2423
+ * this again once the graph is drawn. */
2424
+ return;
2425
+ }
2426
+
2427
+ if(priv.edit_mode) {
2428
+ graph_add_plugin(gobj, "history");
2429
+ } else {
2430
+ graph_remove_plugin(gobj, "history");
2431
+ }
2432
+
2433
+ update_history_buttons(gobj);
2434
+ }
2435
+
2255
2436
  /************************************************************
2256
2437
  *
2257
2438
  ************************************************************/
@@ -2657,6 +2838,7 @@ function graph_add_plugin(gobj, plugin_key, options)
2657
2838
  let graph = priv.graph;
2658
2839
 
2659
2840
  let plugin = graph_get_plugin(gobj, plugin_key);
2841
+ let existed = !!plugin;
2660
2842
  if(!plugin) {
2661
2843
  let plugin_def = {
2662
2844
  key: plugin_key,
@@ -2672,7 +2854,11 @@ function graph_add_plugin(gobj, plugin_key, options)
2672
2854
 
2673
2855
  switch(plugin_key) {
2674
2856
  case "history":
2675
- if(plugin) {
2857
+ /* Wired once per INSTANCE: this is called again on every
2858
+ * load and on every entry into edition, and a second
2859
+ * listener on the same emitter is a second repaint of the
2860
+ * same two buttons. */
2861
+ if(plugin && !existed) {
2676
2862
  plugin.on(HistoryEvent.CHANGE, () => {
2677
2863
  update_history_buttons(gobj);
2678
2864
  });
@@ -2824,6 +3010,26 @@ const RESIZE_HANDLE_SIZE = 8;
2824
3010
  const RESIZE_MIN_VP = 20;
2825
3011
  const RESIZE_MIN_WORLD = 30;
2826
3012
 
3013
+ /*
3014
+ * A handle is TWO boxes: the one you see and the one you can
3015
+ * hit. They are the same box for a mouse, and on a touch
3016
+ * screen the hit box grows to a fingertip while the mark stays
3017
+ * a mark -- a 44px white square on each corner would hide the
3018
+ * node it is there to resize.
3019
+ */
3020
+ const RESIZE_HANDLE_VISUAL_COARSE = 14;
3021
+ const RESIZE_HANDLE_HIT_COARSE = 44;
3022
+
3023
+ function resize_handle_visual()
3024
+ {
3025
+ return coarse_pointer()? RESIZE_HANDLE_VISUAL_COARSE: RESIZE_HANDLE_SIZE;
3026
+ }
3027
+
3028
+ function resize_handle_hit()
3029
+ {
3030
+ return coarse_pointer()? RESIZE_HANDLE_HIT_COARSE: RESIZE_HANDLE_SIZE;
3031
+ }
3032
+
2827
3033
  /************************************************************
2828
3034
  * SVG icons for floating action buttons (28×28 circles).
2829
3035
  * Each value is a raw SVG string sized 18×18 in a 24×24 viewBox.
@@ -2871,7 +3077,68 @@ const SVG_ICONS = {
2871
3077
  };
2872
3078
 
2873
3079
  /************************************************************
2874
- * Create a 28×28 floating circular icon button.
3080
+ * Is the primary pointer a FINGER?
3081
+ *
3082
+ * Everything this gclass draws over the canvas is sized from
3083
+ * this answer. A mouse is a pixel and can hit an 8px square;
3084
+ * a fingertip covers about 40, and the overlay it lands on
3085
+ * decides between "open the properties" and "delete the node".
3086
+ *
3087
+ * Asked per call, not cached: a tablet with a keyboard dock
3088
+ * changes the answer without reloading the page.
3089
+ ************************************************************/
3090
+ function coarse_pointer()
3091
+ {
3092
+ if(typeof window.matchMedia !== "function") {
3093
+ return false;
3094
+ }
3095
+ try {
3096
+ return window.matchMedia("(pointer: coarse)").matches === true;
3097
+ } catch(e) {
3098
+ return false;
3099
+ }
3100
+ }
3101
+
3102
+ /************************************************************
3103
+ * The size of the round floating buttons (properties,
3104
+ * delete, link), and the GAP between two of them.
3105
+ *
3106
+ * The gap matters as much as the size: `node properties` and
3107
+ * `delete node` are drawn one under the other, and at 28px
3108
+ * with 4px between them a fingertip covers both -- with the
3109
+ * destructive one underneath.
3110
+ ************************************************************/
3111
+ function floating_icon_size()
3112
+ {
3113
+ return coarse_pointer()? 44: 28;
3114
+ }
3115
+
3116
+ function floating_icon_gap()
3117
+ {
3118
+ return coarse_pointer()? 12: 4;
3119
+ }
3120
+
3121
+ /* Centre-to-top offset: the anchor names a point, the button
3122
+ * hangs centred on it. */
3123
+ function floating_icon_dy()
3124
+ {
3125
+ return -floating_icon_size() / 2;
3126
+ }
3127
+
3128
+ /* How far below the first button the second one starts. */
3129
+ function floating_icon_step()
3130
+ {
3131
+ return floating_icon_size() + floating_icon_gap();
3132
+ }
3133
+
3134
+ /* Where a popover opens: clear of the button column. */
3135
+ function floating_popover_dx()
3136
+ {
3137
+ return 4 + floating_icon_size() + 4;
3138
+ }
3139
+
3140
+ /************************************************************
3141
+ * Create a floating circular icon button.
2875
3142
  * @param {string} svgKey - key into SVG_ICONS
2876
3143
  * @param {string} color - border & text color (e.g. '#1890ff')
2877
3144
  * @param {number} left - CSS left in px
@@ -2882,19 +3149,34 @@ const SVG_ICONS = {
2882
3149
  ************************************************************/
2883
3150
  function create_floating_icon(svgKey, color, left, top, title, onClick)
2884
3151
  {
3152
+ const size = floating_icon_size();
2885
3153
  const el = document.createElement('div');
2886
3154
  el.title = title;
3155
+ el.setAttribute('aria-label', title);
2887
3156
  el.innerHTML = SVG_ICONS[svgKey] || '';
2888
3157
  el.style.cssText =
2889
3158
  'position:absolute;' +
2890
3159
  'left:' + left + 'px;' +
2891
3160
  'top:' + top + 'px;' +
2892
- 'width:28px;height:28px;' +
3161
+ 'width:' + size + 'px;height:' + size + 'px;' +
2893
3162
  'display:flex;align-items:center;justify-content:center;' +
2894
3163
  'background:#fff;border:1px solid ' + color + ';border-radius:50%;' +
2895
3164
  'cursor:pointer;pointer-events:all;z-index:11;' +
3165
+ /* The button is a drag handle in one case (the link icon)
3166
+ * and the browser would claim that gesture as a scroll. */
3167
+ 'touch-action:none;' +
2896
3168
  'box-shadow:0 2px 6px rgba(0,0,0,0.15);' +
2897
3169
  'color:' + color + ';';
3170
+
3171
+ /* The glyphs are authored 18×18; on the bigger button that
3172
+ * leaves a ring of empty white, so scale with the button. */
3173
+ const $svg = el.querySelector('svg');
3174
+ if($svg) {
3175
+ const glyph = Math.round(size * 0.5);
3176
+ $svg.setAttribute('width', String(glyph));
3177
+ $svg.setAttribute('height', String(glyph));
3178
+ }
3179
+
2898
3180
  el.addEventListener('click', (e) => {
2899
3181
  e.stopPropagation();
2900
3182
  onClick();
@@ -2954,7 +3236,7 @@ function show_dual_icons(gobj, x, y, gear_title, gear_click, trash_title, trash_
2954
3236
  priv.$container.appendChild(icon_el);
2955
3237
 
2956
3238
  const delete_el = create_floating_icon(
2957
- 'trash', '#ff4d4f', x, y + 32, trash_title, trash_click
3239
+ 'trash', '#ff4d4f', x, y + floating_icon_step(), trash_title, trash_click
2958
3240
  );
2959
3241
  priv.$container.appendChild(delete_el);
2960
3242
 
@@ -2987,6 +3269,32 @@ function create_popover_base(left, top, className, borderColor, minWidth)
2987
3269
  return popover;
2988
3270
  }
2989
3271
 
3272
+ /************************************************************
3273
+ * Extra CSS every control inside a popover carries on a
3274
+ * touch screen.
3275
+ *
3276
+ * A popover is where the graph is TYPED into -- a colour, a
3277
+ * radius, an id -- and its controls were sized by their
3278
+ * padding alone, which lands them at 25-30px. A finger needs
3279
+ * 44, and the number is not ours: it is the floor both Apple
3280
+ * and Google publish, and the one every native control on the
3281
+ * device already meets.
3282
+ ************************************************************/
3283
+ function touch_control_css()
3284
+ {
3285
+ if(!coarse_pointer()) {
3286
+ return '';
3287
+ }
3288
+ return 'min-height:44px;font-size:16px;';
3289
+ }
3290
+
3291
+ /*
3292
+ * 16px is not a taste: iOS Safari ZOOMS the whole page when a
3293
+ * text field smaller than that takes focus, and the page never
3294
+ * zooms back -- the graph is left half off screen after typing
3295
+ * one id.
3296
+ */
3297
+
2990
3298
  function create_form_label(parent, text)
2991
3299
  {
2992
3300
  let label = document.createElement('label');
@@ -3004,7 +3312,7 @@ function create_form_color_input(parent, value, onInput)
3004
3312
  input.style.cssText =
3005
3313
  'width:100%;height:30px;padding:0;' +
3006
3314
  'border:1px solid var(--bulma-border-weak, #d9d9d9);border-radius:4px;' +
3007
- 'cursor:pointer;margin-bottom:10px;';
3315
+ 'cursor:pointer;margin-bottom:10px;' + touch_control_css();
3008
3316
  if(onInput) {
3009
3317
  input.addEventListener('input', onInput);
3010
3318
  }
@@ -3028,7 +3336,7 @@ function create_form_number_input(parent, value, min, max, onInput)
3028
3336
  'background:var(--bulma-scheme-main-bis, #fff);' +
3029
3337
  'color:var(--bulma-text-strong, #333);' +
3030
3338
  'border:1px solid var(--bulma-border-weak, #d9d9d9);border-radius:4px;' +
3031
- 'box-sizing:border-box;margin-bottom:10px;';
3339
+ 'box-sizing:border-box;margin-bottom:10px;' + touch_control_css();
3032
3340
  if(onInput) {
3033
3341
  input.addEventListener('input', onInput);
3034
3342
  }
@@ -3044,7 +3352,8 @@ function create_form_select(parent, options, marginBottom)
3044
3352
  'background:var(--bulma-scheme-main-bis, #fff);' +
3045
3353
  'color:var(--bulma-text-strong, #333);' +
3046
3354
  'border:1px solid var(--bulma-border-weak, #d9d9d9);border-radius:4px;' +
3047
- 'box-sizing:border-box;margin-bottom:' + (marginBottom || '12px') + ';';
3355
+ 'box-sizing:border-box;margin-bottom:' + (marginBottom || '12px') + ';' +
3356
+ touch_control_css();
3048
3357
  for(let opt of options) {
3049
3358
  let o = document.createElement('option');
3050
3359
  o.value = opt.value;
@@ -3068,7 +3377,7 @@ function create_form_button_row(parent, buttons)
3068
3377
  for(let {text, style, onClick} of buttons) {
3069
3378
  let btn = document.createElement('button');
3070
3379
  btn.textContent = t(text);
3071
- btn.style.cssText = style;
3380
+ btn.style.cssText = style + touch_control_css();
3072
3381
  btn.addEventListener('click', (e) => {
3073
3382
  e.stopPropagation();
3074
3383
  onClick();
@@ -3322,10 +3631,10 @@ function toggle_in_selection(gobj, node_id)
3322
3631
  {
3323
3632
  let priv = gobj.priv;
3324
3633
 
3325
- /* The union of the two views of the same thing. A brush clears
3326
- * G6's state on pointerdown without telling anybody, so reading
3327
- * only the state could drop a card that is on screen wearing a
3328
- * ring. */
3634
+ /* The union of the two views of the same thing: either can hold
3635
+ * an id the other lost -- G6's state is what a drag moves, the
3636
+ * painted set is what the reader can see -- and a card on screen
3637
+ * wearing a ring belongs to the selection whichever view has it. */
3329
3638
  let current = new Set(selected_node_ids(gobj));
3330
3639
  for(let id of (priv._selected_paint_ids || [])) {
3331
3640
  current.add(id);
@@ -3448,7 +3757,7 @@ function show_resize_handles(gobj)
3448
3757
  container.appendChild(selRect);
3449
3758
 
3450
3759
  // 8 resize handles: nw, n, ne, w, e, sw, s, se
3451
- const handle_defs = [
3760
+ let handle_defs = [
3452
3761
  { cursor: 'nw-resize', mx: -1, my: -1 },
3453
3762
  { cursor: 'n-resize', mx: 0, my: -1 },
3454
3763
  { cursor: 'ne-resize', mx: 1, my: -1 },
@@ -3459,18 +3768,46 @@ function show_resize_handles(gobj)
3459
3768
  { cursor: 'se-resize', mx: 1, my: 1 },
3460
3769
  ];
3461
3770
 
3771
+ /*
3772
+ * On a touch screen, the CORNERS only. Eight fingertip-sized
3773
+ * hit boxes around a node 90px wide overlap into one blob, and
3774
+ * the edge handles are the ones that lose -- a corner resizes
3775
+ * both axes anyway, so nothing is out of reach.
3776
+ */
3777
+ const hit = resize_handle_hit();
3778
+ const visual = resize_handle_visual();
3779
+ if(coarse_pointer()) {
3780
+ handle_defs = handle_defs.filter((def) => def.mx !== 0 && def.my !== 0);
3781
+ }
3782
+
3462
3783
  const handles = [];
3463
3784
  for(const def of handle_defs) {
3785
+ /* The box that catches the finger: transparent, and never
3786
+ * in front of the mark of a NEIGHBOURING handle. */
3464
3787
  const el = document.createElement('div');
3465
3788
  el.style.cssText =
3466
3789
  'position:absolute;' +
3467
- 'width:' + RESIZE_HANDLE_SIZE + 'px;' +
3468
- 'height:' + RESIZE_HANDLE_SIZE + 'px;' +
3469
- 'background:#fff;' +
3470
- 'border:1px solid #1890ff;' +
3790
+ 'width:' + hit + 'px;' +
3791
+ 'height:' + hit + 'px;' +
3792
+ 'display:flex;align-items:center;justify-content:center;' +
3471
3793
  'cursor:' + def.cursor + ';' +
3472
3794
  'pointer-events:all;' +
3795
+ /* Without this the browser reads the drag as a scroll
3796
+ * and cancels the pointer mid-resize. */
3797
+ 'touch-action:none;' +
3473
3798
  'box-sizing:border-box;';
3799
+
3800
+ /* The box you see. */
3801
+ const mark = document.createElement('div');
3802
+ mark.style.cssText =
3803
+ 'width:' + visual + 'px;' +
3804
+ 'height:' + visual + 'px;' +
3805
+ 'background:#fff;' +
3806
+ 'border:1px solid #1890ff;' +
3807
+ 'pointer-events:none;' +
3808
+ 'box-sizing:border-box;';
3809
+ el.appendChild(mark);
3810
+
3474
3811
  el.addEventListener('pointerdown', (e) => {
3475
3812
  start_node_resize(gobj, e, def.mx, def.my);
3476
3813
  });
@@ -3523,7 +3860,7 @@ function update_resize_handles_position(gobj)
3523
3860
  function apply_handles_to_rect(gobj, rect)
3524
3861
  {
3525
3862
  let priv = gobj.priv;
3526
- const HALF = RESIZE_HANDLE_SIZE / 2;
3863
+ const HALF = resize_handle_hit() / 2;
3527
3864
 
3528
3865
  const left = rect.left;
3529
3866
  const top = rect.top;
@@ -3544,23 +3881,15 @@ function apply_handles_to_rect(gobj, rect)
3544
3881
  'box-sizing:border-box;';
3545
3882
  }
3546
3883
 
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';
3884
+ /* Each handle carries the corner it is: -1 / 0 / +1 maps to
3885
+ * the near edge, the middle and the far edge. Read from the
3886
+ * handle instead of a parallel table, because the table is
3887
+ * not the same length on a touch screen. */
3888
+ for(const handle of priv._resize_handles) {
3889
+ const x = left + ((handle.mx + 1) / 2) * w;
3890
+ const y = top + ((handle.my + 1) / 2) * h;
3891
+ handle.el.style.left = (x - HALF) + 'px';
3892
+ handle.el.style.top = (y - HALF) + 'px';
3564
3893
  }
3565
3894
  }
3566
3895
 
@@ -3680,6 +4009,26 @@ function start_node_resize(gobj, e, mx, my)
3680
4009
  ************************************************************/
3681
4010
  const PORT_HANDLE_SIZE = 8;
3682
4011
 
4012
+ /*
4013
+ * Same two-box idea as the node handles. Kept smaller than a
4014
+ * full 44: these four sit on the RIM of a port that is often a
4015
+ * 6px dot, so a fingertip-sized box on each would cover the
4016
+ * neighbouring ports as well -- and picking a port is how a
4017
+ * link is started.
4018
+ */
4019
+ const PORT_HANDLE_VISUAL_COARSE = 12;
4020
+ const PORT_HANDLE_HIT_COARSE = 32;
4021
+
4022
+ function port_handle_visual()
4023
+ {
4024
+ return coarse_pointer()? PORT_HANDLE_VISUAL_COARSE: PORT_HANDLE_SIZE;
4025
+ }
4026
+
4027
+ function port_handle_hit()
4028
+ {
4029
+ return coarse_pointer()? PORT_HANDLE_HIT_COARSE: PORT_HANDLE_SIZE;
4030
+ }
4031
+
3683
4032
  /************************************************************
3684
4033
  * Compute the canvas (world) position of a port on a node.
3685
4034
  * Returns {x, y} in world coordinates.
@@ -3731,6 +4080,28 @@ function get_port_radius(gobj, node_id, port_key)
3731
4080
  return style.portR || 6;
3732
4081
  }
3733
4082
 
4083
+ /************************************************************
4084
+ * How much wider than the port itself its hit area is,
4085
+ * measured on the SCREEN and converted to world units.
4086
+ *
4087
+ * It used to be a flat `+4` in WORLD units, which is a
4088
+ * different target on every zoom: at the 50% a phone lands on
4089
+ * after fit, a 6-unit port plus 4 is a 5px radius -- one
4090
+ * fifth of a fingertip, and picking a port is the first step
4091
+ * of every link.
4092
+ ************************************************************/
4093
+ function port_hit_slop_world(graph)
4094
+ {
4095
+ let slop = coarse_pointer()? 22: 6;
4096
+ let zoom = 1;
4097
+ try {
4098
+ zoom = graph.getZoom() || 1;
4099
+ } catch(e) {
4100
+ zoom = 1;
4101
+ }
4102
+ return slop / zoom;
4103
+ }
4104
+
3734
4105
  /************************************************************
3735
4106
  * Detect if a click in canvas coordinates hits a port.
3736
4107
  * Returns the port key string, or null if no port hit.
@@ -3749,6 +4120,7 @@ function detect_port_click(gobj, node_id, canvasX, canvasY)
3749
4120
 
3750
4121
  let best_key = null;
3751
4122
  let best_dist = Infinity;
4123
+ let slop = port_hit_slop_world(graph);
3752
4124
 
3753
4125
  for(let i = 0; i < ports.length; i++) {
3754
4126
  let pl = ports[i].placement || [0.5, 0.5];
@@ -3760,8 +4132,9 @@ function detect_port_click(gobj, node_id, canvasX, canvasY)
3760
4132
  let dy = canvasY - py;
3761
4133
  let dist = Math.sqrt(dx * dx + dy * dy);
3762
4134
 
3763
- // Hit area is the port radius + a tolerance of 4 world units
3764
- if(dist <= r + 4 && dist < best_dist) {
4135
+ /* Nearest wins, so the enlarged areas of two ports
4136
+ * overlapping is not a tie -- it is the closer one. */
4137
+ if(dist <= r + slop && dist < best_dist) {
3765
4138
  best_dist = dist;
3766
4139
  best_key = ports[i].key;
3767
4140
  }
@@ -3838,7 +4211,8 @@ function show_link_icon_if_fkey(gobj)
3838
4211
  let vp = graph.getViewportByCanvas([canvasPos.x, canvasPos.y]);
3839
4212
 
3840
4213
  let icon = create_floating_icon(
3841
- 'link', '#52c41a', vp[0] + 18, vp[1] - 14,
4214
+ 'link', '#52c41a',
4215
+ vp[0] + floating_icon_size() / 2 + 4, vp[1] + floating_icon_dy(),
3842
4216
  t('link to hook'), () => {
3843
4217
  // Click (no drag) toggles linking mode
3844
4218
  if(!priv._linking_mode) {
@@ -3877,8 +4251,9 @@ function update_link_icon_position(gobj)
3877
4251
  return;
3878
4252
  }
3879
4253
  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';
4254
+ priv._link_icon_el.style.left =
4255
+ (vp[0] + floating_icon_size() / 2 + 4) + 'px';
4256
+ priv._link_icon_el.style.top = (vp[1] + floating_icon_dy()) + 'px';
3882
4257
  } catch(e) {
3883
4258
  hide_link_icon(gobj);
3884
4259
  }
@@ -4262,19 +4637,33 @@ function show_port_resize_handles(gobj)
4262
4637
  { cursor: 'w-resize', dx: -1, dy: 0 },
4263
4638
  ];
4264
4639
 
4640
+ const hit = port_handle_hit();
4641
+ const visual = port_handle_visual();
4642
+
4265
4643
  const handles = [];
4266
4644
  for(const def of handle_defs) {
4267
4645
  const el = document.createElement('div');
4268
4646
  el.style.cssText =
4269
4647
  'position:absolute;' +
4270
- 'width:' + PORT_HANDLE_SIZE + 'px;' +
4271
- 'height:' + PORT_HANDLE_SIZE + 'px;' +
4648
+ 'width:' + hit + 'px;' +
4649
+ 'height:' + hit + 'px;' +
4650
+ 'display:flex;align-items:center;justify-content:center;' +
4651
+ 'cursor:' + def.cursor + ';' +
4652
+ 'pointer-events:all;' +
4653
+ 'touch-action:none;' +
4654
+ 'box-sizing:border-box;';
4655
+
4656
+ const mark = document.createElement('div');
4657
+ mark.style.cssText =
4658
+ 'width:' + visual + 'px;' +
4659
+ 'height:' + visual + 'px;' +
4272
4660
  'background:#fff;' +
4273
4661
  'border:1px solid #fa8c16;' +
4274
4662
  'border-radius:50%;' +
4275
- 'cursor:' + def.cursor + ';' +
4276
- 'pointer-events:all;' +
4663
+ 'pointer-events:none;' +
4277
4664
  'box-sizing:border-box;';
4665
+ el.appendChild(mark);
4666
+
4278
4667
  el.addEventListener('pointerdown', (e) => {
4279
4668
  start_port_resize(gobj, e);
4280
4669
  });
@@ -4342,7 +4731,7 @@ function update_port_resize_handles_position(gobj)
4342
4731
  function apply_port_handles(gobj, cx, cy, vpR)
4343
4732
  {
4344
4733
  let priv = gobj.priv;
4345
- const HALF = PORT_HANDLE_SIZE / 2;
4734
+ const HALF = port_handle_hit() / 2;
4346
4735
 
4347
4736
  // Dashed ring
4348
4737
  const ring = priv._port_ring;
@@ -4534,7 +4923,7 @@ function show_edge_icon(gobj)
4534
4923
  return;
4535
4924
  }
4536
4925
 
4537
- let icons = show_dual_icons(gobj, mid.x + 4, mid.y - 14,
4926
+ let icons = show_dual_icons(gobj, mid.x + 4, mid.y + floating_icon_dy(),
4538
4927
  t('edge properties'), () => toggle_edge_popover(gobj),
4539
4928
  t('unlink'), () => request_unlink_edge(gobj)
4540
4929
  );
@@ -4562,16 +4951,18 @@ function update_edge_icon_position(gobj)
4562
4951
  return;
4563
4952
  }
4564
4953
  priv._edge_icon_el.style.left = (mid.x + 4) + 'px';
4565
- priv._edge_icon_el.style.top = (mid.y - 14) + 'px';
4954
+ priv._edge_icon_el.style.top = (mid.y + floating_icon_dy()) + 'px';
4566
4955
  if(priv._edge_delete_el) {
4567
4956
  priv._edge_delete_el.style.left = (mid.x + 4) + 'px';
4568
- priv._edge_delete_el.style.top = (mid.y + 18) + 'px';
4957
+ priv._edge_delete_el.style.top =
4958
+ (mid.y + floating_icon_dy() + floating_icon_step()) + 'px';
4569
4959
  }
4570
4960
 
4571
4961
  // Reposition popover if open
4572
4962
  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';
4963
+ priv._edge_popover_el.style.left =
4964
+ (mid.x + floating_popover_dx()) + 'px';
4965
+ priv._edge_popover_el.style.top = (mid.y + floating_icon_dy()) + 'px';
4575
4966
  clamp_popover_position(gobj, priv._edge_popover_el);
4576
4967
  }
4577
4968
  } catch(e) {
@@ -4631,7 +5022,10 @@ function show_edge_popover(gobj)
4631
5022
  return;
4632
5023
  }
4633
5024
 
4634
- const popover = create_popover_base(mid.x + 36, mid.y - 14, 'g6-edge-popover', '#d9d9d9', 180);
5025
+ const popover = create_popover_base(
5026
+ mid.x + floating_popover_dx(), mid.y + floating_icon_dy(),
5027
+ 'g6-edge-popover', '#d9d9d9', 180
5028
+ );
4635
5029
 
4636
5030
  // Live preview: update the selected edge in real time
4637
5031
  function preview_edge() {
@@ -4747,7 +5141,7 @@ function show_node_icon(gobj)
4747
5141
  return;
4748
5142
  }
4749
5143
 
4750
- let icons = show_dual_icons(gobj, rect.right + 4, rect.top - 14,
5144
+ let icons = show_dual_icons(gobj, rect.right + 4, rect.top + floating_icon_dy(),
4751
5145
  t('node properties'), () => toggle_node_popover(gobj),
4752
5146
  t('delete node'), () => request_delete_node(gobj)
4753
5147
  );
@@ -4781,16 +5175,18 @@ function update_node_icon_position(gobj)
4781
5175
  return;
4782
5176
  }
4783
5177
  priv._node_icon_el.style.left = (rect.right + 4) + 'px';
4784
- priv._node_icon_el.style.top = (rect.top - 14) + 'px';
5178
+ priv._node_icon_el.style.top = (rect.top + floating_icon_dy()) + 'px';
4785
5179
  if(priv._node_delete_el) {
4786
5180
  priv._node_delete_el.style.left = (rect.right + 4) + 'px';
4787
- priv._node_delete_el.style.top = (rect.top + 18) + 'px';
5181
+ priv._node_delete_el.style.top =
5182
+ (rect.top + floating_icon_dy() + floating_icon_step()) + 'px';
4788
5183
  }
4789
5184
 
4790
5185
  // Reposition popover if open
4791
5186
  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';
5187
+ priv._node_popover_el.style.left =
5188
+ (rect.right + floating_popover_dx()) + 'px';
5189
+ priv._node_popover_el.style.top = (rect.top + floating_icon_dy()) + 'px';
4794
5190
  clamp_popover_position(gobj, priv._node_popover_el);
4795
5191
  }
4796
5192
  } catch(e) {
@@ -4843,7 +5239,10 @@ function show_node_popover(gobj)
4843
5239
  return;
4844
5240
  }
4845
5241
 
4846
- const popover = create_popover_base(rect.right + 36, rect.top - 14, 'g6-node-popover', '#d9d9d9', 180);
5242
+ const popover = create_popover_base(
5243
+ rect.right + floating_popover_dx(), rect.top + floating_icon_dy(),
5244
+ 'g6-node-popover', '#d9d9d9', 180
5245
+ );
4847
5246
 
4848
5247
  let node_graph_type = nodeData.data && nodeData.data.desc ?
4849
5248
  nodeData.data.desc.node_treedb_type : null;
@@ -5831,7 +6230,7 @@ function show_create_popover(gobj)
5831
6230
  let topicSelect = document.createElement('select');
5832
6231
  topicSelect.style.cssText =
5833
6232
  'width:100%;padding:6px;border:1px solid #d9d9d9;border-radius:4px;' +
5834
- 'font-size:13px;margin-bottom:8px;';
6233
+ 'font-size:13px;margin-bottom:8px;' + touch_control_css();
5835
6234
  for(let i = 0; i < topics.length; i++) {
5836
6235
  let opt = document.createElement('option');
5837
6236
  opt.value = topics[i].topic_name;
@@ -5874,7 +6273,8 @@ function show_create_popover(gobj)
5874
6273
  idInput.placeholder = t('node id');
5875
6274
  idInput.style.cssText =
5876
6275
  'width:100%;padding:6px;border:1px solid #d9d9d9;border-radius:4px;' +
5877
- 'font-size:13px;margin-bottom:10px;box-sizing:border-box;';
6276
+ 'font-size:13px;margin-bottom:10px;box-sizing:border-box;' +
6277
+ touch_control_css();
5878
6278
  popover.appendChild(idInput);
5879
6279
 
5880
6280
  // Error message area
@@ -6668,11 +7068,7 @@ function ac_load_data(gobj, event, kw, src)
6668
7068
  log_error(`${gobj_short_name(gobj)}: cannot fit the graph: ${e}`);
6669
7069
  });
6670
7070
  }
6671
- if(priv.edit_mode) {
6672
- graph_add_plugin(gobj, "history");
6673
- } else {
6674
- graph_remove_plugin(gobj, "history");
6675
- }
7071
+ sync_history_plugin(gobj);
6676
7072
  /* Nodes exist and are positioned now: apply a focus
6677
7073
  * requested before the data was ready (deep link). */
6678
7074
  if(priv._pending_focus_topic !== null) {
@@ -6960,6 +7356,16 @@ function ac_resize(gobj, event, kw, src)
6960
7356
  }
6961
7357
  }
6962
7358
 
7359
+ /* Crossing the width where the toolbars start folding changes
7360
+ * what they contain -- a fold button appears or goes away --
7361
+ * and nothing else would ever rebuild them. Only on the
7362
+ * CROSSING: a resize fires on every frame of a drag. */
7363
+ let can_collapse = toolbars_can_collapse(gobj);
7364
+ if(can_collapse !== priv._toolbars_could_collapse) {
7365
+ priv._toolbars_could_collapse = can_collapse;
7366
+ update_toolbar(gobj);
7367
+ }
7368
+
6963
7369
  return 0;
6964
7370
  }
6965
7371
 
@@ -7018,6 +7424,22 @@ function ac_zoom_reset(gobj, event, kw, src)
7018
7424
  return 0;
7019
7425
  }
7020
7426
 
7427
+ /************************************************************
7428
+ * Fold / unfold the floating toolbars.
7429
+ *
7430
+ * One flag for BOTH: they are one wall over the canvas, and
7431
+ * the button that unfolds them has to be the same button
7432
+ * whichever edge the user reaches for.
7433
+ ************************************************************/
7434
+ function ac_toggle_toolbars(gobj, event, kw, src)
7435
+ {
7436
+ let priv = gobj.priv;
7437
+
7438
+ priv.toolbar_collapsed = !toolbars_collapsed(gobj);
7439
+ update_toolbar(gobj);
7440
+ return 0;
7441
+ }
7442
+
7021
7443
  function ac_auto_fit(gobj, event, kw, src)
7022
7444
  {
7023
7445
  graph_fitview(gobj);
@@ -7104,6 +7526,10 @@ function ac_set_operation_mode(gobj, event, kw, src)
7104
7526
  gobj_write_attr(gobj, "operation_mode", kw.operation_mode);
7105
7527
  configure_behaviour(gobj);
7106
7528
  update_toolbar(gobj);
7529
+
7530
+ /* `configure_behaviour` is what decides `edit_mode`, and the
7531
+ * history plugin belongs to that decision. */
7532
+ sync_history_plugin(gobj);
7107
7533
  return 0;
7108
7534
  }
7109
7535
 
@@ -7506,6 +7932,7 @@ function create_gclass(gclass_name)
7506
7932
  ["EV_ZOOM_IN", ac_zoom_in, null],
7507
7933
  ["EV_ZOOM_OUT", ac_zoom_out, null],
7508
7934
  ["EV_ZOOM_RESET", ac_zoom_reset, null],
7935
+ ["EV_TOGGLE_TOOLBARS", ac_toggle_toolbars, null],
7509
7936
  ["EV_AUTO_FIT", ac_auto_fit, null],
7510
7937
  ["EV_ZOOM_SELECTION", ac_zoom_selection, null],
7511
7938
  ["EV_FOCUS_TOPIC", ac_focus_topic, null],
@@ -7553,6 +7980,7 @@ function create_gclass(gclass_name)
7553
7980
  ["EV_ZOOM_IN", 0],
7554
7981
  ["EV_ZOOM_OUT", 0],
7555
7982
  ["EV_ZOOM_RESET", 0],
7983
+ ["EV_TOGGLE_TOOLBARS", 0],
7556
7984
  ["EV_AUTO_FIT", 0],
7557
7985
  ["EV_ZOOM_SELECTION", 0],
7558
7986
  ["EV_FOCUS_TOPIC", 0],