@yuneta/gobj-ui 7.17.0 → 7.18.1

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
@@ -680,6 +680,8 @@ Two implementation notes worth keeping:
680
680
  | fit | `fitView()`: the whole graph in the viewport |
681
681
  | **`1:1`** | `zoomTo(1)`: actual size |
682
682
  | ── | |
683
+ | **fit to selection** | `fit`, for the part that is selected (edition only, disabled while nothing is) |
684
+ | ── | |
683
685
  | full screen | the container, not the camera |
684
686
 
685
687
  Two of those rows are the answer to a real complaint, and the reasoning
@@ -695,6 +697,14 @@ generalises:
695
697
  that offers it, because there is no glyph anybody recognises for it.
696
698
  - **the zoom level is shown** because `1:1` is a jump to a number, and a jump
697
699
  to a number is only meaningful next to the number you are on.
700
+ - **fit to selection** wears the `fit` icon with a marked object inside it,
701
+ drawn rather than borrowed: the two sit next to each other and are the same
702
+ action at two scopes, so they have to read as a family. It appears only in
703
+ edition — a button that can never be enabled is furniture, not a control —
704
+ and is disabled while nothing is selected. `fitView()` has no subset form,
705
+ so the bounds are measured off the elements and the zoom is clamped to the
706
+ graph's own `zoomRange`, the only limit that is not invented here. One card
707
+ filling the view is not a bug: that is what zooming to it means.
698
708
 
699
709
  The **separators are gaps, not lines**: every item already carries a hairline
700
710
  against its neighbour, so one more line would not group anything. Full screen
@@ -705,8 +715,9 @@ Both toolbars (this one and the edit one) **follow the theme**. They used to be
705
715
  pinned to a light background in both themes, with the icon colour pinned dark
706
716
  so it survived that — two light islands over a dark canvas.
707
717
 
708
- **New keys for consumers: `actual size`, `zoom level`** (both tooltips, so a
709
- host that has not defined them shows the key on hover and nothing else breaks).
718
+ **New keys for consumers: `actual size`, `zoom level`, `zoom to selection`**
719
+ (all tooltips, so a host that has not defined them shows the key on hover and
720
+ nothing else breaks).
710
721
 
711
722
  ### Selecting several nodes, and moving them together
712
723
 
@@ -719,6 +730,7 @@ clicked":
719
730
  | **shift + click** | adds that node to the selection, or takes it out |
720
731
  | **shift + drag on the canvas** | rubber band: the selection becomes what it enclosed |
721
732
  | **ctrl/cmd + A** | every node |
733
+ | the **fit-to-selection** button | puts the viewport on what is selected |
722
734
  | drag any selected node | **moves the whole selection**, as one undo |
723
735
  | **Delete** / **Backspace** | deletes the selection, after a confirmation that counts what it takes |
724
736
  | **Esc**, or a click on the canvas | clears it |
@@ -53617,6 +53617,15 @@ var ICONS = `<svg xmlns="http://www.w3.org/2000/svg">
53617
53617
  <symbol id="g6-icon-fit" viewBox="0 0 448 512">
53618
53618
  <path fill="currentColor" d="M32 32C14.3 32 0 46.3 0 64l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-64 64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 32zM64 352c0-17.7-14.3-32-32-32S0 334.3 0 352l0 96c0 17.7 14.3 32 32 32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0 0-64zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32l64 0 0 64c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32l-96 0zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64-64 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l96 0c17.7 0 32-14.3 32-32l0-96z"/>
53619
53619
  </symbol>
53620
+ <!-- fit to selection: the same corner brackets as the fit icon,
53621
+ around a marked object. The two sit side by side in the
53622
+ toolbar and have to read as a family, which a borrowed glyph
53623
+ would not. (No backticks in here: the sprite lives inside a
53624
+ template literal.) -->
53625
+ <symbol id="g6-icon-fit-selection" viewBox="0 0 448 512">
53626
+ <path fill="currentColor" d="M32 32C14.3 32 0 46.3 0 64l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-64 64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 32zM64 352c0-17.7-14.3-32-32-32S0 334.3 0 352l0 96c0 17.7 14.3 32 32 32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0 0-64zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32l64 0 0 64c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32l-96 0zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64-64 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l96 0c17.7 0 32-14.3 32-32l0-96z"/>
53627
+ <rect fill="currentColor" x="144" y="176" width="160" height="160" rx="24"/>
53628
+ </symbol>
53620
53629
  <!-- crosshairs (center) -->
53621
53630
  <symbol id="g6-icon-center" viewBox="0 0 576 512">
53622
53631
  <path fill="currentColor" d="M288-16c17.7 0 32 14.3 32 32l0 18.3c98.1 14 175.7 91.6 189.7 189.7l18.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-18.3 0c-14 98.1-91.6 175.7-189.7 189.7l0 18.3c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-18.3C157.9 463.7 80.3 386.1 66.3 288L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l18.3 0C80.3 125.9 157.9 48.3 256 34.3L256 16c0-17.7 14.3-32 32-32zM131.2 288c12.7 62.7 62.1 112.1 124.8 124.8l0-12.8c0-17.7 14.3-32 32-32s32 14.3 32 32l0 12.8c62.7-12.7 112.1-62.1 124.8-124.8L432 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l12.8 0C432.1 161.3 382.7 111.9 320 99.2l0 12.8c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-12.8C193.3 111.9 143.9 161.3 131.2 224l12.8 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-12.8 0zM288 208a48 48 0 1 1 0 96 48 48 0 1 1 0-96z"/>
@@ -53859,6 +53868,7 @@ var PRIVATE_DATA$1 = {
53859
53868
  _link_saved_styles: [],
53860
53869
  _focus_topic: null,
53861
53870
  _focus_ids: [],
53871
+ _on_pointerdown_focus: null,
53862
53872
  _selected_paint_ids: [],
53863
53873
  _pending_focus_topic: null,
53864
53874
  _pending_find: null,
@@ -53923,6 +53933,10 @@ function mt_destroy$1(gobj) {
53923
53933
  i18next.default.off("languageChanged", priv._on_language_changed);
53924
53934
  priv._on_language_changed = null;
53925
53935
  }
53936
+ if (priv._on_pointerdown_focus) {
53937
+ priv.$container.removeEventListener("pointerdown", priv._on_pointerdown_focus);
53938
+ priv._on_pointerdown_focus = null;
53939
+ }
53926
53940
  if (priv.theme_observer) {
53927
53941
  priv.theme_observer.disconnect();
53928
53942
  priv.theme_observer = null;
@@ -54088,6 +54102,16 @@ function configure_events(gobj) {
54088
54102
  update_toolbar(gobj);
54089
54103
  };
54090
54104
  i18next.default.on("languageChanged", priv._on_language_changed);
54105
+ priv._on_pointerdown_focus = (ev) => {
54106
+ let target = ev.target;
54107
+ if (target && typeof target.closest === "function") {
54108
+ if (target.closest("input, textarea, select, [contenteditable]")) return;
54109
+ if (target.closest(".g6-confirm-popover, .g6-create-popover,.g6-node-popover, .g6-edge-popover")) return;
54110
+ }
54111
+ let $canvas = priv.$container.querySelector("canvas");
54112
+ if ($canvas && typeof $canvas.focus === "function") $canvas.focus({ preventScroll: true });
54113
+ };
54114
+ priv.$container.addEventListener("pointerdown", priv._on_pointerdown_focus);
54091
54115
  graph.on("keydown", (evt) => {
54092
54116
  let ctrl = !!(evt.ctrlKey || evt.metaKey);
54093
54117
  if (ctrl && (evt.key === "a" || evt.key === "A")) {
@@ -54150,6 +54174,7 @@ function update_toolbar(gobj) {
54150
54174
  if (graph_get_plugin(gobj, "toolbar")) graph.updatePlugin({ key: "toolbar" });
54151
54175
  configure_toolbar_edit(gobj);
54152
54176
  if (graph_get_plugin(gobj, "toolbar-edit")) graph.updatePlugin({ key: "toolbar-edit" });
54177
+ update_zoom_selection_button(gobj);
54153
54178
  }
54154
54179
  /************************************************************
54155
54180
  * The floating toolbars follow the theme.
@@ -54250,6 +54275,13 @@ function configure_toolbar(gobj) {
54250
54275
  title: (0, i18next.t)("actual size")
54251
54276
  }
54252
54277
  ];
54278
+ if (priv.edit_mode) items.push({
54279
+ id: "g6-icon-fit-selection",
54280
+ value: "fit-selection",
54281
+ className: "EV_ZOOM_SELECTION",
54282
+ title: (0, i18next.t)("zoom to selection"),
54283
+ disabled: true
54284
+ });
54253
54285
  if ((0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_fullscreen")) {
54254
54286
  items.push({ separator: true });
54255
54287
  if (priv.is_fullscreen) items.push({
@@ -54281,6 +54313,9 @@ function configure_toolbar(gobj) {
54281
54313
  case "auto-fit":
54282
54314
  (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_AUTO_FIT", {}, gobj);
54283
54315
  break;
54316
+ case "fit-selection":
54317
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_ZOOM_SELECTION", {}, gobj);
54318
+ break;
54284
54319
  case "center":
54285
54320
  (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_CENTER", {}, gobj);
54286
54321
  break;
@@ -55086,6 +55121,52 @@ async function graph_fit_readable(gobj) {
55086
55121
  }
55087
55122
  }
55088
55123
  /************************************************************
55124
+ * Put the viewport on the selection: what `fit` does, for a part.
55125
+ *
55126
+ * Computed rather than delegated because `fitView()` fits the whole
55127
+ * graph and there is no subset form of it. The bounds come from the
55128
+ * elements themselves, so a node's real card size is what is
55129
+ * measured, not an assumed one; the zoom is clamped to the graph's
55130
+ * own `zoomRange`, which is the only limit that is not invented
55131
+ * here. A selection of one card filling the view is not a bug --
55132
+ * that is what zooming to it means, and it is what every editor
55133
+ * that offers the action does.
55134
+ ************************************************************/
55135
+ async function graph_fit_selection(gobj, ids) {
55136
+ let graph = gobj.priv.graph;
55137
+ if (!graph || !ids || !ids.length) return;
55138
+ let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
55139
+ let found = 0;
55140
+ for (let id of ids) {
55141
+ let bounds;
55142
+ try {
55143
+ bounds = graph.getElementRenderBounds(id);
55144
+ } catch (e) {
55145
+ continue;
55146
+ }
55147
+ if (!bounds || !bounds.min || !bounds.max) continue;
55148
+ minx = Math.min(minx, bounds.min[0]);
55149
+ miny = Math.min(miny, bounds.min[1]);
55150
+ maxx = Math.max(maxx, bounds.max[0]);
55151
+ maxy = Math.max(maxy, bounds.max[1]);
55152
+ found++;
55153
+ }
55154
+ if (!found) {
55155
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: the selection has no bounds to fit`);
55156
+ return;
55157
+ }
55158
+ let size = graph.getSize();
55159
+ let vw = Math.max(1, size[0] - 96);
55160
+ let vh = Math.max(1, size[1] - 96);
55161
+ let bw = Math.max(1, maxx - minx);
55162
+ let bh = Math.max(1, maxy - miny);
55163
+ let zoom = Math.min(vw / bw, vh / bh);
55164
+ let range = graph.getZoomRange() || [.2, 4];
55165
+ zoom = Math.max(range[0], Math.min(range[1], zoom));
55166
+ await graph.zoomTo(zoom);
55167
+ await graph.focusElement(ids);
55168
+ }
55169
+ /************************************************************
55089
55170
  * Focus the graph on one topic: highlight (amber 'active' state)
55090
55171
  * every node of that topic and centre the viewport on them. An
55091
55172
  * empty topic clears the highlight. If the graph is not rendered
@@ -55554,6 +55635,23 @@ function paint_selection(gobj, ids) {
55554
55635
  let next = ids || [];
55555
55636
  priv._selected_paint_ids = next;
55556
55637
  repaint_cards(gobj, /* @__PURE__ */ new Set([...prev, ...next]));
55638
+ update_zoom_selection_button(gobj);
55639
+ }
55640
+ /************************************************************
55641
+ * Zoom-to-selection can only act on a selection.
55642
+ *
55643
+ * The attribute is toggled on the element instead of re-rendering
55644
+ * the toolbar, which would rebuild its innerHTML -- and with it
55645
+ * drop the disabled state of every OTHER button -- each time a
55646
+ * card is ticked.
55647
+ ************************************************************/
55648
+ function update_zoom_selection_button(gobj) {
55649
+ let priv = gobj.priv;
55650
+ let $container = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container");
55651
+ if (!$container) return;
55652
+ if (!$container.querySelector(".EV_ZOOM_SELECTION")) return;
55653
+ if ((priv._selected_paint_ids || []).length > 0) enableElements($container, ".EV_ZOOM_SELECTION");
55654
+ else disableElements($container, ".EV_ZOOM_SELECTION");
55557
55655
  }
55558
55656
  /************************************************************
55559
55657
  * Select a SET of nodes: what a marquee and a shift-click do.
@@ -58058,6 +58156,18 @@ function ac_auto_fit(gobj, event, kw, src) {
58058
58156
  graph_fitview(gobj);
58059
58157
  return 0;
58060
58158
  }
58159
+ /************************************************************
58160
+ * Zoom to what is selected.
58161
+ ************************************************************/
58162
+ function ac_zoom_selection(gobj, event, kw, src) {
58163
+ let ids = gobj.priv._selected_paint_ids || [];
58164
+ if (!ids.length) {
58165
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: zoom to selection with nothing selected`);
58166
+ return -1;
58167
+ }
58168
+ graph_fit_selection(gobj, ids);
58169
+ return 0;
58170
+ }
58061
58171
  function ac_focus_topic(gobj, event, kw, src) {
58062
58172
  graph_focus_topic(gobj, kw && kw.topic);
58063
58173
  return 0;
@@ -58129,7 +58239,7 @@ function ac_key_down(gobj, event, kw, src) {
58129
58239
  let fullscreen = graph_get_plugin(gobj, "fullscreen");
58130
58240
  if (fullscreen) {
58131
58241
  if (key === "F" || key === "f") fullscreen.request();
58132
- else if (key === "Escape") fullscreen.exit();
58242
+ else if (key === "Escape" && priv.is_fullscreen) fullscreen.exit();
58133
58243
  }
58134
58244
  }
58135
58245
  if (!priv.edit_mode) return 0;
@@ -58431,6 +58541,11 @@ function create_gclass$1(gclass_name) {
58431
58541
  ac_auto_fit,
58432
58542
  null
58433
58543
  ],
58544
+ [
58545
+ "EV_ZOOM_SELECTION",
58546
+ ac_zoom_selection,
58547
+ null
58548
+ ],
58434
58549
  [
58435
58550
  "EV_FOCUS_TOPIC",
58436
58551
  ac_focus_topic,
@@ -58525,6 +58640,7 @@ function create_gclass$1(gclass_name) {
58525
58640
  ["EV_ZOOM_OUT", 0],
58526
58641
  ["EV_ZOOM_RESET", 0],
58527
58642
  ["EV_AUTO_FIT", 0],
58643
+ ["EV_ZOOM_SELECTION", 0],
58528
58644
  ["EV_FOCUS_TOPIC", 0],
58529
58645
  ["EV_FIND_NODES", 0],
58530
58646
  ["EV_FIND_RESULT", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT],
@@ -53604,6 +53604,15 @@ var ICONS = `<svg xmlns="http://www.w3.org/2000/svg">
53604
53604
  <symbol id="g6-icon-fit" viewBox="0 0 448 512">
53605
53605
  <path fill="currentColor" d="M32 32C14.3 32 0 46.3 0 64l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-64 64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 32zM64 352c0-17.7-14.3-32-32-32S0 334.3 0 352l0 96c0 17.7 14.3 32 32 32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0 0-64zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32l64 0 0 64c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32l-96 0zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64-64 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l96 0c17.7 0 32-14.3 32-32l0-96z"/>
53606
53606
  </symbol>
53607
+ <!-- fit to selection: the same corner brackets as the fit icon,
53608
+ around a marked object. The two sit side by side in the
53609
+ toolbar and have to read as a family, which a borrowed glyph
53610
+ would not. (No backticks in here: the sprite lives inside a
53611
+ template literal.) -->
53612
+ <symbol id="g6-icon-fit-selection" viewBox="0 0 448 512">
53613
+ <path fill="currentColor" d="M32 32C14.3 32 0 46.3 0 64l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-64 64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 32zM64 352c0-17.7-14.3-32-32-32S0 334.3 0 352l0 96c0 17.7 14.3 32 32 32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0 0-64zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32l64 0 0 64c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32l-96 0zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64-64 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l96 0c17.7 0 32-14.3 32-32l0-96z"/>
53614
+ <rect fill="currentColor" x="144" y="176" width="160" height="160" rx="24"/>
53615
+ </symbol>
53607
53616
  <!-- crosshairs (center) -->
53608
53617
  <symbol id="g6-icon-center" viewBox="0 0 576 512">
53609
53618
  <path fill="currentColor" d="M288-16c17.7 0 32 14.3 32 32l0 18.3c98.1 14 175.7 91.6 189.7 189.7l18.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-18.3 0c-14 98.1-91.6 175.7-189.7 189.7l0 18.3c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-18.3C157.9 463.7 80.3 386.1 66.3 288L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l18.3 0C80.3 125.9 157.9 48.3 256 34.3L256 16c0-17.7 14.3-32 32-32zM131.2 288c12.7 62.7 62.1 112.1 124.8 124.8l0-12.8c0-17.7 14.3-32 32-32s32 14.3 32 32l0 12.8c62.7-12.7 112.1-62.1 124.8-124.8L432 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l12.8 0C432.1 161.3 382.7 111.9 320 99.2l0 12.8c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-12.8C193.3 111.9 143.9 161.3 131.2 224l12.8 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-12.8 0zM288 208a48 48 0 1 1 0 96 48 48 0 1 1 0-96z"/>
@@ -53846,6 +53855,7 @@ var PRIVATE_DATA$1 = {
53846
53855
  _link_saved_styles: [],
53847
53856
  _focus_topic: null,
53848
53857
  _focus_ids: [],
53858
+ _on_pointerdown_focus: null,
53849
53859
  _selected_paint_ids: [],
53850
53860
  _pending_focus_topic: null,
53851
53861
  _pending_find: null,
@@ -53910,6 +53920,10 @@ function mt_destroy$1(gobj) {
53910
53920
  i18next.off("languageChanged", priv._on_language_changed);
53911
53921
  priv._on_language_changed = null;
53912
53922
  }
53923
+ if (priv._on_pointerdown_focus) {
53924
+ priv.$container.removeEventListener("pointerdown", priv._on_pointerdown_focus);
53925
+ priv._on_pointerdown_focus = null;
53926
+ }
53913
53927
  if (priv.theme_observer) {
53914
53928
  priv.theme_observer.disconnect();
53915
53929
  priv.theme_observer = null;
@@ -54075,6 +54089,16 @@ function configure_events(gobj) {
54075
54089
  update_toolbar(gobj);
54076
54090
  };
54077
54091
  i18next.on("languageChanged", priv._on_language_changed);
54092
+ priv._on_pointerdown_focus = (ev) => {
54093
+ let target = ev.target;
54094
+ if (target && typeof target.closest === "function") {
54095
+ if (target.closest("input, textarea, select, [contenteditable]")) return;
54096
+ if (target.closest(".g6-confirm-popover, .g6-create-popover,.g6-node-popover, .g6-edge-popover")) return;
54097
+ }
54098
+ let $canvas = priv.$container.querySelector("canvas");
54099
+ if ($canvas && typeof $canvas.focus === "function") $canvas.focus({ preventScroll: true });
54100
+ };
54101
+ priv.$container.addEventListener("pointerdown", priv._on_pointerdown_focus);
54078
54102
  graph.on("keydown", (evt) => {
54079
54103
  let ctrl = !!(evt.ctrlKey || evt.metaKey);
54080
54104
  if (ctrl && (evt.key === "a" || evt.key === "A")) {
@@ -54137,6 +54161,7 @@ function update_toolbar(gobj) {
54137
54161
  if (graph_get_plugin(gobj, "toolbar")) graph.updatePlugin({ key: "toolbar" });
54138
54162
  configure_toolbar_edit(gobj);
54139
54163
  if (graph_get_plugin(gobj, "toolbar-edit")) graph.updatePlugin({ key: "toolbar-edit" });
54164
+ update_zoom_selection_button(gobj);
54140
54165
  }
54141
54166
  /************************************************************
54142
54167
  * The floating toolbars follow the theme.
@@ -54237,6 +54262,13 @@ function configure_toolbar(gobj) {
54237
54262
  title: t("actual size")
54238
54263
  }
54239
54264
  ];
54265
+ if (priv.edit_mode) items.push({
54266
+ id: "g6-icon-fit-selection",
54267
+ value: "fit-selection",
54268
+ className: "EV_ZOOM_SELECTION",
54269
+ title: t("zoom to selection"),
54270
+ disabled: true
54271
+ });
54240
54272
  if (gobj_read_bool_attr(gobj, "with_fullscreen")) {
54241
54273
  items.push({ separator: true });
54242
54274
  if (priv.is_fullscreen) items.push({
@@ -54268,6 +54300,9 @@ function configure_toolbar(gobj) {
54268
54300
  case "auto-fit":
54269
54301
  gobj_send_event(gobj, "EV_AUTO_FIT", {}, gobj);
54270
54302
  break;
54303
+ case "fit-selection":
54304
+ gobj_send_event(gobj, "EV_ZOOM_SELECTION", {}, gobj);
54305
+ break;
54271
54306
  case "center":
54272
54307
  gobj_send_event(gobj, "EV_CENTER", {}, gobj);
54273
54308
  break;
@@ -55073,6 +55108,52 @@ async function graph_fit_readable(gobj) {
55073
55108
  }
55074
55109
  }
55075
55110
  /************************************************************
55111
+ * Put the viewport on the selection: what `fit` does, for a part.
55112
+ *
55113
+ * Computed rather than delegated because `fitView()` fits the whole
55114
+ * graph and there is no subset form of it. The bounds come from the
55115
+ * elements themselves, so a node's real card size is what is
55116
+ * measured, not an assumed one; the zoom is clamped to the graph's
55117
+ * own `zoomRange`, which is the only limit that is not invented
55118
+ * here. A selection of one card filling the view is not a bug --
55119
+ * that is what zooming to it means, and it is what every editor
55120
+ * that offers the action does.
55121
+ ************************************************************/
55122
+ async function graph_fit_selection(gobj, ids) {
55123
+ let graph = gobj.priv.graph;
55124
+ if (!graph || !ids || !ids.length) return;
55125
+ let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
55126
+ let found = 0;
55127
+ for (let id of ids) {
55128
+ let bounds;
55129
+ try {
55130
+ bounds = graph.getElementRenderBounds(id);
55131
+ } catch (e) {
55132
+ continue;
55133
+ }
55134
+ if (!bounds || !bounds.min || !bounds.max) continue;
55135
+ minx = Math.min(minx, bounds.min[0]);
55136
+ miny = Math.min(miny, bounds.min[1]);
55137
+ maxx = Math.max(maxx, bounds.max[0]);
55138
+ maxy = Math.max(maxy, bounds.max[1]);
55139
+ found++;
55140
+ }
55141
+ if (!found) {
55142
+ log_error(`${gobj_short_name(gobj)}: the selection has no bounds to fit`);
55143
+ return;
55144
+ }
55145
+ let size = graph.getSize();
55146
+ let vw = Math.max(1, size[0] - 96);
55147
+ let vh = Math.max(1, size[1] - 96);
55148
+ let bw = Math.max(1, maxx - minx);
55149
+ let bh = Math.max(1, maxy - miny);
55150
+ let zoom = Math.min(vw / bw, vh / bh);
55151
+ let range = graph.getZoomRange() || [.2, 4];
55152
+ zoom = Math.max(range[0], Math.min(range[1], zoom));
55153
+ await graph.zoomTo(zoom);
55154
+ await graph.focusElement(ids);
55155
+ }
55156
+ /************************************************************
55076
55157
  * Focus the graph on one topic: highlight (amber 'active' state)
55077
55158
  * every node of that topic and centre the viewport on them. An
55078
55159
  * empty topic clears the highlight. If the graph is not rendered
@@ -55541,6 +55622,23 @@ function paint_selection(gobj, ids) {
55541
55622
  let next = ids || [];
55542
55623
  priv._selected_paint_ids = next;
55543
55624
  repaint_cards(gobj, /* @__PURE__ */ new Set([...prev, ...next]));
55625
+ update_zoom_selection_button(gobj);
55626
+ }
55627
+ /************************************************************
55628
+ * Zoom-to-selection can only act on a selection.
55629
+ *
55630
+ * The attribute is toggled on the element instead of re-rendering
55631
+ * the toolbar, which would rebuild its innerHTML -- and with it
55632
+ * drop the disabled state of every OTHER button -- each time a
55633
+ * card is ticked.
55634
+ ************************************************************/
55635
+ function update_zoom_selection_button(gobj) {
55636
+ let priv = gobj.priv;
55637
+ let $container = gobj_read_attr(gobj, "$container");
55638
+ if (!$container) return;
55639
+ if (!$container.querySelector(".EV_ZOOM_SELECTION")) return;
55640
+ if ((priv._selected_paint_ids || []).length > 0) enableElements($container, ".EV_ZOOM_SELECTION");
55641
+ else disableElements($container, ".EV_ZOOM_SELECTION");
55544
55642
  }
55545
55643
  /************************************************************
55546
55644
  * Select a SET of nodes: what a marquee and a shift-click do.
@@ -58045,6 +58143,18 @@ function ac_auto_fit(gobj, event, kw, src) {
58045
58143
  graph_fitview(gobj);
58046
58144
  return 0;
58047
58145
  }
58146
+ /************************************************************
58147
+ * Zoom to what is selected.
58148
+ ************************************************************/
58149
+ function ac_zoom_selection(gobj, event, kw, src) {
58150
+ let ids = gobj.priv._selected_paint_ids || [];
58151
+ if (!ids.length) {
58152
+ log_error(`${gobj_short_name(gobj)}: zoom to selection with nothing selected`);
58153
+ return -1;
58154
+ }
58155
+ graph_fit_selection(gobj, ids);
58156
+ return 0;
58157
+ }
58048
58158
  function ac_focus_topic(gobj, event, kw, src) {
58049
58159
  graph_focus_topic(gobj, kw && kw.topic);
58050
58160
  return 0;
@@ -58116,7 +58226,7 @@ function ac_key_down(gobj, event, kw, src) {
58116
58226
  let fullscreen = graph_get_plugin(gobj, "fullscreen");
58117
58227
  if (fullscreen) {
58118
58228
  if (key === "F" || key === "f") fullscreen.request();
58119
- else if (key === "Escape") fullscreen.exit();
58229
+ else if (key === "Escape" && priv.is_fullscreen) fullscreen.exit();
58120
58230
  }
58121
58231
  }
58122
58232
  if (!priv.edit_mode) return 0;
@@ -58418,6 +58528,11 @@ function create_gclass$1(gclass_name) {
58418
58528
  ac_auto_fit,
58419
58529
  null
58420
58530
  ],
58531
+ [
58532
+ "EV_ZOOM_SELECTION",
58533
+ ac_zoom_selection,
58534
+ null
58535
+ ],
58421
58536
  [
58422
58537
  "EV_FOCUS_TOPIC",
58423
58538
  ac_focus_topic,
@@ -58512,6 +58627,7 @@ function create_gclass$1(gclass_name) {
58512
58627
  ["EV_ZOOM_OUT", 0],
58513
58628
  ["EV_ZOOM_RESET", 0],
58514
58629
  ["EV_AUTO_FIT", 0],
58630
+ ["EV_ZOOM_SELECTION", 0],
58515
58631
  ["EV_FOCUS_TOPIC", 0],
58516
58632
  ["EV_FIND_NODES", 0],
58517
58633
  ["EV_FIND_RESULT", event_flag_t.EVF_OUTPUT_EVENT],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.17.0",
3
+ "version": "7.18.1",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -333,6 +333,7 @@ let PRIVATE_DATA = {
333
333
  _link_saved_styles: [], // saved port styles to restore on cancel
334
334
  _focus_topic: null, // topic currently focused (EV_FOCUS_TOPIC)
335
335
  _focus_ids: [], // node ids carrying the focus 'active' state
336
+ _on_pointerdown_focus: null, // listener keeping the keyboard on the canvas
336
337
  _selected_paint_ids: [], // node ids whose card is PAINTED selected
337
338
  // (G6's 'selected' state is the selection
338
339
  // itself; this is what is on screen, and
@@ -459,6 +460,13 @@ function mt_destroy(gobj)
459
460
  priv._on_language_changed = null;
460
461
  }
461
462
 
463
+ if(priv._on_pointerdown_focus) {
464
+ priv.$container.removeEventListener(
465
+ "pointerdown", priv._on_pointerdown_focus
466
+ );
467
+ priv._on_pointerdown_focus = null;
468
+ }
469
+
462
470
  if(priv.theme_observer) {
463
471
  priv.theme_observer.disconnect();
464
472
  priv.theme_observer = null;
@@ -787,6 +795,36 @@ function configure_events(gobj)
787
795
  };
788
796
  i18next.on('languageChanged', priv._on_language_changed);
789
797
 
798
+ /* Clicking a CARD is clicking a DOM element inside the container,
799
+ * and that takes the keyboard away from G6's canvas -- the only
800
+ * element that receives keydown. Selecting two nodes by clicking
801
+ * them and then pressing Escape did nothing, which is the most
802
+ * ordinary way there is to reach for it. The focus goes back on
803
+ * every press inside the graph.
804
+ *
805
+ * Except on what is there to be typed into: the popovers this
806
+ * gclass appends to its own container carry inputs, and stealing
807
+ * their focus on pointerdown would make them impossible to fill
808
+ * in.
809
+ */
810
+ priv._on_pointerdown_focus = (ev) => {
811
+ let target = ev.target;
812
+ if(target && typeof target.closest === "function") {
813
+ if(target.closest("input, textarea, select, [contenteditable]")) {
814
+ return;
815
+ }
816
+ if(target.closest(".g6-confirm-popover, .g6-create-popover," +
817
+ ".g6-node-popover, .g6-edge-popover")) {
818
+ return;
819
+ }
820
+ }
821
+ let $canvas = priv.$container.querySelector("canvas");
822
+ if($canvas && typeof $canvas.focus === "function") {
823
+ $canvas.focus({preventScroll: true});
824
+ }
825
+ };
826
+ priv.$container.addEventListener("pointerdown", priv._on_pointerdown_focus);
827
+
790
828
  /* The canvas carries a `tabIndex` of its own, so a keydown reaches
791
829
  * us only while the GRAPH has focus. That is what keeps Ctrl+A in
792
830
  * the find box a text selection and not a selection of every node:
@@ -912,6 +950,10 @@ function update_toolbar(gobj)
912
950
  key: 'toolbar-edit',
913
951
  });
914
952
  }
953
+
954
+ /* A re-render rebuilds the innerHTML, so a button whose enabled
955
+ * state is not part of getItems() has to be told again. */
956
+ update_zoom_selection_button(gobj);
915
957
  }
916
958
 
917
959
  /************************************************************
@@ -1031,6 +1073,20 @@ function configure_toolbar(gobj)
1031
1073
  { text: '1:1', value: 'reset', className: 'EV_ZOOM_RESET', title: t('actual size') },
1032
1074
  ];
1033
1075
 
1076
+ /* Only in edition, because that is where a selection
1077
+ * can exist: a button that is permanently disabled is
1078
+ * not a control, it is furniture. Disabled while the
1079
+ * selection is empty -- `paint_selection` is the one
1080
+ * funnel every change goes through, so it is the one
1081
+ * place that has to say so. */
1082
+ if(priv.edit_mode) {
1083
+ items.push({
1084
+ id: 'g6-icon-fit-selection', value: 'fit-selection',
1085
+ className: 'EV_ZOOM_SELECTION',
1086
+ title: t('zoom to selection'), disabled: true
1087
+ });
1088
+ }
1089
+
1034
1090
  if(gobj_read_bool_attr(gobj, "with_fullscreen")) {
1035
1091
  /* Full screen is a WINDOW control, not a camera one:
1036
1092
  * it goes in its own group. */
@@ -1062,6 +1118,9 @@ function configure_toolbar(gobj)
1062
1118
  case 'auto-fit':
1063
1119
  gobj_send_event(gobj, "EV_AUTO_FIT", {}, gobj);
1064
1120
  break;
1121
+ case 'fit-selection':
1122
+ gobj_send_event(gobj, "EV_ZOOM_SELECTION", {}, gobj);
1123
+ break;
1065
1124
  case 'center':
1066
1125
  gobj_send_event(gobj, "EV_CENTER", {}, gobj);
1067
1126
  break;
@@ -2290,6 +2349,65 @@ async function graph_fit_readable(gobj)
2290
2349
  }
2291
2350
  }
2292
2351
 
2352
+ /************************************************************
2353
+ * Put the viewport on the selection: what `fit` does, for a part.
2354
+ *
2355
+ * Computed rather than delegated because `fitView()` fits the whole
2356
+ * graph and there is no subset form of it. The bounds come from the
2357
+ * elements themselves, so a node's real card size is what is
2358
+ * measured, not an assumed one; the zoom is clamped to the graph's
2359
+ * own `zoomRange`, which is the only limit that is not invented
2360
+ * here. A selection of one card filling the view is not a bug --
2361
+ * that is what zooming to it means, and it is what every editor
2362
+ * that offers the action does.
2363
+ ************************************************************/
2364
+ async function graph_fit_selection(gobj, ids)
2365
+ {
2366
+ let priv = gobj.priv;
2367
+ let graph = priv.graph;
2368
+
2369
+ if(!graph || !ids || !ids.length) {
2370
+ return;
2371
+ }
2372
+
2373
+ let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
2374
+ let found = 0;
2375
+ for(let id of ids) {
2376
+ let bounds;
2377
+ try {
2378
+ bounds = graph.getElementRenderBounds(id);
2379
+ } catch(e) {
2380
+ continue; /* gone since it was selected */
2381
+ }
2382
+ if(!bounds || !bounds.min || !bounds.max) {
2383
+ continue;
2384
+ }
2385
+ minx = Math.min(minx, bounds.min[0]);
2386
+ miny = Math.min(miny, bounds.min[1]);
2387
+ maxx = Math.max(maxx, bounds.max[0]);
2388
+ maxy = Math.max(maxy, bounds.max[1]);
2389
+ found++;
2390
+ }
2391
+ if(!found) {
2392
+ log_error(`${gobj_short_name(gobj)}: the selection has no bounds to fit`);
2393
+ return;
2394
+ }
2395
+
2396
+ const MARGIN = 48;
2397
+ let size = graph.getSize();
2398
+ let vw = Math.max(1, size[0] - MARGIN * 2);
2399
+ let vh = Math.max(1, size[1] - MARGIN * 2);
2400
+ let bw = Math.max(1, maxx - minx);
2401
+ let bh = Math.max(1, maxy - miny);
2402
+
2403
+ let zoom = Math.min(vw / bw, vh / bh);
2404
+ let range = graph.getZoomRange() || [0.2, 4];
2405
+ zoom = Math.max(range[0], Math.min(range[1], zoom));
2406
+
2407
+ await graph.zoomTo(zoom);
2408
+ await graph.focusElement(ids);
2409
+ }
2410
+
2293
2411
  /************************************************************
2294
2412
  * Focus the graph on one topic: highlight (amber 'active' state)
2295
2413
  * every node of that topic and centre the viewport on them. An
@@ -3049,6 +3167,36 @@ function paint_selection(gobj, ids)
3049
3167
  priv._selected_paint_ids = next;
3050
3168
 
3051
3169
  repaint_cards(gobj, new Set([...prev, ...next]));
3170
+ update_zoom_selection_button(gobj);
3171
+ }
3172
+
3173
+ /************************************************************
3174
+ * Zoom-to-selection can only act on a selection.
3175
+ *
3176
+ * The attribute is toggled on the element instead of re-rendering
3177
+ * the toolbar, which would rebuild its innerHTML -- and with it
3178
+ * drop the disabled state of every OTHER button -- each time a
3179
+ * card is ticked.
3180
+ ************************************************************/
3181
+ function update_zoom_selection_button(gobj)
3182
+ {
3183
+ let priv = gobj.priv;
3184
+ let $container = gobj_read_attr(gobj, "$container");
3185
+
3186
+ if(!$container) {
3187
+ return;
3188
+ }
3189
+
3190
+ let $btn = $container.querySelector(".EV_ZOOM_SELECTION");
3191
+ if(!$btn) {
3192
+ return; /* no toolbar, or not in edition: nothing to enable */
3193
+ }
3194
+
3195
+ if((priv._selected_paint_ids || []).length > 0) {
3196
+ enableElements($container, ".EV_ZOOM_SELECTION");
3197
+ } else {
3198
+ disableElements($container, ".EV_ZOOM_SELECTION");
3199
+ }
3052
3200
  }
3053
3201
 
3054
3202
  /************************************************************
@@ -6774,6 +6922,25 @@ function ac_auto_fit(gobj, event, kw, src)
6774
6922
  return 0;
6775
6923
  }
6776
6924
 
6925
+ /************************************************************
6926
+ * Zoom to what is selected.
6927
+ ************************************************************/
6928
+ function ac_zoom_selection(gobj, event, kw, src)
6929
+ {
6930
+ let ids = gobj.priv._selected_paint_ids || [];
6931
+
6932
+ if(!ids.length) {
6933
+ /* The button is disabled without a selection, so getting here
6934
+ * means the event came from somewhere else. */
6935
+ log_error(`${gobj_short_name(gobj)}: zoom to selection with nothing selected`);
6936
+ return -1;
6937
+ }
6938
+
6939
+ graph_fit_selection(gobj, ids);
6940
+
6941
+ return 0;
6942
+ }
6943
+
6777
6944
  function ac_focus_topic(gobj, event, kw, src)
6778
6945
  {
6779
6946
  graph_focus_topic(gobj, kw && kw.topic);
@@ -6869,11 +7036,17 @@ function ac_key_down(gobj, event, kw, src)
6869
7036
  if(fullscreen) {
6870
7037
  if(key === "F" || key === "f") {
6871
7038
  fullscreen.request();
6872
- } else if(key === "Escape") {
6873
- /* Escape also clears the selection below, and both are
6874
- * right: the browser exits full screen on Escape
6875
- * whatever we do, so refusing to clear as well would
6876
- * only make the key do less than it appears to. */
7039
+ } else if(key === "Escape" && priv.is_fullscreen) {
7040
+ /* Only while actually IN full screen. Asking to leave
7041
+ * a full screen nobody entered throws, and since this
7042
+ * is now an action rather than a listener, that throw
7043
+ * takes the rest of the key with it -- which is how
7044
+ * Escape stopped clearing the selection.
7045
+ *
7046
+ * Escape does both when both apply, and that is right:
7047
+ * the browser leaves full screen on Escape whatever we
7048
+ * do, so refusing to clear as well would only make the
7049
+ * key do less than it appears to. */
6877
7050
  fullscreen.exit();
6878
7051
  }
6879
7052
  }
@@ -7232,6 +7405,7 @@ function create_gclass(gclass_name)
7232
7405
  ["EV_ZOOM_OUT", ac_zoom_out, null],
7233
7406
  ["EV_ZOOM_RESET", ac_zoom_reset, null],
7234
7407
  ["EV_AUTO_FIT", ac_auto_fit, null],
7408
+ ["EV_ZOOM_SELECTION", ac_zoom_selection, null],
7235
7409
  ["EV_FOCUS_TOPIC", ac_focus_topic, null],
7236
7410
  ["EV_FIND_NODES", ac_find_nodes, null],
7237
7411
  ["EV_CENTER", ac_center, null],
@@ -7278,6 +7452,7 @@ function create_gclass(gclass_name)
7278
7452
  ["EV_ZOOM_OUT", 0],
7279
7453
  ["EV_ZOOM_RESET", 0],
7280
7454
  ["EV_AUTO_FIT", 0],
7455
+ ["EV_ZOOM_SELECTION", 0],
7281
7456
  ["EV_FOCUS_TOPIC", 0],
7282
7457
  ["EV_FIND_NODES", 0],
7283
7458
  ["EV_FIND_RESULT", event_flag_t.EVF_OUTPUT_EVENT],
package/src/lib_icons.js CHANGED
@@ -52,6 +52,15 @@ const ICONS = `<svg xmlns="http://www.w3.org/2000/svg">
52
52
  <symbol id="g6-icon-fit" viewBox="0 0 448 512">
53
53
  <path fill="currentColor" d="M32 32C14.3 32 0 46.3 0 64l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-64 64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 32zM64 352c0-17.7-14.3-32-32-32S0 334.3 0 352l0 96c0 17.7 14.3 32 32 32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0 0-64zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32l64 0 0 64c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32l-96 0zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64-64 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l96 0c17.7 0 32-14.3 32-32l0-96z"/>
54
54
  </symbol>
55
+ <!-- fit to selection: the same corner brackets as the fit icon,
56
+ around a marked object. The two sit side by side in the
57
+ toolbar and have to read as a family, which a borrowed glyph
58
+ would not. (No backticks in here: the sprite lives inside a
59
+ template literal.) -->
60
+ <symbol id="g6-icon-fit-selection" viewBox="0 0 448 512">
61
+ <path fill="currentColor" d="M32 32C14.3 32 0 46.3 0 64l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-64 64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 32zM64 352c0-17.7-14.3-32-32-32S0 334.3 0 352l0 96c0 17.7 14.3 32 32 32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0 0-64zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32l64 0 0 64c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32l-96 0zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64-64 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l96 0c17.7 0 32-14.3 32-32l0-96z"/>
62
+ <rect fill="currentColor" x="144" y="176" width="160" height="160" rx="24"/>
63
+ </symbol>
55
64
  <!-- crosshairs (center) -->
56
65
  <symbol id="g6-icon-center" viewBox="0 0 576 512">
57
66
  <path fill="currentColor" d="M288-16c17.7 0 32 14.3 32 32l0 18.3c98.1 14 175.7 91.6 189.7 189.7l18.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-18.3 0c-14 98.1-91.6 175.7-189.7 189.7l0 18.3c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-18.3C157.9 463.7 80.3 386.1 66.3 288L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l18.3 0C80.3 125.9 157.9 48.3 256 34.3L256 16c0-17.7 14.3-32 32-32zM131.2 288c12.7 62.7 62.1 112.1 124.8 124.8l0-12.8c0-17.7 14.3-32 32-32s32 14.3 32 32l0 12.8c62.7-12.7 112.1-62.1 124.8-124.8L432 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l12.8 0C432.1 161.3 382.7 111.9 320 99.2l0 12.8c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-12.8C193.3 111.9 143.9 161.3 131.2 224l12.8 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-12.8 0zM288 208a48 48 0 1 1 0 96 48 48 0 1 1 0-96z"/>