@yuneta/gobj-ui 7.26.26 → 7.26.28

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.
Files changed (44) hide show
  1. package/README.md +473 -19
  2. package/dist/gobj-ui.cjs.js +1100 -231
  3. package/dist/gobj-ui.es.js +1101 -232
  4. package/package.json +1 -1
  5. package/scripts/validate-locales.mjs +2334 -275
  6. package/src/c_g6_nodes_tree.js +1143 -260
  7. package/src/c_yui_form.js +11 -1
  8. package/src/c_yui_fsm_graph.js +5 -0
  9. package/src/c_yui_gclass.js +11 -0
  10. package/src/c_yui_gobj_tree_js.js +17 -0
  11. package/src/c_yui_json_graph.js +5 -0
  12. package/src/c_yui_nav.js +9 -6
  13. package/src/c_yui_pager.js +1 -1
  14. package/src/c_yui_period.js +11 -4
  15. package/src/c_yui_schema_editor.js +19 -7
  16. package/src/c_yui_shell.js +7 -7
  17. package/src/c_yui_treedb_graph.js +15 -6
  18. package/src/c_yui_treedb_schema.js +5 -0
  19. package/src/c_yui_treedb_topic_with_form.js +10 -10
  20. package/src/c_yui_treedb_topics.js +6 -6
  21. package/src/c_yui_window.js +2 -2
  22. package/src/c_yui_wizard.js +8 -1
  23. package/src/form_json_field.js +7 -0
  24. package/src/g6_canvas_tab_order.js +84 -0
  25. package/src/g6_canvas_tab_order.wiring.test.js +232 -0
  26. package/src/g6_nodes_tree_controls.test.js +547 -0
  27. package/src/g6_nodes_tree_focus.wiring.test.js +1360 -0
  28. package/src/g6_nodes_tree_i18n_keys.test.js +275 -0
  29. package/src/i18n_key_functions.js +146 -0
  30. package/src/i18n_key_functions.test.js +1204 -0
  31. package/src/lib_graph.js +38 -1
  32. package/src/nav_backbar_name.wiring.test.js +82 -0
  33. package/src/schema_flags.js +47 -0
  34. package/src/schema_import.js +7 -0
  35. package/src/shell_modals.js +20 -16
  36. package/src/shell_route_map.js +12 -2
  37. package/src/wizard_helpers.js +8 -0
  38. package/src/yui_dev.js +44 -33
  39. package/src/yui_dev_controls.test.js +174 -0
  40. package/src/yui_gclass_view.js +2 -2
  41. package/src/yui_graph_camera.js +1 -1
  42. package/src/yui_install.js +10 -8
  43. package/src/yui_table_select.js +2 -2
  44. package/src/yui_time.js +18 -3
@@ -178,11 +178,29 @@ import {yui_shell_of} from "./c_yui_shell.js";
178
178
  import {yui_shell_show_error} from "./shell_modals.js";
179
179
  import {g6_event_kw} from "./g6_event_kw.js";
180
180
  import {css_color_or_null} from "./yui_data_color.js";
181
+ import {keep_canvas_tab_order} from "./g6_canvas_tab_order.js";
181
182
 
182
183
  /***************************************************************
183
184
  * The markup of one toolbar item. Everything that goes into it is
184
185
  * escaped, the read-out branch too: its text, title and value are
185
186
  * values (a zoom figure, a translated title), never markup.
187
+ *
188
+ * An item is a BUTTON for the keyboard too: `role="button"` and
189
+ * `tabindex="0"`, and YuiToolbar presses it on Enter or Space.
190
+ * `title_key` is the i18n key of `title`: written as
191
+ * `data-i18n-title` / `data-i18n-aria-label`, so the name carries
192
+ * its key like every other control's.
193
+ *
194
+ * A `disabled` item is born the way disableElements() (lib_graph.js)
195
+ * leaves one: `aria-disabled`, out of the Tab order, and the
196
+ * tabindex it takes once enabled kept in `data-enabled-tabindex`,
197
+ * where enableElements() finds it. A toggle (`pressed` given, true
198
+ * or false) says its state in `aria-pressed`, as its look says it
199
+ * with `pressed_state`. `data-control` names the CONTROL the item
200
+ * is, whatever state it shows: `control` when the item gives one
201
+ * (full screen is `request-fullscreen` or `exit-fullscreen`, and
202
+ * one control), its `value` otherwise -- it is what a rebuild puts
203
+ * the focus back on.
186
204
  ***************************************************************/
187
205
  function toolbar_item_html(item)
188
206
  {
@@ -192,17 +210,28 @@ function toolbar_item_html(item)
192
210
 
193
211
  const extra_class = item.className ? ` ${escapeHtml(String(item.className))}` : '';
194
212
  const title = escapeHtml(String(item.title ?? ''));
213
+ let i18n_attrs = '';
214
+ if(item.title_key) {
215
+ const key = escapeHtml(String(item.title_key));
216
+ i18n_attrs = ` data-i18n-title="${key}" data-i18n-aria-label="${key}"`;
217
+ }
195
218
 
196
219
  if(item.readout) {
197
220
  return (
198
221
  `<div class="g6-toolbar-readout${extra_class}"` +
199
- ` title="${title}" aria-label="${title}">` +
222
+ ` title="${title}" aria-label="${title}"${i18n_attrs}>` +
200
223
  `${escapeHtml(String(item.text ?? ''))}` +
201
224
  `</div>`
202
225
  );
203
226
  }
204
227
 
205
- const disabled = item.disabled ? ' disabled' : '';
228
+ const state = item.disabled
229
+ ? ' tabindex="-1" aria-disabled="true" data-enabled-tabindex="0" disabled'
230
+ : ' tabindex="0"';
231
+ const pressed = (item.pressed === undefined)
232
+ ? ''
233
+ : ` aria-pressed="${item.pressed ? 'true' : 'false'}"`;
234
+ const control = escapeHtml(String(item.control ?? item.value));
206
235
  const is_text = (item.text !== undefined);
207
236
  const kind_class = is_text ? ' g6-toolbar-item-text' : '';
208
237
  const body = is_text
@@ -213,8 +242,9 @@ function toolbar_item_html(item)
213
242
 
214
243
  return (
215
244
  `<div class="g6-toolbar-item${kind_class}${extra_class}"` +
216
- ` value="${escapeHtml(String(item.value))}" title="${title}"` +
217
- ` aria-label="${title}"${disabled}>` +
245
+ ` role="button"${state}${pressed}` +
246
+ ` value="${escapeHtml(String(item.value))}" data-control="${control}"` +
247
+ ` title="${title}" aria-label="${title}"${i18n_attrs}>` +
218
248
  `${body}` +
219
249
  `</div>`
220
250
  );
@@ -233,6 +263,11 @@ function toolbar_item_html(item)
233
263
  * readout — a text item that REPORTS instead of acting (the
234
264
  * zoom level); it is not a button
235
265
  * separator — a group divider, not an item
266
+ * title_key — the i18n key of `title` (see toolbar_item_html)
267
+ * pressed — boolean; the item is a TOGGLE, and this is its
268
+ * state (`aria-pressed`)
269
+ * control — the identity of the control when its `value`
270
+ * changes with its state (full screen enter/exit)
236
271
  *
237
272
  * The base class fires onClick only for elements whose class list
238
273
  * contains `g6-toolbar-item`, so a separator and a readout carry
@@ -243,15 +278,168 @@ function toolbar_item_html(item)
243
278
  * functions (set_submit_state, disableElements, …) work on toolbar
244
279
  * icons exactly like on regular Bulma/FA buttons — no special
245
280
  * icon-specific helpers needed.
281
+ *
282
+ * The items are buttons for the KEYBOARD as well:
283
+ * - Enter or Space CLICKS the focused item, and the click bubbles
284
+ * to the base class's own handler, so the key reaches the same
285
+ * onClick a pointer does. A `disabled` item takes the key and
286
+ * does nothing, as its `pointer-events: none` does for a press.
287
+ * - A POINTER press does not move the focus to an item (it stays
288
+ * on the canvas, where the graph's keys are heard), the way the
289
+ * floating buttons keep it (see keep_focus_on_press).
290
+ * - A disabled item is out of the Tab order and says it is
291
+ * disabled (`aria-disabled`), from its first render
292
+ * (toolbar_item_html) and on every change (disableElements /
293
+ * enableElements in lib_graph.js).
294
+ * - A rebuild (update) replaces the innerHTML, the focused item
295
+ * with it: the focus goes back to the item of the same CONTROL
296
+ * (`data-control`: Enter on "enter full screen" lands on "exit
297
+ * full screen"), or to the canvas when there is none. And a
298
+ * toolbar that goes away (destroy) hands it to the canvas
299
+ * first. A removed element that held the focus leaves it on
300
+ * <body> in Firefox, which fires no focusout for it, and the
301
+ * graph goes deaf.
246
302
  ***************************************************************/
247
303
  class YuiToolbar extends Toolbar
248
304
  {
305
+ constructor(context, options)
306
+ {
307
+ super(context, options);
308
+ this.$element.addEventListener('pointerdown', keep_focus_on_toolbar_press);
309
+ this.$element.addEventListener('keydown', press_toolbar_item_on_key);
310
+ }
311
+
312
+ async update(options)
313
+ {
314
+ const control = focused_toolbar_item_control(this.$element);
315
+ await super.update(options);
316
+ if(control !== null) {
317
+ refocus_toolbar_item(this.$element, control);
318
+ }
319
+ }
320
+
321
+ destroy()
322
+ {
323
+ if(!this.destroyed && this.$element) {
324
+ this.$element.removeEventListener('pointerdown', keep_focus_on_toolbar_press);
325
+ this.$element.removeEventListener('keydown', press_toolbar_item_on_key);
326
+ if(this.$element.contains(document.activeElement)) {
327
+ focus_main_canvas(this.$element.parentNode);
328
+ }
329
+ }
330
+ super.destroy();
331
+ }
332
+
249
333
  async getDOMContent()
250
334
  {
251
335
  const items = await this.options.getItems();
252
336
  return items.map(toolbar_item_html).join('');
253
337
  }
254
338
  }
339
+
340
+ /***************************************************************
341
+ * A POINTER press on a button of this view does not take the
342
+ * focus. The container's capture listener has just put it on the
343
+ * canvas, where the graph's keys are heard; a press that moved it
344
+ * on to the button would leave it on something this view removes
345
+ * -- Escape deselects and takes the floating buttons away, the ✕
346
+ * closes its popover, a rebuild replaces the toolbar -- and
347
+ * Firefox fires no focusout for a removed element: the focus falls
348
+ * to <body> and the graph hears nothing until it is clicked again.
349
+ * Cancelling the pointerdown cancels the mousedown that moves the
350
+ * focus; the click still comes, and Tab still reaches the button.
351
+ ***************************************************************/
352
+ function keep_focus_on_press(el)
353
+ {
354
+ el.addEventListener('pointerdown', (e) => {
355
+ e.preventDefault();
356
+ });
357
+ }
358
+
359
+ /* The same, for the items of a toolbar, which is rebuilt whole:
360
+ * delegated from the toolbar element. */
361
+ function keep_focus_on_toolbar_press(e)
362
+ {
363
+ let target = e.target;
364
+ if(target && typeof target.closest === 'function' &&
365
+ target.closest('.g6-toolbar-item')) {
366
+ e.preventDefault();
367
+ }
368
+ }
369
+
370
+ /***************************************************************
371
+ * The keyboard to the graph: the focus on its main canvas.
372
+ ***************************************************************/
373
+ function focus_main_canvas($container)
374
+ {
375
+ if(!$container) {
376
+ return;
377
+ }
378
+ let $canvas = main_canvas_of($container);
379
+ if($canvas && $canvas.isConnected && typeof $canvas.focus === 'function') {
380
+ $canvas.focus({preventScroll: true});
381
+ }
382
+ }
383
+
384
+ /***************************************************************
385
+ * Enter or Space on a toolbar item clicks it; the click bubbles
386
+ * to G6's toolbar handler, which reads the item's `value`.
387
+ ***************************************************************/
388
+ function press_toolbar_item_on_key(e)
389
+ {
390
+ if(e.key !== 'Enter' && e.key !== ' ') {
391
+ return;
392
+ }
393
+ let target = e.target;
394
+ let item = (target && typeof target.closest === 'function')?
395
+ target.closest('.g6-toolbar-item') : null;
396
+ if(!item) {
397
+ return;
398
+ }
399
+ /* The key pressed a button: it is not the graph's (the
400
+ * container forwards every keydown to G6), and Space would
401
+ * scroll the page. */
402
+ e.preventDefault();
403
+ e.stopPropagation();
404
+ if(item.hasAttribute('disabled')) {
405
+ return;
406
+ }
407
+ item.click();
408
+ }
409
+
410
+ /***************************************************************
411
+ * The control (`data-control`) of the toolbar item that has the
412
+ * focus, or null.
413
+ ***************************************************************/
414
+ function focused_toolbar_item_control($toolbar)
415
+ {
416
+ let active = document.activeElement;
417
+ if(!$toolbar || !active || active === $toolbar || !$toolbar.contains(active)) {
418
+ return null;
419
+ }
420
+ if(!active.classList || !active.classList.contains('g6-toolbar-item')) {
421
+ return null;
422
+ }
423
+ return active.getAttribute('data-control');
424
+ }
425
+
426
+ /***************************************************************
427
+ * After a rebuild: the focus on the item of that control, in
428
+ * whatever state the rebuild shows it, or on the canvas when the
429
+ * rebuild has none (a fold took it away).
430
+ ***************************************************************/
431
+ function refocus_toolbar_item($toolbar, control)
432
+ {
433
+ let items = $toolbar.querySelectorAll('.g6-toolbar-item');
434
+ for(let i = 0; i < items.length; i++) {
435
+ if(items[i].getAttribute('data-control') === control) {
436
+ items[i].focus({preventScroll: true});
437
+ return;
438
+ }
439
+ }
440
+ focus_main_canvas($toolbar.parentNode);
441
+ }
442
+
255
443
  register(ExtensionCategory.PLUGIN, 'yui-toolbar', YuiToolbar);
256
444
  ensure_drag_canvas_patch();
257
445
  ensure_brush_select_owned();
@@ -340,6 +528,23 @@ const SHAPE_SIZE = {
340
528
  * default: a figure is a POINT of the topology, and a circle is
341
529
  * the one figure that says nothing more than that. */
342
530
  const NODE_SHAPES = ['square', 'circle', 'diamond', 'triangle', 'hexagon', 'star'];
531
+
532
+ /* The keys of this view that reach t() as DATA or as an ARGUMENT -- the
533
+ * shape tables, the labels of the property popovers (create_form_label),
534
+ * the confirm button (show_confirm_popover), the names of the floating
535
+ * buttons (create_floating_icon, show_dual_icons) and the shell's error
536
+ * messages (yui_shell_show_error): read by a consumer's validate-locales,
537
+ * which asks the locales for every key of an exported `*_I18N_KEYS` array
538
+ * of a module the app mounts. A shape added to NODE_SHAPES or PORT_SHAPES,
539
+ * or a new key handed to one of those helpers, goes here too
540
+ * (g6_nodes_tree_i18n_keys.test.js checks every call). */
541
+ export const G6_NODES_TREE_I18N_KEYS = [
542
+ 'square', 'circle', 'diamond', 'triangle', 'hexagon', 'star',
543
+ 'shape', 'radius', 'apply to', 'line width', 'color', 'fill color', 'stroke color',
544
+ 'delete', 'unlink',
545
+ 'node properties', 'delete node', 'edge properties', 'link to hook', 'port properties',
546
+ 'some records were gone before the delete'
547
+ ];
343
548
  const DEFAULT_NODE_SHAPE = 'circle';
344
549
 
345
550
  /***************************************************************
@@ -506,6 +711,7 @@ let PRIVATE_DATA = {
506
711
  _saved_graph_properties: {}, // the same, as the BACKEND has it: what a Save compares against
507
712
  _graphs_writes_owed: {}, // topic_name → true: a __graphs__ write the host said did not land
508
713
  _graphs_load: 0, // the LOAD a __graphs__ write belongs to: EV_CLEAR_DATA starts another
714
+ _save_lit: false, // what the Save was last told (light_save_button)
509
715
  yet_showed: false,
510
716
  edit_mode: false,
511
717
  operation_mode: null,
@@ -540,6 +746,7 @@ let PRIVATE_DATA = {
540
746
  _detail_node_id: null, // node id the detail popover is showing
541
747
  _delete_confirm_el: null, // delete confirmation popover
542
748
  _unlink_confirm_el: null, // unlink confirmation popover
749
+ _confirm_questions: {}, // priv key of a confirm popover → the {what, id|ids} it asks
543
750
  _create_popover_el: null, // create node popover element
544
751
  _context_node_id: null, // node id for context menu target
545
752
  _context_port_key: null, // port key for context menu target (null = node body)
@@ -548,6 +755,7 @@ let PRIVATE_DATA = {
548
755
  _link_source: null, // {node_id, port_key, col, topic_name} of fkey being linked
549
756
  _link_icon_el: null, // floating link icon on fkey port
550
757
  _link_drag_svg: null, // SVG overlay for drag line
758
+ _link_drag: null, // the link drag in flight: {left, top, unwire}
551
759
  _link_valid_hooks: [], // [{node_id, port_key}] compatible hook targets
552
760
  _link_saved_styles: [], // saved port styles to restore on cancel
553
761
  _focus_topic: null, // topic currently focused (EV_FOCUS_TOPIC)
@@ -1034,6 +1242,10 @@ function build_graph(gobj)
1034
1242
  */
1035
1243
  graph.setTheme(priv.theme);
1036
1244
 
1245
+ /* Tab goes from the graph to its own buttons: the main canvas in
1246
+ * the Tab order, the other layers out (g6_canvas_tab_order.js). */
1247
+ keep_canvas_tab_order(gobj, graph);
1248
+
1037
1249
  /* A card is an HTML node, and G6's HTML nodes do not pass the wheel
1038
1250
  * on: over a card the graph neither scrolled nor zoomed. Only a wheel
1039
1251
  * over a CARD is handed back to the canvas -- the toolbars and the
@@ -1410,10 +1622,13 @@ function update_toolbar(gobj)
1410
1622
  });
1411
1623
  }
1412
1624
 
1413
- /* A re-render rebuilds the innerHTML, so a button whose enabled
1414
- * state is not part of getItems() has to be told again. */
1415
- update_zoom_selection_button(gobj);
1416
- update_selection_mode_button(gobj);
1625
+ /* Nothing to tell the items after this: what each one shows --
1626
+ * pressed, lit, disabled -- is computed in its getItems(). The
1627
+ * rebuild lands LATER (G6 reads the list now and writes the
1628
+ * innerHTML once its promise resolves), so an element told here
1629
+ * was the one being replaced: zoom to selection came back
1630
+ * disabled over a selection, and save, undo and redo over
1631
+ * history still pending. */
1417
1632
  }
1418
1633
 
1419
1634
  /************************************************************
@@ -1477,12 +1692,12 @@ function toolbar_fold_item(gobj)
1477
1692
  if(toolbars_collapsed(gobj)) {
1478
1693
  return {
1479
1694
  id: 'g6-icon-toolbar-show', value: 'toolbar-toggle',
1480
- className: 'EV_TOGGLE_TOOLBARS', title: t('show toolbar')
1695
+ className: 'EV_TOGGLE_TOOLBARS', title: t('show toolbar'), title_key: 'show toolbar'
1481
1696
  };
1482
1697
  }
1483
1698
  return {
1484
1699
  id: 'g6-icon-toolbar-hide', value: 'toolbar-toggle',
1485
- className: 'EV_TOGGLE_TOOLBARS', title: t('hide toolbar')
1700
+ className: 'EV_TOGGLE_TOOLBARS', title: t('hide toolbar'), title_key: 'hide toolbar'
1486
1701
  };
1487
1702
  }
1488
1703
 
@@ -1579,15 +1794,15 @@ function configure_toolbar(gobj)
1579
1794
  }
1580
1795
 
1581
1796
  let items = [
1582
- { id: 'g6-icon-zoom-in', value: 'zoom-in', className: 'EV_ZOOM_IN', title: t('zoom in') },
1583
- { id: 'g6-icon-zoom-out', value: 'zoom-out', className: 'EV_ZOOM_OUT', title: t('zoom out') },
1797
+ { id: 'g6-icon-zoom-in', value: 'zoom-in', className: 'EV_ZOOM_IN', title: t('zoom in'), title_key: 'zoom in' },
1798
+ { id: 'g6-icon-zoom-out', value: 'zoom-out', className: 'EV_ZOOM_OUT', title: t('zoom out'), title_key: 'zoom out' },
1584
1799
  /* What the two buttons above change. Every editor
1585
1800
  * that offers a zoom shows the number; without it
1586
1801
  * `1:1` is a jump to a value nobody was told. */
1587
1802
  { readout: true, className: 'G6_ZOOM_LEVEL',
1588
- text: zoom_percent_text(gobj), title: t('zoom level') },
1803
+ text: zoom_percent_text(gobj), title: t('zoom level'), title_key: 'zoom level' },
1589
1804
  { separator: true },
1590
- { id: 'g6-icon-fit', value: 'auto-fit', className: 'EV_AUTO_FIT', title: t('auto fit') },
1805
+ { id: 'g6-icon-fit', value: 'auto-fit', className: 'EV_AUTO_FIT', title: t('auto fit'), title_key: 'auto fit' },
1591
1806
  /* `1:1`, not a house. The action is `zoomTo(1)`: it
1592
1807
  * sets the SCALE and leaves the camera where it was,
1593
1808
  * which is neither of the two things a house means
@@ -1597,7 +1812,7 @@ function configure_toolbar(gobj)
1597
1812
  * answered with the same corner of it at 100%.
1598
1813
  * Actual size is WRITTEN in every editor that offers
1599
1814
  * it, never drawn: there is no glyph for it. */
1600
- { text: '1:1', value: 'reset', className: 'EV_ZOOM_RESET', title: t('actual size') },
1815
+ { text: '1:1', value: 'reset', className: 'EV_ZOOM_RESET', title: t('actual size'), title_key: 'actual size' },
1601
1816
  /* Pick one node and every zoom leaves it in the
1602
1817
  * middle. The pressed classes are baked in HERE and
1603
1818
  * not set afterwards, for the same reason the
@@ -1612,21 +1827,25 @@ function configure_toolbar(gobj)
1612
1827
  className: 'EV_TOGGLE_ANCHOR' +
1613
1828
  (priv.anchor_state === "on"? ' pressed_state' : '') +
1614
1829
  (priv.anchor_state === "arming"? ' color_pending_state' : ''),
1615
- title: anchor_title(priv.anchor_state)
1830
+ pressed: priv.anchor_state === "on",
1831
+ ...anchor_title(priv.anchor_state)
1616
1832
  },
1617
1833
  ];
1618
1834
 
1619
1835
  /* Only in edition, because that is where a selection
1620
1836
  * can exist: a button that is permanently disabled is
1621
1837
  * not a control, it is furniture. Disabled while the
1622
- * selection is empty -- `paint_selection` is the one
1623
- * funnel every change goes through, so it is the one
1624
- * place that has to say so. */
1838
+ * selection is empty, asked HERE as well as by
1839
+ * `paint_selection` (the one funnel every change goes
1840
+ * through, which tells the live item): a rebuild draws
1841
+ * the item from this list, and an item born disabled
1842
+ * came back that way over a selection. */
1625
1843
  if(priv.edit_mode) {
1626
1844
  items.push({
1627
1845
  id: 'g6-icon-fit-selection', value: 'fit-selection',
1628
1846
  className: 'EV_ZOOM_SELECTION',
1629
- title: t('zoom to selection'), disabled: true
1847
+ title: t('zoom to selection'), title_key: 'zoom to selection',
1848
+ disabled: !selection_is_painted(gobj)
1630
1849
  });
1631
1850
  }
1632
1851
 
@@ -1634,13 +1853,16 @@ function configure_toolbar(gobj)
1634
1853
  /* Full screen is a WINDOW control, not a camera one:
1635
1854
  * it goes in its own group. */
1636
1855
  items.push({ separator: true });
1856
+ /* ONE control in two states (`control`): the
1857
+ * focus a rebuild gives back lands on the other
1858
+ * state's button. */
1637
1859
  if(priv.is_fullscreen) {
1638
1860
  items.push(
1639
- { id: 'g6-icon-fullscreen-exit', value: 'exit-fullscreen', className: 'EV_EXIT_FULLSCREEN', title: t('exit full screen') }
1861
+ { id: 'g6-icon-fullscreen-exit', value: 'exit-fullscreen', control: 'fullscreen', className: 'EV_EXIT_FULLSCREEN', title: t('exit full screen'), title_key: 'exit full screen' }
1640
1862
  );
1641
1863
  } else {
1642
1864
  items.push(
1643
- { id: 'g6-icon-fullscreen', value: 'request-fullscreen', className: 'EV_REQUEST_FULLSCREEN', title: t('enter full screen') }
1865
+ { id: 'g6-icon-fullscreen', value: 'request-fullscreen', control: 'fullscreen', className: 'EV_REQUEST_FULLSCREEN', title: t('enter full screen'), title_key: 'enter full screen' }
1644
1866
  );
1645
1867
  }
1646
1868
  }
@@ -1731,8 +1953,14 @@ function configure_toolbar_edit(gobj)
1731
1953
  position: 'left-top',
1732
1954
  style: toolbar_style(),
1733
1955
  getItems: () => {
1956
+ /* Save, undo and redo bake their state for the same
1957
+ * reason as the selection mode below: told only to
1958
+ * the live element (update_history_buttons), they
1959
+ * came back disabled from every rebuild while the
1960
+ * history still had something to undo. */
1961
+ let lit = edit_buttons_lit(gobj);
1734
1962
  return [
1735
- { id: 'g6-icon-plus', value: 'create-node', className: 'EV_CREATE_NODE_BTN color_create_state', title: t('create node') },
1963
+ { id: 'g6-icon-plus', value: 'create-node', className: 'EV_CREATE_NODE_BTN color_create_state', title: t('create node'), title_key: 'create node' },
1736
1964
  /* The mode is carried in the class getItems()
1737
1965
  * returns, not only pushed onto the element after
1738
1966
  * the fact: this toolbar is rebuilt whole (a fold,
@@ -1744,17 +1972,30 @@ function configure_toolbar_edit(gobj)
1744
1972
  id: 'g6-icon-select-mode', value: 'selection-mode',
1745
1973
  className: 'EV_TOGGLE_SELECTION_MODE' +
1746
1974
  (priv.selection_mode? ' pressed_state' : ''),
1747
- title: t('selection mode')
1975
+ pressed: !!priv.selection_mode,
1976
+ title: t('selection mode'), title_key: 'selection mode'
1977
+ },
1978
+ {
1979
+ id: 'g6-icon-save', value: 'save',
1980
+ className: 'EV_SAVE_GRAPH' + (lit.save? ' color_submit_state' : ''),
1981
+ title: t('save'), title_key: 'save', disabled: !lit.save
1982
+ },
1983
+ {
1984
+ id: 'g6-icon-undo', value: 'undo',
1985
+ className: 'EV_HISTORY_UNDO' + (lit.undo? ' color_active_state' : ''),
1986
+ title: t('undo'), title_key: 'undo', disabled: !lit.undo
1987
+ },
1988
+ {
1989
+ id: 'g6-icon-redo', value: 'redo',
1990
+ className: 'EV_HISTORY_REDO' + (lit.redo? ' color_active_state' : ''),
1991
+ title: t('redo'), title_key: 'redo', disabled: !lit.redo
1748
1992
  },
1749
- { id: 'g6-icon-save', value: 'save', className: 'EV_SAVE_GRAPH', title: t('save'), disabled: true },
1750
- { id: 'g6-icon-undo', value: 'undo', className: 'EV_HISTORY_UNDO', title: t('undo'), disabled: true },
1751
- { id: 'g6-icon-redo', value: 'redo', className: 'EV_HISTORY_REDO', title: t('redo'), disabled: true },
1752
1993
  ];
1753
1994
  },
1754
1995
  onClick: (value) => {
1755
1996
  switch(value) {
1756
1997
  case 'create-node':
1757
- toggle_create_popover(gobj);
1998
+ gobj_send_event(gobj, "EV_TOGGLE_CREATE_POPOVER", {}, gobj);
1758
1999
  break;
1759
2000
  case 'selection-mode':
1760
2001
  gobj_send_event(gobj, "EV_TOGGLE_SELECTION_MODE", {}, gobj);
@@ -3421,8 +3662,7 @@ function update_history_buttons(gobj)
3421
3662
  set_active_state($container, ".EV_HISTORY_UNDO", true);
3422
3663
 
3423
3664
  // Pending changes exist, enable save
3424
- enableElements($container, ".EV_SAVE_GRAPH");
3425
- set_submit_state($container, ".EV_SAVE_GRAPH", true);
3665
+ light_save_button(gobj, true);
3426
3666
  } else {
3427
3667
  disableElements($container, ".EV_HISTORY_UNDO");
3428
3668
  set_active_state($container, ".EV_HISTORY_UNDO", false);
@@ -3430,13 +3670,7 @@ function update_history_buttons(gobj)
3430
3670
  /* No more undos: the graph is back to what was loaded
3431
3671
  * or saved -- unless a write of it did not land. That
3432
3672
  * one is still owed, and only a Save pays it. */
3433
- if(graphs_write_is_owed(gobj)) {
3434
- enableElements($container, ".EV_SAVE_GRAPH");
3435
- set_submit_state($container, ".EV_SAVE_GRAPH", true);
3436
- } else {
3437
- disableElements($container, ".EV_SAVE_GRAPH");
3438
- set_submit_state($container, ".EV_SAVE_GRAPH", false);
3439
- }
3673
+ light_save_button(gobj, graphs_write_is_owed(gobj));
3440
3674
  }
3441
3675
  } else {
3442
3676
  disableElements($container, ".EV_HISTORY_REDO");
@@ -3444,10 +3678,52 @@ function update_history_buttons(gobj)
3444
3678
 
3445
3679
  disableElements($container, ".EV_HISTORY_UNDO");
3446
3680
  set_active_state($container, ".EV_HISTORY_UNDO", false);
3681
+
3682
+ /* No history (a clear, a refresh): nothing to save but a
3683
+ * write still owed -- and `_save_lit` is what the next
3684
+ * rebuild of the toolbar draws. */
3685
+ light_save_button(gobj, graphs_write_is_owed(gobj));
3447
3686
  }
3448
3687
  }
3449
3688
  }
3450
3689
 
3690
+ /************************************************************
3691
+ * What Save, Undo and Redo show when the edit toolbar is
3692
+ * built (its getItems): Undo and Redo what the history can do
3693
+ * now; Save what it was last told (`_save_lit`, see
3694
+ * light_save_button), because more than the history lights
3695
+ * it -- a change the history does not record
3696
+ * (mark_graph_dirty), a write owed -- and update_history_
3697
+ * buttons() is where those are weighed.
3698
+ ************************************************************/
3699
+ function edit_buttons_lit(gobj)
3700
+ {
3701
+ let history = graph_get_plugin(gobj, "history");
3702
+
3703
+ return {
3704
+ undo: !!history && history.canUndo(),
3705
+ redo: !!history && history.canRedo(),
3706
+ save: !!gobj.priv._save_lit,
3707
+ };
3708
+ }
3709
+
3710
+ /************************************************************
3711
+ * Light the Save, or put it out: on the live element, and in
3712
+ * `_save_lit`, where the next rebuild of the toolbar reads it.
3713
+ ************************************************************/
3714
+ function light_save_button(gobj, lit)
3715
+ {
3716
+ let $container = gobj_read_attr(gobj, "$container");
3717
+
3718
+ gobj.priv._save_lit = !!lit;
3719
+ if(lit) {
3720
+ enableElements($container, ".EV_SAVE_GRAPH");
3721
+ } else {
3722
+ disableElements($container, ".EV_SAVE_GRAPH");
3723
+ }
3724
+ set_submit_state($container, ".EV_SAVE_GRAPH", !!lit);
3725
+ }
3726
+
3451
3727
  /************************************************************
3452
3728
  * Graph utility functions
3453
3729
  *
@@ -4707,22 +4983,63 @@ function floating_popover_dx()
4707
4983
  return 4 + floating_icon_size() + 4;
4708
4984
  }
4709
4985
 
4986
+ /************************************************************
4987
+ * A key press IS the click on an element drawn as a button.
4988
+ *
4989
+ * Enter or Space clicks it, so the keyboard reaches the very
4990
+ * handler the pointer does, whatever that handler is. Stopped
4991
+ * here, as the click is: this view's container forwards every
4992
+ * keydown to G6 (its shortcuts, and EV_KEY_DOWN), and a key
4993
+ * that pressed a button is not the graph's. Space would scroll
4994
+ * the page as well. Any other key goes on to the graph, so
4995
+ * Escape and Delete keep doing there what they do.
4996
+ ************************************************************/
4997
+ function click_on_enter_or_space(el)
4998
+ {
4999
+ el.addEventListener('keydown', (e) => {
5000
+ if(e.key === 'Enter' || e.key === ' ') {
5001
+ e.preventDefault();
5002
+ e.stopPropagation();
5003
+ el.click();
5004
+ }
5005
+ });
5006
+ }
5007
+
4710
5008
  /************************************************************
4711
5009
  * Create a floating circular icon button.
4712
- * @param {string} svgKey - key into SVG_ICONS
4713
- * @param {string} color - border & text color (e.g. '#1890ff')
4714
- * @param {number} left - CSS left in px
4715
- * @param {number} top - CSS top in px
4716
- * @param {string} title - tooltip text
4717
- * @param {Function} onClick
5010
+ *
5011
+ * A button for the keyboard too: `role` and `tabindex`, and
5012
+ * Enter or Space click it (click_on_enter_or_space). Its name
5013
+ * carries its KEY, so refresh_language() renames it on a
5014
+ * language switch; a key handed in here goes in
5015
+ * G6_NODES_TREE_I18N_KEYS, because no scan sees it.
5016
+ *
5017
+ * Its click does one thing: it sends `event` to `gobj`, with
5018
+ * `kw` -- the plain identity of what the button belongs to --
5019
+ * and the ACTION does the work, where the `machine` trace sees
5020
+ * it. A pointer press does not take the focus
5021
+ * (keep_focus_on_press).
5022
+ *
5023
+ * @param {string} svgKey - key into SVG_ICONS
5024
+ * @param {string} color - border & text color (e.g. '#1890ff')
5025
+ * @param {number} left - CSS left in px
5026
+ * @param {number} top - CSS top in px
5027
+ * @param {string} title_key - i18n key of its title and aria-label
5028
+ * @param {object} gobj - who hears the click
5029
+ * @param {string} event - an input event of this gclass
5030
+ * @param {object} kw - plain JSON identity, copied per click
4718
5031
  * @returns {HTMLElement}
4719
5032
  ************************************************************/
4720
- function create_floating_icon(svgKey, color, left, top, title, onClick)
5033
+ function create_floating_icon(svgKey, color, left, top, title_key, gobj, event, kw)
4721
5034
  {
4722
5035
  const size = floating_icon_size();
4723
5036
  const el = document.createElement('div');
4724
- el.title = title;
4725
- el.setAttribute('aria-label', title);
5037
+ el.setAttribute('role', 'button');
5038
+ el.setAttribute('tabindex', '0');
5039
+ el.setAttribute('title', t(title_key));
5040
+ el.setAttribute('data-i18n-title', title_key);
5041
+ el.setAttribute('aria-label', t(title_key));
5042
+ el.setAttribute('data-i18n-aria-label', title_key);
4726
5043
  el.innerHTML = SVG_ICONS[svgKey] || '';
4727
5044
  el.style.cssText =
4728
5045
  'position:absolute;' +
@@ -4749,13 +5066,25 @@ function create_floating_icon(svgKey, color, left, top, title, onClick)
4749
5066
 
4750
5067
  el.addEventListener('click', (e) => {
4751
5068
  e.stopPropagation();
4752
- onClick();
5069
+ gobj_send_event(gobj, event, json_deep_copy(kw || {}), gobj);
4753
5070
  });
5071
+ keep_focus_on_press(el);
5072
+ click_on_enter_or_space(el);
4754
5073
  return el;
4755
5074
  }
4756
5075
 
4757
5076
  /************************************************************
4758
5077
  * Remove one or more overlay elements from priv and the DOM.
5078
+ *
5079
+ * An overlay that holds the focus -- a floating button reached
5080
+ * with Tab, the ✕ of the detail popover, a field of a popover --
5081
+ * gives it back to the canvas BEFORE it goes. Removing the
5082
+ * focused element is a focus loss the browsers do not report
5083
+ * alike: Chromium fires a focusout, which _on_focusout_restore
5084
+ * answers; Firefox fires none and leaves the focus on <body>,
5085
+ * and the graph is deaf to Escape, Delete and ctrl+A until the
5086
+ * next click on it.
5087
+ *
4759
5088
  * @param {object} gobj
4760
5089
  * @param {...string} keys - priv property names to remove
4761
5090
  ************************************************************/
@@ -4764,7 +5093,11 @@ function hide_overlay(gobj, ...keys)
4764
5093
  let priv = gobj.priv;
4765
5094
  for(let key of keys) {
4766
5095
  if(priv[key]) {
4767
- priv[key].remove();
5096
+ let el = priv[key];
5097
+ if(el.contains(document.activeElement) && !gobj_is_destroying(gobj)) {
5098
+ focus_main_canvas(priv.$container);
5099
+ }
5100
+ el.remove();
4768
5101
  priv[key] = null;
4769
5102
  }
4770
5103
  }
@@ -4822,9 +5155,7 @@ function graphs_write_is_owed(gobj)
4822
5155
 
4823
5156
  function mark_graph_dirty(gobj)
4824
5157
  {
4825
- let $container = gobj_read_attr(gobj, "$container");
4826
- enableElements($container, ".EV_SAVE_GRAPH");
4827
- set_submit_state($container, ".EV_SAVE_GRAPH", true);
5158
+ light_save_button(gobj, true);
4828
5159
  }
4829
5160
 
4830
5161
  /************************************************************
@@ -4841,19 +5172,21 @@ function extract_size(style)
4841
5172
 
4842
5173
  /************************************************************
4843
5174
  * Show a pair of floating icons (gear + trash) next to an
4844
- * element. Returns {icon_el, delete_el}.
5175
+ * element. The titles are i18n KEYS (see create_floating_icon);
5176
+ * each click sends its event to `gobj` with the same identity
5177
+ * `kw`. Returns {icon_el, delete_el}.
4845
5178
  ************************************************************/
4846
- function show_dual_icons(gobj, x, y, gear_title, gear_click, trash_title, trash_click)
5179
+ function show_dual_icons(gobj, x, y, gear_key, gear_event, trash_key, trash_event, kw)
4847
5180
  {
4848
5181
  let priv = gobj.priv;
4849
5182
 
4850
5183
  const icon_el = create_floating_icon(
4851
- 'gear', '#1890ff', x, y, gear_title, gear_click
5184
+ 'gear', '#1890ff', x, y, gear_key, gobj, gear_event, kw
4852
5185
  );
4853
5186
  priv.$container.appendChild(icon_el);
4854
5187
 
4855
5188
  const delete_el = create_floating_icon(
4856
- 'trash', '#ff4d4f', x, y + floating_icon_step(), trash_title, trash_click
5189
+ 'trash', '#ff4d4f', x, y + floating_icon_step(), trash_key, gobj, trash_event, kw
4857
5190
  );
4858
5191
  priv.$container.appendChild(delete_el);
4859
5192
 
@@ -4931,15 +5264,43 @@ function touch_control_css()
4931
5264
  * one id.
4932
5265
  */
4933
5266
 
5267
+ /*
5268
+ * A form of the property popovers is a LABEL followed by its control. The
5269
+ * label carries its key (`text` is one) and the control built next is
5270
+ * NAMED by it: a `<label>` beside a control names nothing, so the control
5271
+ * gets the label's `for`, and its own `title` and `aria-label`, with their
5272
+ * keys (REV-CLOUD V19.4.1: 11 controls without a name).
5273
+ */
5274
+ let form_control_seq = 0;
5275
+
4934
5276
  function create_form_label(parent, text)
4935
5277
  {
4936
5278
  let label = document.createElement('label');
4937
5279
  label.textContent = t(text);
5280
+ label.setAttribute('data-i18n', text);
4938
5281
  label.style.cssText = 'display:block;margin-bottom:4px;font-weight:500;';
4939
5282
  parent.appendChild(label);
4940
5283
  return label;
4941
5284
  }
4942
5285
 
5286
+ function name_form_control(parent, control)
5287
+ {
5288
+ let label = parent.lastElementChild;
5289
+ if(!label || label.tagName !== 'LABEL') {
5290
+ return;
5291
+ }
5292
+ let key = label.getAttribute('data-i18n');
5293
+ if(!key) {
5294
+ return;
5295
+ }
5296
+ control.id = 'g6-form-control-' + (++form_control_seq);
5297
+ label.htmlFor = control.id;
5298
+ control.setAttribute('title', t(key));
5299
+ control.setAttribute('data-i18n-title', key);
5300
+ control.setAttribute('aria-label', t(key));
5301
+ control.setAttribute('data-i18n-aria-label', key);
5302
+ }
5303
+
4943
5304
  function create_form_color_input(parent, value, onInput)
4944
5305
  {
4945
5306
  let input = document.createElement('input');
@@ -4952,6 +5313,7 @@ function create_form_color_input(parent, value, onInput)
4952
5313
  if(onInput) {
4953
5314
  input.addEventListener('input', onInput);
4954
5315
  }
5316
+ name_form_control(parent, input);
4955
5317
  parent.appendChild(input);
4956
5318
  return input;
4957
5319
  }
@@ -4976,6 +5338,7 @@ function create_form_number_input(parent, value, min, max, onInput)
4976
5338
  if(onInput) {
4977
5339
  input.addEventListener('input', onInput);
4978
5340
  }
5341
+ name_form_control(parent, input);
4979
5342
  parent.appendChild(input);
4980
5343
  return input;
4981
5344
  }
@@ -4991,11 +5354,14 @@ function create_form_select(parent, options, marginBottom)
4991
5354
  'box-sizing:border-box;margin-bottom:' + (marginBottom || '12px') + ';' +
4992
5355
  touch_control_css();
4993
5356
  for(let opt of options) {
5357
+ /* `i18n` is the option's key: it carries it, and `value` stays explicit */
4994
5358
  let o = document.createElement('option');
4995
5359
  o.value = opt.value;
4996
- o.textContent = opt.label;
5360
+ o.textContent = t(opt.i18n);
5361
+ o.setAttribute('data-i18n', opt.i18n);
4997
5362
  select.appendChild(o);
4998
5363
  }
5364
+ name_form_control(parent, select);
4999
5365
  parent.appendChild(select);
5000
5366
  return select;
5001
5367
  }
@@ -5006,11 +5372,23 @@ const BTN_STYLE_CANCEL =
5006
5372
  'border:1px solid var(--bulma-border-weak, #d9d9d9);' +
5007
5373
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;';
5008
5374
 
5009
- function create_form_button_row(parent, buttons)
5375
+ /************************************************************
5376
+ * The buttons of a popover: each one SENDS an input event of
5377
+ * this gclass to `gobj`, and the action does the work, where
5378
+ * the `machine` trace sees it (V20.5 #4). A button is
5379
+ * {i18n, style, event, kw, form}
5380
+ * `kw` is the plain identity of what the popover is about
5381
+ * (copied per click); `form` names the controls whose `value`
5382
+ * goes in the kw too, as the control says it -- a string, the
5383
+ * action parses it. The kw says what was asked for, in the
5384
+ * trace: the identity and what the form held when it was
5385
+ * pressed.
5386
+ ************************************************************/
5387
+ function create_form_button_row(parent, buttons, gobj)
5010
5388
  {
5011
5389
  let btnRow = document.createElement('div');
5012
5390
  btnRow.style.cssText = 'display:flex;gap:8px;';
5013
- for(let {i18n, style, onClick} of buttons) {
5391
+ for(let {i18n, style, event, kw, form} of buttons) {
5014
5392
  /* `i18n` is the key (named so that validate-locales sees a literal
5015
5393
  * one): the button carries it, and its name */
5016
5394
  let btn = document.createElement('button');
@@ -5023,7 +5401,7 @@ function create_form_button_row(parent, buttons)
5023
5401
  btn.style.cssText = style + touch_control_css();
5024
5402
  btn.addEventListener('click', (e) => {
5025
5403
  e.stopPropagation();
5026
- onClick();
5404
+ gobj_send_event(gobj, event, form_kw(kw, form), gobj);
5027
5405
  });
5028
5406
  btnRow.appendChild(btn);
5029
5407
  }
@@ -5031,11 +5409,27 @@ function create_form_button_row(parent, buttons)
5031
5409
  return btnRow;
5032
5410
  }
5033
5411
 
5412
+ /* The kw a popover button sends: its identity, and the value of
5413
+ * each control of its form. */
5414
+ function form_kw(kw, form)
5415
+ {
5416
+ let out = json_deep_copy(kw || {});
5417
+ for(let [name, control] of Object.entries(form || {})) {
5418
+ out[name] = String(control.value);
5419
+ }
5420
+ return out;
5421
+ }
5422
+
5034
5423
  /************************************************************
5035
5424
  * Show a confirmation popover next to a target element.
5036
- * Returns the popover element.
5425
+ *
5426
+ * `question` is the plain identity of what is asked, {what,
5427
+ * id|ids}: the confirm button sends it with EV_CONFIRMED and the
5428
+ * cancel button with EV_CANCEL_CONFIRM, and the actions answer.
5429
+ * `priv_key` is where the popover is kept, and it must be the
5430
+ * one CONFIRM_POPOVER_OF names for `question.what`.
5037
5431
  ************************************************************/
5038
- function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_color, onConfirm, priv_key)
5432
+ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_color, question, priv_key)
5039
5433
  {
5040
5434
  hide_overlay(gobj, priv_key);
5041
5435
 
@@ -5068,18 +5462,21 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
5068
5462
  {
5069
5463
  i18n: 'cancel',
5070
5464
  style: BTN_STYLE_CANCEL,
5071
- onClick: () => hide_overlay(gobj, priv_key),
5465
+ event: "EV_CANCEL_CONFIRM",
5466
+ kw: question,
5072
5467
  },
5073
5468
  {
5074
5469
  i18n: confirm_text,
5075
5470
  style: 'flex:1;padding:6px;background:' + confirm_color + ';color:#fff;border:none;' +
5076
5471
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
5077
- onClick: onConfirm,
5472
+ event: "EV_CONFIRMED",
5473
+ kw: question,
5078
5474
  },
5079
- ]);
5475
+ ], gobj);
5080
5476
 
5081
5477
  priv.$container.appendChild(popover);
5082
5478
  priv[priv_key] = popover;
5479
+ priv._confirm_questions[priv_key] = json_deep_copy(question);
5083
5480
  clamp_popover_position(gobj, popover);
5084
5481
  }
5085
5482
 
@@ -5191,13 +5588,17 @@ function paint_selection(gobj, ids)
5191
5588
  * Zoom-to-selection can only act on a selection.
5192
5589
  *
5193
5590
  * The attribute is toggled on the element instead of re-rendering
5194
- * the toolbar, which would rebuild its innerHTML -- and with it
5195
- * drop the disabled state of every OTHER button -- each time a
5196
- * card is ticked.
5591
+ * the toolbar, which would rebuild its whole innerHTML each time a
5592
+ * card is ticked. A rebuild asks the same question
5593
+ * (selection_is_painted) in the toolbar's getItems().
5197
5594
  ************************************************************/
5595
+ function selection_is_painted(gobj)
5596
+ {
5597
+ return (gobj.priv._selected_paint_ids || []).length > 0;
5598
+ }
5599
+
5198
5600
  function update_zoom_selection_button(gobj)
5199
5601
  {
5200
- let priv = gobj.priv;
5201
5602
  let $container = gobj_read_attr(gobj, "$container");
5202
5603
 
5203
5604
  if(!$container) {
@@ -5209,7 +5610,7 @@ function update_zoom_selection_button(gobj)
5209
5610
  return; /* no toolbar, or not in edition: nothing to enable */
5210
5611
  }
5211
5612
 
5212
- if((priv._selected_paint_ids || []).length > 0) {
5613
+ if(selection_is_painted(gobj)) {
5213
5614
  enableElements($container, ".EV_ZOOM_SELECTION");
5214
5615
  } else {
5215
5616
  disableElements($container, ".EV_ZOOM_SELECTION");
@@ -5219,12 +5620,13 @@ function update_zoom_selection_button(gobj)
5219
5620
  /************************************************************
5220
5621
  * The selection mode is a state, so it has to LOOK like one:
5221
5622
  * a PRESSED toggle, not a button wearing one of the palette's
5222
- * action colours (see `pressed_state` in lib_graph.css).
5623
+ * action colours (see `pressed_state` in lib_graph.css). And
5624
+ * it SAYS it, in `aria-pressed`, for whoever does not see it.
5223
5625
  *
5224
5626
  * Toggled on the element rather than re-rendered with the
5225
5627
  * toolbar, for the same reason as the button above: a
5226
- * re-render rebuilds the innerHTML and drops what every OTHER
5227
- * button was told.
5628
+ * re-render rebuilds the whole innerHTML. A rebuild draws the
5629
+ * same state, baked in the edit toolbar's getItems().
5228
5630
  ************************************************************/
5229
5631
  function update_selection_mode_button(gobj)
5230
5632
  {
@@ -5240,6 +5642,9 @@ function update_selection_mode_button(gobj)
5240
5642
  }
5241
5643
 
5242
5644
  set_pressed_state($container, ".EV_TOGGLE_SELECTION_MODE", priv.selection_mode);
5645
+ $container.querySelectorAll(".EV_TOGGLE_SELECTION_MODE").forEach(($el) => {
5646
+ $el.setAttribute("aria-pressed", priv.selection_mode? "true" : "false");
5647
+ });
5243
5648
  }
5244
5649
 
5245
5650
  /************************************************************
@@ -5934,21 +6339,22 @@ function show_link_icon_if_fkey(gobj)
5934
6339
  }
5935
6340
  let vp = graph.getViewportByCanvas([canvasPos.x, canvasPos.y]);
5936
6341
 
6342
+ /* A click (no drag) enters the linking mode; a press that drags
6343
+ * enters it on its own (start_link_drag, below). */
6344
+ let identity = {node_id: priv._selected_node_id, port_key: priv._selected_port_key};
5937
6345
  let icon = create_floating_icon(
5938
6346
  'link', '#52c41a',
5939
6347
  vp[0] + floating_icon_size() / 2 + 4, vp[1] + floating_icon_dy(),
5940
- t('link to hook'), () => {
5941
- // Click (no drag) toggles linking mode
5942
- if(!priv._linking_mode) {
5943
- enter_linking_mode(gobj);
5944
- }
5945
- }
6348
+ 'link to hook', gobj, "EV_ENTER_LINKING_MODE", identity
5946
6349
  );
5947
- // Add drag support: pointerdown starts drag-link
6350
+ /* The press may be a drag: EV_START_LINK_DRAG, with the port's
6351
+ * identity and where the pointer went down. */
5948
6352
  icon.addEventListener('pointerdown', (e) => {
5949
6353
  e.stopPropagation();
5950
6354
  e.preventDefault();
5951
- start_link_drag(gobj, e);
6355
+ gobj_send_event(gobj, "EV_START_LINK_DRAG", Object.assign(
6356
+ json_deep_copy(identity), {client_x: e.clientX, client_y: e.clientY}
6357
+ ), gobj);
5952
6358
  });
5953
6359
  priv.$container.appendChild(icon);
5954
6360
  priv._link_icon_el = icon;
@@ -6024,7 +6430,8 @@ function show_port_icon(gobj)
6024
6430
  }
6025
6431
  let icon = create_floating_icon(
6026
6432
  'gear', '#1890ff', xy.x, xy.y,
6027
- t('port properties'), () => toggle_port_popover(gobj)
6433
+ 'port properties', gobj, "EV_TOGGLE_PORT_POPOVER",
6434
+ {node_id: priv._selected_node_id, port_key: priv._selected_port_key}
6028
6435
  );
6029
6436
  priv.$container.appendChild(icon);
6030
6437
  priv._port_icon_el = icon;
@@ -6138,7 +6545,7 @@ function show_port_popover(gobj)
6138
6545
 
6139
6546
  create_form_label(popover, 'shape');
6140
6547
  let shapeSelect = create_form_select(popover, PORT_SHAPES.map((shape) => {
6141
- return {value: shape, label: t(shape)};
6548
+ return {value: shape, i18n: shape};
6142
6549
  }));
6143
6550
  shapeSelect.value = orig_shape;
6144
6551
  shapeSelect.addEventListener('change', preview_port);
@@ -6148,31 +6555,29 @@ function show_port_popover(gobj)
6148
6555
 
6149
6556
  create_form_label(popover, 'apply to');
6150
6557
  let scopeSelect = create_form_select(popover, [
6151
- {value: 'this', label: t('this port')},
6152
- {value: 'same_topic', label: t('same port in topic')},
6153
- {value: 'all', label: t('all ports')},
6558
+ {value: 'this', i18n: 'this port'},
6559
+ {value: 'same_topic', i18n: 'same port in topic'},
6560
+ {value: 'all', i18n: 'all ports'},
6154
6561
  ]);
6155
6562
 
6156
6563
  create_form_button_row(popover, [
6157
6564
  {
6158
6565
  i18n: 'cancel',
6159
6566
  style: BTN_STYLE_CANCEL,
6160
- onClick: () => {
6161
- hide_port_popover(gobj); /* which undoes the preview */
6162
- },
6567
+ event: "EV_CANCEL_PORT_POPOVER",
6568
+ kw: {node_id: node_id, port_key: port_key},
6163
6569
  },
6164
6570
  {
6165
6571
  i18n: 'apply',
6166
6572
  style: 'flex:1;padding:6px;background:#1890ff;color:#fff;border:none;' +
6167
6573
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
6168
- onClick: () => {
6169
- drop_preview_undo(gobj, '_port_preview_undo');
6170
- apply_port_properties(gobj, node_id, port_key,
6171
- shapeSelect.value, parseInt(rInput.value) || orig_r, scopeSelect.value
6172
- );
6173
- },
6574
+ event: "EV_APPLY_PORT_POPOVER",
6575
+ /* `r_before`: what an unreadable radius falls back to; the
6576
+ * preview has already written the field's on the port. */
6577
+ kw: {node_id: node_id, port_key: port_key, r_before: orig_r},
6578
+ form: {shape: shapeSelect, r: rInput, scope: scopeSelect},
6174
6579
  },
6175
- ]);
6580
+ ], gobj);
6176
6581
 
6177
6582
  priv.$container.appendChild(popover);
6178
6583
  priv._port_popover_el = popover;
@@ -6483,8 +6888,16 @@ function try_complete_link(gobj, clicked_node_id, clicked_port_key)
6483
6888
  /************************************************************
6484
6889
  * Drag-link: drag from link icon to a hook port.
6485
6890
  * Draws a temporary SVG line from the fkey port to the cursor.
6891
+ *
6892
+ * Run by ac_start_link_drag. The pointer's MOVES only draw the
6893
+ * line and the cursor -- the drag's own picture, no action, and
6894
+ * one event per move would bury the machine trace -- while the
6895
+ * END of the gesture is an event: EV_LINK_DRAG_END {client_x,
6896
+ * client_y, dragged} on the release, EV_LINK_DRAG_CANCEL when
6897
+ * the browser takes the pointer. Their actions take the drag
6898
+ * down (end_link_drag) and link, or leave the linking mode.
6486
6899
  ************************************************************/
6487
- function start_link_drag(gobj, e)
6900
+ function start_link_drag(gobj, client_x, client_y)
6488
6901
  {
6489
6902
  let priv = gobj.priv;
6490
6903
  let graph = priv.graph;
@@ -6511,8 +6924,8 @@ function start_link_drag(gobj, e)
6511
6924
  let line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
6512
6925
  line.setAttribute('x1', startX);
6513
6926
  line.setAttribute('y1', startY);
6514
- line.setAttribute('x2', e.clientX - containerRect.left);
6515
- line.setAttribute('y2', e.clientY - containerRect.top);
6927
+ line.setAttribute('x2', client_x - containerRect.left);
6928
+ line.setAttribute('y2', client_y - containerRect.top);
6516
6929
  line.setAttribute('stroke', '#52c41a');
6517
6930
  line.setAttribute('stroke-width', '2');
6518
6931
  line.setAttribute('stroke-dasharray', '6 3');
@@ -6536,60 +6949,61 @@ function start_link_drag(gobj, e)
6536
6949
  }
6537
6950
 
6538
6951
  function onPointerUp(ev) {
6539
- document.removeEventListener('pointermove', onPointerMove);
6540
- document.removeEventListener('pointerup', onPointerUp);
6541
- document.removeEventListener('pointercancel', onPointerCancel);
6542
- priv.$container.style.cursor = '';
6543
-
6544
- // Remove drag line
6545
- if(priv._link_drag_svg) {
6546
- priv._link_drag_svg.remove();
6547
- priv._link_drag_svg = null;
6548
- }
6549
-
6550
- if(!dragged) {
6551
- // It was a click, not a drag — let the click handler deal with it
6552
- return;
6553
- }
6554
-
6555
- // Check if dropped on a valid hook port
6556
- let cx = ev.clientX - containerRect.left;
6557
- let cy = ev.clientY - containerRect.top;
6558
- let canvasPt = graph.getCanvasByViewport([cx, cy]);
6559
- let target = find_hook_at_point(gobj, canvasPt[0], canvasPt[1]);
6560
-
6561
- if(target) {
6562
- try_complete_link(gobj, target.node_id, target.port_key);
6563
- } else {
6564
- exit_linking_mode(gobj);
6565
- }
6952
+ gobj_send_event(gobj, "EV_LINK_DRAG_END", {
6953
+ client_x: ev.clientX,
6954
+ client_y: ev.clientY,
6955
+ dragged: dragged,
6956
+ }, gobj);
6566
6957
  }
6567
6958
 
6568
- /* A cancelled pointer (the browser took the touch) is no drop: the
6569
- * listeners go, and what was there stays. Without it they stayed
6570
- * on `document` until some later pointerup, which then dropped. */
6959
+ /* A cancelled pointer (the browser took the touch) is no drop. */
6571
6960
  function onPointerCancel(ev) {
6961
+ gobj_send_event(gobj, "EV_LINK_DRAG_CANCEL", {}, gobj);
6962
+ }
6963
+
6964
+ function unwire() {
6572
6965
  document.removeEventListener('pointermove', onPointerMove);
6573
6966
  document.removeEventListener('pointerup', onPointerUp);
6574
6967
  document.removeEventListener('pointercancel', onPointerCancel);
6575
- priv.$container.style.cursor = '';
6576
- if(priv._link_drag_svg) {
6577
- priv._link_drag_svg.remove();
6578
- priv._link_drag_svg = null;
6579
- }
6580
- exit_linking_mode(gobj);
6581
6968
  }
6582
6969
 
6583
6970
  document.addEventListener('pointermove', onPointerMove);
6584
6971
  document.addEventListener('pointerup', onPointerUp);
6585
6972
  document.addEventListener('pointercancel', onPointerCancel);
6586
- /* A view destroyed in the middle of the drag takes them off too
6587
- * (mt_destroy): they are closures over this gobj, on `document`. */
6588
- gobj.priv._drag_unwire = function() {
6589
- document.removeEventListener('pointermove', onPointerMove);
6590
- document.removeEventListener('pointerup', onPointerUp);
6591
- document.removeEventListener('pointercancel', onPointerCancel);
6973
+
6974
+ /* Where the line is measured from, for the drop, and how to take
6975
+ * the listeners off. A view destroyed in the middle of the drag
6976
+ * takes them off too (mt_destroy, through `_drag_unwire`): they
6977
+ * are closures over this gobj, on `document`. */
6978
+ priv._link_drag = {
6979
+ left: containerRect.left,
6980
+ top: containerRect.top,
6981
+ unwire: unwire,
6592
6982
  };
6983
+ priv._drag_unwire = unwire;
6984
+ }
6985
+
6986
+ /************************************************************
6987
+ * Take a link drag down: its listeners, its line, its cursor.
6988
+ * What was linked, or not, is the caller's.
6989
+ ************************************************************/
6990
+ function end_link_drag(gobj)
6991
+ {
6992
+ let priv = gobj.priv;
6993
+ let drag = priv._link_drag;
6994
+
6995
+ priv._link_drag = null;
6996
+ if(drag) {
6997
+ drag.unwire();
6998
+ if(priv._drag_unwire === drag.unwire) {
6999
+ priv._drag_unwire = null;
7000
+ }
7001
+ }
7002
+ priv.$container.style.cursor = '';
7003
+ if(priv._link_drag_svg) {
7004
+ priv._link_drag_svg.remove();
7005
+ priv._link_drag_svg = null;
7006
+ }
6593
7007
  }
6594
7008
 
6595
7009
  /************************************************************
@@ -6969,9 +7383,9 @@ function show_edge_icon(gobj)
6969
7383
  }
6970
7384
 
6971
7385
  let icons = show_dual_icons(gobj, mid.x + 4, mid.y + floating_icon_dy(),
6972
- t('edge properties'), () => toggle_edge_popover(gobj),
6973
- t('unlink'),
6974
- () => gobj_send_event(gobj, "EV_REQUEST_UNLINK_EDGE", {}, gobj)
7386
+ 'edge properties', "EV_TOGGLE_EDGE_POPOVER",
7387
+ 'unlink', "EV_REQUEST_UNLINK_EDGE",
7388
+ {edge_id: priv._selected_edge_id}
6975
7389
  );
6976
7390
  priv._edge_icon_el = icons.icon_el;
6977
7391
  priv._edge_delete_el = icons.delete_el;
@@ -7100,33 +7514,27 @@ function show_edge_popover(gobj)
7100
7514
  // Apply-to scope
7101
7515
  create_form_label(popover, 'apply to');
7102
7516
  let scopeSelect = create_form_select(popover, [
7103
- { value: 'this', label: t('this edge') },
7104
- { value: 'same_type', label: t('same type edges') },
7105
- { value: 'all', label: t('all edges') },
7517
+ { value: 'this', i18n: 'this edge' },
7518
+ { value: 'same_type', i18n: 'same type edges' },
7519
+ { value: 'all', i18n: 'all edges' },
7106
7520
  ]);
7107
7521
 
7108
7522
  create_form_button_row(popover, [
7109
7523
  {
7110
7524
  i18n: 'cancel',
7111
7525
  style: BTN_STYLE_CANCEL,
7112
- onClick: () => {
7113
- deselect_edge(gobj); /* which undoes the preview */
7114
- },
7526
+ event: "EV_CANCEL_EDGE_POPOVER",
7527
+ kw: {edge_id: edge_id},
7115
7528
  },
7116
7529
  {
7117
7530
  i18n: 'apply',
7118
7531
  style: 'flex:1;padding:6px;background:#52c41a;color:#fff;border:none;' +
7119
7532
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
7120
- onClick: () => {
7121
- drop_preview_undo(gobj, '_edge_preview_undo');
7122
- apply_edge_properties(gobj, edge_id,
7123
- parseInt(lwInput.value) || 2,
7124
- colorInput.value,
7125
- scopeSelect.value
7126
- );
7127
- },
7533
+ event: "EV_APPLY_EDGE_POPOVER",
7534
+ kw: {edge_id: edge_id},
7535
+ form: {line_width: lwInput, color: colorInput, scope: scopeSelect},
7128
7536
  },
7129
- ]);
7537
+ ], gobj);
7130
7538
 
7131
7539
  priv.$container.appendChild(popover);
7132
7540
  priv._edge_popover_el = popover;
@@ -7197,9 +7605,9 @@ function show_node_icon(gobj)
7197
7605
  }
7198
7606
 
7199
7607
  let icons = show_dual_icons(gobj, rect.right + 4, rect.top + floating_icon_dy(),
7200
- t('node properties'), () => toggle_node_popover(gobj),
7201
- t('delete node'),
7202
- () => gobj_send_event(gobj, "EV_REQUEST_DELETE_NODE", {}, gobj)
7608
+ 'node properties', "EV_TOGGLE_NODE_POPOVER",
7609
+ 'delete node', "EV_REQUEST_DELETE_NODE",
7610
+ {node_id: priv._selected_node_id}
7203
7611
  );
7204
7612
  priv._node_icon_el = icons.icon_el;
7205
7613
  priv._node_delete_el = icons.delete_el;
@@ -7339,7 +7747,7 @@ function show_node_popover(gobj)
7339
7747
  * reshape either way. */
7340
7748
  create_form_label(popover, 'shape');
7341
7749
  let shapeSelect = create_form_select(popover, NODE_SHAPES.map((shape) => {
7342
- return {value: shape, label: t(shape)};
7750
+ return {value: shape, i18n: shape};
7343
7751
  }));
7344
7752
  shapeSelect.value = nodeData.data && nodeData.data.desc
7345
7753
  ? node_figure_of(gobj, nodeData.data.desc, nodeData.data.graph_props || {})
@@ -7349,38 +7757,32 @@ function show_node_popover(gobj)
7349
7757
  // Apply-to scope
7350
7758
  create_form_label(popover, 'apply to');
7351
7759
  let scopeSelect = create_form_select(popover, [
7352
- { value: 'this', label: t('this node') },
7353
- { value: 'same_topic', label: t('same topic nodes') },
7354
- { value: 'all', label: t('all nodes') },
7760
+ { value: 'this', i18n: 'this node' },
7761
+ { value: 'same_topic', i18n: 'same topic nodes' },
7762
+ { value: 'all', i18n: 'all nodes' },
7355
7763
  ]);
7356
7764
 
7357
7765
  create_form_button_row(popover, [
7358
7766
  {
7359
7767
  i18n: 'cancel',
7360
7768
  style: BTN_STYLE_CANCEL,
7361
- onClick: () => {
7362
- hide_node_popover(gobj); /* which undoes the preview */
7363
- },
7769
+ event: "EV_CANCEL_NODE_POPOVER",
7770
+ kw: {node_id: node_id},
7364
7771
  },
7365
7772
  {
7366
7773
  i18n: 'apply',
7367
7774
  style: 'flex:1;padding:6px;background:#1890ff;color:#fff;border:none;' +
7368
7775
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
7369
- onClick: () => {
7370
- drop_preview_undo(gobj, '_node_preview_undo');
7371
- apply_node_properties(gobj, node_id,
7372
- fillInput.value,
7373
- strokeInput.value,
7374
- parseInt(lwInput.value) || 1,
7375
- scopeSelect.value,
7376
- /* Only a figure somebody CHOSE is written: the
7377
- * one the select opened on is today's default,
7378
- * and saving it would freeze it on the node. */
7379
- (shapeSelect.value !== orig_figure)? shapeSelect.value : ""
7380
- );
7776
+ event: "EV_APPLY_NODE_POPOVER",
7777
+ /* `shape_before`: the figure the select opened on, which
7778
+ * the action does not write (see ac_apply_node_popover). */
7779
+ kw: {node_id: node_id, shape_before: orig_figure},
7780
+ form: {
7781
+ fill: fillInput, stroke: strokeInput, line_width: lwInput,
7782
+ scope: scopeSelect, shape: shapeSelect
7381
7783
  },
7382
7784
  },
7383
- ]);
7785
+ ], gobj);
7384
7786
 
7385
7787
  priv.$container.appendChild(popover);
7386
7788
  priv._node_popover_el = popover;
@@ -7408,6 +7810,49 @@ function hide_node_detail(gobj)
7408
7810
  hide_overlay(gobj, '_node_detail_el');
7409
7811
  }
7410
7812
 
7813
+ /************************************************************
7814
+ * The ✕ of the detail popover. Built as an element and not in
7815
+ * the popover's markup, so that it is a whole button: named
7816
+ * with its key (a language switch renames it), reachable by
7817
+ * the keyboard and pressed by Enter or Space as by the pointer.
7818
+ * Its click sends EV_CLOSE_NODE_DETAIL {node_id} and nothing
7819
+ * else; a pointer press does not take the focus.
7820
+ ************************************************************/
7821
+ function create_detail_close(gobj, color, node_id)
7822
+ {
7823
+ let el = document.createElement('div');
7824
+ el.className = 'g6-detail-close';
7825
+ el.setAttribute('role', 'button');
7826
+ el.setAttribute('tabindex', '0');
7827
+ el.setAttribute('title', t('close'));
7828
+ el.setAttribute('data-i18n-title', 'close');
7829
+ el.setAttribute('aria-label', t('close'));
7830
+ el.setAttribute('data-i18n-aria-label', 'close');
7831
+ el.style.color = color;
7832
+ el.textContent = '×';
7833
+ el.addEventListener('click', (e) => {
7834
+ e.stopPropagation();
7835
+ gobj_send_event(gobj, "EV_CLOSE_NODE_DETAIL", {node_id: node_id}, gobj);
7836
+ });
7837
+ keep_focus_on_press(el);
7838
+ click_on_enter_or_space(el);
7839
+ return el;
7840
+ }
7841
+
7842
+ /* A `div` of the detail popover: its class, and its text as a
7843
+ * text node. */
7844
+ function detail_div(class_name, text)
7845
+ {
7846
+ let el = document.createElement('div');
7847
+ if(class_name) {
7848
+ el.className = class_name;
7849
+ }
7850
+ if(text !== undefined) {
7851
+ el.textContent = text;
7852
+ }
7853
+ return el;
7854
+ }
7855
+
7411
7856
  function show_node_detail_popover(gobj, node_id)
7412
7857
  {
7413
7858
  let priv = gobj.priv;
@@ -7455,7 +7900,30 @@ function show_node_detail_popover(gobj, node_id)
7455
7900
  "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, " +
7456
7901
  "Helvetica, Arial, sans-serif";
7457
7902
 
7458
- let rows = '';
7903
+ /*
7904
+ * Built as elements, every value a TEXT node: an id, a topic, a
7905
+ * field and its value are data, never markup.
7906
+ *
7907
+ * The head is STICKY: the box scrolls, and with the head scrolling
7908
+ * away the ✕ left the screen -- on a phone, where the box is most
7909
+ * of the screen, that is a popover you cannot close without
7910
+ * scrolling back up for the button. Its background is the
7911
+ * popover's own, or the rows would show through it.
7912
+ */
7913
+ let head = detail_div('g6-detail-head');
7914
+ head.style.background = bg;
7915
+ let names = detail_div('');
7916
+ names.style.flex = '1 1 auto';
7917
+ names.style.minWidth = '0';
7918
+ names.appendChild(detail_div('g6-detail-id', id));
7919
+ let topic_el = detail_div('g6-detail-topic', topic);
7920
+ topic_el.style.color = sub;
7921
+ names.appendChild(topic_el);
7922
+ head.appendChild(names);
7923
+ head.appendChild(create_detail_close(gobj, sub, node_id));
7924
+ popover.appendChild(head);
7925
+
7926
+ let n_rows = 0;
7459
7927
  for(let key of Object.keys(record)) {
7460
7928
  if(key.indexOf('__') === 0) { // skip internal metadata
7461
7929
  continue;
@@ -7472,42 +7940,20 @@ function show_node_detail_popover(gobj, node_id)
7472
7940
  if(vs.length > 240) {
7473
7941
  vs = vs.slice(0, 240) + '…';
7474
7942
  }
7475
- rows +=
7476
- `<div class="g6-detail-row" style="border-top:1px solid ${rowbd};">` +
7477
- `<div class="g6-detail-key" style="color:${sub};">` +
7478
- `${escapeHtml(key)}</div>` +
7479
- `<div class="g6-detail-val">${escapeHtml(vs)}</div>` +
7480
- `</div>`;
7943
+ let row = detail_div('g6-detail-row');
7944
+ row.style.borderTop = `1px solid ${rowbd}`;
7945
+ let key_el = detail_div('g6-detail-key', key);
7946
+ key_el.style.color = sub;
7947
+ row.appendChild(key_el);
7948
+ row.appendChild(detail_div('g6-detail-val', vs));
7949
+ popover.appendChild(row);
7950
+ n_rows++;
7481
7951
  }
7482
-
7483
- /*
7484
- * The head is STICKY: the box scrolls, and with the head scrolling
7485
- * away the ✕ left the screen -- on a phone, where the box is most
7486
- * of the screen, that is a popover you cannot close without
7487
- * scrolling back up for the button. Its background is the
7488
- * popover's own, or the rows would show through it.
7489
- */
7490
- popover.innerHTML =
7491
- `<div class="g6-detail-head" style="background:${bg};">` +
7492
- `<div style="flex:1 1 auto;min-width:0;">` +
7493
- `<div class="g6-detail-id">${escapeHtml(id)}</div>` +
7494
- `<div class="g6-detail-topic" style="color:${sub};">` +
7495
- `${escapeHtml(topic)}</div>` +
7496
- `</div>` +
7497
- `<div class="g6-detail-close" role="button" tabindex="0"` +
7498
- ` aria-label="${escapeHtml(t('close'))}"` +
7499
- ` title="${escapeHtml(t('close'))}"` +
7500
- ` style="color:${sub};">×</div>` +
7501
- `</div>` +
7502
- (rows || `<div class="g6-detail-empty" style="color:${sub};">` +
7503
- `${escapeHtml(t('no fields'))}</div>`);
7504
-
7505
- let close_el = popover.querySelector('.g6-detail-close');
7506
- if(close_el) {
7507
- close_el.addEventListener('click', (e) => {
7508
- e.stopPropagation();
7509
- hide_node_detail(gobj);
7510
- });
7952
+ if(!n_rows) {
7953
+ let empty = detail_div('g6-detail-empty', t('no fields'));
7954
+ empty.setAttribute('data-i18n', 'no fields');
7955
+ empty.style.color = sub;
7956
+ popover.appendChild(empty);
7511
7957
  }
7512
7958
 
7513
7959
  priv.$container.appendChild(popover);
@@ -8327,10 +8773,10 @@ function request_delete_selection(gobj)
8327
8773
  show_confirm_popover(gobj, null,
8328
8774
  delete_question(nodes),
8329
8775
  'delete', '#ff4d4f',
8330
- () => gobj_send_event(gobj, "EV_CONFIRMED", {
8776
+ {
8331
8777
  what: "delete_selection",
8332
8778
  ids: nodes.map((nd) => String(nd.id))
8333
- }, gobj),
8779
+ },
8334
8780
  '_delete_confirm_el'
8335
8781
  );
8336
8782
  }
@@ -8367,10 +8813,10 @@ function show_delete_confirm(gobj, nodeData)
8367
8813
  show_confirm_popover(gobj, priv._node_delete_el,
8368
8814
  delete_question([nodeData]),
8369
8815
  'delete', '#ff4d4f',
8370
- () => gobj_send_event(gobj, "EV_CONFIRMED", {
8816
+ {
8371
8817
  what: "delete_node",
8372
8818
  id: String(nodeData.id)
8373
- }, gobj),
8819
+ },
8374
8820
  '_delete_confirm_el'
8375
8821
  );
8376
8822
  }
@@ -8452,6 +8898,7 @@ function show_create_popover(gobj)
8452
8898
  popover.appendChild(topicLabel);
8453
8899
 
8454
8900
  let topicSelect = document.createElement('select');
8901
+ topicSelect.className = 'G6_CREATE_TOPIC';
8455
8902
  topicSelect.setAttribute('title', t('topic'));
8456
8903
  topicSelect.setAttribute('data-i18n-title', 'topic');
8457
8904
  topicSelect.setAttribute('aria-label', t('topic'));
@@ -8493,10 +8940,15 @@ function show_create_popover(gobj)
8493
8940
  // Node ID input
8494
8941
  let idLabel = document.createElement('div');
8495
8942
  idLabel.style.cssText = 'margin-bottom:4px;font-weight:500;';
8496
- idLabel.textContent = 'Id:';
8943
+ let idWord = document.createElement('span');
8944
+ idWord.textContent = t('node id');
8945
+ idWord.setAttribute('data-i18n', 'node id');
8946
+ idLabel.appendChild(idWord);
8947
+ idLabel.appendChild(document.createTextNode(':'));
8497
8948
  popover.appendChild(idLabel);
8498
8949
 
8499
8950
  let idInput = document.createElement('input');
8951
+ idInput.className = 'G6_CREATE_ID';
8500
8952
  idInput.type = 'text';
8501
8953
  idInput.placeholder = t('node id');
8502
8954
  idInput.setAttribute('data-i18n-placeholder', 'node id');
@@ -8512,48 +8964,39 @@ function show_create_popover(gobj)
8512
8964
 
8513
8965
  // Error message area
8514
8966
  let errorEl = document.createElement('div');
8967
+ errorEl.className = 'G6_CREATE_ERROR';
8515
8968
  errorEl.style.cssText =
8516
8969
  'color:#ff4d4f;font-size:12px;margin-bottom:8px;display:none;';
8517
8970
  popover.appendChild(errorEl);
8518
8971
 
8519
- // Button row
8520
- let createBtn; // need reference for Enter key handler
8521
- create_form_button_row(popover, [
8972
+ /* Create sends what the form holds; an empty id is answered by
8973
+ * the action (ac_submit_new_node), in the popover. */
8974
+ let btnRow = create_form_button_row(popover, [
8522
8975
  {
8523
8976
  i18n: 'cancel',
8524
8977
  style: BTN_STYLE_CANCEL,
8525
- onClick: () => hide_create_popover(gobj),
8978
+ event: "EV_CLOSE_CREATE_POPOVER",
8979
+ kw: {},
8526
8980
  },
8527
8981
  {
8528
8982
  i18n: 'create',
8529
8983
  style: 'flex:1;padding:6px;background:#1890ff;color:#fff;border:none;' +
8530
8984
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
8531
- onClick: () => {
8532
- let node_id = idInput.value.trim();
8533
- if(!node_id) {
8534
- errorEl.textContent = t('node id required');
8535
- errorEl.setAttribute('data-i18n', 'node id required');
8536
- errorEl.style.display = 'block';
8537
- idInput.focus();
8538
- return;
8539
- }
8540
- gobj_send_event(gobj, "EV_SUBMIT_NEW_NODE", {
8541
- topic_name: String(topicSelect.value),
8542
- id: node_id
8543
- }, gobj);
8544
- },
8985
+ event: "EV_SUBMIT_NEW_NODE",
8986
+ kw: {},
8987
+ form: {topic_name: topicSelect, id: idInput},
8545
8988
  },
8546
- ]);
8547
- createBtn = popover.querySelector('button:last-child');
8989
+ ], gobj);
8990
+ let createBtn = btnRow.lastElementChild; // for the Enter key
8548
8991
 
8549
- // Enter key submits
8992
+ // Enter key submits, Escape closes: the two buttons' events
8550
8993
  idInput.addEventListener('keydown', (e) => {
8551
8994
  if(e.key === 'Enter') {
8552
8995
  e.preventDefault();
8553
8996
  createBtn.click();
8554
8997
  } else if(e.key === 'Escape') {
8555
8998
  e.preventDefault();
8556
- hide_create_popover(gobj);
8999
+ gobj_send_event(gobj, "EV_CLOSE_CREATE_POPOVER", {}, gobj);
8557
9000
  }
8558
9001
  });
8559
9002
 
@@ -8646,10 +9089,10 @@ function show_unlink_confirm(gobj, edgeData)
8646
9089
  t('unlink') + ' ' + d.child_id + ' → ' + d.parent_id + '?' +
8647
9090
  '\n' + t('neither record is deleted'),
8648
9091
  'unlink', '#ff4d4f',
8649
- () => gobj_send_event(gobj, "EV_CONFIRMED", {
9092
+ {
8650
9093
  what: "unlink_edge",
8651
9094
  id: String(edgeData.id)
8652
- }, gobj),
9095
+ },
8653
9096
  '_unlink_confirm_el'
8654
9097
  );
8655
9098
  }
@@ -10646,10 +11089,7 @@ function ac_save_graph(gobj, event, kw, src)
10646
11089
  let priv = gobj.priv;
10647
11090
 
10648
11091
  if(priv.edit_mode) {
10649
- let $container = gobj_read_attr(gobj, "$container");
10650
-
10651
- disableElements($container, ".EV_SAVE_GRAPH");
10652
- set_submit_state($container, ".EV_SAVE_GRAPH", false);
11092
+ light_save_button(gobj, false);
10653
11093
 
10654
11094
  let history = graph_get_plugin(gobj, "history");
10655
11095
  if(history) {
@@ -11111,17 +11551,27 @@ function ac_center_anchor(gobj, event, kw, src)
11111
11551
  }
11112
11552
 
11113
11553
  /************************************************************
11114
- * What the anchor button says it will do next.
11554
+ * What the anchor button says it will do next: its `title`,
11555
+ * and the `title_key` it is translated from.
11115
11556
  ************************************************************/
11116
11557
  function anchor_title(state)
11117
11558
  {
11118
11559
  if(state === "arming") {
11119
- return t('click the element to centre on');
11560
+ return {
11561
+ title: t('click the element to centre on'),
11562
+ title_key: 'click the element to centre on'
11563
+ };
11120
11564
  }
11121
11565
  if(state === "on") {
11122
- return t('centred: click to release');
11566
+ return {
11567
+ title: t('centred: click to release'),
11568
+ title_key: 'centred: click to release'
11569
+ };
11123
11570
  }
11124
- return t('anchor view');
11571
+ return {
11572
+ title: t('anchor view'),
11573
+ title_key: 'anchor view'
11574
+ };
11125
11575
  }
11126
11576
 
11127
11577
  /************************************************************
@@ -11652,11 +12102,15 @@ function ac_set_operation_mode(gobj, event, kw, src)
11652
12102
  {
11653
12103
  gobj_write_attr(gobj, "operation_mode", kw.operation_mode);
11654
12104
  configure_behaviour(gobj);
11655
- update_toolbar(gobj);
11656
12105
 
11657
12106
  /* `configure_behaviour` is what decides `edit_mode`, and the
11658
- * history plugin belongs to that decision. */
12107
+ * history plugin belongs to that decision. BEFORE the toolbars:
12108
+ * the edit toolbar entering edition reads its Save from what
12109
+ * update_history_buttons() says of the new history -- after
12110
+ * it, the toolbar was made with what the Save was told in the
12111
+ * LAST edition. */
11659
12112
  sync_history_plugin(gobj);
12113
+ update_toolbar(gobj);
11660
12114
  return 0;
11661
12115
  }
11662
12116
 
@@ -11941,31 +12395,181 @@ function ac_context_menu_item(gobj, event, kw, src)
11941
12395
  }
11942
12396
 
11943
12397
  /************************************************************
11944
- * The delete icon floating beside the selected node.
12398
+ * Is the identity a floating button was drawn with still what
12399
+ * is selected?
12400
+ *
12401
+ * The buttons are removed with their selection and drawn again
12402
+ * for the next one, so a click carrying another identity is a
12403
+ * broken invariant -- said loudly, never acted on: it would
12404
+ * delete, unlink or edit what the reader is not looking at.
12405
+ ************************************************************/
12406
+ function floating_button_is_current(gobj, event, kw)
12407
+ {
12408
+ let priv = gobj.priv;
12409
+ let want = {
12410
+ node_id: priv._selected_node_id || "",
12411
+ port_key: priv._selected_port_key || "",
12412
+ edge_id: priv._selected_edge_id || "",
12413
+ };
12414
+ let keys = Object.keys(kw || {});
12415
+ let matches = keys.length > 0 && keys.every((k) => {
12416
+ return Object.prototype.hasOwnProperty.call(want, k) &&
12417
+ !!kw[k] && kw[k] === want[k];
12418
+ });
12419
+ if(!matches) {
12420
+ log_error(`${gobj_short_name(gobj)}: ${event} for ${JSON.stringify(kw || {})}, ` +
12421
+ `but the selection is ${JSON.stringify(want)}`);
12422
+ }
12423
+ return matches;
12424
+ }
12425
+
12426
+ /************************************************************
12427
+ * {node_id} -- the delete icon floating beside the selected
12428
+ * node.
11945
12429
  ************************************************************/
11946
12430
  function ac_request_delete_node(gobj, event, kw, src)
11947
12431
  {
12432
+ if(!floating_button_is_current(gobj, event, kw)) {
12433
+ return -1; // Error already logged
12434
+ }
11948
12435
  request_delete_node(gobj);
11949
12436
  return 0;
11950
12437
  }
11951
12438
 
11952
12439
  /************************************************************
11953
- * The unlink icon floating beside the selected edge.
12440
+ * {edge_id} -- the unlink icon floating beside the selected
12441
+ * edge.
11954
12442
  ************************************************************/
11955
12443
  function ac_request_unlink_edge(gobj, event, kw, src)
11956
12444
  {
12445
+ if(!floating_button_is_current(gobj, event, kw)) {
12446
+ return -1; // Error already logged
12447
+ }
11957
12448
  request_unlink_edge(gobj);
11958
12449
  return 0;
11959
12450
  }
11960
12451
 
12452
+ /************************************************************
12453
+ * {node_id} -- the gear beside the selected node: its
12454
+ * properties popover, opened or closed.
12455
+ ************************************************************/
12456
+ function ac_toggle_node_popover(gobj, event, kw, src)
12457
+ {
12458
+ if(!floating_button_is_current(gobj, event, kw)) {
12459
+ return -1; // Error already logged
12460
+ }
12461
+ toggle_node_popover(gobj);
12462
+ return 0;
12463
+ }
12464
+
12465
+ /************************************************************
12466
+ * {edge_id} -- the gear beside the selected edge.
12467
+ ************************************************************/
12468
+ function ac_toggle_edge_popover(gobj, event, kw, src)
12469
+ {
12470
+ if(!floating_button_is_current(gobj, event, kw)) {
12471
+ return -1; // Error already logged
12472
+ }
12473
+ toggle_edge_popover(gobj);
12474
+ return 0;
12475
+ }
12476
+
12477
+ /************************************************************
12478
+ * {node_id, port_key} -- the gear beside the selected port.
12479
+ ************************************************************/
12480
+ function ac_toggle_port_popover(gobj, event, kw, src)
12481
+ {
12482
+ if(!floating_button_is_current(gobj, event, kw)) {
12483
+ return -1; // Error already logged
12484
+ }
12485
+ toggle_port_popover(gobj);
12486
+ return 0;
12487
+ }
12488
+
12489
+ /************************************************************
12490
+ * {node_id, port_key} -- the link icon of a selected fkey port,
12491
+ * CLICKED (a press that drags enters the mode on its own, from
12492
+ * start_link_drag): the compatible hooks light up, and the next
12493
+ * click on one of them links.
12494
+ ************************************************************/
12495
+ function ac_enter_linking_mode(gobj, event, kw, src)
12496
+ {
12497
+ let priv = gobj.priv;
12498
+
12499
+ if(!floating_button_is_current(gobj, event, kw)) {
12500
+ return -1; // Error already logged
12501
+ }
12502
+ if(!priv._linking_mode) {
12503
+ enter_linking_mode(gobj);
12504
+ }
12505
+ return 0;
12506
+ }
12507
+
12508
+ /************************************************************
12509
+ * {node_id} -- the ✕ of the detail popover.
12510
+ ************************************************************/
12511
+ function ac_close_node_detail(gobj, event, kw, src)
12512
+ {
12513
+ let priv = gobj.priv;
12514
+ let node_id = (kw && kw.node_id) || "";
12515
+
12516
+ if(!priv._node_detail_el || !node_id || node_id !== priv._detail_node_id) {
12517
+ log_error(`${gobj_short_name(gobj)}: ${event} for '${node_id}', ` +
12518
+ `but the detail popover shows '${priv._detail_node_id || ""}'`);
12519
+ return -1;
12520
+ }
12521
+ hide_node_detail(gobj);
12522
+ return 0;
12523
+ }
12524
+
12525
+ /************************************************************
12526
+ * {} -- the create button of the edit toolbar: the create
12527
+ * popover, opened or closed. Only in edition, where that
12528
+ * toolbar is.
12529
+ ************************************************************/
12530
+ function ac_toggle_create_popover(gobj, event, kw, src)
12531
+ {
12532
+ let priv = gobj.priv;
12533
+
12534
+ if(!priv.edit_mode) {
12535
+ log_error(`${gobj_short_name(gobj)}: ${event} outside edition`);
12536
+ return -1;
12537
+ }
12538
+ toggle_create_popover(gobj);
12539
+ return 0;
12540
+ }
12541
+
12542
+ /************************************************************
12543
+ * {} -- the cancel of the create popover, or Escape in its id.
12544
+ ************************************************************/
12545
+ function ac_close_create_popover(gobj, event, kw, src)
12546
+ {
12547
+ let priv = gobj.priv;
12548
+
12549
+ if(!priv._create_popover_el) {
12550
+ log_error(`${gobj_short_name(gobj)}: ${event} with no create popover open`);
12551
+ return -1;
12552
+ }
12553
+ hide_create_popover(gobj);
12554
+ return 0;
12555
+ }
12556
+
11961
12557
  /************************************************************
11962
12558
  * {topic_name, id} -- the create popover was filled in.
12559
+ *
12560
+ * An empty id with the popover open is the person's to fix,
12561
+ * not a fault: it is said IN the popover, where they typed.
11963
12562
  ************************************************************/
11964
12563
  function ac_submit_new_node(gobj, event, kw, src)
11965
12564
  {
11966
- let topic_name = (kw && kw.topic_name)? kw.topic_name : "";
11967
- let node_id = (kw && kw.id)? kw.id : "";
12565
+ let priv = gobj.priv;
12566
+ let topic_name = (kw && kw.topic_name)? String(kw.topic_name) : "";
12567
+ let node_id = (kw && kw.id)? String(kw.id).trim() : "";
11968
12568
 
12569
+ if(topic_name && !node_id && priv._create_popover_el) {
12570
+ say_node_id_required(gobj);
12571
+ return 0;
12572
+ }
11969
12573
  if(!topic_name || !node_id) {
11970
12574
  log_error(`${gobj_short_name(gobj)}: ${event} without topic or id`);
11971
12575
  return -1;
@@ -11974,6 +12578,248 @@ function ac_submit_new_node(gobj, event, kw, src)
11974
12578
  return 0;
11975
12579
  }
11976
12580
 
12581
+ /* "node id required", under the id of the create popover, and
12582
+ * the focus back on the id. */
12583
+ function say_node_id_required(gobj)
12584
+ {
12585
+ let $popover = gobj.priv._create_popover_el;
12586
+ let $error = $popover.querySelector('.G6_CREATE_ERROR');
12587
+ let $id = $popover.querySelector('.G6_CREATE_ID');
12588
+
12589
+ if(!$error || !$id) {
12590
+ log_error(`${gobj_short_name(gobj)}: the create popover has no id or no error line`);
12591
+ return;
12592
+ }
12593
+ $error.textContent = t('node id required');
12594
+ $error.setAttribute('data-i18n', 'node id required');
12595
+ $error.style.display = 'block';
12596
+ $id.focus();
12597
+ }
12598
+
12599
+ /************************************************************
12600
+ * Is `popover_key` open, and for the selection `kw` names?
12601
+ * `keys` are the identity in `kw` (the rest is what the form
12602
+ * held). A button of a popover that is not the one open is a
12603
+ * broken invariant -- said loudly, never acted on.
12604
+ ************************************************************/
12605
+ function popover_is_current(gobj, event, kw, popover_key, keys)
12606
+ {
12607
+ let priv = gobj.priv;
12608
+
12609
+ if(!priv[popover_key]) {
12610
+ log_error(`${gobj_short_name(gobj)}: ${event} for ${JSON.stringify(kw || {})}, ` +
12611
+ `but its popover is not open`);
12612
+ return false;
12613
+ }
12614
+ let identity = {};
12615
+ for(let k of keys) {
12616
+ identity[k] = kw? kw[k] : undefined;
12617
+ }
12618
+ return floating_button_is_current(gobj, event, identity);
12619
+ }
12620
+
12621
+ /************************************************************
12622
+ * {node_id, shape_before, fill, stroke, line_width, scope,
12623
+ * shape} -- apply of the node popover.
12624
+ ************************************************************/
12625
+ function ac_apply_node_popover(gobj, event, kw, src)
12626
+ {
12627
+ if(!popover_is_current(gobj, event, kw, '_node_popover_el', ['node_id'])) {
12628
+ return -1; // Error already logged
12629
+ }
12630
+ let shape = String(kw.shape || "");
12631
+ drop_preview_undo(gobj, '_node_preview_undo');
12632
+ apply_node_properties(gobj, kw.node_id,
12633
+ kw.fill,
12634
+ kw.stroke,
12635
+ parseInt(kw.line_width) || 1,
12636
+ kw.scope,
12637
+ /* Only a figure somebody CHOSE is written: the one the
12638
+ * select opened on is today's default, and saving it
12639
+ * would freeze it on the node. */
12640
+ (shape !== kw.shape_before)? shape : ""
12641
+ );
12642
+ return 0;
12643
+ }
12644
+
12645
+ /************************************************************
12646
+ * {node_id} -- cancel of the node popover, which undoes the
12647
+ * preview.
12648
+ ************************************************************/
12649
+ function ac_cancel_node_popover(gobj, event, kw, src)
12650
+ {
12651
+ if(!popover_is_current(gobj, event, kw, '_node_popover_el', ['node_id'])) {
12652
+ return -1; // Error already logged
12653
+ }
12654
+ hide_node_popover(gobj);
12655
+ return 0;
12656
+ }
12657
+
12658
+ /************************************************************
12659
+ * {edge_id, line_width, color, scope} -- apply of the edge
12660
+ * popover.
12661
+ ************************************************************/
12662
+ function ac_apply_edge_popover(gobj, event, kw, src)
12663
+ {
12664
+ if(!popover_is_current(gobj, event, kw, '_edge_popover_el', ['edge_id'])) {
12665
+ return -1; // Error already logged
12666
+ }
12667
+ drop_preview_undo(gobj, '_edge_preview_undo');
12668
+ apply_edge_properties(gobj, kw.edge_id,
12669
+ parseInt(kw.line_width) || 2,
12670
+ kw.color,
12671
+ kw.scope
12672
+ );
12673
+ return 0;
12674
+ }
12675
+
12676
+ /************************************************************
12677
+ * {edge_id} -- cancel of the edge popover: the edge is let go,
12678
+ * which undoes the preview.
12679
+ ************************************************************/
12680
+ function ac_cancel_edge_popover(gobj, event, kw, src)
12681
+ {
12682
+ if(!popover_is_current(gobj, event, kw, '_edge_popover_el', ['edge_id'])) {
12683
+ return -1; // Error already logged
12684
+ }
12685
+ deselect_edge(gobj);
12686
+ return 0;
12687
+ }
12688
+
12689
+ /************************************************************
12690
+ * {node_id, port_key, r_before, shape, r, scope} -- apply of
12691
+ * the port popover.
12692
+ ************************************************************/
12693
+ function ac_apply_port_popover(gobj, event, kw, src)
12694
+ {
12695
+ if(!popover_is_current(gobj, event, kw, '_port_popover_el', ['node_id', 'port_key'])) {
12696
+ return -1; // Error already logged
12697
+ }
12698
+ drop_preview_undo(gobj, '_port_preview_undo');
12699
+ apply_port_properties(gobj, kw.node_id, kw.port_key,
12700
+ kw.shape, parseInt(kw.r) || kw.r_before, kw.scope
12701
+ );
12702
+ return 0;
12703
+ }
12704
+
12705
+ /************************************************************
12706
+ * {node_id, port_key} -- cancel of the port popover, which
12707
+ * undoes the preview.
12708
+ ************************************************************/
12709
+ function ac_cancel_port_popover(gobj, event, kw, src)
12710
+ {
12711
+ if(!popover_is_current(gobj, event, kw, '_port_popover_el', ['node_id', 'port_key'])) {
12712
+ return -1; // Error already logged
12713
+ }
12714
+ hide_port_popover(gobj);
12715
+ return 0;
12716
+ }
12717
+
12718
+ /* Where the confirm popover of each question is kept. */
12719
+ const CONFIRM_POPOVER_OF = {
12720
+ delete_selection: '_delete_confirm_el',
12721
+ delete_node: '_delete_confirm_el',
12722
+ unlink_edge: '_unlink_confirm_el',
12723
+ };
12724
+
12725
+ function same_question(a, b)
12726
+ {
12727
+ return !!a && !!b && a.what === b.what &&
12728
+ JSON.stringify(a.id ?? null) === JSON.stringify(b.id ?? null) &&
12729
+ JSON.stringify(a.ids ?? null) === JSON.stringify(b.ids ?? null);
12730
+ }
12731
+
12732
+ /************************************************************
12733
+ * {what, id|ids} -- the person said no in a confirm popover:
12734
+ * the question it carries must be the one open.
12735
+ ************************************************************/
12736
+ function ac_cancel_confirm(gobj, event, kw, src)
12737
+ {
12738
+ let priv = gobj.priv;
12739
+ let key = CONFIRM_POPOVER_OF[(kw && kw.what) || ""];
12740
+ let asked = key? priv._confirm_questions[key] : null;
12741
+
12742
+ if(!key || !priv[key] || !same_question(asked, kw)) {
12743
+ log_error(`${gobj_short_name(gobj)}: ${event} for ${JSON.stringify(kw || {})}, ` +
12744
+ `but the question open is ${JSON.stringify((priv[key] && asked) || {})}`);
12745
+ return -1;
12746
+ }
12747
+ hide_overlay(gobj, key);
12748
+ return 0;
12749
+ }
12750
+
12751
+ /************************************************************
12752
+ * {node_id, port_key, client_x, client_y} -- a press on the
12753
+ * link icon of the selected fkey port, which may be a drag.
12754
+ ************************************************************/
12755
+ function ac_start_link_drag(gobj, event, kw, src)
12756
+ {
12757
+ let priv = gobj.priv;
12758
+
12759
+ if(!floating_button_is_current(gobj, event,
12760
+ {node_id: kw && kw.node_id, port_key: kw && kw.port_key})) {
12761
+ return -1; // Error already logged
12762
+ }
12763
+ if(priv._link_drag) {
12764
+ /* The release of the last one never came: it is dropped,
12765
+ * and this press starts over. */
12766
+ log_warning(`${gobj_short_name(gobj)}: ${event} with a link drag still in flight`);
12767
+ end_link_drag(gobj);
12768
+ }
12769
+ start_link_drag(gobj, Number(kw.client_x) || 0, Number(kw.client_y) || 0);
12770
+ return 0;
12771
+ }
12772
+
12773
+ /************************************************************
12774
+ * {client_x, client_y, dragged} -- the pointer of a link drag
12775
+ * went up. Not dragged, it was a click: the click comes next
12776
+ * (EV_ENTER_LINKING_MODE). Dragged, it links to the hook it was
12777
+ * dropped on, or leaves the linking mode.
12778
+ ************************************************************/
12779
+ function ac_link_drag_end(gobj, event, kw, src)
12780
+ {
12781
+ let priv = gobj.priv;
12782
+ let drag = priv._link_drag;
12783
+
12784
+ if(!drag) {
12785
+ log_error(`${gobj_short_name(gobj)}: ${event} with no link drag in flight`);
12786
+ return -1;
12787
+ }
12788
+ end_link_drag(gobj);
12789
+ if(!kw || !kw.dragged) {
12790
+ return 0;
12791
+ }
12792
+
12793
+ let cx = Number(kw.client_x) - drag.left;
12794
+ let cy = Number(kw.client_y) - drag.top;
12795
+ let canvasPt = priv.graph.getCanvasByViewport([cx, cy]);
12796
+ let target = find_hook_at_point(gobj, canvasPt[0], canvasPt[1]);
12797
+
12798
+ if(target) {
12799
+ try_complete_link(gobj, target.node_id, target.port_key);
12800
+ } else {
12801
+ exit_linking_mode(gobj);
12802
+ }
12803
+ return 0;
12804
+ }
12805
+
12806
+ /************************************************************
12807
+ * {} -- the browser took the pointer of a link drag: no drop,
12808
+ * and the linking mode goes.
12809
+ ************************************************************/
12810
+ function ac_link_drag_cancel(gobj, event, kw, src)
12811
+ {
12812
+ let priv = gobj.priv;
12813
+
12814
+ if(!priv._link_drag) {
12815
+ log_error(`${gobj_short_name(gobj)}: ${event} with no link drag in flight`);
12816
+ return -1;
12817
+ }
12818
+ end_link_drag(gobj);
12819
+ exit_linking_mode(gobj);
12820
+ return 0;
12821
+ }
12822
+
11977
12823
  /************************************************************
11978
12824
  * {what, id|ids} -- the person said yes in a confirm popover.
11979
12825
  *
@@ -12266,8 +13112,25 @@ function create_gclass(gclass_name)
12266
13112
  ["EV_CONTEXT_MENU_ITEM", ac_context_menu_item, null],
12267
13113
  ["EV_REQUEST_DELETE_NODE", ac_request_delete_node, null],
12268
13114
  ["EV_REQUEST_UNLINK_EDGE", ac_request_unlink_edge, null],
13115
+ ["EV_TOGGLE_NODE_POPOVER", ac_toggle_node_popover, null],
13116
+ ["EV_TOGGLE_EDGE_POPOVER", ac_toggle_edge_popover, null],
13117
+ ["EV_TOGGLE_PORT_POPOVER", ac_toggle_port_popover, null],
13118
+ ["EV_ENTER_LINKING_MODE", ac_enter_linking_mode, null],
13119
+ ["EV_CLOSE_NODE_DETAIL", ac_close_node_detail, null],
13120
+ ["EV_TOGGLE_CREATE_POPOVER", ac_toggle_create_popover, null],
13121
+ ["EV_CLOSE_CREATE_POPOVER", ac_close_create_popover, null],
12269
13122
  ["EV_SUBMIT_NEW_NODE", ac_submit_new_node, null],
13123
+ ["EV_APPLY_NODE_POPOVER", ac_apply_node_popover, null],
13124
+ ["EV_CANCEL_NODE_POPOVER", ac_cancel_node_popover, null],
13125
+ ["EV_APPLY_EDGE_POPOVER", ac_apply_edge_popover, null],
13126
+ ["EV_CANCEL_EDGE_POPOVER", ac_cancel_edge_popover, null],
13127
+ ["EV_APPLY_PORT_POPOVER", ac_apply_port_popover, null],
13128
+ ["EV_CANCEL_PORT_POPOVER", ac_cancel_port_popover, null],
12270
13129
  ["EV_CONFIRMED", ac_confirmed, null],
13130
+ ["EV_CANCEL_CONFIRM", ac_cancel_confirm, null],
13131
+ ["EV_START_LINK_DRAG", ac_start_link_drag, null],
13132
+ ["EV_LINK_DRAG_END", ac_link_drag_end, null],
13133
+ ["EV_LINK_DRAG_CANCEL", ac_link_drag_cancel, null],
12271
13134
  ["EV_CANVAS_CLICK", ac_canvas_click, null],
12272
13135
  ["EV_NODE_DRAG_END", ac_node_drag_end, null],
12273
13136
  ["EV_BRUSH_SELECT", ac_brush_select, null],
@@ -12336,8 +13199,25 @@ function create_gclass(gclass_name)
12336
13199
  ["EV_CONTEXT_MENU_ITEM", 0],
12337
13200
  ["EV_REQUEST_DELETE_NODE", 0],
12338
13201
  ["EV_REQUEST_UNLINK_EDGE", 0],
13202
+ ["EV_TOGGLE_NODE_POPOVER", 0],
13203
+ ["EV_TOGGLE_EDGE_POPOVER", 0],
13204
+ ["EV_TOGGLE_PORT_POPOVER", 0],
13205
+ ["EV_ENTER_LINKING_MODE", 0],
13206
+ ["EV_CLOSE_NODE_DETAIL", 0],
13207
+ ["EV_TOGGLE_CREATE_POPOVER", 0],
13208
+ ["EV_CLOSE_CREATE_POPOVER", 0],
12339
13209
  ["EV_SUBMIT_NEW_NODE", 0],
13210
+ ["EV_APPLY_NODE_POPOVER", 0],
13211
+ ["EV_CANCEL_NODE_POPOVER", 0],
13212
+ ["EV_APPLY_EDGE_POPOVER", 0],
13213
+ ["EV_CANCEL_EDGE_POPOVER", 0],
13214
+ ["EV_APPLY_PORT_POPOVER", 0],
13215
+ ["EV_CANCEL_PORT_POPOVER", 0],
12340
13216
  ["EV_CONFIRMED", 0],
13217
+ ["EV_CANCEL_CONFIRM", 0],
13218
+ ["EV_START_LINK_DRAG", 0],
13219
+ ["EV_LINK_DRAG_END", 0],
13220
+ ["EV_LINK_DRAG_CANCEL", 0],
12341
13221
  ["EV_CANVAS_CLICK", 0],
12342
13222
  ["EV_NODE_DRAG_END", 0],
12343
13223
  ["EV_BRUSH_SELECT", 0],
@@ -12461,4 +13341,7 @@ function register_c_g6_nodes_tree()
12461
13341
  export {
12462
13342
  register_c_g6_nodes_tree,
12463
13343
  toolbar_item_html, // exported for its test
13344
+ YuiToolbar, // exported for its test
13345
+ create_floating_icon, // exported for its test
13346
+ create_detail_close, // exported for its test
12464
13347
  };