@yuneta/gobj-ui 7.26.27 → 7.26.29

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 (45) hide show
  1. package/README.md +590 -25
  2. package/dist/gobj-ui.cjs.js +1080 -229
  3. package/dist/gobj-ui.es.js +1081 -230
  4. package/package.json +4 -2
  5. package/scripts/validate-locales.mjs +2656 -315
  6. package/src/c_g6_nodes_tree.js +1086 -253
  7. package/src/c_yui_form.js +16 -4
  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 +18 -1
  11. package/src/c_yui_json_graph.js +5 -0
  12. package/src/c_yui_nav.js +21 -16
  13. package/src/c_yui_pager.js +1 -1
  14. package/src/c_yui_period.js +10 -3
  15. package/src/c_yui_schema_editor.js +34 -18
  16. package/src/c_yui_shell.js +11 -10
  17. package/src/c_yui_treedb_graph.js +17 -9
  18. package/src/c_yui_treedb_schema.js +5 -0
  19. package/src/c_yui_treedb_topic_with_form.js +9 -9
  20. package/src/c_yui_treedb_topics.js +13 -13
  21. package/src/c_yui_window.js +2 -2
  22. package/src/c_yui_window_manager.js +5 -2
  23. package/src/c_yui_wizard.js +8 -1
  24. package/src/form_json_field.js +7 -0
  25. package/src/g6_canvas_tab_order.js +84 -0
  26. package/src/g6_canvas_tab_order.wiring.test.js +232 -0
  27. package/src/g6_nodes_tree_controls.test.js +547 -0
  28. package/src/g6_nodes_tree_focus.wiring.test.js +1360 -0
  29. package/src/g6_nodes_tree_i18n_keys.test.js +241 -5
  30. package/src/i18n_key_functions.js +235 -0
  31. package/src/i18n_key_functions.test.js +1525 -0
  32. package/src/lib_graph.js +38 -1
  33. package/src/nav_backbar_name.wiring.test.js +82 -0
  34. package/src/schema_flags.js +47 -0
  35. package/src/schema_import.js +7 -0
  36. package/src/shell_modals.js +20 -16
  37. package/src/shell_route_map.js +13 -3
  38. package/src/wizard_helpers.js +8 -0
  39. package/src/yui_dev.js +16 -3
  40. package/src/yui_dev_controls.test.js +174 -0
  41. package/src/yui_gclass_view.js +1 -1
  42. package/src/yui_graph_camera.js +1 -1
  43. package/src/yui_install.js +10 -8
  44. package/src/yui_table_select.js +2 -2
  45. package/src/yui_time.js +17 -2
@@ -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();
@@ -343,15 +531,19 @@ const NODE_SHAPES = ['square', 'circle', 'diamond', 'triangle', 'hexagon', 'star
343
531
 
344
532
  /* The keys of this view that reach t() as DATA or as an ARGUMENT -- the
345
533
  * shape tables, the labels of the property popovers (create_form_label),
346
- * the confirm button (show_confirm_popover): read by a consumer's
347
- * validate-locales, which asks the locales for every key of an exported
348
- * `*_I18N_KEYS` array of a module the app mounts. A shape added to
349
- * NODE_SHAPES or PORT_SHAPES, or a new label, goes here too (a test checks
350
- * the shapes). */
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). */
351
541
  export const G6_NODES_TREE_I18N_KEYS = [
352
542
  'square', 'circle', 'diamond', 'triangle', 'hexagon', 'star',
353
543
  'shape', 'radius', 'apply to', 'line width', 'color', 'fill color', 'stroke color',
354
- 'delete'
544
+ 'delete', 'unlink',
545
+ 'node properties', 'delete node', 'edge properties', 'link to hook', 'port properties',
546
+ 'some records were gone before the delete'
355
547
  ];
356
548
  const DEFAULT_NODE_SHAPE = 'circle';
357
549
 
@@ -519,6 +711,7 @@ let PRIVATE_DATA = {
519
711
  _saved_graph_properties: {}, // the same, as the BACKEND has it: what a Save compares against
520
712
  _graphs_writes_owed: {}, // topic_name → true: a __graphs__ write the host said did not land
521
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)
522
715
  yet_showed: false,
523
716
  edit_mode: false,
524
717
  operation_mode: null,
@@ -553,6 +746,7 @@ let PRIVATE_DATA = {
553
746
  _detail_node_id: null, // node id the detail popover is showing
554
747
  _delete_confirm_el: null, // delete confirmation popover
555
748
  _unlink_confirm_el: null, // unlink confirmation popover
749
+ _confirm_questions: {}, // priv key of a confirm popover → the {what, id|ids} it asks
556
750
  _create_popover_el: null, // create node popover element
557
751
  _context_node_id: null, // node id for context menu target
558
752
  _context_port_key: null, // port key for context menu target (null = node body)
@@ -561,6 +755,7 @@ let PRIVATE_DATA = {
561
755
  _link_source: null, // {node_id, port_key, col, topic_name} of fkey being linked
562
756
  _link_icon_el: null, // floating link icon on fkey port
563
757
  _link_drag_svg: null, // SVG overlay for drag line
758
+ _link_drag: null, // the link drag in flight: {left, top, unwire}
564
759
  _link_valid_hooks: [], // [{node_id, port_key}] compatible hook targets
565
760
  _link_saved_styles: [], // saved port styles to restore on cancel
566
761
  _focus_topic: null, // topic currently focused (EV_FOCUS_TOPIC)
@@ -1047,6 +1242,10 @@ function build_graph(gobj)
1047
1242
  */
1048
1243
  graph.setTheme(priv.theme);
1049
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
+
1050
1249
  /* A card is an HTML node, and G6's HTML nodes do not pass the wheel
1051
1250
  * on: over a card the graph neither scrolled nor zoomed. Only a wheel
1052
1251
  * over a CARD is handed back to the canvas -- the toolbars and the
@@ -1423,10 +1622,13 @@ function update_toolbar(gobj)
1423
1622
  });
1424
1623
  }
1425
1624
 
1426
- /* A re-render rebuilds the innerHTML, so a button whose enabled
1427
- * state is not part of getItems() has to be told again. */
1428
- update_zoom_selection_button(gobj);
1429
- 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. */
1430
1632
  }
1431
1633
 
1432
1634
  /************************************************************
@@ -1490,12 +1692,12 @@ function toolbar_fold_item(gobj)
1490
1692
  if(toolbars_collapsed(gobj)) {
1491
1693
  return {
1492
1694
  id: 'g6-icon-toolbar-show', value: 'toolbar-toggle',
1493
- className: 'EV_TOGGLE_TOOLBARS', title: t('show toolbar')
1695
+ className: 'EV_TOGGLE_TOOLBARS', title: t('show toolbar'), title_key: 'show toolbar'
1494
1696
  };
1495
1697
  }
1496
1698
  return {
1497
1699
  id: 'g6-icon-toolbar-hide', value: 'toolbar-toggle',
1498
- className: 'EV_TOGGLE_TOOLBARS', title: t('hide toolbar')
1700
+ className: 'EV_TOGGLE_TOOLBARS', title: t('hide toolbar'), title_key: 'hide toolbar'
1499
1701
  };
1500
1702
  }
1501
1703
 
@@ -1592,15 +1794,15 @@ function configure_toolbar(gobj)
1592
1794
  }
1593
1795
 
1594
1796
  let items = [
1595
- { id: 'g6-icon-zoom-in', value: 'zoom-in', className: 'EV_ZOOM_IN', title: t('zoom in') },
1596
- { 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' },
1597
1799
  /* What the two buttons above change. Every editor
1598
1800
  * that offers a zoom shows the number; without it
1599
1801
  * `1:1` is a jump to a value nobody was told. */
1600
1802
  { readout: true, className: 'G6_ZOOM_LEVEL',
1601
- text: zoom_percent_text(gobj), title: t('zoom level') },
1803
+ text: zoom_percent_text(gobj), title: t('zoom level'), title_key: 'zoom level' },
1602
1804
  { separator: true },
1603
- { 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' },
1604
1806
  /* `1:1`, not a house. The action is `zoomTo(1)`: it
1605
1807
  * sets the SCALE and leaves the camera where it was,
1606
1808
  * which is neither of the two things a house means
@@ -1610,7 +1812,7 @@ function configure_toolbar(gobj)
1610
1812
  * answered with the same corner of it at 100%.
1611
1813
  * Actual size is WRITTEN in every editor that offers
1612
1814
  * it, never drawn: there is no glyph for it. */
1613
- { 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' },
1614
1816
  /* Pick one node and every zoom leaves it in the
1615
1817
  * middle. The pressed classes are baked in HERE and
1616
1818
  * not set afterwards, for the same reason the
@@ -1625,21 +1827,25 @@ function configure_toolbar(gobj)
1625
1827
  className: 'EV_TOGGLE_ANCHOR' +
1626
1828
  (priv.anchor_state === "on"? ' pressed_state' : '') +
1627
1829
  (priv.anchor_state === "arming"? ' color_pending_state' : ''),
1628
- title: anchor_title(priv.anchor_state)
1830
+ pressed: priv.anchor_state === "on",
1831
+ ...anchor_title(priv.anchor_state)
1629
1832
  },
1630
1833
  ];
1631
1834
 
1632
1835
  /* Only in edition, because that is where a selection
1633
1836
  * can exist: a button that is permanently disabled is
1634
1837
  * not a control, it is furniture. Disabled while the
1635
- * selection is empty -- `paint_selection` is the one
1636
- * funnel every change goes through, so it is the one
1637
- * 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. */
1638
1843
  if(priv.edit_mode) {
1639
1844
  items.push({
1640
1845
  id: 'g6-icon-fit-selection', value: 'fit-selection',
1641
1846
  className: 'EV_ZOOM_SELECTION',
1642
- title: t('zoom to selection'), disabled: true
1847
+ title: t('zoom to selection'), title_key: 'zoom to selection',
1848
+ disabled: !selection_is_painted(gobj)
1643
1849
  });
1644
1850
  }
1645
1851
 
@@ -1647,13 +1853,16 @@ function configure_toolbar(gobj)
1647
1853
  /* Full screen is a WINDOW control, not a camera one:
1648
1854
  * it goes in its own group. */
1649
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. */
1650
1859
  if(priv.is_fullscreen) {
1651
1860
  items.push(
1652
- { 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' }
1653
1862
  );
1654
1863
  } else {
1655
1864
  items.push(
1656
- { 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' }
1657
1866
  );
1658
1867
  }
1659
1868
  }
@@ -1744,8 +1953,14 @@ function configure_toolbar_edit(gobj)
1744
1953
  position: 'left-top',
1745
1954
  style: toolbar_style(),
1746
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);
1747
1962
  return [
1748
- { 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' },
1749
1964
  /* The mode is carried in the class getItems()
1750
1965
  * returns, not only pushed onto the element after
1751
1966
  * the fact: this toolbar is rebuilt whole (a fold,
@@ -1757,17 +1972,30 @@ function configure_toolbar_edit(gobj)
1757
1972
  id: 'g6-icon-select-mode', value: 'selection-mode',
1758
1973
  className: 'EV_TOGGLE_SELECTION_MODE' +
1759
1974
  (priv.selection_mode? ' pressed_state' : ''),
1760
- 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
1761
1992
  },
1762
- { id: 'g6-icon-save', value: 'save', className: 'EV_SAVE_GRAPH', title: t('save'), disabled: true },
1763
- { id: 'g6-icon-undo', value: 'undo', className: 'EV_HISTORY_UNDO', title: t('undo'), disabled: true },
1764
- { id: 'g6-icon-redo', value: 'redo', className: 'EV_HISTORY_REDO', title: t('redo'), disabled: true },
1765
1993
  ];
1766
1994
  },
1767
1995
  onClick: (value) => {
1768
1996
  switch(value) {
1769
1997
  case 'create-node':
1770
- toggle_create_popover(gobj);
1998
+ gobj_send_event(gobj, "EV_TOGGLE_CREATE_POPOVER", {}, gobj);
1771
1999
  break;
1772
2000
  case 'selection-mode':
1773
2001
  gobj_send_event(gobj, "EV_TOGGLE_SELECTION_MODE", {}, gobj);
@@ -3434,8 +3662,7 @@ function update_history_buttons(gobj)
3434
3662
  set_active_state($container, ".EV_HISTORY_UNDO", true);
3435
3663
 
3436
3664
  // Pending changes exist, enable save
3437
- enableElements($container, ".EV_SAVE_GRAPH");
3438
- set_submit_state($container, ".EV_SAVE_GRAPH", true);
3665
+ light_save_button(gobj, true);
3439
3666
  } else {
3440
3667
  disableElements($container, ".EV_HISTORY_UNDO");
3441
3668
  set_active_state($container, ".EV_HISTORY_UNDO", false);
@@ -3443,13 +3670,7 @@ function update_history_buttons(gobj)
3443
3670
  /* No more undos: the graph is back to what was loaded
3444
3671
  * or saved -- unless a write of it did not land. That
3445
3672
  * one is still owed, and only a Save pays it. */
3446
- if(graphs_write_is_owed(gobj)) {
3447
- enableElements($container, ".EV_SAVE_GRAPH");
3448
- set_submit_state($container, ".EV_SAVE_GRAPH", true);
3449
- } else {
3450
- disableElements($container, ".EV_SAVE_GRAPH");
3451
- set_submit_state($container, ".EV_SAVE_GRAPH", false);
3452
- }
3673
+ light_save_button(gobj, graphs_write_is_owed(gobj));
3453
3674
  }
3454
3675
  } else {
3455
3676
  disableElements($container, ".EV_HISTORY_REDO");
@@ -3457,10 +3678,52 @@ function update_history_buttons(gobj)
3457
3678
 
3458
3679
  disableElements($container, ".EV_HISTORY_UNDO");
3459
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));
3460
3686
  }
3461
3687
  }
3462
3688
  }
3463
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
+
3464
3727
  /************************************************************
3465
3728
  * Graph utility functions
3466
3729
  *
@@ -4720,22 +4983,63 @@ function floating_popover_dx()
4720
4983
  return 4 + floating_icon_size() + 4;
4721
4984
  }
4722
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
+
4723
5008
  /************************************************************
4724
5009
  * Create a floating circular icon button.
4725
- * @param {string} svgKey - key into SVG_ICONS
4726
- * @param {string} color - border & text color (e.g. '#1890ff')
4727
- * @param {number} left - CSS left in px
4728
- * @param {number} top - CSS top in px
4729
- * @param {string} title - tooltip text
4730
- * @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
4731
5031
  * @returns {HTMLElement}
4732
5032
  ************************************************************/
4733
- function create_floating_icon(svgKey, color, left, top, title, onClick)
5033
+ function create_floating_icon(svgKey, color, left, top, title_key, gobj, event, kw)
4734
5034
  {
4735
5035
  const size = floating_icon_size();
4736
5036
  const el = document.createElement('div');
4737
- el.title = title;
4738
- 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);
4739
5043
  el.innerHTML = SVG_ICONS[svgKey] || '';
4740
5044
  el.style.cssText =
4741
5045
  'position:absolute;' +
@@ -4762,13 +5066,25 @@ function create_floating_icon(svgKey, color, left, top, title, onClick)
4762
5066
 
4763
5067
  el.addEventListener('click', (e) => {
4764
5068
  e.stopPropagation();
4765
- onClick();
5069
+ gobj_send_event(gobj, event, json_deep_copy(kw || {}), gobj);
4766
5070
  });
5071
+ keep_focus_on_press(el);
5072
+ click_on_enter_or_space(el);
4767
5073
  return el;
4768
5074
  }
4769
5075
 
4770
5076
  /************************************************************
4771
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
+ *
4772
5088
  * @param {object} gobj
4773
5089
  * @param {...string} keys - priv property names to remove
4774
5090
  ************************************************************/
@@ -4777,7 +5093,11 @@ function hide_overlay(gobj, ...keys)
4777
5093
  let priv = gobj.priv;
4778
5094
  for(let key of keys) {
4779
5095
  if(priv[key]) {
4780
- 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();
4781
5101
  priv[key] = null;
4782
5102
  }
4783
5103
  }
@@ -4835,9 +5155,7 @@ function graphs_write_is_owed(gobj)
4835
5155
 
4836
5156
  function mark_graph_dirty(gobj)
4837
5157
  {
4838
- let $container = gobj_read_attr(gobj, "$container");
4839
- enableElements($container, ".EV_SAVE_GRAPH");
4840
- set_submit_state($container, ".EV_SAVE_GRAPH", true);
5158
+ light_save_button(gobj, true);
4841
5159
  }
4842
5160
 
4843
5161
  /************************************************************
@@ -4854,19 +5172,21 @@ function extract_size(style)
4854
5172
 
4855
5173
  /************************************************************
4856
5174
  * Show a pair of floating icons (gear + trash) next to an
4857
- * 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}.
4858
5178
  ************************************************************/
4859
- 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)
4860
5180
  {
4861
5181
  let priv = gobj.priv;
4862
5182
 
4863
5183
  const icon_el = create_floating_icon(
4864
- 'gear', '#1890ff', x, y, gear_title, gear_click
5184
+ 'gear', '#1890ff', x, y, gear_key, gobj, gear_event, kw
4865
5185
  );
4866
5186
  priv.$container.appendChild(icon_el);
4867
5187
 
4868
5188
  const delete_el = create_floating_icon(
4869
- '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
4870
5190
  );
4871
5191
  priv.$container.appendChild(delete_el);
4872
5192
 
@@ -5052,11 +5372,23 @@ const BTN_STYLE_CANCEL =
5052
5372
  'border:1px solid var(--bulma-border-weak, #d9d9d9);' +
5053
5373
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;';
5054
5374
 
5055
- 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)
5056
5388
  {
5057
5389
  let btnRow = document.createElement('div');
5058
5390
  btnRow.style.cssText = 'display:flex;gap:8px;';
5059
- for(let {i18n, style, onClick} of buttons) {
5391
+ for(let {i18n, style, event, kw, form} of buttons) {
5060
5392
  /* `i18n` is the key (named so that validate-locales sees a literal
5061
5393
  * one): the button carries it, and its name */
5062
5394
  let btn = document.createElement('button');
@@ -5069,7 +5401,7 @@ function create_form_button_row(parent, buttons)
5069
5401
  btn.style.cssText = style + touch_control_css();
5070
5402
  btn.addEventListener('click', (e) => {
5071
5403
  e.stopPropagation();
5072
- onClick();
5404
+ gobj_send_event(gobj, event, form_kw(kw, form), gobj);
5073
5405
  });
5074
5406
  btnRow.appendChild(btn);
5075
5407
  }
@@ -5077,11 +5409,27 @@ function create_form_button_row(parent, buttons)
5077
5409
  return btnRow;
5078
5410
  }
5079
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
+
5080
5423
  /************************************************************
5081
5424
  * Show a confirmation popover next to a target element.
5082
- * 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`.
5083
5431
  ************************************************************/
5084
- 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)
5085
5433
  {
5086
5434
  hide_overlay(gobj, priv_key);
5087
5435
 
@@ -5114,18 +5462,21 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
5114
5462
  {
5115
5463
  i18n: 'cancel',
5116
5464
  style: BTN_STYLE_CANCEL,
5117
- onClick: () => hide_overlay(gobj, priv_key),
5465
+ event: "EV_CANCEL_CONFIRM",
5466
+ kw: question,
5118
5467
  },
5119
5468
  {
5120
5469
  i18n: confirm_text,
5121
5470
  style: 'flex:1;padding:6px;background:' + confirm_color + ';color:#fff;border:none;' +
5122
5471
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
5123
- onClick: onConfirm,
5472
+ event: "EV_CONFIRMED",
5473
+ kw: question,
5124
5474
  },
5125
- ]);
5475
+ ], gobj);
5126
5476
 
5127
5477
  priv.$container.appendChild(popover);
5128
5478
  priv[priv_key] = popover;
5479
+ priv._confirm_questions[priv_key] = json_deep_copy(question);
5129
5480
  clamp_popover_position(gobj, popover);
5130
5481
  }
5131
5482
 
@@ -5237,13 +5588,17 @@ function paint_selection(gobj, ids)
5237
5588
  * Zoom-to-selection can only act on a selection.
5238
5589
  *
5239
5590
  * The attribute is toggled on the element instead of re-rendering
5240
- * the toolbar, which would rebuild its innerHTML -- and with it
5241
- * drop the disabled state of every OTHER button -- each time a
5242
- * 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().
5243
5594
  ************************************************************/
5595
+ function selection_is_painted(gobj)
5596
+ {
5597
+ return (gobj.priv._selected_paint_ids || []).length > 0;
5598
+ }
5599
+
5244
5600
  function update_zoom_selection_button(gobj)
5245
5601
  {
5246
- let priv = gobj.priv;
5247
5602
  let $container = gobj_read_attr(gobj, "$container");
5248
5603
 
5249
5604
  if(!$container) {
@@ -5255,7 +5610,7 @@ function update_zoom_selection_button(gobj)
5255
5610
  return; /* no toolbar, or not in edition: nothing to enable */
5256
5611
  }
5257
5612
 
5258
- if((priv._selected_paint_ids || []).length > 0) {
5613
+ if(selection_is_painted(gobj)) {
5259
5614
  enableElements($container, ".EV_ZOOM_SELECTION");
5260
5615
  } else {
5261
5616
  disableElements($container, ".EV_ZOOM_SELECTION");
@@ -5265,12 +5620,13 @@ function update_zoom_selection_button(gobj)
5265
5620
  /************************************************************
5266
5621
  * The selection mode is a state, so it has to LOOK like one:
5267
5622
  * a PRESSED toggle, not a button wearing one of the palette's
5268
- * 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.
5269
5625
  *
5270
5626
  * Toggled on the element rather than re-rendered with the
5271
5627
  * toolbar, for the same reason as the button above: a
5272
- * re-render rebuilds the innerHTML and drops what every OTHER
5273
- * button was told.
5628
+ * re-render rebuilds the whole innerHTML. A rebuild draws the
5629
+ * same state, baked in the edit toolbar's getItems().
5274
5630
  ************************************************************/
5275
5631
  function update_selection_mode_button(gobj)
5276
5632
  {
@@ -5286,6 +5642,9 @@ function update_selection_mode_button(gobj)
5286
5642
  }
5287
5643
 
5288
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
+ });
5289
5648
  }
5290
5649
 
5291
5650
  /************************************************************
@@ -5980,21 +6339,22 @@ function show_link_icon_if_fkey(gobj)
5980
6339
  }
5981
6340
  let vp = graph.getViewportByCanvas([canvasPos.x, canvasPos.y]);
5982
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};
5983
6345
  let icon = create_floating_icon(
5984
6346
  'link', '#52c41a',
5985
6347
  vp[0] + floating_icon_size() / 2 + 4, vp[1] + floating_icon_dy(),
5986
- t('link to hook'), () => {
5987
- // Click (no drag) toggles linking mode
5988
- if(!priv._linking_mode) {
5989
- enter_linking_mode(gobj);
5990
- }
5991
- }
6348
+ 'link to hook', gobj, "EV_ENTER_LINKING_MODE", identity
5992
6349
  );
5993
- // 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. */
5994
6352
  icon.addEventListener('pointerdown', (e) => {
5995
6353
  e.stopPropagation();
5996
6354
  e.preventDefault();
5997
- 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);
5998
6358
  });
5999
6359
  priv.$container.appendChild(icon);
6000
6360
  priv._link_icon_el = icon;
@@ -6070,7 +6430,8 @@ function show_port_icon(gobj)
6070
6430
  }
6071
6431
  let icon = create_floating_icon(
6072
6432
  'gear', '#1890ff', xy.x, xy.y,
6073
- 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}
6074
6435
  );
6075
6436
  priv.$container.appendChild(icon);
6076
6437
  priv._port_icon_el = icon;
@@ -6203,22 +6564,20 @@ function show_port_popover(gobj)
6203
6564
  {
6204
6565
  i18n: 'cancel',
6205
6566
  style: BTN_STYLE_CANCEL,
6206
- onClick: () => {
6207
- hide_port_popover(gobj); /* which undoes the preview */
6208
- },
6567
+ event: "EV_CANCEL_PORT_POPOVER",
6568
+ kw: {node_id: node_id, port_key: port_key},
6209
6569
  },
6210
6570
  {
6211
6571
  i18n: 'apply',
6212
6572
  style: 'flex:1;padding:6px;background:#1890ff;color:#fff;border:none;' +
6213
6573
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
6214
- onClick: () => {
6215
- drop_preview_undo(gobj, '_port_preview_undo');
6216
- apply_port_properties(gobj, node_id, port_key,
6217
- shapeSelect.value, parseInt(rInput.value) || orig_r, scopeSelect.value
6218
- );
6219
- },
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},
6220
6579
  },
6221
- ]);
6580
+ ], gobj);
6222
6581
 
6223
6582
  priv.$container.appendChild(popover);
6224
6583
  priv._port_popover_el = popover;
@@ -6529,8 +6888,16 @@ function try_complete_link(gobj, clicked_node_id, clicked_port_key)
6529
6888
  /************************************************************
6530
6889
  * Drag-link: drag from link icon to a hook port.
6531
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.
6532
6899
  ************************************************************/
6533
- function start_link_drag(gobj, e)
6900
+ function start_link_drag(gobj, client_x, client_y)
6534
6901
  {
6535
6902
  let priv = gobj.priv;
6536
6903
  let graph = priv.graph;
@@ -6557,8 +6924,8 @@ function start_link_drag(gobj, e)
6557
6924
  let line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
6558
6925
  line.setAttribute('x1', startX);
6559
6926
  line.setAttribute('y1', startY);
6560
- line.setAttribute('x2', e.clientX - containerRect.left);
6561
- line.setAttribute('y2', e.clientY - containerRect.top);
6927
+ line.setAttribute('x2', client_x - containerRect.left);
6928
+ line.setAttribute('y2', client_y - containerRect.top);
6562
6929
  line.setAttribute('stroke', '#52c41a');
6563
6930
  line.setAttribute('stroke-width', '2');
6564
6931
  line.setAttribute('stroke-dasharray', '6 3');
@@ -6582,60 +6949,61 @@ function start_link_drag(gobj, e)
6582
6949
  }
6583
6950
 
6584
6951
  function onPointerUp(ev) {
6585
- document.removeEventListener('pointermove', onPointerMove);
6586
- document.removeEventListener('pointerup', onPointerUp);
6587
- document.removeEventListener('pointercancel', onPointerCancel);
6588
- priv.$container.style.cursor = '';
6589
-
6590
- // Remove drag line
6591
- if(priv._link_drag_svg) {
6592
- priv._link_drag_svg.remove();
6593
- priv._link_drag_svg = null;
6594
- }
6595
-
6596
- if(!dragged) {
6597
- // It was a click, not a drag — let the click handler deal with it
6598
- return;
6599
- }
6600
-
6601
- // Check if dropped on a valid hook port
6602
- let cx = ev.clientX - containerRect.left;
6603
- let cy = ev.clientY - containerRect.top;
6604
- let canvasPt = graph.getCanvasByViewport([cx, cy]);
6605
- let target = find_hook_at_point(gobj, canvasPt[0], canvasPt[1]);
6606
-
6607
- if(target) {
6608
- try_complete_link(gobj, target.node_id, target.port_key);
6609
- } else {
6610
- exit_linking_mode(gobj);
6611
- }
6952
+ gobj_send_event(gobj, "EV_LINK_DRAG_END", {
6953
+ client_x: ev.clientX,
6954
+ client_y: ev.clientY,
6955
+ dragged: dragged,
6956
+ }, gobj);
6612
6957
  }
6613
6958
 
6614
- /* A cancelled pointer (the browser took the touch) is no drop: the
6615
- * listeners go, and what was there stays. Without it they stayed
6616
- * on `document` until some later pointerup, which then dropped. */
6959
+ /* A cancelled pointer (the browser took the touch) is no drop. */
6617
6960
  function onPointerCancel(ev) {
6961
+ gobj_send_event(gobj, "EV_LINK_DRAG_CANCEL", {}, gobj);
6962
+ }
6963
+
6964
+ function unwire() {
6618
6965
  document.removeEventListener('pointermove', onPointerMove);
6619
6966
  document.removeEventListener('pointerup', onPointerUp);
6620
6967
  document.removeEventListener('pointercancel', onPointerCancel);
6621
- priv.$container.style.cursor = '';
6622
- if(priv._link_drag_svg) {
6623
- priv._link_drag_svg.remove();
6624
- priv._link_drag_svg = null;
6625
- }
6626
- exit_linking_mode(gobj);
6627
6968
  }
6628
6969
 
6629
6970
  document.addEventListener('pointermove', onPointerMove);
6630
6971
  document.addEventListener('pointerup', onPointerUp);
6631
6972
  document.addEventListener('pointercancel', onPointerCancel);
6632
- /* A view destroyed in the middle of the drag takes them off too
6633
- * (mt_destroy): they are closures over this gobj, on `document`. */
6634
- gobj.priv._drag_unwire = function() {
6635
- document.removeEventListener('pointermove', onPointerMove);
6636
- document.removeEventListener('pointerup', onPointerUp);
6637
- 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,
6638
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
+ }
6639
7007
  }
6640
7008
 
6641
7009
  /************************************************************
@@ -7015,9 +7383,9 @@ function show_edge_icon(gobj)
7015
7383
  }
7016
7384
 
7017
7385
  let icons = show_dual_icons(gobj, mid.x + 4, mid.y + floating_icon_dy(),
7018
- t('edge properties'), () => toggle_edge_popover(gobj),
7019
- t('unlink'),
7020
- () => 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}
7021
7389
  );
7022
7390
  priv._edge_icon_el = icons.icon_el;
7023
7391
  priv._edge_delete_el = icons.delete_el;
@@ -7155,24 +7523,18 @@ function show_edge_popover(gobj)
7155
7523
  {
7156
7524
  i18n: 'cancel',
7157
7525
  style: BTN_STYLE_CANCEL,
7158
- onClick: () => {
7159
- deselect_edge(gobj); /* which undoes the preview */
7160
- },
7526
+ event: "EV_CANCEL_EDGE_POPOVER",
7527
+ kw: {edge_id: edge_id},
7161
7528
  },
7162
7529
  {
7163
7530
  i18n: 'apply',
7164
7531
  style: 'flex:1;padding:6px;background:#52c41a;color:#fff;border:none;' +
7165
7532
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
7166
- onClick: () => {
7167
- drop_preview_undo(gobj, '_edge_preview_undo');
7168
- apply_edge_properties(gobj, edge_id,
7169
- parseInt(lwInput.value) || 2,
7170
- colorInput.value,
7171
- scopeSelect.value
7172
- );
7173
- },
7533
+ event: "EV_APPLY_EDGE_POPOVER",
7534
+ kw: {edge_id: edge_id},
7535
+ form: {line_width: lwInput, color: colorInput, scope: scopeSelect},
7174
7536
  },
7175
- ]);
7537
+ ], gobj);
7176
7538
 
7177
7539
  priv.$container.appendChild(popover);
7178
7540
  priv._edge_popover_el = popover;
@@ -7243,9 +7605,9 @@ function show_node_icon(gobj)
7243
7605
  }
7244
7606
 
7245
7607
  let icons = show_dual_icons(gobj, rect.right + 4, rect.top + floating_icon_dy(),
7246
- t('node properties'), () => toggle_node_popover(gobj),
7247
- t('delete node'),
7248
- () => 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}
7249
7611
  );
7250
7612
  priv._node_icon_el = icons.icon_el;
7251
7613
  priv._node_delete_el = icons.delete_el;
@@ -7404,29 +7766,23 @@ function show_node_popover(gobj)
7404
7766
  {
7405
7767
  i18n: 'cancel',
7406
7768
  style: BTN_STYLE_CANCEL,
7407
- onClick: () => {
7408
- hide_node_popover(gobj); /* which undoes the preview */
7409
- },
7769
+ event: "EV_CANCEL_NODE_POPOVER",
7770
+ kw: {node_id: node_id},
7410
7771
  },
7411
7772
  {
7412
7773
  i18n: 'apply',
7413
7774
  style: 'flex:1;padding:6px;background:#1890ff;color:#fff;border:none;' +
7414
7775
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
7415
- onClick: () => {
7416
- drop_preview_undo(gobj, '_node_preview_undo');
7417
- apply_node_properties(gobj, node_id,
7418
- fillInput.value,
7419
- strokeInput.value,
7420
- parseInt(lwInput.value) || 1,
7421
- scopeSelect.value,
7422
- /* Only a figure somebody CHOSE is written: the
7423
- * one the select opened on is today's default,
7424
- * and saving it would freeze it on the node. */
7425
- (shapeSelect.value !== orig_figure)? shapeSelect.value : ""
7426
- );
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
7427
7783
  },
7428
7784
  },
7429
- ]);
7785
+ ], gobj);
7430
7786
 
7431
7787
  priv.$container.appendChild(popover);
7432
7788
  priv._node_popover_el = popover;
@@ -7454,6 +7810,49 @@ function hide_node_detail(gobj)
7454
7810
  hide_overlay(gobj, '_node_detail_el');
7455
7811
  }
7456
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
+
7457
7856
  function show_node_detail_popover(gobj, node_id)
7458
7857
  {
7459
7858
  let priv = gobj.priv;
@@ -7501,7 +7900,30 @@ function show_node_detail_popover(gobj, node_id)
7501
7900
  "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, " +
7502
7901
  "Helvetica, Arial, sans-serif";
7503
7902
 
7504
- 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;
7505
7927
  for(let key of Object.keys(record)) {
7506
7928
  if(key.indexOf('__') === 0) { // skip internal metadata
7507
7929
  continue;
@@ -7518,42 +7940,20 @@ function show_node_detail_popover(gobj, node_id)
7518
7940
  if(vs.length > 240) {
7519
7941
  vs = vs.slice(0, 240) + '…';
7520
7942
  }
7521
- rows +=
7522
- `<div class="g6-detail-row" style="border-top:1px solid ${rowbd};">` +
7523
- `<div class="g6-detail-key" style="color:${sub};">` +
7524
- `${escapeHtml(key)}</div>` +
7525
- `<div class="g6-detail-val">${escapeHtml(vs)}</div>` +
7526
- `</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++;
7527
7951
  }
7528
-
7529
- /*
7530
- * The head is STICKY: the box scrolls, and with the head scrolling
7531
- * away the ✕ left the screen -- on a phone, where the box is most
7532
- * of the screen, that is a popover you cannot close without
7533
- * scrolling back up for the button. Its background is the
7534
- * popover's own, or the rows would show through it.
7535
- */
7536
- popover.innerHTML =
7537
- `<div class="g6-detail-head" style="background:${bg};">` +
7538
- `<div style="flex:1 1 auto;min-width:0;">` +
7539
- `<div class="g6-detail-id">${escapeHtml(id)}</div>` +
7540
- `<div class="g6-detail-topic" style="color:${sub};">` +
7541
- `${escapeHtml(topic)}</div>` +
7542
- `</div>` +
7543
- `<div class="g6-detail-close" role="button" tabindex="0"` +
7544
- ` aria-label="${escapeHtml(t('close'))}"` +
7545
- ` title="${escapeHtml(t('close'))}"` +
7546
- ` style="color:${sub};">×</div>` +
7547
- `</div>` +
7548
- (rows || `<div class="g6-detail-empty" style="color:${sub};">` +
7549
- `${escapeHtml(t('no fields'))}</div>`);
7550
-
7551
- let close_el = popover.querySelector('.g6-detail-close');
7552
- if(close_el) {
7553
- close_el.addEventListener('click', (e) => {
7554
- e.stopPropagation();
7555
- hide_node_detail(gobj);
7556
- });
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);
7557
7957
  }
7558
7958
 
7559
7959
  priv.$container.appendChild(popover);
@@ -8373,10 +8773,10 @@ function request_delete_selection(gobj)
8373
8773
  show_confirm_popover(gobj, null,
8374
8774
  delete_question(nodes),
8375
8775
  'delete', '#ff4d4f',
8376
- () => gobj_send_event(gobj, "EV_CONFIRMED", {
8776
+ {
8377
8777
  what: "delete_selection",
8378
8778
  ids: nodes.map((nd) => String(nd.id))
8379
- }, gobj),
8779
+ },
8380
8780
  '_delete_confirm_el'
8381
8781
  );
8382
8782
  }
@@ -8413,10 +8813,10 @@ function show_delete_confirm(gobj, nodeData)
8413
8813
  show_confirm_popover(gobj, priv._node_delete_el,
8414
8814
  delete_question([nodeData]),
8415
8815
  'delete', '#ff4d4f',
8416
- () => gobj_send_event(gobj, "EV_CONFIRMED", {
8816
+ {
8417
8817
  what: "delete_node",
8418
8818
  id: String(nodeData.id)
8419
- }, gobj),
8819
+ },
8420
8820
  '_delete_confirm_el'
8421
8821
  );
8422
8822
  }
@@ -8498,6 +8898,7 @@ function show_create_popover(gobj)
8498
8898
  popover.appendChild(topicLabel);
8499
8899
 
8500
8900
  let topicSelect = document.createElement('select');
8901
+ topicSelect.className = 'G6_CREATE_TOPIC';
8501
8902
  topicSelect.setAttribute('title', t('topic'));
8502
8903
  topicSelect.setAttribute('data-i18n-title', 'topic');
8503
8904
  topicSelect.setAttribute('aria-label', t('topic'));
@@ -8547,6 +8948,7 @@ function show_create_popover(gobj)
8547
8948
  popover.appendChild(idLabel);
8548
8949
 
8549
8950
  let idInput = document.createElement('input');
8951
+ idInput.className = 'G6_CREATE_ID';
8550
8952
  idInput.type = 'text';
8551
8953
  idInput.placeholder = t('node id');
8552
8954
  idInput.setAttribute('data-i18n-placeholder', 'node id');
@@ -8562,48 +8964,39 @@ function show_create_popover(gobj)
8562
8964
 
8563
8965
  // Error message area
8564
8966
  let errorEl = document.createElement('div');
8967
+ errorEl.className = 'G6_CREATE_ERROR';
8565
8968
  errorEl.style.cssText =
8566
8969
  'color:#ff4d4f;font-size:12px;margin-bottom:8px;display:none;';
8567
8970
  popover.appendChild(errorEl);
8568
8971
 
8569
- // Button row
8570
- let createBtn; // need reference for Enter key handler
8571
- 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, [
8572
8975
  {
8573
8976
  i18n: 'cancel',
8574
8977
  style: BTN_STYLE_CANCEL,
8575
- onClick: () => hide_create_popover(gobj),
8978
+ event: "EV_CLOSE_CREATE_POPOVER",
8979
+ kw: {},
8576
8980
  },
8577
8981
  {
8578
8982
  i18n: 'create',
8579
8983
  style: 'flex:1;padding:6px;background:#1890ff;color:#fff;border:none;' +
8580
8984
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
8581
- onClick: () => {
8582
- let node_id = idInput.value.trim();
8583
- if(!node_id) {
8584
- errorEl.textContent = t('node id required');
8585
- errorEl.setAttribute('data-i18n', 'node id required');
8586
- errorEl.style.display = 'block';
8587
- idInput.focus();
8588
- return;
8589
- }
8590
- gobj_send_event(gobj, "EV_SUBMIT_NEW_NODE", {
8591
- topic_name: String(topicSelect.value),
8592
- id: node_id
8593
- }, gobj);
8594
- },
8985
+ event: "EV_SUBMIT_NEW_NODE",
8986
+ kw: {},
8987
+ form: {topic_name: topicSelect, id: idInput},
8595
8988
  },
8596
- ]);
8597
- createBtn = popover.querySelector('button:last-child');
8989
+ ], gobj);
8990
+ let createBtn = btnRow.lastElementChild; // for the Enter key
8598
8991
 
8599
- // Enter key submits
8992
+ // Enter key submits, Escape closes: the two buttons' events
8600
8993
  idInput.addEventListener('keydown', (e) => {
8601
8994
  if(e.key === 'Enter') {
8602
8995
  e.preventDefault();
8603
8996
  createBtn.click();
8604
8997
  } else if(e.key === 'Escape') {
8605
8998
  e.preventDefault();
8606
- hide_create_popover(gobj);
8999
+ gobj_send_event(gobj, "EV_CLOSE_CREATE_POPOVER", {}, gobj);
8607
9000
  }
8608
9001
  });
8609
9002
 
@@ -8696,10 +9089,10 @@ function show_unlink_confirm(gobj, edgeData)
8696
9089
  t('unlink') + ' ' + d.child_id + ' → ' + d.parent_id + '?' +
8697
9090
  '\n' + t('neither record is deleted'),
8698
9091
  'unlink', '#ff4d4f',
8699
- () => gobj_send_event(gobj, "EV_CONFIRMED", {
9092
+ {
8700
9093
  what: "unlink_edge",
8701
9094
  id: String(edgeData.id)
8702
- }, gobj),
9095
+ },
8703
9096
  '_unlink_confirm_el'
8704
9097
  );
8705
9098
  }
@@ -10696,10 +11089,7 @@ function ac_save_graph(gobj, event, kw, src)
10696
11089
  let priv = gobj.priv;
10697
11090
 
10698
11091
  if(priv.edit_mode) {
10699
- let $container = gobj_read_attr(gobj, "$container");
10700
-
10701
- disableElements($container, ".EV_SAVE_GRAPH");
10702
- set_submit_state($container, ".EV_SAVE_GRAPH", false);
11092
+ light_save_button(gobj, false);
10703
11093
 
10704
11094
  let history = graph_get_plugin(gobj, "history");
10705
11095
  if(history) {
@@ -11161,17 +11551,27 @@ function ac_center_anchor(gobj, event, kw, src)
11161
11551
  }
11162
11552
 
11163
11553
  /************************************************************
11164
- * 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.
11165
11556
  ************************************************************/
11166
11557
  function anchor_title(state)
11167
11558
  {
11168
11559
  if(state === "arming") {
11169
- 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
+ };
11170
11564
  }
11171
11565
  if(state === "on") {
11172
- return t('centred: click to release');
11566
+ return {
11567
+ title: t('centred: click to release'),
11568
+ title_key: 'centred: click to release'
11569
+ };
11173
11570
  }
11174
- return t('anchor view');
11571
+ return {
11572
+ title: t('anchor view'),
11573
+ title_key: 'anchor view'
11574
+ };
11175
11575
  }
11176
11576
 
11177
11577
  /************************************************************
@@ -11702,11 +12102,15 @@ function ac_set_operation_mode(gobj, event, kw, src)
11702
12102
  {
11703
12103
  gobj_write_attr(gobj, "operation_mode", kw.operation_mode);
11704
12104
  configure_behaviour(gobj);
11705
- update_toolbar(gobj);
11706
12105
 
11707
12106
  /* `configure_behaviour` is what decides `edit_mode`, and the
11708
- * 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. */
11709
12112
  sync_history_plugin(gobj);
12113
+ update_toolbar(gobj);
11710
12114
  return 0;
11711
12115
  }
11712
12116
 
@@ -11991,31 +12395,181 @@ function ac_context_menu_item(gobj, event, kw, src)
11991
12395
  }
11992
12396
 
11993
12397
  /************************************************************
11994
- * 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.
11995
12429
  ************************************************************/
11996
12430
  function ac_request_delete_node(gobj, event, kw, src)
11997
12431
  {
12432
+ if(!floating_button_is_current(gobj, event, kw)) {
12433
+ return -1; // Error already logged
12434
+ }
11998
12435
  request_delete_node(gobj);
11999
12436
  return 0;
12000
12437
  }
12001
12438
 
12002
12439
  /************************************************************
12003
- * The unlink icon floating beside the selected edge.
12440
+ * {edge_id} -- the unlink icon floating beside the selected
12441
+ * edge.
12004
12442
  ************************************************************/
12005
12443
  function ac_request_unlink_edge(gobj, event, kw, src)
12006
12444
  {
12445
+ if(!floating_button_is_current(gobj, event, kw)) {
12446
+ return -1; // Error already logged
12447
+ }
12007
12448
  request_unlink_edge(gobj);
12008
12449
  return 0;
12009
12450
  }
12010
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
+
12011
12557
  /************************************************************
12012
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.
12013
12562
  ************************************************************/
12014
12563
  function ac_submit_new_node(gobj, event, kw, src)
12015
12564
  {
12016
- let topic_name = (kw && kw.topic_name)? kw.topic_name : "";
12017
- 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() : "";
12018
12568
 
12569
+ if(topic_name && !node_id && priv._create_popover_el) {
12570
+ say_node_id_required(gobj);
12571
+ return 0;
12572
+ }
12019
12573
  if(!topic_name || !node_id) {
12020
12574
  log_error(`${gobj_short_name(gobj)}: ${event} without topic or id`);
12021
12575
  return -1;
@@ -12024,6 +12578,248 @@ function ac_submit_new_node(gobj, event, kw, src)
12024
12578
  return 0;
12025
12579
  }
12026
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
+
12027
12823
  /************************************************************
12028
12824
  * {what, id|ids} -- the person said yes in a confirm popover.
12029
12825
  *
@@ -12316,8 +13112,25 @@ function create_gclass(gclass_name)
12316
13112
  ["EV_CONTEXT_MENU_ITEM", ac_context_menu_item, null],
12317
13113
  ["EV_REQUEST_DELETE_NODE", ac_request_delete_node, null],
12318
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],
12319
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],
12320
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],
12321
13134
  ["EV_CANVAS_CLICK", ac_canvas_click, null],
12322
13135
  ["EV_NODE_DRAG_END", ac_node_drag_end, null],
12323
13136
  ["EV_BRUSH_SELECT", ac_brush_select, null],
@@ -12386,8 +13199,25 @@ function create_gclass(gclass_name)
12386
13199
  ["EV_CONTEXT_MENU_ITEM", 0],
12387
13200
  ["EV_REQUEST_DELETE_NODE", 0],
12388
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],
12389
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],
12390
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],
12391
13221
  ["EV_CANVAS_CLICK", 0],
12392
13222
  ["EV_NODE_DRAG_END", 0],
12393
13223
  ["EV_BRUSH_SELECT", 0],
@@ -12511,4 +13341,7 @@ function register_c_g6_nodes_tree()
12511
13341
  export {
12512
13342
  register_c_g6_nodes_tree,
12513
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
12514
13347
  };