@yuneta/gobj-ui 7.23.9 → 7.23.11

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.
package/README.md CHANGED
@@ -877,11 +877,19 @@ a card with a border sitting on the graph). Above that width nothing changes.
877
877
 
878
878
  **New keys for consumers: `show toolbar`, `hide toolbar`.**
879
879
 
880
- What is still desktop-only, deliberately: **multi-selection**. `brush-select`
881
- is bound to `shift`, and `drag-canvas` stands aside for the same key — there
882
- is no shift on a phone, and giving the rubber band a touch gesture means
883
- taking one away from panning. Selecting nodes one at a time works on a phone;
884
- selecting a region does not.
880
+ **Multi-selection reaches a finger through a MODE, not through a gesture**
881
+ (`7.23.11`). Both halves of it — shift+click to add a card, shift+drag for the
882
+ rubber band — hang off a key a phone does not have, and there is no spare
883
+ gesture to give them: G6 binds panning and the band to the same plain drag, so
884
+ one of the two has to stand aside. The edit toolbar carries a **selection
885
+ mode** toggle (the dashed marquee, next to `+`): while it is on, a tap picks a
886
+ card and a drag on the background draws the band, and panning is what stands
887
+ aside. It is deliberately a button and not a heuristic — the toolbar says which
888
+ of the two the graph is listening for, and turning it off gives the camera
889
+ back. It is not device-specific: the same button spares a desktop reader the
890
+ key. Leaving edition turns it off, and it is not persisted.
891
+
892
+ **New key for consumers: `selection mode`.**
885
893
 
886
894
  ### Tabs opened at runtime, and the two decisions their url costs
887
895
 
@@ -928,6 +936,7 @@ clicked":
928
936
  | click a node | selects it **and opens it**: resize handles, ports, popovers |
929
937
  | **shift + click** | adds that node to the selection, or takes it out |
930
938
  | **shift + drag on the canvas** | rubber band: the selection becomes what it enclosed |
939
+ | the **selection mode** button | makes those two the PLAIN gestures — a tap picks, a canvas drag is the band — for as long as it is on (panning stands aside meanwhile) |
931
940
  | **ctrl/cmd + A** | every node |
932
941
  | the **fit-to-selection** button | puts the viewport on what is selected |
933
942
  | drag any selected node | **moves the whole selection**, as one undo |
@@ -964,12 +973,28 @@ gone wrong:
964
973
  work — so a marquee shows up in the `machine` trace like every other action.
965
974
  Shift+click is not G6's `click-select` at all: this gclass already owns
966
975
  `EV_NODE_CLICK`, and adding a second selection owner outside the FSM is how
967
- the two end up disagreeing.
968
-
969
- Panning gives way while **Shift** is held (`drag-canvas` takes an `enable`
970
- predicate), or the canvas would pan under the rubber band — G6 binds
971
- `drag-canvas` straight to the drag events, and its own docs warn that the two
972
- gestures cannot both be a plain drag.
976
+ the two end up disagreeing. The band comes from `g6_brush_select_owned.js`,
977
+ a `brush-select` under our own id with ONE half removed: the built-in also
978
+ rewrites the `selected` state of every node and every edge on each canvas
979
+ click, behind the gclass and outside the history pause with which it sets
980
+ and clears the selection — a recorded command whose before and after are
981
+ identical, which is a lit Save on a graph nobody has touched (`7.23.10`).
982
+
983
+ Panning gives way while **Shift** is held AND while the pointer is on a card
984
+ (`drag-canvas` takes an `enable` predicate) — the first because the canvas
985
+ would otherwise pan under the rubber band, the second because it would
986
+ otherwise pan under the node being dragged. That second half is G6's own
987
+ default, and an `enable` REPLACES the default rather than adding to it: while
988
+ it was missing, a drag moved the card at twice the pointer and slid the whole
989
+ graph underneath it (`7.23.10`).
990
+
991
+ **Undo, Redo and Save are the history plugin, and it follows the MODE.** It is
992
+ installed on entering edition and removed on leaving it — including when
993
+ edition is reached through the mode selector on a graph whose data is already
994
+ loaded, which is the ordinary way in. Before `7.23.10` it was installed at the
995
+ arrival of the last topic of the load and only if the graph was in edition
996
+ right then, so that ordinary way left the graph with dead Undo/Redo buttons
997
+ and a `history_pause()` nothing answered, while Save still lit on its own.
973
998
 
974
999
  **A delete says what it takes, whether it is one or twenty.** The Delete key
975
1000
  asks the same question the per-node delete icon asks, built by the same
@@ -47493,6 +47493,19 @@ var ICONS = `<svg xmlns="http://www.w3.org/2000/svg">
47493
47493
  <symbol id="g6-icon-toolbar-hide" viewBox="0 0 384 512">
47494
47494
  <path fill="currentColor" d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/>
47495
47495
  </symbol>
47496
+ <!-- dashed marquee (selection mode).
47497
+ The band ITSELF, because that is what the button turns on: a
47498
+ drag on the canvas draws one and a tap picks a card. Not the
47499
+ fit-to-selection glyph next to it in the other toolbar -- that
47500
+ one is solid brackets around a solid object and means "put the
47501
+ camera on what is picked", which is the opposite end of the
47502
+ gesture. Dashes: 16 segments of 59 with gaps of 36 over a
47503
+ perimeter of 1520, so they land on the corners. -->
47504
+ <symbol id="g6-icon-select-mode" viewBox="0 0 512 512">
47505
+ <rect x="46" y="86" width="420" height="340" fill="none"
47506
+ stroke="currentColor" stroke-width="40"
47507
+ stroke-dasharray="59 36"/>
47508
+ </symbol>
47496
47509
  <!-- floppy-disk (save) -->
47497
47510
  <symbol id="g6-icon-save" viewBox="0 0 448 512">
47498
47511
  <path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>
@@ -47507,6 +47520,58 @@ function inject_svg_icons() {
47507
47520
  document.body.appendChild(div);
47508
47521
  }
47509
47522
  //#endregion
47523
+ //#region src/g6_brush_select_owned.js
47524
+ /***********************************************************************
47525
+ * g6_brush_select_owned.js
47526
+ *
47527
+ * The rubber band, for a graph whose FSM owns the selection.
47528
+ *
47529
+ * Why this exists
47530
+ * ----------------
47531
+ * G6 v5 `brush-select` does two things: it draws the band and
47532
+ * it keeps the `selected` element state. The second one is
47533
+ * not ours to give away -- `C_G6_NODES_TREE` owns the
47534
+ * selection, sets it in `set_selection()` and clears it in
47535
+ * `deselect_node()`, both with the history recorder paused,
47536
+ * because a selection is not an edit.
47537
+ *
47538
+ * The built-in disagrees on one path: on EVERY canvas click
47539
+ * it calls `clearElementsStates()`, which writes the state of
47540
+ * every node and every edge of the graph -- whether or not
47541
+ * any of them carried it -- outside our pause. G6's history
47542
+ * then records the redraw as a command whose `original` and
47543
+ * `current` are identical: an undo entry that undoes nothing,
47544
+ * and a Save button lit on a graph nobody has touched. One
47545
+ * click on the background was enough.
47546
+ *
47547
+ * So this subclass drops that half and keeps the band. The
47548
+ * selection still clears on a canvas click -- through
47549
+ * `EV_CANVAS_CLICK`, where the gclass has always cleared it.
47550
+ *
47551
+ * Registered under its OWN id: the built-in `brush-select` is
47552
+ * right for a graph that does not own its selection, and no
47553
+ * other consumer of this library asked for this.
47554
+ *
47555
+ * Copyright (c) 2026, ArtGins.
47556
+ * All Rights Reserved.
47557
+ ***********************************************************************/
47558
+ /************************************************************
47559
+ * Same band, no state bookkeeping behind the gclass's back
47560
+ ************************************************************/
47561
+ var BrushSelectOwned = class extends _antv_g6.BrushSelect {
47562
+ clearElementsStates() {}
47563
+ };
47564
+ /************************************************************
47565
+ * Register once, under our own id
47566
+ ************************************************************/
47567
+ var BRUSH_SELECT_OWNED = "yui-brush-select";
47568
+ var __brush_select_registered__ = false;
47569
+ function ensure_brush_select_owned() {
47570
+ if (__brush_select_registered__) return;
47571
+ (0, _antv_g6.register)(_antv_g6.ExtensionCategory.BEHAVIOR, BRUSH_SELECT_OWNED, BrushSelectOwned);
47572
+ __brush_select_registered__ = true;
47573
+ }
47574
+ //#endregion
47510
47575
  //#region src/c_g6_nodes_tree.js
47511
47576
  /***********************************************************************
47512
47577
  * c_g6_nodes_tree.js
@@ -47565,6 +47630,7 @@ var YuiToolbar = class extends _antv_g6.Toolbar {
47565
47630
  };
47566
47631
  (0, _antv_g6.register)(_antv_g6.ExtensionCategory.PLUGIN, "yui-toolbar", YuiToolbar);
47567
47632
  ensure_drag_canvas_patch();
47633
+ ensure_brush_select_owned();
47568
47634
  ensure_pinch_zoom_patch();
47569
47635
  /***************************************************************
47570
47636
  * Constants
@@ -47689,6 +47755,7 @@ var PRIVATE_DATA$4 = {
47689
47755
  toolbar_collapsed: true,
47690
47756
  _toolbars_could_collapse: null,
47691
47757
  _on_focusout_restore: null,
47758
+ selection_mode: false,
47692
47759
  _selected_paint_ids: [],
47693
47760
  _pending_focus_topic: null,
47694
47761
  _pending_find: null,
@@ -48012,6 +48079,7 @@ function update_toolbar(gobj) {
48012
48079
  configure_toolbar_edit(gobj);
48013
48080
  if (graph_get_plugin(gobj, "toolbar-edit")) graph.updatePlugin({ key: "toolbar-edit" });
48014
48081
  update_zoom_selection_button(gobj);
48082
+ update_selection_mode_button(gobj);
48015
48083
  }
48016
48084
  /************************************************************
48017
48085
  * The floating toolbars follow the theme.
@@ -48226,7 +48294,8 @@ function configure_toolbar(gobj) {
48226
48294
  * Contains edit-mode buttons: Undo, Redo, Save
48227
48295
  ************************************************************/
48228
48296
  function configure_toolbar_edit(gobj) {
48229
- if (!gobj.priv.edit_mode || toolbars_collapsed(gobj)) {
48297
+ let priv = gobj.priv;
48298
+ if (!priv.edit_mode || toolbars_collapsed(gobj)) {
48230
48299
  graph_remove_plugin(gobj, "toolbar-edit");
48231
48300
  return;
48232
48301
  }
@@ -48245,6 +48314,12 @@ function configure_toolbar_edit(gobj) {
48245
48314
  className: "EV_CREATE_NODE_BTN color_create_state",
48246
48315
  title: (0, i18next.t)("create node")
48247
48316
  },
48317
+ {
48318
+ id: "g6-icon-select-mode",
48319
+ value: "selection-mode",
48320
+ className: "EV_TOGGLE_SELECTION_MODE" + (priv.selection_mode ? " color_active_state" : ""),
48321
+ title: (0, i18next.t)("selection mode")
48322
+ },
48248
48323
  {
48249
48324
  id: "g6-icon-save",
48250
48325
  value: "save",
@@ -48273,6 +48348,9 @@ function configure_toolbar_edit(gobj) {
48273
48348
  case "create-node":
48274
48349
  toggle_create_popover(gobj);
48275
48350
  break;
48351
+ case "selection-mode":
48352
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_TOGGLE_SELECTION_MODE", {}, gobj);
48353
+ break;
48276
48354
  case "undo":
48277
48355
  (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_HISTORY_UNDO", {}, gobj);
48278
48356
  break;
@@ -48315,14 +48393,19 @@ function configure_behaviour(gobj) {
48315
48393
  {
48316
48394
  type: "drag-canvas",
48317
48395
  key: "drag-canvas",
48318
- enable: (event) => !event.shiftKey
48396
+ enable: (event) => {
48397
+ if (event.shiftKey || priv.selection_mode) return false;
48398
+ if ("targetType" in event) return event.targetType === "canvas";
48399
+ return true;
48400
+ }
48319
48401
  },
48320
48402
  "zoom-canvas",
48321
48403
  "drag-element",
48322
48404
  {
48323
- type: "brush-select",
48405
+ type: BRUSH_SELECT_OWNED,
48324
48406
  key: "brush-select",
48325
- trigger: ["shift"],
48407
+ trigger: priv.selection_mode ? [] : ["shift"],
48408
+ enable: (event) => event.targetType === "canvas",
48326
48409
  enableElements: ["node"],
48327
48410
  immediately: false,
48328
48411
  onSelect: (states) => {
@@ -48338,7 +48421,10 @@ function configure_behaviour(gobj) {
48338
48421
  break;
48339
48422
  default: (0, _yuneta_gobj_js.log_error)(`operation mode unknown: ${operation_mode}`);
48340
48423
  }
48341
- if (!priv.edit_mode) deselect_node(gobj);
48424
+ if (!priv.edit_mode) {
48425
+ priv.selection_mode = false;
48426
+ deselect_node(gobj);
48427
+ }
48342
48428
  graph_write_behaviors(gobj, behaviors);
48343
48429
  }
48344
48430
  /************************************************************
@@ -48904,6 +48990,30 @@ function history_resume(gobj) {
48904
48990
  gobj.priv._history_paused = false;
48905
48991
  }
48906
48992
  /************************************************************
48993
+ * The history plugin exists while the graph is in EDITION,
48994
+ * and only there.
48995
+ *
48996
+ * It used to be installed at one MOMENT -- the arrival of the
48997
+ * last topic of the load -- and only if the graph happened to
48998
+ * be in edition right then. Entering edition afterwards, which
48999
+ * is the ordinary way in (the graph opens in reading and the
49000
+ * mode selector is next to it), left no history at all: Undo
49001
+ * and Redo were dead buttons that never even lit, `history_
49002
+ * pause()` had no `beforeAddCommand` to answer, and the Save
49003
+ * button was told the graph was dirty by `mark_graph_dirty()`
49004
+ * alone -- which is why a move offered Save without Undo.
49005
+ *
49006
+ * So it follows the MODE, and every place that changes the
49007
+ * mode calls this.
49008
+ ************************************************************/
49009
+ function sync_history_plugin(gobj) {
49010
+ let priv = gobj.priv;
49011
+ if (!priv.graph || !priv.graph_rendered) return;
49012
+ if (priv.edit_mode) graph_add_plugin(gobj, "history");
49013
+ else graph_remove_plugin(gobj, "history");
49014
+ update_history_buttons(gobj);
49015
+ }
49016
+ /************************************************************
48907
49017
  *
48908
49018
  ************************************************************/
48909
49019
  function update_history_buttons(gobj) {
@@ -49194,6 +49304,7 @@ function graph_add_plugin(gobj, plugin_key, options) {
49194
49304
  let priv = gobj.priv;
49195
49305
  let graph = priv.graph;
49196
49306
  let plugin = graph_get_plugin(gobj, plugin_key);
49307
+ let existed = !!plugin;
49197
49308
  if (!plugin) {
49198
49309
  let plugin_def = {
49199
49310
  key: plugin_key,
@@ -49204,7 +49315,7 @@ function graph_add_plugin(gobj, plugin_key, options) {
49204
49315
  }
49205
49316
  plugin = graph_get_plugin(gobj, plugin_key);
49206
49317
  switch (plugin_key) {
49207
- case "history": if (plugin) {
49318
+ case "history": if (plugin && !existed) {
49208
49319
  plugin.on(_antv_g6.HistoryEvent.CHANGE, () => {
49209
49320
  update_history_buttons(gobj);
49210
49321
  });
@@ -49639,6 +49750,21 @@ function update_zoom_selection_button(gobj) {
49639
49750
  else disableElements($container, ".EV_ZOOM_SELECTION");
49640
49751
  }
49641
49752
  /************************************************************
49753
+ * The selection mode is a state, so it has to LOOK like one.
49754
+ *
49755
+ * Toggled on the element rather than re-rendered with the
49756
+ * toolbar, for the same reason as the button above: a
49757
+ * re-render rebuilds the innerHTML and drops what every OTHER
49758
+ * button was told.
49759
+ ************************************************************/
49760
+ function update_selection_mode_button(gobj) {
49761
+ let priv = gobj.priv;
49762
+ let $container = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container");
49763
+ if (!$container) return;
49764
+ if (!$container.querySelector(".EV_TOGGLE_SELECTION_MODE")) return;
49765
+ set_active_state($container, ".EV_TOGGLE_SELECTION_MODE", priv.selection_mode);
49766
+ }
49767
+ /************************************************************
49642
49768
  * Select a SET of nodes: what a marquee and a shift-click do.
49643
49769
  *
49644
49770
  * It leaves `_selected_node_id` null even for a set of one,
@@ -51935,8 +52061,7 @@ function ac_load_data(gobj, event, kw, src) {
51935
52061
  if (auto_laid) graph_fit_readable(gobj).catch((e) => {
51936
52062
  (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: cannot fit the graph: ${e}`);
51937
52063
  });
51938
- if (priv.edit_mode) graph_add_plugin(gobj, "history");
51939
- else graph_remove_plugin(gobj, "history");
52064
+ sync_history_plugin(gobj);
51940
52065
  if (priv._pending_focus_topic !== null) {
51941
52066
  let ft = priv._pending_focus_topic;
51942
52067
  priv._pending_focus_topic = null;
@@ -52238,6 +52363,41 @@ function ac_set_operation_mode$1(gobj, event, kw, src) {
52238
52363
  (0, _yuneta_gobj_js.gobj_write_attr)(gobj, "operation_mode", kw.operation_mode);
52239
52364
  configure_behaviour(gobj);
52240
52365
  update_toolbar(gobj);
52366
+ sync_history_plugin(gobj);
52367
+ return 0;
52368
+ }
52369
+ /************************************************************
52370
+ * Selection mode on/off, from the edit toolbar.
52371
+ *
52372
+ * Shift is what the selection is built on, and a telephone has
52373
+ * no Shift: the marquee and the add-to-selection click were a
52374
+ * desktop feature by construction. This makes that key a MODE
52375
+ * -- while it is on, a tap picks a card and a drag on the
52376
+ * background draws the band, so the same two gestures are
52377
+ * reachable with one finger.
52378
+ *
52379
+ * It costs panning while it is on, and deliberately: G6 binds
52380
+ * both to a plain drag, and a gesture cannot be two things.
52381
+ * That is why it is a button and not a heuristic -- the reader
52382
+ * turns it off to move the camera, and the toolbar says which
52383
+ * of the two the graph is listening for.
52384
+ ************************************************************/
52385
+ function ac_toggle_selection_mode(gobj, event, kw, src) {
52386
+ let priv = gobj.priv;
52387
+ if (!priv.edit_mode) {
52388
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: selection mode asked outside edition`);
52389
+ return 0;
52390
+ }
52391
+ priv.selection_mode = !priv.selection_mode;
52392
+ if (priv.selection_mode) {
52393
+ deselect_port(gobj);
52394
+ hide_node_icon(gobj);
52395
+ hide_node_popover(gobj);
52396
+ hide_resize_handles(gobj);
52397
+ priv._selected_node_id = null;
52398
+ }
52399
+ configure_behaviour(gobj);
52400
+ update_selection_mode_button(gobj);
52241
52401
  return 0;
52242
52402
  }
52243
52403
  /************************************************************
@@ -52305,7 +52465,7 @@ function ac_node_click$2(gobj, event, kw, src) {
52305
52465
  topic_name: nodedata.data.desc.topic_name,
52306
52466
  record: nodedata.data.record
52307
52467
  });
52308
- if (priv.edit_mode && kw.evt.shiftKey) toggle_in_selection(gobj, node_id);
52468
+ if (priv.edit_mode && (kw.evt.shiftKey || priv.selection_mode)) toggle_in_selection(gobj, node_id);
52309
52469
  else if (priv.edit_mode) {
52310
52470
  let containerRect = priv.$container.getBoundingClientRect();
52311
52471
  let canvasPoint = graph.getCanvasByViewport([kw.evt.client.x - containerRect.left, kw.evt.client.y - containerRect.top]);
@@ -52603,6 +52763,11 @@ function create_gclass$4(gclass_name) {
52603
52763
  ac_set_operation_mode$1,
52604
52764
  null
52605
52765
  ],
52766
+ [
52767
+ "EV_TOGGLE_SELECTION_MODE",
52768
+ ac_toggle_selection_mode,
52769
+ null
52770
+ ],
52606
52771
  [
52607
52772
  "EV_SAVE_GRAPH",
52608
52773
  ac_save_graph,
@@ -52677,6 +52842,7 @@ function create_gclass$4(gclass_name) {
52677
52842
  ["EV_FULLSCREEN", 0],
52678
52843
  ["EV_SET_LAYOUT", 0],
52679
52844
  ["EV_SET_OPERATION_MODE", 0],
52845
+ ["EV_TOGGLE_SELECTION_MODE", 0],
52680
52846
  ["EV_HISTORY_UNDO", 0],
52681
52847
  ["EV_HISTORY_REDO", 0],
52682
52848
  ["EV_SAVE_GRAPH", 0],
@@ -5,7 +5,7 @@ import { createJSONEditor } from "vanilla-jsoneditor";
5
5
  import { TabulatorFull } from "tabulator-tables";
6
6
  import * as maplibregl from "maplibre-gl";
7
7
  import uPlot from "uplot";
8
- import { BaseLayout, CanvasEvent, Circle, CommonEvent, DragCanvas, EdgeEvent, ExtensionCategory, Graph, HistoryEvent, NodeEvent, Toolbar, ZoomCanvas, register } from "@antv/g6";
8
+ import { BaseLayout, BrushSelect, CanvasEvent, Circle, CommonEvent, DragCanvas, EdgeEvent, ExtensionCategory, Graph, HistoryEvent, NodeEvent, Toolbar, ZoomCanvas, register } from "@antv/g6";
9
9
  //#region \0rolldown/runtime.js
10
10
  var __create = Object.create;
11
11
  var __defProp = Object.defineProperty;
@@ -47480,6 +47480,19 @@ var ICONS = `<svg xmlns="http://www.w3.org/2000/svg">
47480
47480
  <symbol id="g6-icon-toolbar-hide" viewBox="0 0 384 512">
47481
47481
  <path fill="currentColor" d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/>
47482
47482
  </symbol>
47483
+ <!-- dashed marquee (selection mode).
47484
+ The band ITSELF, because that is what the button turns on: a
47485
+ drag on the canvas draws one and a tap picks a card. Not the
47486
+ fit-to-selection glyph next to it in the other toolbar -- that
47487
+ one is solid brackets around a solid object and means "put the
47488
+ camera on what is picked", which is the opposite end of the
47489
+ gesture. Dashes: 16 segments of 59 with gaps of 36 over a
47490
+ perimeter of 1520, so they land on the corners. -->
47491
+ <symbol id="g6-icon-select-mode" viewBox="0 0 512 512">
47492
+ <rect x="46" y="86" width="420" height="340" fill="none"
47493
+ stroke="currentColor" stroke-width="40"
47494
+ stroke-dasharray="59 36"/>
47495
+ </symbol>
47483
47496
  <!-- floppy-disk (save) -->
47484
47497
  <symbol id="g6-icon-save" viewBox="0 0 448 512">
47485
47498
  <path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>
@@ -47494,6 +47507,58 @@ function inject_svg_icons() {
47494
47507
  document.body.appendChild(div);
47495
47508
  }
47496
47509
  //#endregion
47510
+ //#region src/g6_brush_select_owned.js
47511
+ /***********************************************************************
47512
+ * g6_brush_select_owned.js
47513
+ *
47514
+ * The rubber band, for a graph whose FSM owns the selection.
47515
+ *
47516
+ * Why this exists
47517
+ * ----------------
47518
+ * G6 v5 `brush-select` does two things: it draws the band and
47519
+ * it keeps the `selected` element state. The second one is
47520
+ * not ours to give away -- `C_G6_NODES_TREE` owns the
47521
+ * selection, sets it in `set_selection()` and clears it in
47522
+ * `deselect_node()`, both with the history recorder paused,
47523
+ * because a selection is not an edit.
47524
+ *
47525
+ * The built-in disagrees on one path: on EVERY canvas click
47526
+ * it calls `clearElementsStates()`, which writes the state of
47527
+ * every node and every edge of the graph -- whether or not
47528
+ * any of them carried it -- outside our pause. G6's history
47529
+ * then records the redraw as a command whose `original` and
47530
+ * `current` are identical: an undo entry that undoes nothing,
47531
+ * and a Save button lit on a graph nobody has touched. One
47532
+ * click on the background was enough.
47533
+ *
47534
+ * So this subclass drops that half and keeps the band. The
47535
+ * selection still clears on a canvas click -- through
47536
+ * `EV_CANVAS_CLICK`, where the gclass has always cleared it.
47537
+ *
47538
+ * Registered under its OWN id: the built-in `brush-select` is
47539
+ * right for a graph that does not own its selection, and no
47540
+ * other consumer of this library asked for this.
47541
+ *
47542
+ * Copyright (c) 2026, ArtGins.
47543
+ * All Rights Reserved.
47544
+ ***********************************************************************/
47545
+ /************************************************************
47546
+ * Same band, no state bookkeeping behind the gclass's back
47547
+ ************************************************************/
47548
+ var BrushSelectOwned = class extends BrushSelect {
47549
+ clearElementsStates() {}
47550
+ };
47551
+ /************************************************************
47552
+ * Register once, under our own id
47553
+ ************************************************************/
47554
+ var BRUSH_SELECT_OWNED = "yui-brush-select";
47555
+ var __brush_select_registered__ = false;
47556
+ function ensure_brush_select_owned() {
47557
+ if (__brush_select_registered__) return;
47558
+ register(ExtensionCategory.BEHAVIOR, BRUSH_SELECT_OWNED, BrushSelectOwned);
47559
+ __brush_select_registered__ = true;
47560
+ }
47561
+ //#endregion
47497
47562
  //#region src/c_g6_nodes_tree.js
47498
47563
  /***********************************************************************
47499
47564
  * c_g6_nodes_tree.js
@@ -47552,6 +47617,7 @@ var YuiToolbar = class extends Toolbar {
47552
47617
  };
47553
47618
  register(ExtensionCategory.PLUGIN, "yui-toolbar", YuiToolbar);
47554
47619
  ensure_drag_canvas_patch();
47620
+ ensure_brush_select_owned();
47555
47621
  ensure_pinch_zoom_patch();
47556
47622
  /***************************************************************
47557
47623
  * Constants
@@ -47676,6 +47742,7 @@ var PRIVATE_DATA$4 = {
47676
47742
  toolbar_collapsed: true,
47677
47743
  _toolbars_could_collapse: null,
47678
47744
  _on_focusout_restore: null,
47745
+ selection_mode: false,
47679
47746
  _selected_paint_ids: [],
47680
47747
  _pending_focus_topic: null,
47681
47748
  _pending_find: null,
@@ -47999,6 +48066,7 @@ function update_toolbar(gobj) {
47999
48066
  configure_toolbar_edit(gobj);
48000
48067
  if (graph_get_plugin(gobj, "toolbar-edit")) graph.updatePlugin({ key: "toolbar-edit" });
48001
48068
  update_zoom_selection_button(gobj);
48069
+ update_selection_mode_button(gobj);
48002
48070
  }
48003
48071
  /************************************************************
48004
48072
  * The floating toolbars follow the theme.
@@ -48213,7 +48281,8 @@ function configure_toolbar(gobj) {
48213
48281
  * Contains edit-mode buttons: Undo, Redo, Save
48214
48282
  ************************************************************/
48215
48283
  function configure_toolbar_edit(gobj) {
48216
- if (!gobj.priv.edit_mode || toolbars_collapsed(gobj)) {
48284
+ let priv = gobj.priv;
48285
+ if (!priv.edit_mode || toolbars_collapsed(gobj)) {
48217
48286
  graph_remove_plugin(gobj, "toolbar-edit");
48218
48287
  return;
48219
48288
  }
@@ -48232,6 +48301,12 @@ function configure_toolbar_edit(gobj) {
48232
48301
  className: "EV_CREATE_NODE_BTN color_create_state",
48233
48302
  title: t("create node")
48234
48303
  },
48304
+ {
48305
+ id: "g6-icon-select-mode",
48306
+ value: "selection-mode",
48307
+ className: "EV_TOGGLE_SELECTION_MODE" + (priv.selection_mode ? " color_active_state" : ""),
48308
+ title: t("selection mode")
48309
+ },
48235
48310
  {
48236
48311
  id: "g6-icon-save",
48237
48312
  value: "save",
@@ -48260,6 +48335,9 @@ function configure_toolbar_edit(gobj) {
48260
48335
  case "create-node":
48261
48336
  toggle_create_popover(gobj);
48262
48337
  break;
48338
+ case "selection-mode":
48339
+ gobj_send_event(gobj, "EV_TOGGLE_SELECTION_MODE", {}, gobj);
48340
+ break;
48263
48341
  case "undo":
48264
48342
  gobj_send_event(gobj, "EV_HISTORY_UNDO", {}, gobj);
48265
48343
  break;
@@ -48302,14 +48380,19 @@ function configure_behaviour(gobj) {
48302
48380
  {
48303
48381
  type: "drag-canvas",
48304
48382
  key: "drag-canvas",
48305
- enable: (event) => !event.shiftKey
48383
+ enable: (event) => {
48384
+ if (event.shiftKey || priv.selection_mode) return false;
48385
+ if ("targetType" in event) return event.targetType === "canvas";
48386
+ return true;
48387
+ }
48306
48388
  },
48307
48389
  "zoom-canvas",
48308
48390
  "drag-element",
48309
48391
  {
48310
- type: "brush-select",
48392
+ type: BRUSH_SELECT_OWNED,
48311
48393
  key: "brush-select",
48312
- trigger: ["shift"],
48394
+ trigger: priv.selection_mode ? [] : ["shift"],
48395
+ enable: (event) => event.targetType === "canvas",
48313
48396
  enableElements: ["node"],
48314
48397
  immediately: false,
48315
48398
  onSelect: (states) => {
@@ -48325,7 +48408,10 @@ function configure_behaviour(gobj) {
48325
48408
  break;
48326
48409
  default: log_error(`operation mode unknown: ${operation_mode}`);
48327
48410
  }
48328
- if (!priv.edit_mode) deselect_node(gobj);
48411
+ if (!priv.edit_mode) {
48412
+ priv.selection_mode = false;
48413
+ deselect_node(gobj);
48414
+ }
48329
48415
  graph_write_behaviors(gobj, behaviors);
48330
48416
  }
48331
48417
  /************************************************************
@@ -48891,6 +48977,30 @@ function history_resume(gobj) {
48891
48977
  gobj.priv._history_paused = false;
48892
48978
  }
48893
48979
  /************************************************************
48980
+ * The history plugin exists while the graph is in EDITION,
48981
+ * and only there.
48982
+ *
48983
+ * It used to be installed at one MOMENT -- the arrival of the
48984
+ * last topic of the load -- and only if the graph happened to
48985
+ * be in edition right then. Entering edition afterwards, which
48986
+ * is the ordinary way in (the graph opens in reading and the
48987
+ * mode selector is next to it), left no history at all: Undo
48988
+ * and Redo were dead buttons that never even lit, `history_
48989
+ * pause()` had no `beforeAddCommand` to answer, and the Save
48990
+ * button was told the graph was dirty by `mark_graph_dirty()`
48991
+ * alone -- which is why a move offered Save without Undo.
48992
+ *
48993
+ * So it follows the MODE, and every place that changes the
48994
+ * mode calls this.
48995
+ ************************************************************/
48996
+ function sync_history_plugin(gobj) {
48997
+ let priv = gobj.priv;
48998
+ if (!priv.graph || !priv.graph_rendered) return;
48999
+ if (priv.edit_mode) graph_add_plugin(gobj, "history");
49000
+ else graph_remove_plugin(gobj, "history");
49001
+ update_history_buttons(gobj);
49002
+ }
49003
+ /************************************************************
48894
49004
  *
48895
49005
  ************************************************************/
48896
49006
  function update_history_buttons(gobj) {
@@ -49181,6 +49291,7 @@ function graph_add_plugin(gobj, plugin_key, options) {
49181
49291
  let priv = gobj.priv;
49182
49292
  let graph = priv.graph;
49183
49293
  let plugin = graph_get_plugin(gobj, plugin_key);
49294
+ let existed = !!plugin;
49184
49295
  if (!plugin) {
49185
49296
  let plugin_def = {
49186
49297
  key: plugin_key,
@@ -49191,7 +49302,7 @@ function graph_add_plugin(gobj, plugin_key, options) {
49191
49302
  }
49192
49303
  plugin = graph_get_plugin(gobj, plugin_key);
49193
49304
  switch (plugin_key) {
49194
- case "history": if (plugin) {
49305
+ case "history": if (plugin && !existed) {
49195
49306
  plugin.on(HistoryEvent.CHANGE, () => {
49196
49307
  update_history_buttons(gobj);
49197
49308
  });
@@ -49626,6 +49737,21 @@ function update_zoom_selection_button(gobj) {
49626
49737
  else disableElements($container, ".EV_ZOOM_SELECTION");
49627
49738
  }
49628
49739
  /************************************************************
49740
+ * The selection mode is a state, so it has to LOOK like one.
49741
+ *
49742
+ * Toggled on the element rather than re-rendered with the
49743
+ * toolbar, for the same reason as the button above: a
49744
+ * re-render rebuilds the innerHTML and drops what every OTHER
49745
+ * button was told.
49746
+ ************************************************************/
49747
+ function update_selection_mode_button(gobj) {
49748
+ let priv = gobj.priv;
49749
+ let $container = gobj_read_attr(gobj, "$container");
49750
+ if (!$container) return;
49751
+ if (!$container.querySelector(".EV_TOGGLE_SELECTION_MODE")) return;
49752
+ set_active_state($container, ".EV_TOGGLE_SELECTION_MODE", priv.selection_mode);
49753
+ }
49754
+ /************************************************************
49629
49755
  * Select a SET of nodes: what a marquee and a shift-click do.
49630
49756
  *
49631
49757
  * It leaves `_selected_node_id` null even for a set of one,
@@ -51922,8 +52048,7 @@ function ac_load_data(gobj, event, kw, src) {
51922
52048
  if (auto_laid) graph_fit_readable(gobj).catch((e) => {
51923
52049
  log_error(`${gobj_short_name(gobj)}: cannot fit the graph: ${e}`);
51924
52050
  });
51925
- if (priv.edit_mode) graph_add_plugin(gobj, "history");
51926
- else graph_remove_plugin(gobj, "history");
52051
+ sync_history_plugin(gobj);
51927
52052
  if (priv._pending_focus_topic !== null) {
51928
52053
  let ft = priv._pending_focus_topic;
51929
52054
  priv._pending_focus_topic = null;
@@ -52225,6 +52350,41 @@ function ac_set_operation_mode$1(gobj, event, kw, src) {
52225
52350
  gobj_write_attr(gobj, "operation_mode", kw.operation_mode);
52226
52351
  configure_behaviour(gobj);
52227
52352
  update_toolbar(gobj);
52353
+ sync_history_plugin(gobj);
52354
+ return 0;
52355
+ }
52356
+ /************************************************************
52357
+ * Selection mode on/off, from the edit toolbar.
52358
+ *
52359
+ * Shift is what the selection is built on, and a telephone has
52360
+ * no Shift: the marquee and the add-to-selection click were a
52361
+ * desktop feature by construction. This makes that key a MODE
52362
+ * -- while it is on, a tap picks a card and a drag on the
52363
+ * background draws the band, so the same two gestures are
52364
+ * reachable with one finger.
52365
+ *
52366
+ * It costs panning while it is on, and deliberately: G6 binds
52367
+ * both to a plain drag, and a gesture cannot be two things.
52368
+ * That is why it is a button and not a heuristic -- the reader
52369
+ * turns it off to move the camera, and the toolbar says which
52370
+ * of the two the graph is listening for.
52371
+ ************************************************************/
52372
+ function ac_toggle_selection_mode(gobj, event, kw, src) {
52373
+ let priv = gobj.priv;
52374
+ if (!priv.edit_mode) {
52375
+ log_error(`${gobj_short_name(gobj)}: selection mode asked outside edition`);
52376
+ return 0;
52377
+ }
52378
+ priv.selection_mode = !priv.selection_mode;
52379
+ if (priv.selection_mode) {
52380
+ deselect_port(gobj);
52381
+ hide_node_icon(gobj);
52382
+ hide_node_popover(gobj);
52383
+ hide_resize_handles(gobj);
52384
+ priv._selected_node_id = null;
52385
+ }
52386
+ configure_behaviour(gobj);
52387
+ update_selection_mode_button(gobj);
52228
52388
  return 0;
52229
52389
  }
52230
52390
  /************************************************************
@@ -52292,7 +52452,7 @@ function ac_node_click$2(gobj, event, kw, src) {
52292
52452
  topic_name: nodedata.data.desc.topic_name,
52293
52453
  record: nodedata.data.record
52294
52454
  });
52295
- if (priv.edit_mode && kw.evt.shiftKey) toggle_in_selection(gobj, node_id);
52455
+ if (priv.edit_mode && (kw.evt.shiftKey || priv.selection_mode)) toggle_in_selection(gobj, node_id);
52296
52456
  else if (priv.edit_mode) {
52297
52457
  let containerRect = priv.$container.getBoundingClientRect();
52298
52458
  let canvasPoint = graph.getCanvasByViewport([kw.evt.client.x - containerRect.left, kw.evt.client.y - containerRect.top]);
@@ -52590,6 +52750,11 @@ function create_gclass$4(gclass_name) {
52590
52750
  ac_set_operation_mode$1,
52591
52751
  null
52592
52752
  ],
52753
+ [
52754
+ "EV_TOGGLE_SELECTION_MODE",
52755
+ ac_toggle_selection_mode,
52756
+ null
52757
+ ],
52593
52758
  [
52594
52759
  "EV_SAVE_GRAPH",
52595
52760
  ac_save_graph,
@@ -52664,6 +52829,7 @@ function create_gclass$4(gclass_name) {
52664
52829
  ["EV_FULLSCREEN", 0],
52665
52830
  ["EV_SET_LAYOUT", 0],
52666
52831
  ["EV_SET_OPERATION_MODE", 0],
52832
+ ["EV_TOGGLE_SELECTION_MODE", 0],
52667
52833
  ["EV_HISTORY_UNDO", 0],
52668
52834
  ["EV_HISTORY_REDO", 0],
52669
52835
  ["EV_SAVE_GRAPH", 0],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.9",
3
+ "version": "7.23.11",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -103,6 +103,10 @@ import i18next, {t} from "i18next";
103
103
 
104
104
  import {inject_svg_icons} from "./lib_icons.js";
105
105
  import {ensure_drag_canvas_patch} from "./g6_drag_canvas_touch.js";
106
+ import {
107
+ ensure_brush_select_owned,
108
+ BRUSH_SELECT_OWNED,
109
+ } from "./g6_brush_select_owned.js";
106
110
  import {
107
111
  ensure_pinch_zoom_patch,
108
112
  install_long_press_contextmenu,
@@ -176,6 +180,7 @@ class YuiToolbar extends Toolbar
176
180
  }
177
181
  register(ExtensionCategory.PLUGIN, 'yui-toolbar', YuiToolbar);
178
182
  ensure_drag_canvas_patch();
183
+ ensure_brush_select_owned();
179
184
  ensure_pinch_zoom_patch();
180
185
 
181
186
  /***************************************************************
@@ -345,6 +350,9 @@ let PRIVATE_DATA = {
345
350
  toolbar_collapsed: true, // floating toolbars folded (narrow only)
346
351
  _toolbars_could_collapse: null, // last answer, to notice it changed
347
352
  _on_focusout_restore: null, // ...and putting it back when it goes nowhere
353
+ selection_mode: false, // taps pick cards, a canvas drag is the band
354
+ // (edition only, transient: a mode nobody
355
+ // can see is a mode nobody can leave)
348
356
  _selected_paint_ids: [], // node ids whose card is PAINTED selected
349
357
  // (G6's 'selected' state is the selection
350
358
  // itself; this is what is on screen, and
@@ -1021,6 +1029,7 @@ function update_toolbar(gobj)
1021
1029
  /* A re-render rebuilds the innerHTML, so a button whose enabled
1022
1030
  * state is not part of getItems() has to be told again. */
1023
1031
  update_zoom_selection_button(gobj);
1032
+ update_selection_mode_button(gobj);
1024
1033
  }
1025
1034
 
1026
1035
  /************************************************************
@@ -1321,6 +1330,19 @@ function configure_toolbar_edit(gobj)
1321
1330
  getItems: () => {
1322
1331
  return [
1323
1332
  { id: 'g6-icon-plus', value: 'create-node', className: 'EV_CREATE_NODE_BTN color_create_state', title: t('create node') },
1333
+ /* The mode is carried in the class getItems()
1334
+ * returns, not only pushed onto the element after
1335
+ * the fact: this toolbar is rebuilt whole (a fold,
1336
+ * a language change), and the rebuilt DOM is not
1337
+ * there yet when the rebuild returns -- so a
1338
+ * button told afterwards came back OFF while the
1339
+ * graph was still listening for a selection. */
1340
+ {
1341
+ id: 'g6-icon-select-mode', value: 'selection-mode',
1342
+ className: 'EV_TOGGLE_SELECTION_MODE' +
1343
+ (priv.selection_mode? ' color_active_state' : ''),
1344
+ title: t('selection mode')
1345
+ },
1324
1346
  { id: 'g6-icon-save', value: 'save', className: 'EV_SAVE_GRAPH', title: t('save'), disabled: true },
1325
1347
  { id: 'g6-icon-undo', value: 'undo', className: 'EV_HISTORY_UNDO', title: t('undo'), disabled: true },
1326
1348
  { id: 'g6-icon-redo', value: 'redo', className: 'EV_HISTORY_REDO', title: t('redo'), disabled: true },
@@ -1331,6 +1353,9 @@ function configure_toolbar_edit(gobj)
1331
1353
  case 'create-node':
1332
1354
  toggle_create_popover(gobj);
1333
1355
  break;
1356
+ case 'selection-mode':
1357
+ gobj_send_event(gobj, "EV_TOGGLE_SELECTION_MODE", {}, gobj);
1358
+ break;
1334
1359
  case 'undo':
1335
1360
  gobj_send_event(gobj, "EV_HISTORY_UNDO", {}, gobj);
1336
1361
  break;
@@ -1410,11 +1435,28 @@ function configure_behaviour(gobj)
1410
1435
  /* Panning gives way while Shift is held: that is the
1411
1436
  * marquee's gesture, and G6 binds drag-canvas straight
1412
1437
  * to the drag events, so without this the canvas pans
1413
- * under the rubber band. */
1438
+ * under the rubber band.
1439
+ *
1440
+ * And it gives way to a NODE drag, which is what G6's
1441
+ * own default `enable` says and what this one has to
1442
+ * repeat: `enable` REPLACES it, it does not add to it.
1443
+ * Dropping the `targetType` half made every drag of a
1444
+ * card pan the canvas as well as move the card -- the
1445
+ * card ran at twice the pointer and the whole graph
1446
+ * slid underneath it, on a move whose RESULT was
1447
+ * right, because a pan writes nothing. */
1414
1448
  {
1415
1449
  type: "drag-canvas",
1416
1450
  key: "drag-canvas",
1417
- enable: (event) => !event.shiftKey,
1451
+ enable: (event) => {
1452
+ if(event.shiftKey || priv.selection_mode) {
1453
+ return false;
1454
+ }
1455
+ if('targetType' in event) {
1456
+ return event.targetType === 'canvas';
1457
+ }
1458
+ return true; /* a key, not a pointer */
1459
+ },
1418
1460
  },
1419
1461
  "zoom-canvas",
1420
1462
  /* Moves EVERY node in the `selected` state, not just
@@ -1429,9 +1471,25 @@ function configure_behaviour(gobj)
1429
1471
  * ids travel with the event and the action does not
1430
1472
  * have to race it. */
1431
1473
  {
1432
- type: "brush-select",
1474
+ /* Ours: the built-in also rewrites the state of
1475
+ * EVERY element on a canvas click, behind the
1476
+ * gclass's back and outside its history pause --
1477
+ * a command that changed nothing, and a Save
1478
+ * button lit on a graph nobody had touched. */
1479
+ type: BRUSH_SELECT_OWNED,
1433
1480
  key: "brush-select",
1434
- trigger: ["shift"],
1481
+ /* There is no Shift on a phone. The toolbar's
1482
+ * selection mode is that key made into a state:
1483
+ * with it on, the band needs no modifier -- and
1484
+ * panning stands aside above, because the two
1485
+ * cannot both be a plain drag. */
1486
+ trigger: priv.selection_mode ? [] : ["shift"],
1487
+ /* A band starts on the BACKGROUND. G6's default
1488
+ * (`enable: true`) does not look at what is under
1489
+ * the pointer, so a drag that begins on a card
1490
+ * drew a band and moved the card at the same
1491
+ * time. */
1492
+ enable: (event) => event.targetType === 'canvas',
1435
1493
  enableElements: ["node"],
1436
1494
  /* Left at G6's default (false): the set is read at
1437
1495
  * pointerup, not on every pointermove. Each answer
@@ -1467,6 +1525,9 @@ function configure_behaviour(gobj)
1467
1525
  break;
1468
1526
  }
1469
1527
  if(!priv.edit_mode) {
1528
+ /* The selection and the mode that picks it both belong to
1529
+ * edition, and neither survives leaving it. */
1530
+ priv.selection_mode = false;
1470
1531
  deselect_node(gobj);
1471
1532
  }
1472
1533
  graph_write_behaviors(gobj, behaviors);
@@ -2370,6 +2431,42 @@ function history_resume(gobj)
2370
2431
  gobj.priv._history_paused = false;
2371
2432
  }
2372
2433
 
2434
+ /************************************************************
2435
+ * The history plugin exists while the graph is in EDITION,
2436
+ * and only there.
2437
+ *
2438
+ * It used to be installed at one MOMENT -- the arrival of the
2439
+ * last topic of the load -- and only if the graph happened to
2440
+ * be in edition right then. Entering edition afterwards, which
2441
+ * is the ordinary way in (the graph opens in reading and the
2442
+ * mode selector is next to it), left no history at all: Undo
2443
+ * and Redo were dead buttons that never even lit, `history_
2444
+ * pause()` had no `beforeAddCommand` to answer, and the Save
2445
+ * button was told the graph was dirty by `mark_graph_dirty()`
2446
+ * alone -- which is why a move offered Save without Undo.
2447
+ *
2448
+ * So it follows the MODE, and every place that changes the
2449
+ * mode calls this.
2450
+ ************************************************************/
2451
+ function sync_history_plugin(gobj)
2452
+ {
2453
+ let priv = gobj.priv;
2454
+
2455
+ if(!priv.graph || !priv.graph_rendered) {
2456
+ /* Nothing to hang a plugin on yet: the load path calls
2457
+ * this again once the graph is drawn. */
2458
+ return;
2459
+ }
2460
+
2461
+ if(priv.edit_mode) {
2462
+ graph_add_plugin(gobj, "history");
2463
+ } else {
2464
+ graph_remove_plugin(gobj, "history");
2465
+ }
2466
+
2467
+ update_history_buttons(gobj);
2468
+ }
2469
+
2373
2470
  /************************************************************
2374
2471
  *
2375
2472
  ************************************************************/
@@ -2775,6 +2872,7 @@ function graph_add_plugin(gobj, plugin_key, options)
2775
2872
  let graph = priv.graph;
2776
2873
 
2777
2874
  let plugin = graph_get_plugin(gobj, plugin_key);
2875
+ let existed = !!plugin;
2778
2876
  if(!plugin) {
2779
2877
  let plugin_def = {
2780
2878
  key: plugin_key,
@@ -2790,7 +2888,11 @@ function graph_add_plugin(gobj, plugin_key, options)
2790
2888
 
2791
2889
  switch(plugin_key) {
2792
2890
  case "history":
2793
- if(plugin) {
2891
+ /* Wired once per INSTANCE: this is called again on every
2892
+ * load and on every entry into edition, and a second
2893
+ * listener on the same emitter is a second repaint of the
2894
+ * same two buttons. */
2895
+ if(plugin && !existed) {
2794
2896
  plugin.on(HistoryEvent.CHANGE, () => {
2795
2897
  update_history_buttons(gobj);
2796
2898
  });
@@ -3505,6 +3607,30 @@ function update_zoom_selection_button(gobj)
3505
3607
  }
3506
3608
  }
3507
3609
 
3610
+ /************************************************************
3611
+ * The selection mode is a state, so it has to LOOK like one.
3612
+ *
3613
+ * Toggled on the element rather than re-rendered with the
3614
+ * toolbar, for the same reason as the button above: a
3615
+ * re-render rebuilds the innerHTML and drops what every OTHER
3616
+ * button was told.
3617
+ ************************************************************/
3618
+ function update_selection_mode_button(gobj)
3619
+ {
3620
+ let priv = gobj.priv;
3621
+ let $container = gobj_read_attr(gobj, "$container");
3622
+
3623
+ if(!$container) {
3624
+ return;
3625
+ }
3626
+
3627
+ if(!$container.querySelector(".EV_TOGGLE_SELECTION_MODE")) {
3628
+ return; /* no edit toolbar: not in edition, or folded away */
3629
+ }
3630
+
3631
+ set_active_state($container, ".EV_TOGGLE_SELECTION_MODE", priv.selection_mode);
3632
+ }
3633
+
3508
3634
  /************************************************************
3509
3635
  * Select a SET of nodes: what a marquee and a shift-click do.
3510
3636
  *
@@ -3563,10 +3689,10 @@ function toggle_in_selection(gobj, node_id)
3563
3689
  {
3564
3690
  let priv = gobj.priv;
3565
3691
 
3566
- /* The union of the two views of the same thing. A brush clears
3567
- * G6's state on pointerdown without telling anybody, so reading
3568
- * only the state could drop a card that is on screen wearing a
3569
- * ring. */
3692
+ /* The union of the two views of the same thing: either can hold
3693
+ * an id the other lost -- G6's state is what a drag moves, the
3694
+ * painted set is what the reader can see -- and a card on screen
3695
+ * wearing a ring belongs to the selection whichever view has it. */
3570
3696
  let current = new Set(selected_node_ids(gobj));
3571
3697
  for(let id of (priv._selected_paint_ids || [])) {
3572
3698
  current.add(id);
@@ -7000,11 +7126,7 @@ function ac_load_data(gobj, event, kw, src)
7000
7126
  log_error(`${gobj_short_name(gobj)}: cannot fit the graph: ${e}`);
7001
7127
  });
7002
7128
  }
7003
- if(priv.edit_mode) {
7004
- graph_add_plugin(gobj, "history");
7005
- } else {
7006
- graph_remove_plugin(gobj, "history");
7007
- }
7129
+ sync_history_plugin(gobj);
7008
7130
  /* Nodes exist and are positioned now: apply a focus
7009
7131
  * requested before the data was ready (deep link). */
7010
7132
  if(priv._pending_focus_topic !== null) {
@@ -7462,6 +7584,55 @@ function ac_set_operation_mode(gobj, event, kw, src)
7462
7584
  gobj_write_attr(gobj, "operation_mode", kw.operation_mode);
7463
7585
  configure_behaviour(gobj);
7464
7586
  update_toolbar(gobj);
7587
+
7588
+ /* `configure_behaviour` is what decides `edit_mode`, and the
7589
+ * history plugin belongs to that decision. */
7590
+ sync_history_plugin(gobj);
7591
+ return 0;
7592
+ }
7593
+
7594
+ /************************************************************
7595
+ * Selection mode on/off, from the edit toolbar.
7596
+ *
7597
+ * Shift is what the selection is built on, and a telephone has
7598
+ * no Shift: the marquee and the add-to-selection click were a
7599
+ * desktop feature by construction. This makes that key a MODE
7600
+ * -- while it is on, a tap picks a card and a drag on the
7601
+ * background draws the band, so the same two gestures are
7602
+ * reachable with one finger.
7603
+ *
7604
+ * It costs panning while it is on, and deliberately: G6 binds
7605
+ * both to a plain drag, and a gesture cannot be two things.
7606
+ * That is why it is a button and not a heuristic -- the reader
7607
+ * turns it off to move the camera, and the toolbar says which
7608
+ * of the two the graph is listening for.
7609
+ ************************************************************/
7610
+ function ac_toggle_selection_mode(gobj, event, kw, src)
7611
+ {
7612
+ let priv = gobj.priv;
7613
+
7614
+ if(!priv.edit_mode) {
7615
+ log_error(`${gobj_short_name(gobj)}: selection mode asked outside edition`);
7616
+ return 0;
7617
+ }
7618
+
7619
+ priv.selection_mode = !priv.selection_mode;
7620
+
7621
+ if(priv.selection_mode) {
7622
+ /* A set has no handles, no ports and no popover: those hang
7623
+ * off `_selected_node_id`, the node OPENED for editing, and
7624
+ * in this mode a tap no longer opens one. The selection
7625
+ * itself stays -- entering the mode is not a way to lose it. */
7626
+ deselect_port(gobj);
7627
+ hide_node_icon(gobj);
7628
+ hide_node_popover(gobj);
7629
+ hide_resize_handles(gobj);
7630
+ priv._selected_node_id = null;
7631
+ }
7632
+
7633
+ configure_behaviour(gobj);
7634
+ update_selection_mode_button(gobj);
7635
+
7465
7636
  return 0;
7466
7637
  }
7467
7638
 
@@ -7564,11 +7735,13 @@ function ac_node_click(gobj, event, kw, src)
7564
7735
  record: nodedata.data.record
7565
7736
  });
7566
7737
 
7567
- if(priv.edit_mode && kw.evt.shiftKey) {
7738
+ if(priv.edit_mode && (kw.evt.shiftKey || priv.selection_mode)) {
7568
7739
  /* Shift+click extends the selection, the way it does
7569
- * everywhere. It never looks for a port: a port is a
7570
- * one-node affordance, and this gesture is about the
7571
- * set. */
7740
+ * everywhere -- and so does a plain tap while the
7741
+ * toolbar's selection mode is on, which is that key
7742
+ * for a finger. It never looks for a port: a port is
7743
+ * a one-node affordance, and this gesture is about
7744
+ * the set. */
7572
7745
  toggle_in_selection(gobj, node_id);
7573
7746
  } else if(priv.edit_mode) {
7574
7747
  // Check if click hits a port
@@ -7873,6 +8046,7 @@ function create_gclass(gclass_name)
7873
8046
  ["EV_FULLSCREEN", ac_fullscreen, null],
7874
8047
  ["EV_SET_LAYOUT", ac_set_layout, null],
7875
8048
  ["EV_SET_OPERATION_MODE", ac_set_operation_mode, null],
8049
+ ["EV_TOGGLE_SELECTION_MODE", ac_toggle_selection_mode, null],
7876
8050
  ["EV_SAVE_GRAPH", ac_save_graph, null],
7877
8051
  ["EV_HISTORY_UNDO", ac_history_undo, null],
7878
8052
  ["EV_HISTORY_REDO", ac_history_redo, null],
@@ -7923,6 +8097,7 @@ function create_gclass(gclass_name)
7923
8097
  ["EV_FULLSCREEN", 0],
7924
8098
  ["EV_SET_LAYOUT", 0],
7925
8099
  ["EV_SET_OPERATION_MODE", 0],
8100
+ ["EV_TOGGLE_SELECTION_MODE", 0],
7926
8101
  ["EV_HISTORY_UNDO", 0],
7927
8102
  ["EV_HISTORY_REDO", 0],
7928
8103
  ["EV_SAVE_GRAPH", 0],
@@ -0,0 +1,69 @@
1
+ /***********************************************************************
2
+ * g6_brush_select_owned.js
3
+ *
4
+ * The rubber band, for a graph whose FSM owns the selection.
5
+ *
6
+ * Why this exists
7
+ * ----------------
8
+ * G6 v5 `brush-select` does two things: it draws the band and
9
+ * it keeps the `selected` element state. The second one is
10
+ * not ours to give away -- `C_G6_NODES_TREE` owns the
11
+ * selection, sets it in `set_selection()` and clears it in
12
+ * `deselect_node()`, both with the history recorder paused,
13
+ * because a selection is not an edit.
14
+ *
15
+ * The built-in disagrees on one path: on EVERY canvas click
16
+ * it calls `clearElementsStates()`, which writes the state of
17
+ * every node and every edge of the graph -- whether or not
18
+ * any of them carried it -- outside our pause. G6's history
19
+ * then records the redraw as a command whose `original` and
20
+ * `current` are identical: an undo entry that undoes nothing,
21
+ * and a Save button lit on a graph nobody has touched. One
22
+ * click on the background was enough.
23
+ *
24
+ * So this subclass drops that half and keeps the band. The
25
+ * selection still clears on a canvas click -- through
26
+ * `EV_CANVAS_CLICK`, where the gclass has always cleared it.
27
+ *
28
+ * Registered under its OWN id: the built-in `brush-select` is
29
+ * right for a graph that does not own its selection, and no
30
+ * other consumer of this library asked for this.
31
+ *
32
+ * Copyright (c) 2026, ArtGins.
33
+ * All Rights Reserved.
34
+ ***********************************************************************/
35
+ import {
36
+ BrushSelect,
37
+ ExtensionCategory,
38
+ register,
39
+ } from '@antv/g6';
40
+
41
+ /************************************************************
42
+ * Same band, no state bookkeeping behind the gclass's back
43
+ ************************************************************/
44
+ class BrushSelectOwned extends BrushSelect {
45
+ clearElementsStates() {
46
+ /*
47
+ * Deliberately empty. The gclass clears the selection from
48
+ * its own EV_CANVAS_CLICK action, with history paused.
49
+ */
50
+ }
51
+ }
52
+
53
+ /************************************************************
54
+ * Register once, under our own id
55
+ ************************************************************/
56
+ const BRUSH_SELECT_OWNED = 'yui-brush-select';
57
+
58
+ let __brush_select_registered__ = false;
59
+
60
+ function ensure_brush_select_owned()
61
+ {
62
+ if(__brush_select_registered__) {
63
+ return;
64
+ }
65
+ register(ExtensionCategory.BEHAVIOR, BRUSH_SELECT_OWNED, BrushSelectOwned);
66
+ __brush_select_registered__ = true;
67
+ }
68
+
69
+ export { ensure_brush_select_owned, BRUSH_SELECT_OWNED };
package/src/lib_icons.js CHANGED
@@ -127,6 +127,19 @@ const ICONS = `<svg xmlns="http://www.w3.org/2000/svg">
127
127
  <symbol id="g6-icon-toolbar-hide" viewBox="0 0 384 512">
128
128
  <path fill="currentColor" d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/>
129
129
  </symbol>
130
+ <!-- dashed marquee (selection mode).
131
+ The band ITSELF, because that is what the button turns on: a
132
+ drag on the canvas draws one and a tap picks a card. Not the
133
+ fit-to-selection glyph next to it in the other toolbar -- that
134
+ one is solid brackets around a solid object and means "put the
135
+ camera on what is picked", which is the opposite end of the
136
+ gesture. Dashes: 16 segments of 59 with gaps of 36 over a
137
+ perimeter of 1520, so they land on the corners. -->
138
+ <symbol id="g6-icon-select-mode" viewBox="0 0 512 512">
139
+ <rect x="46" y="86" width="420" height="340" fill="none"
140
+ stroke="currentColor" stroke-width="40"
141
+ stroke-dasharray="59 36"/>
142
+ </symbol>
130
143
  <!-- floppy-disk (save) -->
131
144
  <symbol id="g6-icon-save" viewBox="0 0 448 512">
132
145
  <path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>