@yuneta/gobj-ui 7.16.0 → 7.18.0

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
 
@@ -718,8 +729,17 @@ clicked":
718
729
  | click a node | selects it **and opens it**: resize handles, ports, popovers |
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 |
732
+ | **ctrl/cmd + A** | every node |
733
+ | the **fit-to-selection** button | puts the viewport on what is selected |
721
734
  | drag any selected node | **moves the whole selection**, as one undo |
722
- | click the canvas | clears it |
735
+ | **Delete** / **Backspace** | deletes the selection, after a confirmation that counts what it takes |
736
+ | **Esc**, or a click on the canvas | clears it |
737
+
738
+ The keys reach the graph only while the **graph has focus** — G6 gives its
739
+ canvas a `tabIndex` of its own — which is what keeps `ctrl+A` inside the find
740
+ box a selection of the TEXT and not of every node: the focus is in the input,
741
+ and the input is not inside the canvas. They arrive as `EV_KEY_DOWN` and the
742
+ action decides, so a key is as visible in the `machine` trace as a click.
723
743
 
724
744
  Three decisions are worth knowing, because each one is where this could have
725
745
  gone wrong:
@@ -752,6 +772,17 @@ predicate), or the canvas would pan under the rubber band — G6 binds
752
772
  `drag-canvas` straight to the drag events, and its own docs warn that the two
753
773
  gestures cannot both be a plain drag.
754
774
 
775
+ **A delete says what it takes, whether it is one or twenty.** The Delete key
776
+ asks the same question the per-node delete icon asks, built by the same
777
+ function: the record's key when there is one, the count when there are more,
778
+ and then the two lines that are actually at stake — *N children will be
779
+ UNLINKED, not deleted* and *it will be detached from M parents* — where over a
780
+ set the numbers are the sums. These views delete with `force`, so an operator
781
+ pressing Delete over twelve cards has to read eleven detached children, not
782
+ "are you sure". It needs no new keys: the sentence is the one `7.10.0` already
783
+ defined. The question has no icon to hang off, so it is asked in the middle of
784
+ the graph it is about.
785
+
755
786
  **A marquee selects, it does not open.** Even when it encloses exactly one
756
787
  node, the handles and ports stay away: `_selected_node_id` means *the node
757
788
  opened for editing*, and only a click sets it. Everything that hangs off a
@@ -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"/>
@@ -54088,12 +54097,15 @@ function configure_events(gobj) {
54088
54097
  update_toolbar(gobj);
54089
54098
  };
54090
54099
  i18next.default.on("languageChanged", priv._on_language_changed);
54091
- if ((0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_fullscreen")) graph.on("keydown", (evt) => {
54092
- const key = evt.key;
54093
- const fullscreen = graph_get_plugin(gobj, "fullscreen");
54094
- if (!fullscreen) return;
54095
- if (key === "F" || key === "f") fullscreen.request();
54096
- else if (key === "Escape") fullscreen.exit();
54100
+ graph.on("keydown", (evt) => {
54101
+ let ctrl = !!(evt.ctrlKey || evt.metaKey);
54102
+ if (ctrl && (evt.key === "a" || evt.key === "A")) {
54103
+ if (typeof evt.preventDefault === "function") evt.preventDefault();
54104
+ }
54105
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_KEY_DOWN", {
54106
+ key: evt.key,
54107
+ ctrl
54108
+ }, gobj);
54097
54109
  });
54098
54110
  }
54099
54111
  /************************************************************
@@ -54147,6 +54159,7 @@ function update_toolbar(gobj) {
54147
54159
  if (graph_get_plugin(gobj, "toolbar")) graph.updatePlugin({ key: "toolbar" });
54148
54160
  configure_toolbar_edit(gobj);
54149
54161
  if (graph_get_plugin(gobj, "toolbar-edit")) graph.updatePlugin({ key: "toolbar-edit" });
54162
+ update_zoom_selection_button(gobj);
54150
54163
  }
54151
54164
  /************************************************************
54152
54165
  * The floating toolbars follow the theme.
@@ -54247,6 +54260,13 @@ function configure_toolbar(gobj) {
54247
54260
  title: (0, i18next.t)("actual size")
54248
54261
  }
54249
54262
  ];
54263
+ if (priv.edit_mode) items.push({
54264
+ id: "g6-icon-fit-selection",
54265
+ value: "fit-selection",
54266
+ className: "EV_ZOOM_SELECTION",
54267
+ title: (0, i18next.t)("zoom to selection"),
54268
+ disabled: true
54269
+ });
54250
54270
  if ((0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_fullscreen")) {
54251
54271
  items.push({ separator: true });
54252
54272
  if (priv.is_fullscreen) items.push({
@@ -54278,6 +54298,9 @@ function configure_toolbar(gobj) {
54278
54298
  case "auto-fit":
54279
54299
  (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_AUTO_FIT", {}, gobj);
54280
54300
  break;
54301
+ case "fit-selection":
54302
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_ZOOM_SELECTION", {}, gobj);
54303
+ break;
54281
54304
  case "center":
54282
54305
  (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_CENTER", {}, gobj);
54283
54306
  break;
@@ -55083,6 +55106,52 @@ async function graph_fit_readable(gobj) {
55083
55106
  }
55084
55107
  }
55085
55108
  /************************************************************
55109
+ * Put the viewport on the selection: what `fit` does, for a part.
55110
+ *
55111
+ * Computed rather than delegated because `fitView()` fits the whole
55112
+ * graph and there is no subset form of it. The bounds come from the
55113
+ * elements themselves, so a node's real card size is what is
55114
+ * measured, not an assumed one; the zoom is clamped to the graph's
55115
+ * own `zoomRange`, which is the only limit that is not invented
55116
+ * here. A selection of one card filling the view is not a bug --
55117
+ * that is what zooming to it means, and it is what every editor
55118
+ * that offers the action does.
55119
+ ************************************************************/
55120
+ async function graph_fit_selection(gobj, ids) {
55121
+ let graph = gobj.priv.graph;
55122
+ if (!graph || !ids || !ids.length) return;
55123
+ let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
55124
+ let found = 0;
55125
+ for (let id of ids) {
55126
+ let bounds;
55127
+ try {
55128
+ bounds = graph.getElementRenderBounds(id);
55129
+ } catch (e) {
55130
+ continue;
55131
+ }
55132
+ if (!bounds || !bounds.min || !bounds.max) continue;
55133
+ minx = Math.min(minx, bounds.min[0]);
55134
+ miny = Math.min(miny, bounds.min[1]);
55135
+ maxx = Math.max(maxx, bounds.max[0]);
55136
+ maxy = Math.max(maxy, bounds.max[1]);
55137
+ found++;
55138
+ }
55139
+ if (!found) {
55140
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: the selection has no bounds to fit`);
55141
+ return;
55142
+ }
55143
+ let size = graph.getSize();
55144
+ let vw = Math.max(1, size[0] - 96);
55145
+ let vh = Math.max(1, size[1] - 96);
55146
+ let bw = Math.max(1, maxx - minx);
55147
+ let bh = Math.max(1, maxy - miny);
55148
+ let zoom = Math.min(vw / bw, vh / bh);
55149
+ let range = graph.getZoomRange() || [.2, 4];
55150
+ zoom = Math.max(range[0], Math.min(range[1], zoom));
55151
+ await graph.zoomTo(zoom);
55152
+ await graph.focusElement(ids);
55153
+ }
55154
+ /************************************************************
55086
55155
  * Focus the graph on one topic: highlight (amber 'active' state)
55087
55156
  * every node of that topic and centre the viewport on them. An
55088
55157
  * empty topic clears the highlight. If the graph is not rendered
@@ -55470,10 +55539,17 @@ function create_form_button_row(parent, buttons) {
55470
55539
  function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_color, onConfirm, priv_key) {
55471
55540
  hide_overlay(gobj, priv_key);
55472
55541
  let priv = gobj.priv;
55473
- if (!target_el) return;
55474
- let iconRect = target_el.getBoundingClientRect();
55475
55542
  let containerRect = priv.$container.getBoundingClientRect();
55476
- const popover = create_popover_base(iconRect.right - containerRect.left + 6, iconRect.top - containerRect.top - 4, "g6-confirm-popover", "#ff4d4f", 160);
55543
+ let left, top;
55544
+ if (target_el) {
55545
+ let iconRect = target_el.getBoundingClientRect();
55546
+ left = iconRect.right - containerRect.left + 6;
55547
+ top = iconRect.top - containerRect.top - 4;
55548
+ } else {
55549
+ left = Math.round(containerRect.width / 2) - 110;
55550
+ top = Math.round(containerRect.height / 4);
55551
+ }
55552
+ const popover = create_popover_base(left, top, "g6-confirm-popover", "#ff4d4f", 160);
55477
55553
  let msg = document.createElement("div");
55478
55554
  msg.style.cssText = "margin-bottom:10px;font-weight:500;white-space:pre-line;";
55479
55555
  msg.textContent = message;
@@ -55544,6 +55620,23 @@ function paint_selection(gobj, ids) {
55544
55620
  let next = ids || [];
55545
55621
  priv._selected_paint_ids = next;
55546
55622
  repaint_cards(gobj, /* @__PURE__ */ new Set([...prev, ...next]));
55623
+ update_zoom_selection_button(gobj);
55624
+ }
55625
+ /************************************************************
55626
+ * Zoom-to-selection can only act on a selection.
55627
+ *
55628
+ * The attribute is toggled on the element instead of re-rendering
55629
+ * the toolbar, which would rebuild its innerHTML -- and with it
55630
+ * drop the disabled state of every OTHER button -- each time a
55631
+ * card is ticked.
55632
+ ************************************************************/
55633
+ function update_zoom_selection_button(gobj) {
55634
+ let priv = gobj.priv;
55635
+ let $container = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container");
55636
+ if (!$container) return;
55637
+ if (!$container.querySelector(".EV_ZOOM_SELECTION")) return;
55638
+ if ((priv._selected_paint_ids || []).length > 0) enableElements($container, ".EV_ZOOM_SELECTION");
55639
+ else disableElements($container, ".EV_ZOOM_SELECTION");
55547
55640
  }
55548
55641
  /************************************************************
55549
55642
  * Select a SET of nodes: what a marquee and a shift-click do.
@@ -57157,17 +57250,89 @@ function execute_delete_node(gobj, nodeData) {
57157
57250
  deselect_node(gobj);
57158
57251
  }
57159
57252
  /************************************************************
57253
+ * Every node in the graph.
57254
+ ************************************************************/
57255
+ function select_all_nodes(gobj) {
57256
+ let graph = gobj.priv.graph;
57257
+ if (!graph) return;
57258
+ set_selection(gobj, ((graph.getData() || {}).nodes || []).map((nd) => nd.id));
57259
+ }
57260
+ /************************************************************
57261
+ * The nodes currently selected, as their node data.
57262
+ ************************************************************/
57263
+ function selected_nodes_data(gobj) {
57264
+ let priv = gobj.priv;
57265
+ let graph = priv.graph;
57266
+ let ids = new Set(selected_node_ids(gobj));
57267
+ for (let id of priv._selected_paint_ids || []) ids.add(id);
57268
+ if (priv._selected_node_id) ids.add(priv._selected_node_id);
57269
+ let out = [];
57270
+ for (let id of ids) {
57271
+ let nd;
57272
+ try {
57273
+ nd = graph.getNodeData(id);
57274
+ } catch (e) {
57275
+ continue;
57276
+ }
57277
+ if (nd && nd.data && nd.data.desc) out.push(nd);
57278
+ }
57279
+ return out;
57280
+ }
57281
+ /************************************************************
57282
+ * What a delete takes with it, for ONE node or for a set.
57283
+ *
57284
+ * Same sentence in both cases and the same keys, because it is the
57285
+ * same warning: these views delete with `force`, which UNLINKS the
57286
+ * children (they survive, loose) and cleans the node off its
57287
+ * parents. Over a set the two numbers are the sums -- an operator
57288
+ * about to detach eleven records has to read eleven, not "are you
57289
+ * sure".
57290
+ ************************************************************/
57291
+ function delete_question(nodes) {
57292
+ let children = 0;
57293
+ let parents = 0;
57294
+ for (let nd of nodes) {
57295
+ let impact = delete_impact(nd.data.desc, nd.data.record || {});
57296
+ children += impact.children;
57297
+ parents += impact.parents;
57298
+ }
57299
+ let question;
57300
+ if (nodes.length === 1) {
57301
+ let nd = nodes[0];
57302
+ question = (0, i18next.t)("delete") + " " + nd.data.desc.topic_name + ": " + (nd.data.record || {}).id + "?";
57303
+ } else question = (0, i18next.t)("delete") + " " + nodes.length + " " + (0, i18next.t)("records") + "?";
57304
+ if (children > 0) question += "\n" + children + " " + (0, i18next.t)("children will be unlinked, not deleted", { count: children });
57305
+ if (parents > 0) question += "\n" + (0, i18next.t)("it will be detached from") + " " + parents + " " + (0, i18next.t)("parents", { count: parents });
57306
+ return question;
57307
+ }
57308
+ /************************************************************
57309
+ * Delete every selected node (the Delete key).
57310
+ ************************************************************/
57311
+ function request_delete_selection(gobj) {
57312
+ let nodes = selected_nodes_data(gobj);
57313
+ if (!nodes.length) return;
57314
+ if (!(0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "confirm_delete_node")) {
57315
+ execute_delete_selection(gobj, nodes);
57316
+ return;
57317
+ }
57318
+ show_confirm_popover(gobj, null, delete_question(nodes), "delete", "#ff4d4f", () => execute_delete_selection(gobj, nodes), "_delete_confirm_el");
57319
+ }
57320
+ function execute_delete_selection(gobj, nodes) {
57321
+ let priv = gobj.priv;
57322
+ hide_delete_confirm(gobj);
57323
+ for (let nd of nodes) (0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_DELETE_NODE", {
57324
+ treedb_name: priv.treedb_name,
57325
+ topic_name: nd.data.desc.topic_name,
57326
+ record: nd.data.record
57327
+ });
57328
+ deselect_node(gobj);
57329
+ }
57330
+ /************************************************************
57160
57331
  * Show a confirmation popover next to the delete icon.
57161
57332
  ************************************************************/
57162
57333
  function show_delete_confirm(gobj, nodeData) {
57163
57334
  let priv = gobj.priv;
57164
- let record = nodeData.data.record || {};
57165
- let topic_name = nodeData.data.desc.topic_name;
57166
- let impact = delete_impact(nodeData.data.desc, record);
57167
- let question = (0, i18next.t)("delete") + " " + topic_name + ": " + record.id + "?";
57168
- if (impact.children > 0) question += "\n" + impact.children + " " + (0, i18next.t)("children will be unlinked, not deleted", { count: impact.children });
57169
- if (impact.parents > 0) question += "\n" + (0, i18next.t)("it will be detached from") + " " + impact.parents + " " + (0, i18next.t)("parents", { count: impact.parents });
57170
- show_confirm_popover(gobj, priv._node_delete_el, question, "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
57335
+ show_confirm_popover(gobj, priv._node_delete_el, delete_question([nodeData]), "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
57171
57336
  }
57172
57337
  function hide_delete_confirm(gobj) {
57173
57338
  hide_overlay(gobj, "_delete_confirm_el");
@@ -57976,6 +58141,18 @@ function ac_auto_fit(gobj, event, kw, src) {
57976
58141
  graph_fitview(gobj);
57977
58142
  return 0;
57978
58143
  }
58144
+ /************************************************************
58145
+ * Zoom to what is selected.
58146
+ ************************************************************/
58147
+ function ac_zoom_selection(gobj, event, kw, src) {
58148
+ let ids = gobj.priv._selected_paint_ids || [];
58149
+ if (!ids.length) {
58150
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: zoom to selection with nothing selected`);
58151
+ return -1;
58152
+ }
58153
+ graph_fit_selection(gobj, ids);
58154
+ return 0;
58155
+ }
57979
58156
  function ac_focus_topic(gobj, event, kw, src) {
57980
58157
  graph_focus_topic(gobj, kw && kw.topic);
57981
58158
  return 0;
@@ -58034,6 +58211,38 @@ function ac_node_drag_end(gobj, event, kw, src) {
58034
58211
  return 0;
58035
58212
  }
58036
58213
  /************************************************************
58214
+ * A key, while the graph has focus.
58215
+ *
58216
+ * Full screen keeps the two keys it always had. The rest belong to
58217
+ * the selection, so they only mean anything in edition -- outside
58218
+ * it there is nothing selected to clear, select or delete.
58219
+ ************************************************************/
58220
+ function ac_key_down(gobj, event, kw, src) {
58221
+ let priv = gobj.priv;
58222
+ let key = kw && kw.key || "";
58223
+ if ((0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_fullscreen")) {
58224
+ let fullscreen = graph_get_plugin(gobj, "fullscreen");
58225
+ if (fullscreen) {
58226
+ if (key === "F" || key === "f") fullscreen.request();
58227
+ else if (key === "Escape") fullscreen.exit();
58228
+ }
58229
+ }
58230
+ if (!priv.edit_mode) return 0;
58231
+ if (key === "Escape") {
58232
+ deselect_node(gobj);
58233
+ return 0;
58234
+ }
58235
+ if (kw.ctrl && (key === "a" || key === "A")) {
58236
+ select_all_nodes(gobj);
58237
+ return 0;
58238
+ }
58239
+ if (key === "Delete" || key === "Backspace") {
58240
+ request_delete_selection(gobj);
58241
+ return 0;
58242
+ }
58243
+ return 0;
58244
+ }
58245
+ /************************************************************
58037
58246
  * The rubber band let go: these are the nodes it enclosed.
58038
58247
  *
58039
58248
  * They arrive in the kw because `onSelect` runs BEFORE G6 writes
@@ -58292,6 +58501,11 @@ function create_gclass$1(gclass_name) {
58292
58501
  ac_brush_select,
58293
58502
  null
58294
58503
  ],
58504
+ [
58505
+ "EV_KEY_DOWN",
58506
+ ac_key_down,
58507
+ null
58508
+ ],
58295
58509
  [
58296
58510
  "EV_ZOOM_IN",
58297
58511
  ac_zoom_in,
@@ -58312,6 +58526,11 @@ function create_gclass$1(gclass_name) {
58312
58526
  ac_auto_fit,
58313
58527
  null
58314
58528
  ],
58529
+ [
58530
+ "EV_ZOOM_SELECTION",
58531
+ ac_zoom_selection,
58532
+ null
58533
+ ],
58315
58534
  [
58316
58535
  "EV_FOCUS_TOPIC",
58317
58536
  ac_focus_topic,
@@ -58401,10 +58620,12 @@ function create_gclass$1(gclass_name) {
58401
58620
  ["EV_CANVAS_CLICK", 0],
58402
58621
  ["EV_NODE_DRAG_END", 0],
58403
58622
  ["EV_BRUSH_SELECT", 0],
58623
+ ["EV_KEY_DOWN", 0],
58404
58624
  ["EV_ZOOM_IN", 0],
58405
58625
  ["EV_ZOOM_OUT", 0],
58406
58626
  ["EV_ZOOM_RESET", 0],
58407
58627
  ["EV_AUTO_FIT", 0],
58628
+ ["EV_ZOOM_SELECTION", 0],
58408
58629
  ["EV_FOCUS_TOPIC", 0],
58409
58630
  ["EV_FIND_NODES", 0],
58410
58631
  ["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"/>
@@ -54075,12 +54084,15 @@ function configure_events(gobj) {
54075
54084
  update_toolbar(gobj);
54076
54085
  };
54077
54086
  i18next.on("languageChanged", priv._on_language_changed);
54078
- if (gobj_read_bool_attr(gobj, "with_fullscreen")) graph.on("keydown", (evt) => {
54079
- const key = evt.key;
54080
- const fullscreen = graph_get_plugin(gobj, "fullscreen");
54081
- if (!fullscreen) return;
54082
- if (key === "F" || key === "f") fullscreen.request();
54083
- else if (key === "Escape") fullscreen.exit();
54087
+ graph.on("keydown", (evt) => {
54088
+ let ctrl = !!(evt.ctrlKey || evt.metaKey);
54089
+ if (ctrl && (evt.key === "a" || evt.key === "A")) {
54090
+ if (typeof evt.preventDefault === "function") evt.preventDefault();
54091
+ }
54092
+ gobj_send_event(gobj, "EV_KEY_DOWN", {
54093
+ key: evt.key,
54094
+ ctrl
54095
+ }, gobj);
54084
54096
  });
54085
54097
  }
54086
54098
  /************************************************************
@@ -54134,6 +54146,7 @@ function update_toolbar(gobj) {
54134
54146
  if (graph_get_plugin(gobj, "toolbar")) graph.updatePlugin({ key: "toolbar" });
54135
54147
  configure_toolbar_edit(gobj);
54136
54148
  if (graph_get_plugin(gobj, "toolbar-edit")) graph.updatePlugin({ key: "toolbar-edit" });
54149
+ update_zoom_selection_button(gobj);
54137
54150
  }
54138
54151
  /************************************************************
54139
54152
  * The floating toolbars follow the theme.
@@ -54234,6 +54247,13 @@ function configure_toolbar(gobj) {
54234
54247
  title: t("actual size")
54235
54248
  }
54236
54249
  ];
54250
+ if (priv.edit_mode) items.push({
54251
+ id: "g6-icon-fit-selection",
54252
+ value: "fit-selection",
54253
+ className: "EV_ZOOM_SELECTION",
54254
+ title: t("zoom to selection"),
54255
+ disabled: true
54256
+ });
54237
54257
  if (gobj_read_bool_attr(gobj, "with_fullscreen")) {
54238
54258
  items.push({ separator: true });
54239
54259
  if (priv.is_fullscreen) items.push({
@@ -54265,6 +54285,9 @@ function configure_toolbar(gobj) {
54265
54285
  case "auto-fit":
54266
54286
  gobj_send_event(gobj, "EV_AUTO_FIT", {}, gobj);
54267
54287
  break;
54288
+ case "fit-selection":
54289
+ gobj_send_event(gobj, "EV_ZOOM_SELECTION", {}, gobj);
54290
+ break;
54268
54291
  case "center":
54269
54292
  gobj_send_event(gobj, "EV_CENTER", {}, gobj);
54270
54293
  break;
@@ -55070,6 +55093,52 @@ async function graph_fit_readable(gobj) {
55070
55093
  }
55071
55094
  }
55072
55095
  /************************************************************
55096
+ * Put the viewport on the selection: what `fit` does, for a part.
55097
+ *
55098
+ * Computed rather than delegated because `fitView()` fits the whole
55099
+ * graph and there is no subset form of it. The bounds come from the
55100
+ * elements themselves, so a node's real card size is what is
55101
+ * measured, not an assumed one; the zoom is clamped to the graph's
55102
+ * own `zoomRange`, which is the only limit that is not invented
55103
+ * here. A selection of one card filling the view is not a bug --
55104
+ * that is what zooming to it means, and it is what every editor
55105
+ * that offers the action does.
55106
+ ************************************************************/
55107
+ async function graph_fit_selection(gobj, ids) {
55108
+ let graph = gobj.priv.graph;
55109
+ if (!graph || !ids || !ids.length) return;
55110
+ let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
55111
+ let found = 0;
55112
+ for (let id of ids) {
55113
+ let bounds;
55114
+ try {
55115
+ bounds = graph.getElementRenderBounds(id);
55116
+ } catch (e) {
55117
+ continue;
55118
+ }
55119
+ if (!bounds || !bounds.min || !bounds.max) continue;
55120
+ minx = Math.min(minx, bounds.min[0]);
55121
+ miny = Math.min(miny, bounds.min[1]);
55122
+ maxx = Math.max(maxx, bounds.max[0]);
55123
+ maxy = Math.max(maxy, bounds.max[1]);
55124
+ found++;
55125
+ }
55126
+ if (!found) {
55127
+ log_error(`${gobj_short_name(gobj)}: the selection has no bounds to fit`);
55128
+ return;
55129
+ }
55130
+ let size = graph.getSize();
55131
+ let vw = Math.max(1, size[0] - 96);
55132
+ let vh = Math.max(1, size[1] - 96);
55133
+ let bw = Math.max(1, maxx - minx);
55134
+ let bh = Math.max(1, maxy - miny);
55135
+ let zoom = Math.min(vw / bw, vh / bh);
55136
+ let range = graph.getZoomRange() || [.2, 4];
55137
+ zoom = Math.max(range[0], Math.min(range[1], zoom));
55138
+ await graph.zoomTo(zoom);
55139
+ await graph.focusElement(ids);
55140
+ }
55141
+ /************************************************************
55073
55142
  * Focus the graph on one topic: highlight (amber 'active' state)
55074
55143
  * every node of that topic and centre the viewport on them. An
55075
55144
  * empty topic clears the highlight. If the graph is not rendered
@@ -55457,10 +55526,17 @@ function create_form_button_row(parent, buttons) {
55457
55526
  function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_color, onConfirm, priv_key) {
55458
55527
  hide_overlay(gobj, priv_key);
55459
55528
  let priv = gobj.priv;
55460
- if (!target_el) return;
55461
- let iconRect = target_el.getBoundingClientRect();
55462
55529
  let containerRect = priv.$container.getBoundingClientRect();
55463
- const popover = create_popover_base(iconRect.right - containerRect.left + 6, iconRect.top - containerRect.top - 4, "g6-confirm-popover", "#ff4d4f", 160);
55530
+ let left, top;
55531
+ if (target_el) {
55532
+ let iconRect = target_el.getBoundingClientRect();
55533
+ left = iconRect.right - containerRect.left + 6;
55534
+ top = iconRect.top - containerRect.top - 4;
55535
+ } else {
55536
+ left = Math.round(containerRect.width / 2) - 110;
55537
+ top = Math.round(containerRect.height / 4);
55538
+ }
55539
+ const popover = create_popover_base(left, top, "g6-confirm-popover", "#ff4d4f", 160);
55464
55540
  let msg = document.createElement("div");
55465
55541
  msg.style.cssText = "margin-bottom:10px;font-weight:500;white-space:pre-line;";
55466
55542
  msg.textContent = message;
@@ -55531,6 +55607,23 @@ function paint_selection(gobj, ids) {
55531
55607
  let next = ids || [];
55532
55608
  priv._selected_paint_ids = next;
55533
55609
  repaint_cards(gobj, /* @__PURE__ */ new Set([...prev, ...next]));
55610
+ update_zoom_selection_button(gobj);
55611
+ }
55612
+ /************************************************************
55613
+ * Zoom-to-selection can only act on a selection.
55614
+ *
55615
+ * The attribute is toggled on the element instead of re-rendering
55616
+ * the toolbar, which would rebuild its innerHTML -- and with it
55617
+ * drop the disabled state of every OTHER button -- each time a
55618
+ * card is ticked.
55619
+ ************************************************************/
55620
+ function update_zoom_selection_button(gobj) {
55621
+ let priv = gobj.priv;
55622
+ let $container = gobj_read_attr(gobj, "$container");
55623
+ if (!$container) return;
55624
+ if (!$container.querySelector(".EV_ZOOM_SELECTION")) return;
55625
+ if ((priv._selected_paint_ids || []).length > 0) enableElements($container, ".EV_ZOOM_SELECTION");
55626
+ else disableElements($container, ".EV_ZOOM_SELECTION");
55534
55627
  }
55535
55628
  /************************************************************
55536
55629
  * Select a SET of nodes: what a marquee and a shift-click do.
@@ -57144,17 +57237,89 @@ function execute_delete_node(gobj, nodeData) {
57144
57237
  deselect_node(gobj);
57145
57238
  }
57146
57239
  /************************************************************
57240
+ * Every node in the graph.
57241
+ ************************************************************/
57242
+ function select_all_nodes(gobj) {
57243
+ let graph = gobj.priv.graph;
57244
+ if (!graph) return;
57245
+ set_selection(gobj, ((graph.getData() || {}).nodes || []).map((nd) => nd.id));
57246
+ }
57247
+ /************************************************************
57248
+ * The nodes currently selected, as their node data.
57249
+ ************************************************************/
57250
+ function selected_nodes_data(gobj) {
57251
+ let priv = gobj.priv;
57252
+ let graph = priv.graph;
57253
+ let ids = new Set(selected_node_ids(gobj));
57254
+ for (let id of priv._selected_paint_ids || []) ids.add(id);
57255
+ if (priv._selected_node_id) ids.add(priv._selected_node_id);
57256
+ let out = [];
57257
+ for (let id of ids) {
57258
+ let nd;
57259
+ try {
57260
+ nd = graph.getNodeData(id);
57261
+ } catch (e) {
57262
+ continue;
57263
+ }
57264
+ if (nd && nd.data && nd.data.desc) out.push(nd);
57265
+ }
57266
+ return out;
57267
+ }
57268
+ /************************************************************
57269
+ * What a delete takes with it, for ONE node or for a set.
57270
+ *
57271
+ * Same sentence in both cases and the same keys, because it is the
57272
+ * same warning: these views delete with `force`, which UNLINKS the
57273
+ * children (they survive, loose) and cleans the node off its
57274
+ * parents. Over a set the two numbers are the sums -- an operator
57275
+ * about to detach eleven records has to read eleven, not "are you
57276
+ * sure".
57277
+ ************************************************************/
57278
+ function delete_question(nodes) {
57279
+ let children = 0;
57280
+ let parents = 0;
57281
+ for (let nd of nodes) {
57282
+ let impact = delete_impact(nd.data.desc, nd.data.record || {});
57283
+ children += impact.children;
57284
+ parents += impact.parents;
57285
+ }
57286
+ let question;
57287
+ if (nodes.length === 1) {
57288
+ let nd = nodes[0];
57289
+ question = t("delete") + " " + nd.data.desc.topic_name + ": " + (nd.data.record || {}).id + "?";
57290
+ } else question = t("delete") + " " + nodes.length + " " + t("records") + "?";
57291
+ if (children > 0) question += "\n" + children + " " + t("children will be unlinked, not deleted", { count: children });
57292
+ if (parents > 0) question += "\n" + t("it will be detached from") + " " + parents + " " + t("parents", { count: parents });
57293
+ return question;
57294
+ }
57295
+ /************************************************************
57296
+ * Delete every selected node (the Delete key).
57297
+ ************************************************************/
57298
+ function request_delete_selection(gobj) {
57299
+ let nodes = selected_nodes_data(gobj);
57300
+ if (!nodes.length) return;
57301
+ if (!gobj_read_bool_attr(gobj, "confirm_delete_node")) {
57302
+ execute_delete_selection(gobj, nodes);
57303
+ return;
57304
+ }
57305
+ show_confirm_popover(gobj, null, delete_question(nodes), "delete", "#ff4d4f", () => execute_delete_selection(gobj, nodes), "_delete_confirm_el");
57306
+ }
57307
+ function execute_delete_selection(gobj, nodes) {
57308
+ let priv = gobj.priv;
57309
+ hide_delete_confirm(gobj);
57310
+ for (let nd of nodes) gobj_publish_event(gobj, "EV_DELETE_NODE", {
57311
+ treedb_name: priv.treedb_name,
57312
+ topic_name: nd.data.desc.topic_name,
57313
+ record: nd.data.record
57314
+ });
57315
+ deselect_node(gobj);
57316
+ }
57317
+ /************************************************************
57147
57318
  * Show a confirmation popover next to the delete icon.
57148
57319
  ************************************************************/
57149
57320
  function show_delete_confirm(gobj, nodeData) {
57150
57321
  let priv = gobj.priv;
57151
- let record = nodeData.data.record || {};
57152
- let topic_name = nodeData.data.desc.topic_name;
57153
- let impact = delete_impact(nodeData.data.desc, record);
57154
- let question = t("delete") + " " + topic_name + ": " + record.id + "?";
57155
- if (impact.children > 0) question += "\n" + impact.children + " " + t("children will be unlinked, not deleted", { count: impact.children });
57156
- if (impact.parents > 0) question += "\n" + t("it will be detached from") + " " + impact.parents + " " + t("parents", { count: impact.parents });
57157
- show_confirm_popover(gobj, priv._node_delete_el, question, "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
57322
+ show_confirm_popover(gobj, priv._node_delete_el, delete_question([nodeData]), "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
57158
57323
  }
57159
57324
  function hide_delete_confirm(gobj) {
57160
57325
  hide_overlay(gobj, "_delete_confirm_el");
@@ -57963,6 +58128,18 @@ function ac_auto_fit(gobj, event, kw, src) {
57963
58128
  graph_fitview(gobj);
57964
58129
  return 0;
57965
58130
  }
58131
+ /************************************************************
58132
+ * Zoom to what is selected.
58133
+ ************************************************************/
58134
+ function ac_zoom_selection(gobj, event, kw, src) {
58135
+ let ids = gobj.priv._selected_paint_ids || [];
58136
+ if (!ids.length) {
58137
+ log_error(`${gobj_short_name(gobj)}: zoom to selection with nothing selected`);
58138
+ return -1;
58139
+ }
58140
+ graph_fit_selection(gobj, ids);
58141
+ return 0;
58142
+ }
57966
58143
  function ac_focus_topic(gobj, event, kw, src) {
57967
58144
  graph_focus_topic(gobj, kw && kw.topic);
57968
58145
  return 0;
@@ -58021,6 +58198,38 @@ function ac_node_drag_end(gobj, event, kw, src) {
58021
58198
  return 0;
58022
58199
  }
58023
58200
  /************************************************************
58201
+ * A key, while the graph has focus.
58202
+ *
58203
+ * Full screen keeps the two keys it always had. The rest belong to
58204
+ * the selection, so they only mean anything in edition -- outside
58205
+ * it there is nothing selected to clear, select or delete.
58206
+ ************************************************************/
58207
+ function ac_key_down(gobj, event, kw, src) {
58208
+ let priv = gobj.priv;
58209
+ let key = kw && kw.key || "";
58210
+ if (gobj_read_bool_attr(gobj, "with_fullscreen")) {
58211
+ let fullscreen = graph_get_plugin(gobj, "fullscreen");
58212
+ if (fullscreen) {
58213
+ if (key === "F" || key === "f") fullscreen.request();
58214
+ else if (key === "Escape") fullscreen.exit();
58215
+ }
58216
+ }
58217
+ if (!priv.edit_mode) return 0;
58218
+ if (key === "Escape") {
58219
+ deselect_node(gobj);
58220
+ return 0;
58221
+ }
58222
+ if (kw.ctrl && (key === "a" || key === "A")) {
58223
+ select_all_nodes(gobj);
58224
+ return 0;
58225
+ }
58226
+ if (key === "Delete" || key === "Backspace") {
58227
+ request_delete_selection(gobj);
58228
+ return 0;
58229
+ }
58230
+ return 0;
58231
+ }
58232
+ /************************************************************
58024
58233
  * The rubber band let go: these are the nodes it enclosed.
58025
58234
  *
58026
58235
  * They arrive in the kw because `onSelect` runs BEFORE G6 writes
@@ -58279,6 +58488,11 @@ function create_gclass$1(gclass_name) {
58279
58488
  ac_brush_select,
58280
58489
  null
58281
58490
  ],
58491
+ [
58492
+ "EV_KEY_DOWN",
58493
+ ac_key_down,
58494
+ null
58495
+ ],
58282
58496
  [
58283
58497
  "EV_ZOOM_IN",
58284
58498
  ac_zoom_in,
@@ -58299,6 +58513,11 @@ function create_gclass$1(gclass_name) {
58299
58513
  ac_auto_fit,
58300
58514
  null
58301
58515
  ],
58516
+ [
58517
+ "EV_ZOOM_SELECTION",
58518
+ ac_zoom_selection,
58519
+ null
58520
+ ],
58302
58521
  [
58303
58522
  "EV_FOCUS_TOPIC",
58304
58523
  ac_focus_topic,
@@ -58388,10 +58607,12 @@ function create_gclass$1(gclass_name) {
58388
58607
  ["EV_CANVAS_CLICK", 0],
58389
58608
  ["EV_NODE_DRAG_END", 0],
58390
58609
  ["EV_BRUSH_SELECT", 0],
58610
+ ["EV_KEY_DOWN", 0],
58391
58611
  ["EV_ZOOM_IN", 0],
58392
58612
  ["EV_ZOOM_OUT", 0],
58393
58613
  ["EV_ZOOM_RESET", 0],
58394
58614
  ["EV_AUTO_FIT", 0],
58615
+ ["EV_ZOOM_SELECTION", 0],
58395
58616
  ["EV_FOCUS_TOPIC", 0],
58396
58617
  ["EV_FIND_NODES", 0],
58397
58618
  ["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.16.0",
3
+ "version": "7.18.0",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -787,20 +787,24 @@ function configure_events(gobj)
787
787
  };
788
788
  i18next.on('languageChanged', priv._on_language_changed);
789
789
 
790
- if(gobj_read_bool_attr(gobj, "with_fullscreen")) {
791
- graph.on("keydown", (evt) => {
792
- const key = evt.key;
793
- const fullscreen = graph_get_plugin(gobj, "fullscreen");
794
- if(!fullscreen) {
795
- return;
796
- }
797
- if(key === "F" || key === "f") {
798
- fullscreen.request();
799
- } else if(key === "Escape") {
800
- fullscreen.exit();
790
+ /* The canvas carries a `tabIndex` of its own, so a keydown reaches
791
+ * us only while the GRAPH has focus. That is what keeps Ctrl+A in
792
+ * the find box a text selection and not a selection of every node:
793
+ * the focus is in the input, and the input is not inside the
794
+ * canvas. The callback only translates -- the work is in the
795
+ * action, like every other gesture here. */
796
+ graph.on("keydown", (evt) => {
797
+ let ctrl = !!(evt.ctrlKey || evt.metaKey);
798
+
799
+ if(ctrl && (evt.key === "a" || evt.key === "A")) {
800
+ /* Or the browser selects the page's text underneath. */
801
+ if(typeof evt.preventDefault === "function") {
802
+ evt.preventDefault();
801
803
  }
802
- });
803
- }
804
+ }
805
+
806
+ gobj_send_event(gobj, "EV_KEY_DOWN", {key: evt.key, ctrl: ctrl}, gobj);
807
+ });
804
808
  }
805
809
 
806
810
  /************************************************************
@@ -908,6 +912,10 @@ function update_toolbar(gobj)
908
912
  key: 'toolbar-edit',
909
913
  });
910
914
  }
915
+
916
+ /* A re-render rebuilds the innerHTML, so a button whose enabled
917
+ * state is not part of getItems() has to be told again. */
918
+ update_zoom_selection_button(gobj);
911
919
  }
912
920
 
913
921
  /************************************************************
@@ -1027,6 +1035,20 @@ function configure_toolbar(gobj)
1027
1035
  { text: '1:1', value: 'reset', className: 'EV_ZOOM_RESET', title: t('actual size') },
1028
1036
  ];
1029
1037
 
1038
+ /* Only in edition, because that is where a selection
1039
+ * can exist: a button that is permanently disabled is
1040
+ * not a control, it is furniture. Disabled while the
1041
+ * selection is empty -- `paint_selection` is the one
1042
+ * funnel every change goes through, so it is the one
1043
+ * place that has to say so. */
1044
+ if(priv.edit_mode) {
1045
+ items.push({
1046
+ id: 'g6-icon-fit-selection', value: 'fit-selection',
1047
+ className: 'EV_ZOOM_SELECTION',
1048
+ title: t('zoom to selection'), disabled: true
1049
+ });
1050
+ }
1051
+
1030
1052
  if(gobj_read_bool_attr(gobj, "with_fullscreen")) {
1031
1053
  /* Full screen is a WINDOW control, not a camera one:
1032
1054
  * it goes in its own group. */
@@ -1058,6 +1080,9 @@ function configure_toolbar(gobj)
1058
1080
  case 'auto-fit':
1059
1081
  gobj_send_event(gobj, "EV_AUTO_FIT", {}, gobj);
1060
1082
  break;
1083
+ case 'fit-selection':
1084
+ gobj_send_event(gobj, "EV_ZOOM_SELECTION", {}, gobj);
1085
+ break;
1061
1086
  case 'center':
1062
1087
  gobj_send_event(gobj, "EV_CENTER", {}, gobj);
1063
1088
  break;
@@ -2286,6 +2311,65 @@ async function graph_fit_readable(gobj)
2286
2311
  }
2287
2312
  }
2288
2313
 
2314
+ /************************************************************
2315
+ * Put the viewport on the selection: what `fit` does, for a part.
2316
+ *
2317
+ * Computed rather than delegated because `fitView()` fits the whole
2318
+ * graph and there is no subset form of it. The bounds come from the
2319
+ * elements themselves, so a node's real card size is what is
2320
+ * measured, not an assumed one; the zoom is clamped to the graph's
2321
+ * own `zoomRange`, which is the only limit that is not invented
2322
+ * here. A selection of one card filling the view is not a bug --
2323
+ * that is what zooming to it means, and it is what every editor
2324
+ * that offers the action does.
2325
+ ************************************************************/
2326
+ async function graph_fit_selection(gobj, ids)
2327
+ {
2328
+ let priv = gobj.priv;
2329
+ let graph = priv.graph;
2330
+
2331
+ if(!graph || !ids || !ids.length) {
2332
+ return;
2333
+ }
2334
+
2335
+ let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
2336
+ let found = 0;
2337
+ for(let id of ids) {
2338
+ let bounds;
2339
+ try {
2340
+ bounds = graph.getElementRenderBounds(id);
2341
+ } catch(e) {
2342
+ continue; /* gone since it was selected */
2343
+ }
2344
+ if(!bounds || !bounds.min || !bounds.max) {
2345
+ continue;
2346
+ }
2347
+ minx = Math.min(minx, bounds.min[0]);
2348
+ miny = Math.min(miny, bounds.min[1]);
2349
+ maxx = Math.max(maxx, bounds.max[0]);
2350
+ maxy = Math.max(maxy, bounds.max[1]);
2351
+ found++;
2352
+ }
2353
+ if(!found) {
2354
+ log_error(`${gobj_short_name(gobj)}: the selection has no bounds to fit`);
2355
+ return;
2356
+ }
2357
+
2358
+ const MARGIN = 48;
2359
+ let size = graph.getSize();
2360
+ let vw = Math.max(1, size[0] - MARGIN * 2);
2361
+ let vh = Math.max(1, size[1] - MARGIN * 2);
2362
+ let bw = Math.max(1, maxx - minx);
2363
+ let bh = Math.max(1, maxy - miny);
2364
+
2365
+ let zoom = Math.min(vw / bw, vh / bh);
2366
+ let range = graph.getZoomRange() || [0.2, 4];
2367
+ zoom = Math.max(range[0], Math.min(range[1], zoom));
2368
+
2369
+ await graph.zoomTo(zoom);
2370
+ await graph.focusElement(ids);
2371
+ }
2372
+
2289
2373
  /************************************************************
2290
2374
  * Focus the graph on one topic: highlight (amber 'active' state)
2291
2375
  * every node of that topic and centre the viewport on them. An
@@ -2925,14 +3009,19 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
2925
3009
  hide_overlay(gobj, priv_key);
2926
3010
 
2927
3011
  let priv = gobj.priv;
2928
- if(!target_el) {
2929
- return;
2930
- }
2931
-
2932
- let iconRect = target_el.getBoundingClientRect();
2933
3012
  let containerRect = priv.$container.getBoundingClientRect();
2934
- let left = iconRect.right - containerRect.left + 6;
2935
- let top = iconRect.top - containerRect.top - 4;
3013
+ let left, top;
3014
+
3015
+ if(target_el) {
3016
+ let iconRect = target_el.getBoundingClientRect();
3017
+ left = iconRect.right - containerRect.left + 6;
3018
+ top = iconRect.top - containerRect.top - 4;
3019
+ } else {
3020
+ /* A question about a SET has no icon to hang off: it is asked
3021
+ * in the middle of the graph it is about. */
3022
+ left = Math.round(containerRect.width / 2) - 110;
3023
+ top = Math.round(containerRect.height / 4);
3024
+ }
2936
3025
 
2937
3026
  const popover = create_popover_base(left, top, 'g6-confirm-popover', '#ff4d4f', 160);
2938
3027
 
@@ -3040,6 +3129,36 @@ function paint_selection(gobj, ids)
3040
3129
  priv._selected_paint_ids = next;
3041
3130
 
3042
3131
  repaint_cards(gobj, new Set([...prev, ...next]));
3132
+ update_zoom_selection_button(gobj);
3133
+ }
3134
+
3135
+ /************************************************************
3136
+ * Zoom-to-selection can only act on a selection.
3137
+ *
3138
+ * The attribute is toggled on the element instead of re-rendering
3139
+ * the toolbar, which would rebuild its innerHTML -- and with it
3140
+ * drop the disabled state of every OTHER button -- each time a
3141
+ * card is ticked.
3142
+ ************************************************************/
3143
+ function update_zoom_selection_button(gobj)
3144
+ {
3145
+ let priv = gobj.priv;
3146
+ let $container = gobj_read_attr(gobj, "$container");
3147
+
3148
+ if(!$container) {
3149
+ return;
3150
+ }
3151
+
3152
+ let $btn = $container.querySelector(".EV_ZOOM_SELECTION");
3153
+ if(!$btn) {
3154
+ return; /* no toolbar, or not in edition: nothing to enable */
3155
+ }
3156
+
3157
+ if((priv._selected_paint_ids || []).length > 0) {
3158
+ enableElements($container, ".EV_ZOOM_SELECTION");
3159
+ } else {
3160
+ disableElements($container, ".EV_ZOOM_SELECTION");
3161
+ }
3043
3162
  }
3044
3163
 
3045
3164
  /************************************************************
@@ -5347,31 +5466,151 @@ function execute_delete_node(gobj, nodeData)
5347
5466
  }
5348
5467
 
5349
5468
  /************************************************************
5350
- * Show a confirmation popover next to the delete icon.
5469
+ * Every node in the graph.
5351
5470
  ************************************************************/
5352
- function show_delete_confirm(gobj, nodeData)
5471
+ function select_all_nodes(gobj)
5472
+ {
5473
+ let graph = gobj.priv.graph;
5474
+
5475
+ if(!graph) {
5476
+ return;
5477
+ }
5478
+
5479
+ let nodes = (graph.getData() || {}).nodes || [];
5480
+ set_selection(gobj, nodes.map((nd) => nd.id));
5481
+ }
5482
+
5483
+ /************************************************************
5484
+ * The nodes currently selected, as their node data.
5485
+ ************************************************************/
5486
+ function selected_nodes_data(gobj)
5353
5487
  {
5354
5488
  let priv = gobj.priv;
5355
- let record = nodeData.data.record || {};
5356
- let topic_name = nodeData.data.desc.topic_name;
5489
+ let graph = priv.graph;
5490
+
5491
+ let ids = new Set(selected_node_ids(gobj));
5492
+ for(let id of (priv._selected_paint_ids || [])) {
5493
+ ids.add(id);
5494
+ }
5495
+ if(priv._selected_node_id) {
5496
+ ids.add(priv._selected_node_id);
5497
+ }
5498
+
5499
+ let out = [];
5500
+ for(let id of ids) {
5501
+ let nd;
5502
+ try {
5503
+ nd = graph.getNodeData(id);
5504
+ } catch(e) {
5505
+ continue; /* gone since it was selected */
5506
+ }
5507
+ if(nd && nd.data && nd.data.desc) {
5508
+ out.push(nd);
5509
+ }
5510
+ }
5357
5511
 
5358
- /* What the delete takes with it. These views delete with `force`, and
5359
- * `force` UNLINKS a node's children — they survive, loose — and cleans
5360
- * it off its parents. A question that names only the node lets an
5361
- * operator detach eleven records believing they removed one. */
5362
- let impact = delete_impact(nodeData.data.desc, record);
5363
- let question = t('delete') + ' ' + topic_name + ': ' + record.id + '?';
5364
- if(impact.children > 0) {
5365
- question += '\n' + impact.children + ' ' +
5366
- t('children will be unlinked, not deleted', {count: impact.children});
5512
+ return out;
5513
+ }
5514
+
5515
+ /************************************************************
5516
+ * What a delete takes with it, for ONE node or for a set.
5517
+ *
5518
+ * Same sentence in both cases and the same keys, because it is the
5519
+ * same warning: these views delete with `force`, which UNLINKS the
5520
+ * children (they survive, loose) and cleans the node off its
5521
+ * parents. Over a set the two numbers are the sums -- an operator
5522
+ * about to detach eleven records has to read eleven, not "are you
5523
+ * sure".
5524
+ ************************************************************/
5525
+ function delete_question(nodes)
5526
+ {
5527
+ let children = 0;
5528
+ let parents = 0;
5529
+
5530
+ for(let nd of nodes) {
5531
+ let impact = delete_impact(nd.data.desc, nd.data.record || {});
5532
+ children += impact.children;
5533
+ parents += impact.parents;
5534
+ }
5535
+
5536
+ let question;
5537
+ if(nodes.length === 1) {
5538
+ let nd = nodes[0];
5539
+ question = t('delete') + ' ' + nd.data.desc.topic_name + ': ' +
5540
+ (nd.data.record || {}).id + '?';
5541
+ } else {
5542
+ question = t('delete') + ' ' + nodes.length + ' ' + t('records') + '?';
5543
+ }
5544
+
5545
+ if(children > 0) {
5546
+ question += '\n' + children + ' ' +
5547
+ t('children will be unlinked, not deleted', {count: children});
5367
5548
  }
5368
- if(impact.parents > 0) {
5549
+ if(parents > 0) {
5369
5550
  question += '\n' + t('it will be detached from') + ' ' +
5370
- impact.parents + ' ' + t('parents', {count: impact.parents});
5551
+ parents + ' ' + t('parents', {count: parents});
5552
+ }
5553
+
5554
+ return question;
5555
+ }
5556
+
5557
+ /************************************************************
5558
+ * Delete every selected node (the Delete key).
5559
+ ************************************************************/
5560
+ function request_delete_selection(gobj)
5561
+ {
5562
+ let nodes = selected_nodes_data(gobj);
5563
+
5564
+ if(!nodes.length) {
5565
+ return; /* nothing selected: the key means nothing here */
5566
+ }
5567
+
5568
+ if(!gobj_read_bool_attr(gobj, "confirm_delete_node")) {
5569
+ execute_delete_selection(gobj, nodes);
5570
+ return;
5371
5571
  }
5372
5572
 
5573
+ /* No anchor: the set has no icon of its own, so the question is
5574
+ * asked in the middle of the graph. */
5575
+ show_confirm_popover(gobj, null,
5576
+ delete_question(nodes),
5577
+ 'delete', '#ff4d4f',
5578
+ () => execute_delete_selection(gobj, nodes),
5579
+ '_delete_confirm_el'
5580
+ );
5581
+ }
5582
+
5583
+ function execute_delete_selection(gobj, nodes)
5584
+ {
5585
+ let priv = gobj.priv;
5586
+
5587
+ hide_delete_confirm(gobj);
5588
+
5589
+ /* One event per node: a treedb deletes records, it has no bulk
5590
+ * delete, and the host turns each of these into its own command
5591
+ * exactly as the topic table's bulk delete does. */
5592
+ for(let nd of nodes) {
5593
+ gobj_publish_event(gobj, "EV_DELETE_NODE", {
5594
+ treedb_name: priv.treedb_name,
5595
+ topic_name: nd.data.desc.topic_name,
5596
+ record: nd.data.record,
5597
+ });
5598
+ }
5599
+
5600
+ deselect_node(gobj);
5601
+ }
5602
+
5603
+ /************************************************************
5604
+ * Show a confirmation popover next to the delete icon.
5605
+ ************************************************************/
5606
+ function show_delete_confirm(gobj, nodeData)
5607
+ {
5608
+ let priv = gobj.priv;
5609
+
5610
+ /* Same sentence as the Delete key's, from the same place: the
5611
+ * warning does not depend on how the delete was asked for. */
5373
5612
  show_confirm_popover(gobj, priv._node_delete_el,
5374
- question,
5613
+ delete_question([nodeData]),
5375
5614
  'delete', '#ff4d4f',
5376
5615
  () => execute_delete_node(gobj, nodeData),
5377
5616
  '_delete_confirm_el'
@@ -6645,6 +6884,25 @@ function ac_auto_fit(gobj, event, kw, src)
6645
6884
  return 0;
6646
6885
  }
6647
6886
 
6887
+ /************************************************************
6888
+ * Zoom to what is selected.
6889
+ ************************************************************/
6890
+ function ac_zoom_selection(gobj, event, kw, src)
6891
+ {
6892
+ let ids = gobj.priv._selected_paint_ids || [];
6893
+
6894
+ if(!ids.length) {
6895
+ /* The button is disabled without a selection, so getting here
6896
+ * means the event came from somewhere else. */
6897
+ log_error(`${gobj_short_name(gobj)}: zoom to selection with nothing selected`);
6898
+ return -1;
6899
+ }
6900
+
6901
+ graph_fit_selection(gobj, ids);
6902
+
6903
+ return 0;
6904
+ }
6905
+
6648
6906
  function ac_focus_topic(gobj, event, kw, src)
6649
6907
  {
6650
6908
  graph_focus_topic(gobj, kw && kw.topic);
@@ -6723,6 +6981,53 @@ function ac_node_drag_end(gobj, event, kw, src)
6723
6981
  return 0;
6724
6982
  }
6725
6983
 
6984
+ /************************************************************
6985
+ * A key, while the graph has focus.
6986
+ *
6987
+ * Full screen keeps the two keys it always had. The rest belong to
6988
+ * the selection, so they only mean anything in edition -- outside
6989
+ * it there is nothing selected to clear, select or delete.
6990
+ ************************************************************/
6991
+ function ac_key_down(gobj, event, kw, src)
6992
+ {
6993
+ let priv = gobj.priv;
6994
+ let key = (kw && kw.key) || "";
6995
+
6996
+ if(gobj_read_bool_attr(gobj, "with_fullscreen")) {
6997
+ let fullscreen = graph_get_plugin(gobj, "fullscreen");
6998
+ if(fullscreen) {
6999
+ if(key === "F" || key === "f") {
7000
+ fullscreen.request();
7001
+ } else if(key === "Escape") {
7002
+ /* Escape also clears the selection below, and both are
7003
+ * right: the browser exits full screen on Escape
7004
+ * whatever we do, so refusing to clear as well would
7005
+ * only make the key do less than it appears to. */
7006
+ fullscreen.exit();
7007
+ }
7008
+ }
7009
+ }
7010
+
7011
+ if(!priv.edit_mode) {
7012
+ return 0;
7013
+ }
7014
+
7015
+ if(key === "Escape") {
7016
+ deselect_node(gobj);
7017
+ return 0;
7018
+ }
7019
+ if(kw.ctrl && (key === "a" || key === "A")) {
7020
+ select_all_nodes(gobj);
7021
+ return 0;
7022
+ }
7023
+ if(key === "Delete" || key === "Backspace") {
7024
+ request_delete_selection(gobj);
7025
+ return 0;
7026
+ }
7027
+
7028
+ return 0;
7029
+ }
7030
+
6726
7031
  /************************************************************
6727
7032
  * The rubber band let go: these are the nodes it enclosed.
6728
7033
  *
@@ -7049,12 +7354,14 @@ function create_gclass(gclass_name)
7049
7354
  ["EV_CANVAS_CLICK", ac_canvas_click, null],
7050
7355
  ["EV_NODE_DRAG_END", ac_node_drag_end, null],
7051
7356
  ["EV_BRUSH_SELECT", ac_brush_select, null],
7357
+ ["EV_KEY_DOWN", ac_key_down, null],
7052
7358
 
7053
7359
  /*--- Toolbar events ---*/
7054
7360
  ["EV_ZOOM_IN", ac_zoom_in, null],
7055
7361
  ["EV_ZOOM_OUT", ac_zoom_out, null],
7056
7362
  ["EV_ZOOM_RESET", ac_zoom_reset, null],
7057
7363
  ["EV_AUTO_FIT", ac_auto_fit, null],
7364
+ ["EV_ZOOM_SELECTION", ac_zoom_selection, null],
7058
7365
  ["EV_FOCUS_TOPIC", ac_focus_topic, null],
7059
7366
  ["EV_FIND_NODES", ac_find_nodes, null],
7060
7367
  ["EV_CENTER", ac_center, null],
@@ -7094,12 +7401,14 @@ function create_gclass(gclass_name)
7094
7401
  ["EV_CANVAS_CLICK", 0],
7095
7402
  ["EV_NODE_DRAG_END", 0],
7096
7403
  ["EV_BRUSH_SELECT", 0],
7404
+ ["EV_KEY_DOWN", 0],
7097
7405
 
7098
7406
  /*--- Toolbar (internal) ---*/
7099
7407
  ["EV_ZOOM_IN", 0],
7100
7408
  ["EV_ZOOM_OUT", 0],
7101
7409
  ["EV_ZOOM_RESET", 0],
7102
7410
  ["EV_AUTO_FIT", 0],
7411
+ ["EV_ZOOM_SELECTION", 0],
7103
7412
  ["EV_FOCUS_TOPIC", 0],
7104
7413
  ["EV_FIND_NODES", 0],
7105
7414
  ["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"/>