@yuneta/gobj-ui 7.16.0 → 7.17.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
@@ -718,8 +718,16 @@ clicked":
718
718
  | click a node | selects it **and opens it**: resize handles, ports, popovers |
719
719
  | **shift + click** | adds that node to the selection, or takes it out |
720
720
  | **shift + drag on the canvas** | rubber band: the selection becomes what it enclosed |
721
+ | **ctrl/cmd + A** | every node |
721
722
  | drag any selected node | **moves the whole selection**, as one undo |
722
- | click the canvas | clears it |
723
+ | **Delete** / **Backspace** | deletes the selection, after a confirmation that counts what it takes |
724
+ | **Esc**, or a click on the canvas | clears it |
725
+
726
+ The keys reach the graph only while the **graph has focus** — G6 gives its
727
+ canvas a `tabIndex` of its own — which is what keeps `ctrl+A` inside the find
728
+ box a selection of the TEXT and not of every node: the focus is in the input,
729
+ and the input is not inside the canvas. They arrive as `EV_KEY_DOWN` and the
730
+ action decides, so a key is as visible in the `machine` trace as a click.
723
731
 
724
732
  Three decisions are worth knowing, because each one is where this could have
725
733
  gone wrong:
@@ -752,6 +760,17 @@ predicate), or the canvas would pan under the rubber band — G6 binds
752
760
  `drag-canvas` straight to the drag events, and its own docs warn that the two
753
761
  gestures cannot both be a plain drag.
754
762
 
763
+ **A delete says what it takes, whether it is one or twenty.** The Delete key
764
+ asks the same question the per-node delete icon asks, built by the same
765
+ function: the record's key when there is one, the count when there are more,
766
+ and then the two lines that are actually at stake — *N children will be
767
+ UNLINKED, not deleted* and *it will be detached from M parents* — where over a
768
+ set the numbers are the sums. These views delete with `force`, so an operator
769
+ pressing Delete over twelve cards has to read eleven detached children, not
770
+ "are you sure". It needs no new keys: the sentence is the one `7.10.0` already
771
+ defined. The question has no icon to hang off, so it is asked in the middle of
772
+ the graph it is about.
773
+
755
774
  **A marquee selects, it does not open.** Even when it encloses exactly one
756
775
  node, the handles and ports stay away: `_selected_node_id` means *the node
757
776
  opened for editing*, and only a click sets it. Everything that hangs off a
@@ -54088,12 +54088,15 @@ function configure_events(gobj) {
54088
54088
  update_toolbar(gobj);
54089
54089
  };
54090
54090
  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();
54091
+ graph.on("keydown", (evt) => {
54092
+ let ctrl = !!(evt.ctrlKey || evt.metaKey);
54093
+ if (ctrl && (evt.key === "a" || evt.key === "A")) {
54094
+ if (typeof evt.preventDefault === "function") evt.preventDefault();
54095
+ }
54096
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_KEY_DOWN", {
54097
+ key: evt.key,
54098
+ ctrl
54099
+ }, gobj);
54097
54100
  });
54098
54101
  }
54099
54102
  /************************************************************
@@ -55470,10 +55473,17 @@ function create_form_button_row(parent, buttons) {
55470
55473
  function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_color, onConfirm, priv_key) {
55471
55474
  hide_overlay(gobj, priv_key);
55472
55475
  let priv = gobj.priv;
55473
- if (!target_el) return;
55474
- let iconRect = target_el.getBoundingClientRect();
55475
55476
  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);
55477
+ let left, top;
55478
+ if (target_el) {
55479
+ let iconRect = target_el.getBoundingClientRect();
55480
+ left = iconRect.right - containerRect.left + 6;
55481
+ top = iconRect.top - containerRect.top - 4;
55482
+ } else {
55483
+ left = Math.round(containerRect.width / 2) - 110;
55484
+ top = Math.round(containerRect.height / 4);
55485
+ }
55486
+ const popover = create_popover_base(left, top, "g6-confirm-popover", "#ff4d4f", 160);
55477
55487
  let msg = document.createElement("div");
55478
55488
  msg.style.cssText = "margin-bottom:10px;font-weight:500;white-space:pre-line;";
55479
55489
  msg.textContent = message;
@@ -57157,17 +57167,89 @@ function execute_delete_node(gobj, nodeData) {
57157
57167
  deselect_node(gobj);
57158
57168
  }
57159
57169
  /************************************************************
57170
+ * Every node in the graph.
57171
+ ************************************************************/
57172
+ function select_all_nodes(gobj) {
57173
+ let graph = gobj.priv.graph;
57174
+ if (!graph) return;
57175
+ set_selection(gobj, ((graph.getData() || {}).nodes || []).map((nd) => nd.id));
57176
+ }
57177
+ /************************************************************
57178
+ * The nodes currently selected, as their node data.
57179
+ ************************************************************/
57180
+ function selected_nodes_data(gobj) {
57181
+ let priv = gobj.priv;
57182
+ let graph = priv.graph;
57183
+ let ids = new Set(selected_node_ids(gobj));
57184
+ for (let id of priv._selected_paint_ids || []) ids.add(id);
57185
+ if (priv._selected_node_id) ids.add(priv._selected_node_id);
57186
+ let out = [];
57187
+ for (let id of ids) {
57188
+ let nd;
57189
+ try {
57190
+ nd = graph.getNodeData(id);
57191
+ } catch (e) {
57192
+ continue;
57193
+ }
57194
+ if (nd && nd.data && nd.data.desc) out.push(nd);
57195
+ }
57196
+ return out;
57197
+ }
57198
+ /************************************************************
57199
+ * What a delete takes with it, for ONE node or for a set.
57200
+ *
57201
+ * Same sentence in both cases and the same keys, because it is the
57202
+ * same warning: these views delete with `force`, which UNLINKS the
57203
+ * children (they survive, loose) and cleans the node off its
57204
+ * parents. Over a set the two numbers are the sums -- an operator
57205
+ * about to detach eleven records has to read eleven, not "are you
57206
+ * sure".
57207
+ ************************************************************/
57208
+ function delete_question(nodes) {
57209
+ let children = 0;
57210
+ let parents = 0;
57211
+ for (let nd of nodes) {
57212
+ let impact = delete_impact(nd.data.desc, nd.data.record || {});
57213
+ children += impact.children;
57214
+ parents += impact.parents;
57215
+ }
57216
+ let question;
57217
+ if (nodes.length === 1) {
57218
+ let nd = nodes[0];
57219
+ question = (0, i18next.t)("delete") + " " + nd.data.desc.topic_name + ": " + (nd.data.record || {}).id + "?";
57220
+ } else question = (0, i18next.t)("delete") + " " + nodes.length + " " + (0, i18next.t)("records") + "?";
57221
+ if (children > 0) question += "\n" + children + " " + (0, i18next.t)("children will be unlinked, not deleted", { count: children });
57222
+ if (parents > 0) question += "\n" + (0, i18next.t)("it will be detached from") + " " + parents + " " + (0, i18next.t)("parents", { count: parents });
57223
+ return question;
57224
+ }
57225
+ /************************************************************
57226
+ * Delete every selected node (the Delete key).
57227
+ ************************************************************/
57228
+ function request_delete_selection(gobj) {
57229
+ let nodes = selected_nodes_data(gobj);
57230
+ if (!nodes.length) return;
57231
+ if (!(0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "confirm_delete_node")) {
57232
+ execute_delete_selection(gobj, nodes);
57233
+ return;
57234
+ }
57235
+ show_confirm_popover(gobj, null, delete_question(nodes), "delete", "#ff4d4f", () => execute_delete_selection(gobj, nodes), "_delete_confirm_el");
57236
+ }
57237
+ function execute_delete_selection(gobj, nodes) {
57238
+ let priv = gobj.priv;
57239
+ hide_delete_confirm(gobj);
57240
+ for (let nd of nodes) (0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_DELETE_NODE", {
57241
+ treedb_name: priv.treedb_name,
57242
+ topic_name: nd.data.desc.topic_name,
57243
+ record: nd.data.record
57244
+ });
57245
+ deselect_node(gobj);
57246
+ }
57247
+ /************************************************************
57160
57248
  * Show a confirmation popover next to the delete icon.
57161
57249
  ************************************************************/
57162
57250
  function show_delete_confirm(gobj, nodeData) {
57163
57251
  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");
57252
+ show_confirm_popover(gobj, priv._node_delete_el, delete_question([nodeData]), "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
57171
57253
  }
57172
57254
  function hide_delete_confirm(gobj) {
57173
57255
  hide_overlay(gobj, "_delete_confirm_el");
@@ -58034,6 +58116,38 @@ function ac_node_drag_end(gobj, event, kw, src) {
58034
58116
  return 0;
58035
58117
  }
58036
58118
  /************************************************************
58119
+ * A key, while the graph has focus.
58120
+ *
58121
+ * Full screen keeps the two keys it always had. The rest belong to
58122
+ * the selection, so they only mean anything in edition -- outside
58123
+ * it there is nothing selected to clear, select or delete.
58124
+ ************************************************************/
58125
+ function ac_key_down(gobj, event, kw, src) {
58126
+ let priv = gobj.priv;
58127
+ let key = kw && kw.key || "";
58128
+ if ((0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_fullscreen")) {
58129
+ let fullscreen = graph_get_plugin(gobj, "fullscreen");
58130
+ if (fullscreen) {
58131
+ if (key === "F" || key === "f") fullscreen.request();
58132
+ else if (key === "Escape") fullscreen.exit();
58133
+ }
58134
+ }
58135
+ if (!priv.edit_mode) return 0;
58136
+ if (key === "Escape") {
58137
+ deselect_node(gobj);
58138
+ return 0;
58139
+ }
58140
+ if (kw.ctrl && (key === "a" || key === "A")) {
58141
+ select_all_nodes(gobj);
58142
+ return 0;
58143
+ }
58144
+ if (key === "Delete" || key === "Backspace") {
58145
+ request_delete_selection(gobj);
58146
+ return 0;
58147
+ }
58148
+ return 0;
58149
+ }
58150
+ /************************************************************
58037
58151
  * The rubber band let go: these are the nodes it enclosed.
58038
58152
  *
58039
58153
  * They arrive in the kw because `onSelect` runs BEFORE G6 writes
@@ -58292,6 +58406,11 @@ function create_gclass$1(gclass_name) {
58292
58406
  ac_brush_select,
58293
58407
  null
58294
58408
  ],
58409
+ [
58410
+ "EV_KEY_DOWN",
58411
+ ac_key_down,
58412
+ null
58413
+ ],
58295
58414
  [
58296
58415
  "EV_ZOOM_IN",
58297
58416
  ac_zoom_in,
@@ -58401,6 +58520,7 @@ function create_gclass$1(gclass_name) {
58401
58520
  ["EV_CANVAS_CLICK", 0],
58402
58521
  ["EV_NODE_DRAG_END", 0],
58403
58522
  ["EV_BRUSH_SELECT", 0],
58523
+ ["EV_KEY_DOWN", 0],
58404
58524
  ["EV_ZOOM_IN", 0],
58405
58525
  ["EV_ZOOM_OUT", 0],
58406
58526
  ["EV_ZOOM_RESET", 0],
@@ -54075,12 +54075,15 @@ function configure_events(gobj) {
54075
54075
  update_toolbar(gobj);
54076
54076
  };
54077
54077
  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();
54078
+ graph.on("keydown", (evt) => {
54079
+ let ctrl = !!(evt.ctrlKey || evt.metaKey);
54080
+ if (ctrl && (evt.key === "a" || evt.key === "A")) {
54081
+ if (typeof evt.preventDefault === "function") evt.preventDefault();
54082
+ }
54083
+ gobj_send_event(gobj, "EV_KEY_DOWN", {
54084
+ key: evt.key,
54085
+ ctrl
54086
+ }, gobj);
54084
54087
  });
54085
54088
  }
54086
54089
  /************************************************************
@@ -55457,10 +55460,17 @@ function create_form_button_row(parent, buttons) {
55457
55460
  function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_color, onConfirm, priv_key) {
55458
55461
  hide_overlay(gobj, priv_key);
55459
55462
  let priv = gobj.priv;
55460
- if (!target_el) return;
55461
- let iconRect = target_el.getBoundingClientRect();
55462
55463
  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);
55464
+ let left, top;
55465
+ if (target_el) {
55466
+ let iconRect = target_el.getBoundingClientRect();
55467
+ left = iconRect.right - containerRect.left + 6;
55468
+ top = iconRect.top - containerRect.top - 4;
55469
+ } else {
55470
+ left = Math.round(containerRect.width / 2) - 110;
55471
+ top = Math.round(containerRect.height / 4);
55472
+ }
55473
+ const popover = create_popover_base(left, top, "g6-confirm-popover", "#ff4d4f", 160);
55464
55474
  let msg = document.createElement("div");
55465
55475
  msg.style.cssText = "margin-bottom:10px;font-weight:500;white-space:pre-line;";
55466
55476
  msg.textContent = message;
@@ -57144,17 +57154,89 @@ function execute_delete_node(gobj, nodeData) {
57144
57154
  deselect_node(gobj);
57145
57155
  }
57146
57156
  /************************************************************
57157
+ * Every node in the graph.
57158
+ ************************************************************/
57159
+ function select_all_nodes(gobj) {
57160
+ let graph = gobj.priv.graph;
57161
+ if (!graph) return;
57162
+ set_selection(gobj, ((graph.getData() || {}).nodes || []).map((nd) => nd.id));
57163
+ }
57164
+ /************************************************************
57165
+ * The nodes currently selected, as their node data.
57166
+ ************************************************************/
57167
+ function selected_nodes_data(gobj) {
57168
+ let priv = gobj.priv;
57169
+ let graph = priv.graph;
57170
+ let ids = new Set(selected_node_ids(gobj));
57171
+ for (let id of priv._selected_paint_ids || []) ids.add(id);
57172
+ if (priv._selected_node_id) ids.add(priv._selected_node_id);
57173
+ let out = [];
57174
+ for (let id of ids) {
57175
+ let nd;
57176
+ try {
57177
+ nd = graph.getNodeData(id);
57178
+ } catch (e) {
57179
+ continue;
57180
+ }
57181
+ if (nd && nd.data && nd.data.desc) out.push(nd);
57182
+ }
57183
+ return out;
57184
+ }
57185
+ /************************************************************
57186
+ * What a delete takes with it, for ONE node or for a set.
57187
+ *
57188
+ * Same sentence in both cases and the same keys, because it is the
57189
+ * same warning: these views delete with `force`, which UNLINKS the
57190
+ * children (they survive, loose) and cleans the node off its
57191
+ * parents. Over a set the two numbers are the sums -- an operator
57192
+ * about to detach eleven records has to read eleven, not "are you
57193
+ * sure".
57194
+ ************************************************************/
57195
+ function delete_question(nodes) {
57196
+ let children = 0;
57197
+ let parents = 0;
57198
+ for (let nd of nodes) {
57199
+ let impact = delete_impact(nd.data.desc, nd.data.record || {});
57200
+ children += impact.children;
57201
+ parents += impact.parents;
57202
+ }
57203
+ let question;
57204
+ if (nodes.length === 1) {
57205
+ let nd = nodes[0];
57206
+ question = t("delete") + " " + nd.data.desc.topic_name + ": " + (nd.data.record || {}).id + "?";
57207
+ } else question = t("delete") + " " + nodes.length + " " + t("records") + "?";
57208
+ if (children > 0) question += "\n" + children + " " + t("children will be unlinked, not deleted", { count: children });
57209
+ if (parents > 0) question += "\n" + t("it will be detached from") + " " + parents + " " + t("parents", { count: parents });
57210
+ return question;
57211
+ }
57212
+ /************************************************************
57213
+ * Delete every selected node (the Delete key).
57214
+ ************************************************************/
57215
+ function request_delete_selection(gobj) {
57216
+ let nodes = selected_nodes_data(gobj);
57217
+ if (!nodes.length) return;
57218
+ if (!gobj_read_bool_attr(gobj, "confirm_delete_node")) {
57219
+ execute_delete_selection(gobj, nodes);
57220
+ return;
57221
+ }
57222
+ show_confirm_popover(gobj, null, delete_question(nodes), "delete", "#ff4d4f", () => execute_delete_selection(gobj, nodes), "_delete_confirm_el");
57223
+ }
57224
+ function execute_delete_selection(gobj, nodes) {
57225
+ let priv = gobj.priv;
57226
+ hide_delete_confirm(gobj);
57227
+ for (let nd of nodes) gobj_publish_event(gobj, "EV_DELETE_NODE", {
57228
+ treedb_name: priv.treedb_name,
57229
+ topic_name: nd.data.desc.topic_name,
57230
+ record: nd.data.record
57231
+ });
57232
+ deselect_node(gobj);
57233
+ }
57234
+ /************************************************************
57147
57235
  * Show a confirmation popover next to the delete icon.
57148
57236
  ************************************************************/
57149
57237
  function show_delete_confirm(gobj, nodeData) {
57150
57238
  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");
57239
+ show_confirm_popover(gobj, priv._node_delete_el, delete_question([nodeData]), "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
57158
57240
  }
57159
57241
  function hide_delete_confirm(gobj) {
57160
57242
  hide_overlay(gobj, "_delete_confirm_el");
@@ -58021,6 +58103,38 @@ function ac_node_drag_end(gobj, event, kw, src) {
58021
58103
  return 0;
58022
58104
  }
58023
58105
  /************************************************************
58106
+ * A key, while the graph has focus.
58107
+ *
58108
+ * Full screen keeps the two keys it always had. The rest belong to
58109
+ * the selection, so they only mean anything in edition -- outside
58110
+ * it there is nothing selected to clear, select or delete.
58111
+ ************************************************************/
58112
+ function ac_key_down(gobj, event, kw, src) {
58113
+ let priv = gobj.priv;
58114
+ let key = kw && kw.key || "";
58115
+ if (gobj_read_bool_attr(gobj, "with_fullscreen")) {
58116
+ let fullscreen = graph_get_plugin(gobj, "fullscreen");
58117
+ if (fullscreen) {
58118
+ if (key === "F" || key === "f") fullscreen.request();
58119
+ else if (key === "Escape") fullscreen.exit();
58120
+ }
58121
+ }
58122
+ if (!priv.edit_mode) return 0;
58123
+ if (key === "Escape") {
58124
+ deselect_node(gobj);
58125
+ return 0;
58126
+ }
58127
+ if (kw.ctrl && (key === "a" || key === "A")) {
58128
+ select_all_nodes(gobj);
58129
+ return 0;
58130
+ }
58131
+ if (key === "Delete" || key === "Backspace") {
58132
+ request_delete_selection(gobj);
58133
+ return 0;
58134
+ }
58135
+ return 0;
58136
+ }
58137
+ /************************************************************
58024
58138
  * The rubber band let go: these are the nodes it enclosed.
58025
58139
  *
58026
58140
  * They arrive in the kw because `onSelect` runs BEFORE G6 writes
@@ -58279,6 +58393,11 @@ function create_gclass$1(gclass_name) {
58279
58393
  ac_brush_select,
58280
58394
  null
58281
58395
  ],
58396
+ [
58397
+ "EV_KEY_DOWN",
58398
+ ac_key_down,
58399
+ null
58400
+ ],
58282
58401
  [
58283
58402
  "EV_ZOOM_IN",
58284
58403
  ac_zoom_in,
@@ -58388,6 +58507,7 @@ function create_gclass$1(gclass_name) {
58388
58507
  ["EV_CANVAS_CLICK", 0],
58389
58508
  ["EV_NODE_DRAG_END", 0],
58390
58509
  ["EV_BRUSH_SELECT", 0],
58510
+ ["EV_KEY_DOWN", 0],
58391
58511
  ["EV_ZOOM_IN", 0],
58392
58512
  ["EV_ZOOM_OUT", 0],
58393
58513
  ["EV_ZOOM_RESET", 0],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.16.0",
3
+ "version": "7.17.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
  /************************************************************
@@ -2925,14 +2929,19 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
2925
2929
  hide_overlay(gobj, priv_key);
2926
2930
 
2927
2931
  let priv = gobj.priv;
2928
- if(!target_el) {
2929
- return;
2930
- }
2931
-
2932
- let iconRect = target_el.getBoundingClientRect();
2933
2932
  let containerRect = priv.$container.getBoundingClientRect();
2934
- let left = iconRect.right - containerRect.left + 6;
2935
- let top = iconRect.top - containerRect.top - 4;
2933
+ let left, top;
2934
+
2935
+ if(target_el) {
2936
+ let iconRect = target_el.getBoundingClientRect();
2937
+ left = iconRect.right - containerRect.left + 6;
2938
+ top = iconRect.top - containerRect.top - 4;
2939
+ } else {
2940
+ /* A question about a SET has no icon to hang off: it is asked
2941
+ * in the middle of the graph it is about. */
2942
+ left = Math.round(containerRect.width / 2) - 110;
2943
+ top = Math.round(containerRect.height / 4);
2944
+ }
2936
2945
 
2937
2946
  const popover = create_popover_base(left, top, 'g6-confirm-popover', '#ff4d4f', 160);
2938
2947
 
@@ -5347,31 +5356,151 @@ function execute_delete_node(gobj, nodeData)
5347
5356
  }
5348
5357
 
5349
5358
  /************************************************************
5350
- * Show a confirmation popover next to the delete icon.
5359
+ * Every node in the graph.
5351
5360
  ************************************************************/
5352
- function show_delete_confirm(gobj, nodeData)
5361
+ function select_all_nodes(gobj)
5362
+ {
5363
+ let graph = gobj.priv.graph;
5364
+
5365
+ if(!graph) {
5366
+ return;
5367
+ }
5368
+
5369
+ let nodes = (graph.getData() || {}).nodes || [];
5370
+ set_selection(gobj, nodes.map((nd) => nd.id));
5371
+ }
5372
+
5373
+ /************************************************************
5374
+ * The nodes currently selected, as their node data.
5375
+ ************************************************************/
5376
+ function selected_nodes_data(gobj)
5353
5377
  {
5354
5378
  let priv = gobj.priv;
5355
- let record = nodeData.data.record || {};
5356
- let topic_name = nodeData.data.desc.topic_name;
5379
+ let graph = priv.graph;
5357
5380
 
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});
5381
+ let ids = new Set(selected_node_ids(gobj));
5382
+ for(let id of (priv._selected_paint_ids || [])) {
5383
+ ids.add(id);
5384
+ }
5385
+ if(priv._selected_node_id) {
5386
+ ids.add(priv._selected_node_id);
5387
+ }
5388
+
5389
+ let out = [];
5390
+ for(let id of ids) {
5391
+ let nd;
5392
+ try {
5393
+ nd = graph.getNodeData(id);
5394
+ } catch(e) {
5395
+ continue; /* gone since it was selected */
5396
+ }
5397
+ if(nd && nd.data && nd.data.desc) {
5398
+ out.push(nd);
5399
+ }
5400
+ }
5401
+
5402
+ return out;
5403
+ }
5404
+
5405
+ /************************************************************
5406
+ * What a delete takes with it, for ONE node or for a set.
5407
+ *
5408
+ * Same sentence in both cases and the same keys, because it is the
5409
+ * same warning: these views delete with `force`, which UNLINKS the
5410
+ * children (they survive, loose) and cleans the node off its
5411
+ * parents. Over a set the two numbers are the sums -- an operator
5412
+ * about to detach eleven records has to read eleven, not "are you
5413
+ * sure".
5414
+ ************************************************************/
5415
+ function delete_question(nodes)
5416
+ {
5417
+ let children = 0;
5418
+ let parents = 0;
5419
+
5420
+ for(let nd of nodes) {
5421
+ let impact = delete_impact(nd.data.desc, nd.data.record || {});
5422
+ children += impact.children;
5423
+ parents += impact.parents;
5367
5424
  }
5368
- if(impact.parents > 0) {
5425
+
5426
+ let question;
5427
+ if(nodes.length === 1) {
5428
+ let nd = nodes[0];
5429
+ question = t('delete') + ' ' + nd.data.desc.topic_name + ': ' +
5430
+ (nd.data.record || {}).id + '?';
5431
+ } else {
5432
+ question = t('delete') + ' ' + nodes.length + ' ' + t('records') + '?';
5433
+ }
5434
+
5435
+ if(children > 0) {
5436
+ question += '\n' + children + ' ' +
5437
+ t('children will be unlinked, not deleted', {count: children});
5438
+ }
5439
+ if(parents > 0) {
5369
5440
  question += '\n' + t('it will be detached from') + ' ' +
5370
- impact.parents + ' ' + t('parents', {count: impact.parents});
5441
+ parents + ' ' + t('parents', {count: parents});
5371
5442
  }
5372
5443
 
5444
+ return question;
5445
+ }
5446
+
5447
+ /************************************************************
5448
+ * Delete every selected node (the Delete key).
5449
+ ************************************************************/
5450
+ function request_delete_selection(gobj)
5451
+ {
5452
+ let nodes = selected_nodes_data(gobj);
5453
+
5454
+ if(!nodes.length) {
5455
+ return; /* nothing selected: the key means nothing here */
5456
+ }
5457
+
5458
+ if(!gobj_read_bool_attr(gobj, "confirm_delete_node")) {
5459
+ execute_delete_selection(gobj, nodes);
5460
+ return;
5461
+ }
5462
+
5463
+ /* No anchor: the set has no icon of its own, so the question is
5464
+ * asked in the middle of the graph. */
5465
+ show_confirm_popover(gobj, null,
5466
+ delete_question(nodes),
5467
+ 'delete', '#ff4d4f',
5468
+ () => execute_delete_selection(gobj, nodes),
5469
+ '_delete_confirm_el'
5470
+ );
5471
+ }
5472
+
5473
+ function execute_delete_selection(gobj, nodes)
5474
+ {
5475
+ let priv = gobj.priv;
5476
+
5477
+ hide_delete_confirm(gobj);
5478
+
5479
+ /* One event per node: a treedb deletes records, it has no bulk
5480
+ * delete, and the host turns each of these into its own command
5481
+ * exactly as the topic table's bulk delete does. */
5482
+ for(let nd of nodes) {
5483
+ gobj_publish_event(gobj, "EV_DELETE_NODE", {
5484
+ treedb_name: priv.treedb_name,
5485
+ topic_name: nd.data.desc.topic_name,
5486
+ record: nd.data.record,
5487
+ });
5488
+ }
5489
+
5490
+ deselect_node(gobj);
5491
+ }
5492
+
5493
+ /************************************************************
5494
+ * Show a confirmation popover next to the delete icon.
5495
+ ************************************************************/
5496
+ function show_delete_confirm(gobj, nodeData)
5497
+ {
5498
+ let priv = gobj.priv;
5499
+
5500
+ /* Same sentence as the Delete key's, from the same place: the
5501
+ * warning does not depend on how the delete was asked for. */
5373
5502
  show_confirm_popover(gobj, priv._node_delete_el,
5374
- question,
5503
+ delete_question([nodeData]),
5375
5504
  'delete', '#ff4d4f',
5376
5505
  () => execute_delete_node(gobj, nodeData),
5377
5506
  '_delete_confirm_el'
@@ -6723,6 +6852,53 @@ function ac_node_drag_end(gobj, event, kw, src)
6723
6852
  return 0;
6724
6853
  }
6725
6854
 
6855
+ /************************************************************
6856
+ * A key, while the graph has focus.
6857
+ *
6858
+ * Full screen keeps the two keys it always had. The rest belong to
6859
+ * the selection, so they only mean anything in edition -- outside
6860
+ * it there is nothing selected to clear, select or delete.
6861
+ ************************************************************/
6862
+ function ac_key_down(gobj, event, kw, src)
6863
+ {
6864
+ let priv = gobj.priv;
6865
+ let key = (kw && kw.key) || "";
6866
+
6867
+ if(gobj_read_bool_attr(gobj, "with_fullscreen")) {
6868
+ let fullscreen = graph_get_plugin(gobj, "fullscreen");
6869
+ if(fullscreen) {
6870
+ if(key === "F" || key === "f") {
6871
+ 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. */
6877
+ fullscreen.exit();
6878
+ }
6879
+ }
6880
+ }
6881
+
6882
+ if(!priv.edit_mode) {
6883
+ return 0;
6884
+ }
6885
+
6886
+ if(key === "Escape") {
6887
+ deselect_node(gobj);
6888
+ return 0;
6889
+ }
6890
+ if(kw.ctrl && (key === "a" || key === "A")) {
6891
+ select_all_nodes(gobj);
6892
+ return 0;
6893
+ }
6894
+ if(key === "Delete" || key === "Backspace") {
6895
+ request_delete_selection(gobj);
6896
+ return 0;
6897
+ }
6898
+
6899
+ return 0;
6900
+ }
6901
+
6726
6902
  /************************************************************
6727
6903
  * The rubber band let go: these are the nodes it enclosed.
6728
6904
  *
@@ -7049,6 +7225,7 @@ function create_gclass(gclass_name)
7049
7225
  ["EV_CANVAS_CLICK", ac_canvas_click, null],
7050
7226
  ["EV_NODE_DRAG_END", ac_node_drag_end, null],
7051
7227
  ["EV_BRUSH_SELECT", ac_brush_select, null],
7228
+ ["EV_KEY_DOWN", ac_key_down, null],
7052
7229
 
7053
7230
  /*--- Toolbar events ---*/
7054
7231
  ["EV_ZOOM_IN", ac_zoom_in, null],
@@ -7094,6 +7271,7 @@ function create_gclass(gclass_name)
7094
7271
  ["EV_CANVAS_CLICK", 0],
7095
7272
  ["EV_NODE_DRAG_END", 0],
7096
7273
  ["EV_BRUSH_SELECT", 0],
7274
+ ["EV_KEY_DOWN", 0],
7097
7275
 
7098
7276
  /*--- Toolbar (internal) ---*/
7099
7277
  ["EV_ZOOM_IN", 0],