@yuneta/gobj-ui 7.15.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.
@@ -184,6 +184,15 @@ const GCLASS_NAME = "C_G6_NODES_TREE";
184
184
  const HIGHLIGHT_COLOR = "#f0a020";
185
185
  const HIGHLIGHT_HALO = "rgba(240,160,32,0.35)";
186
186
 
187
+ /*
188
+ * The selection ring. Deliberately NOT the amber of the highlight:
189
+ * a node can be a find match AND be selected, and two amber rings
190
+ * would say nothing about either. Blue is what a selection is in
191
+ * every editor, and the ring is drawn OUTSIDE the highlight's halo
192
+ * so the two compose instead of overwriting one another.
193
+ */
194
+ const SELECT_RING = "rgba(59,130,246,0.95)";
195
+
187
196
  /***************************************************************
188
197
  * Internal layout and operation mode definitions
189
198
  ***************************************************************/
@@ -324,6 +333,10 @@ let PRIVATE_DATA = {
324
333
  _link_saved_styles: [], // saved port styles to restore on cancel
325
334
  _focus_topic: null, // topic currently focused (EV_FOCUS_TOPIC)
326
335
  _focus_ids: [], // node ids carrying the focus 'active' state
336
+ _selected_paint_ids: [], // node ids whose card is PAINTED selected
337
+ // (G6's 'selected' state is the selection
338
+ // itself; this is what is on screen, and
339
+ // it exists to diff the repaint)
327
340
  _pending_focus_topic: null, // focus requested before data was loaded
328
341
  _pending_find: null, // find requested before data was loaded
329
342
  _layout_asked: "", // layout the host asked for at create (see mt_create)
@@ -774,20 +787,24 @@ function configure_events(gobj)
774
787
  };
775
788
  i18next.on('languageChanged', priv._on_language_changed);
776
789
 
777
- if(gobj_read_bool_attr(gobj, "with_fullscreen")) {
778
- graph.on("keydown", (evt) => {
779
- const key = evt.key;
780
- const fullscreen = graph_get_plugin(gobj, "fullscreen");
781
- if(!fullscreen) {
782
- return;
783
- }
784
- if(key === "F" || key === "f") {
785
- fullscreen.request();
786
- } else if(key === "Escape") {
787
- 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();
788
803
  }
789
- });
790
- }
804
+ }
805
+
806
+ gobj_send_event(gobj, "EV_KEY_DOWN", {key: evt.key, ctrl: ctrl}, gobj);
807
+ });
791
808
  }
792
809
 
793
810
  /************************************************************
@@ -1182,9 +1199,46 @@ function configure_behaviour(gobj)
1182
1199
  case "edition":
1183
1200
  priv.edit_mode = true;
1184
1201
  behaviors = [
1185
- "drag-canvas",
1202
+ /* Panning gives way while Shift is held: that is the
1203
+ * marquee's gesture, and G6 binds drag-canvas straight
1204
+ * to the drag events, so without this the canvas pans
1205
+ * under the rubber band. */
1206
+ {
1207
+ type: "drag-canvas",
1208
+ key: "drag-canvas",
1209
+ enable: (event) => !event.shiftKey,
1210
+ },
1186
1211
  "zoom-canvas",
1212
+ /* Moves EVERY node in the `selected` state, not just
1213
+ * the one under the pointer, and wraps the whole move
1214
+ * in one history batch -- so a group move is one drag
1215
+ * and one undo. Nothing to configure: `selected` is
1216
+ * already its default `state`. */
1187
1217
  "drag-element",
1218
+ /* Shift+drag on the canvas: the rubber band. The
1219
+ * GESTURE is G6's, its RESULT enters the machine --
1220
+ * `onSelect` fires before G6 writes the state, so the
1221
+ * ids travel with the event and the action does not
1222
+ * have to race it. */
1223
+ {
1224
+ type: "brush-select",
1225
+ key: "brush-select",
1226
+ trigger: ["shift"],
1227
+ enableElements: ["node"],
1228
+ /* Left at G6's default (false): the set is read at
1229
+ * pointerup, not on every pointermove. Each answer
1230
+ * repaints the cards it touches, and doing that per
1231
+ * frame of a rubber band over a hundred nodes is
1232
+ * paid for nothing -- the band already shows what
1233
+ * it covers. */
1234
+ immediately: false,
1235
+ onSelect: (states) => {
1236
+ let ids = Object.keys(states || {}).filter(
1237
+ (id) => (states[id] || []).includes("selected")
1238
+ );
1239
+ gobj_send_event(gobj, "EV_BRUSH_SELECT", {ids: ids}, gobj);
1240
+ },
1241
+ },
1188
1242
  ];
1189
1243
  break;
1190
1244
  case "operation":
@@ -2875,14 +2929,19 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
2875
2929
  hide_overlay(gobj, priv_key);
2876
2930
 
2877
2931
  let priv = gobj.priv;
2878
- if(!target_el) {
2879
- return;
2880
- }
2881
-
2882
- let iconRect = target_el.getBoundingClientRect();
2883
2932
  let containerRect = priv.$container.getBoundingClientRect();
2884
- let left = iconRect.right - containerRect.left + 6;
2885
- 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
+ }
2886
2945
 
2887
2946
  const popover = create_popover_base(left, top, 'g6-confirm-popover', '#ff4d4f', 160);
2888
2947
 
@@ -2948,6 +3007,126 @@ function perform_history_op(gobj, is_redo)
2948
3007
  }
2949
3008
  }
2950
3009
 
3010
+ /************************************************************
3011
+ * The selection.
3012
+ *
3013
+ * G6's `selected` element state IS the selection: `drag-element`
3014
+ * reads it (`getElementDataByState`) to decide what a drag moves,
3015
+ * so keeping the set anywhere else would be a second truth the
3016
+ * drag does not consult. What this gclass keeps is what is
3017
+ * PAINTED -- an html node draws no state style at all (its key
3018
+ * shape is a DOM element), so the ring lives in the card's own
3019
+ * html, exactly like the find highlight, and the painted set
3020
+ * exists to diff the repaint.
3021
+ ************************************************************/
3022
+ function selected_node_ids(gobj)
3023
+ {
3024
+ let graph = gobj.priv.graph;
3025
+
3026
+ if(!graph) {
3027
+ return [];
3028
+ }
3029
+
3030
+ let data;
3031
+ try {
3032
+ data = graph.getElementDataByState('node', 'selected') || [];
3033
+ } catch(e) {
3034
+ return []; /* asked before there is a graph to ask */
3035
+ }
3036
+
3037
+ return data.map((nd) => nd.id);
3038
+ }
3039
+
3040
+ /************************************************************
3041
+ * Move the ring to these nodes and off the ones that had it.
3042
+ ************************************************************/
3043
+ function paint_selection(gobj, ids)
3044
+ {
3045
+ let priv = gobj.priv;
3046
+ let prev = priv._selected_paint_ids || [];
3047
+ let next = ids || [];
3048
+
3049
+ priv._selected_paint_ids = next;
3050
+
3051
+ repaint_cards(gobj, new Set([...prev, ...next]));
3052
+ }
3053
+
3054
+ /************************************************************
3055
+ * Select a SET of nodes: what a marquee and a shift-click do.
3056
+ *
3057
+ * It leaves `_selected_node_id` null even for a set of one,
3058
+ * because that field is not "the selection", it is "the node
3059
+ * opened for editing" -- the resize handles, the ports and the
3060
+ * popovers hang off it, and none of them means anything over a
3061
+ * set. Clicking a node is what opens one (`select_node`); a
3062
+ * marquee selects, it does not open.
3063
+ ************************************************************/
3064
+ function set_selection(gobj, ids)
3065
+ {
3066
+ let priv = gobj.priv;
3067
+ let graph = priv.graph;
3068
+
3069
+ let next = [];
3070
+ for(let id of (ids || [])) {
3071
+ try {
3072
+ if(graph.getNodeData(id)) {
3073
+ next.push(id);
3074
+ }
3075
+ } catch(e) {
3076
+ /* An id the graph does not have: a brush answers with what
3077
+ * it enclosed, and a node can be gone by the time we ask. */
3078
+ }
3079
+ }
3080
+
3081
+ deselect_node(gobj); /* the state, the paint and the affordances */
3082
+
3083
+ if(!next.length) {
3084
+ return;
3085
+ }
3086
+
3087
+ history_pause(gobj);
3088
+ try {
3089
+ let states = {};
3090
+ for(let id of next) {
3091
+ states[id] = ['selected'];
3092
+ }
3093
+ graph.setElementState(states);
3094
+ } catch(e) {
3095
+ log_error(`${gobj_short_name(gobj)}: cannot set the selection: ${e}`);
3096
+ }
3097
+
3098
+ paint_selection(gobj, next);
3099
+
3100
+ graph_draw(gobj).then(() => {
3101
+ history_resume(gobj);
3102
+ });
3103
+ }
3104
+
3105
+ /************************************************************
3106
+ * Add a node to the selection, or take it out of it.
3107
+ ************************************************************/
3108
+ function toggle_in_selection(gobj, node_id)
3109
+ {
3110
+ let priv = gobj.priv;
3111
+
3112
+ /* The union of the two views of the same thing. A brush clears
3113
+ * G6's state on pointerdown without telling anybody, so reading
3114
+ * only the state could drop a card that is on screen wearing a
3115
+ * ring. */
3116
+ let current = new Set(selected_node_ids(gobj));
3117
+ for(let id of (priv._selected_paint_ids || [])) {
3118
+ current.add(id);
3119
+ }
3120
+
3121
+ if(current.has(node_id)) {
3122
+ current.delete(node_id);
3123
+ } else {
3124
+ current.add(node_id);
3125
+ }
3126
+
3127
+ set_selection(gobj, [...current]);
3128
+ }
3129
+
2951
3130
  function select_node(gobj, node_id)
2952
3131
  {
2953
3132
  let priv = gobj.priv;
@@ -2962,6 +3141,7 @@ function select_node(gobj, node_id)
2962
3141
  graph.setElementState(node_id, ['selected']);
2963
3142
  } catch(e) {}
2964
3143
  priv._selected_node_id = node_id;
3144
+ paint_selection(gobj, [node_id]);
2965
3145
 
2966
3146
  // Show resize handles and properties icon
2967
3147
  show_resize_handles(gobj);
@@ -2984,12 +3164,28 @@ function deselect_node(gobj)
2984
3164
  hide_delete_confirm(gobj);
2985
3165
  hide_unlink_confirm(gobj);
2986
3166
 
3167
+ /* Both views of the selection, because either can hold an id the
3168
+ * other lost: G6's state is what a drag moves, the painted set is
3169
+ * what the reader can see. */
3170
+ let clearing = new Set([
3171
+ ...selected_node_ids(gobj),
3172
+ ...(priv._selected_paint_ids || [])
3173
+ ]);
2987
3174
  if(priv._selected_node_id) {
3175
+ clearing.add(priv._selected_node_id);
3176
+ }
3177
+
3178
+ if(clearing.size) {
2988
3179
  history_pause(gobj);
2989
3180
  try {
2990
- graph.setElementState(priv._selected_node_id, []);
3181
+ let states = {};
3182
+ for(let id of clearing) {
3183
+ states[id] = [];
3184
+ }
3185
+ graph.setElementState(states);
2991
3186
  } catch(e) {}
2992
3187
  priv._selected_node_id = null;
3188
+ paint_selection(gobj, []);
2993
3189
 
2994
3190
  graph_draw(gobj).then(() => {
2995
3191
  history_resume(gobj);
@@ -4793,7 +4989,7 @@ function refresh_minimap(gobj)
4793
4989
  * same three-way choice — it lived inline in the theme refresh and is
4794
4990
  * shared now. Returns null for a node that carries no desc.
4795
4991
  ************************************************************/
4796
- function node_innerHTML_of(nd, theme, highlight)
4992
+ function node_innerHTML_of(nd, theme, highlight, selected)
4797
4993
  {
4798
4994
  if(!nd || !nd.data || !nd.data.desc) {
4799
4995
  return null;
@@ -4805,45 +5001,49 @@ function node_innerHTML_of(nd, theme, highlight)
4805
5001
  switch(desc.node_treedb_type) {
4806
5002
  case 'child':
4807
5003
  return build_chip_innerHTML(
4808
- desc.color, theme, record.icon, label, record.id, highlight
5004
+ desc.color, theme, record.icon, label, record.id,
5005
+ highlight, selected
4809
5006
  );
4810
5007
  case 'extended':
4811
5008
  return build_node_innerHTML(
4812
5009
  desc.color, theme, record.icon, label,
4813
- desc.topic_name, true, record.id, highlight
5010
+ desc.topic_name, true, record.id, highlight, selected
4814
5011
  );
4815
5012
  case 'hierarchical':
4816
5013
  return build_node_innerHTML(
4817
5014
  desc.color, theme, record.icon, label,
4818
- desc.topic_name, false, record.id, highlight
5015
+ desc.topic_name, false, record.id, highlight, selected
4819
5016
  );
4820
5017
  }
4821
5018
  return null;
4822
5019
  }
4823
5020
 
4824
5021
  /************************************************************
4825
- * Repaint the cards whose highlight state CHANGES: the ones that had it
4826
- * and lose it, plus the ones that gain it. Only those — a treedb graph
4827
- * is redrawn per keystroke of the find box otherwise.
5022
+ * Repaint these cards with the flags they carry RIGHT NOW.
5023
+ *
5024
+ * One place, because the flags are independent and each used to
5025
+ * be written by whoever repainted last: a find that repainted its
5026
+ * matches erased the selection ring off them, and a selection
5027
+ * repainted over a match erased the amber. Both are read here
5028
+ * from where they live.
4828
5029
  ************************************************************/
4829
- function apply_node_highlight(gobj, prev_ids, next_ids)
5030
+ function repaint_cards(gobj, ids)
4830
5031
  {
4831
5032
  let priv = gobj.priv;
4832
5033
  let graph = priv.graph;
4833
- if(!graph) {
4834
- return;
4835
- }
4836
5034
 
4837
- let next = new Set(next_ids || []);
4838
- let touched = new Set([...(prev_ids || []), ...next]);
4839
- if(touched.size === 0) {
5035
+ if(!graph || !ids || !ids.size) {
4840
5036
  return;
4841
5037
  }
4842
5038
 
5039
+ let focus = new Set(priv._focus_ids || []);
5040
+ let selected = new Set(priv._selected_paint_ids || []);
5041
+
4843
5042
  let updates = [];
4844
- for(let id of touched) {
4845
- let nd = graph.getNodeData(id);
4846
- let html = node_innerHTML_of(nd, priv.theme, next.has(id));
5043
+ for(let id of ids) {
5044
+ let html = node_innerHTML_of(
5045
+ graph.getNodeData(id), priv.theme, focus.has(id), selected.has(id)
5046
+ );
4847
5047
  if(html !== null) {
4848
5048
  updates.push({id: id, style: {innerHTML: html}});
4849
5049
  }
@@ -4851,12 +5051,35 @@ function apply_node_highlight(gobj, prev_ids, next_ids)
4851
5051
  if(!updates.length) {
4852
5052
  return;
4853
5053
  }
5054
+
4854
5055
  try {
4855
5056
  graph.updateNodeData(updates);
4856
5057
  graph.draw();
4857
5058
  } catch(e) {
4858
- log_error(`${gobj_short_name(gobj)}: cannot repaint the highlight: ${e}`);
5059
+ log_error(`${gobj_short_name(gobj)}: cannot repaint the cards: ${e}`);
5060
+ }
5061
+ }
5062
+
5063
+ /************************************************************
5064
+ * Repaint the cards whose highlight state CHANGES: the ones that had it
5065
+ * and lose it, plus the ones that gain it. Only those — a treedb graph
5066
+ * is redrawn per keystroke of the find box otherwise.
5067
+ ************************************************************/
5068
+ function apply_node_highlight(gobj, prev_ids, next_ids)
5069
+ {
5070
+ let priv = gobj.priv;
5071
+ let graph = priv.graph;
5072
+ if(!graph) {
5073
+ return;
5074
+ }
5075
+
5076
+ let next = new Set(next_ids || []);
5077
+ let touched = new Set([...(prev_ids || []), ...next]);
5078
+ if(touched.size === 0) {
5079
+ return;
4859
5080
  }
5081
+
5082
+ repaint_cards(gobj, touched);
4860
5083
  }
4861
5084
 
4862
5085
  function refresh_html_nodes_theme(gobj, theme)
@@ -4871,10 +5094,13 @@ function refresh_html_nodes_theme(gobj, theme)
4871
5094
  * rebuilt every card without it would silently CLEAR the focus or the
4872
5095
  * find that is on screen. Carry it across. */
4873
5096
  let highlighted = new Set(priv._focus_ids || []);
5097
+ let selected = new Set(priv._selected_paint_ids || []);
4874
5098
  let updates = [];
4875
5099
  for(let i = 0; i < nodes.length; i++) {
4876
5100
  let id = nodes[i].id;
4877
- let html = node_innerHTML_of(graph.getNodeData(id), theme, highlighted.has(id));
5101
+ let html = node_innerHTML_of(
5102
+ graph.getNodeData(id), theme, highlighted.has(id), selected.has(id)
5103
+ );
4878
5104
  if(html !== null) {
4879
5105
  updates.push({id: id, style: {innerHTML: html}});
4880
5106
  }
@@ -4913,13 +5139,42 @@ function refresh_default_edges_theme(gobj, theme)
4913
5139
  }
4914
5140
  }
4915
5141
 
5142
+ /************************************************************
5143
+ * The rings a card can wear, composed into one `box-shadow`.
5144
+ *
5145
+ * A node can be a find match and be selected at the same time, so
5146
+ * neither ring may be written by overwriting the other: the amber
5147
+ * halo hugs the card and the blue selection ring is drawn outside
5148
+ * it, which is also the order that reads correctly when only one
5149
+ * of them is on.
5150
+ ************************************************************/
5151
+ function ring_shadow(highlight, selected, base_shadow)
5152
+ {
5153
+ let rings = [];
5154
+
5155
+ if(highlight) {
5156
+ rings.push(`0 0 0 4px ${HIGHLIGHT_HALO}`);
5157
+ }
5158
+ if(selected) {
5159
+ rings.push(`0 0 0 ${highlight? "7px" : "3px"} ${SELECT_RING}`);
5160
+ }
5161
+ if(base_shadow) {
5162
+ rings.push(base_shadow);
5163
+ }
5164
+ if(!rings.length) {
5165
+ return "";
5166
+ }
5167
+
5168
+ return `box-shadow: ${rings.join(", ")};`;
5169
+ }
5170
+
4916
5171
  /************************************************************
4917
5172
  * Build innerHTML for pure-child (leaf) nodes: a compact
4918
5173
  * chip-card. Same colour/typography family as the entity card
4919
5174
  * but lighter (1px border, no shadow, single line). The name is
4920
5175
  * always legible (ellipsis + native title tooltip on overflow).
4921
5176
  ************************************************************/
4922
- function build_chip_innerHTML(color, theme, icon, label, key, highlight)
5177
+ function build_chip_innerHTML(color, theme, icon, label, key, highlight, selected)
4923
5178
  {
4924
5179
  let title = key || label;
4925
5180
  let dark = (theme === "dark");
@@ -4950,7 +5205,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight)
4950
5205
  height: 100%;
4951
5206
  background: ${bg};
4952
5207
  border: ${highlight? "3px" : "1px"} solid ${border};
4953
- ${highlight? `box-shadow: 0 0 0 4px ${HIGHLIGHT_HALO};` : ""}
5208
+ ${ring_shadow(highlight, selected, "")}
4954
5209
  border-radius: 8px;
4955
5210
  color: ${text_color};
4956
5211
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
@@ -4977,7 +5232,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight)
4977
5232
  * colour is kept (per-topic differentiation) but softened via
4978
5233
  * color-mix instead of a harsh saturated fill. Theme-aware.
4979
5234
  ************************************************************/
4980
- function build_node_innerHTML(color, theme, icon, label, topic_name, structural, key, highlight)
5235
+ function build_node_innerHTML(color, theme, icon, label, topic_name, structural, key, highlight, selected)
4981
5236
  {
4982
5237
  let title = key || label;
4983
5238
  let dark = (theme === "dark");
@@ -5040,7 +5295,7 @@ function build_node_innerHTML(color, theme, icon, label, topic_name, structural,
5040
5295
  background: ${bg};
5041
5296
  border: ${highlight? "3px" : "1.5px"} ${border_style} ${border};
5042
5297
  border-radius: 10px;
5043
- box-shadow: ${highlight? `0 0 0 4px ${HIGHLIGHT_HALO}, ${shadow}` : shadow};
5298
+ ${ring_shadow(highlight, selected, shadow)}
5044
5299
  color: ${title_color};
5045
5300
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
5046
5301
  display: flex;
@@ -5101,31 +5356,151 @@ function execute_delete_node(gobj, nodeData)
5101
5356
  }
5102
5357
 
5103
5358
  /************************************************************
5104
- * Show a confirmation popover next to the delete icon.
5359
+ * Every node in the graph.
5105
5360
  ************************************************************/
5106
- 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)
5107
5377
  {
5108
5378
  let priv = gobj.priv;
5109
- let record = nodeData.data.record || {};
5110
- let topic_name = nodeData.data.desc.topic_name;
5379
+ let graph = priv.graph;
5380
+
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;
5424
+ }
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
+ }
5111
5434
 
5112
- /* What the delete takes with it. These views delete with `force`, and
5113
- * `force` UNLINKS a node's children they survive, loose — and cleans
5114
- * it off its parents. A question that names only the node lets an
5115
- * operator detach eleven records believing they removed one. */
5116
- let impact = delete_impact(nodeData.data.desc, record);
5117
- let question = t('delete') + ' ' + topic_name + ': ' + record.id + '?';
5118
- if(impact.children > 0) {
5119
- question += '\n' + impact.children + ' ' +
5120
- t('children will be unlinked, not deleted', {count: impact.children});
5435
+ if(children > 0) {
5436
+ question += '\n' + children + ' ' +
5437
+ t('children will be unlinked, not deleted', {count: children});
5121
5438
  }
5122
- if(impact.parents > 0) {
5439
+ if(parents > 0) {
5123
5440
  question += '\n' + t('it will be detached from') + ' ' +
5124
- impact.parents + ' ' + t('parents', {count: impact.parents});
5441
+ parents + ' ' + t('parents', {count: parents});
5442
+ }
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 */
5125
5456
  }
5126
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. */
5127
5502
  show_confirm_popover(gobj, priv._node_delete_el,
5128
- question,
5503
+ delete_question([nodeData]),
5129
5504
  'delete', '#ff4d4f',
5130
5505
  () => execute_delete_node(gobj, nodeData),
5131
5506
  '_delete_confirm_el'
@@ -6477,6 +6852,67 @@ function ac_node_drag_end(gobj, event, kw, src)
6477
6852
  return 0;
6478
6853
  }
6479
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
+
6902
+ /************************************************************
6903
+ * The rubber band let go: these are the nodes it enclosed.
6904
+ *
6905
+ * They arrive in the kw because `onSelect` runs BEFORE G6 writes
6906
+ * the state, so asking the graph here would answer the previous
6907
+ * selection.
6908
+ ************************************************************/
6909
+ function ac_brush_select(gobj, event, kw, src)
6910
+ {
6911
+ set_selection(gobj, (kw && kw.ids) || []);
6912
+
6913
+ return 0;
6914
+ }
6915
+
6480
6916
  /************************************************************
6481
6917
  * Node click - publish vertex clicked event
6482
6918
  ************************************************************/
@@ -6495,7 +6931,13 @@ function ac_node_click(gobj, event, kw, src)
6495
6931
  record: nodedata.data.record
6496
6932
  });
6497
6933
 
6498
- if(priv.edit_mode) {
6934
+ if(priv.edit_mode && kw.evt.shiftKey) {
6935
+ /* Shift+click extends the selection, the way it does
6936
+ * everywhere. It never looks for a port: a port is a
6937
+ * one-node affordance, and this gesture is about the
6938
+ * set. */
6939
+ toggle_in_selection(gobj, node_id);
6940
+ } else if(priv.edit_mode) {
6499
6941
  // Check if click hits a port
6500
6942
  // Convert client coords to viewport (container-relative) then to canvas
6501
6943
  let containerRect = priv.$container.getBoundingClientRect();
@@ -6782,6 +7224,8 @@ function create_gclass(gclass_name)
6782
7224
  ["EV_NODE_CONTEXT_MENU", ac_node_context_menu, null],
6783
7225
  ["EV_CANVAS_CLICK", ac_canvas_click, null],
6784
7226
  ["EV_NODE_DRAG_END", ac_node_drag_end, null],
7227
+ ["EV_BRUSH_SELECT", ac_brush_select, null],
7228
+ ["EV_KEY_DOWN", ac_key_down, null],
6785
7229
 
6786
7230
  /*--- Toolbar events ---*/
6787
7231
  ["EV_ZOOM_IN", ac_zoom_in, null],
@@ -6826,6 +7270,8 @@ function create_gclass(gclass_name)
6826
7270
  ["EV_NODE_CONTEXT_MENU", 0],
6827
7271
  ["EV_CANVAS_CLICK", 0],
6828
7272
  ["EV_NODE_DRAG_END", 0],
7273
+ ["EV_BRUSH_SELECT", 0],
7274
+ ["EV_KEY_DOWN", 0],
6829
7275
 
6830
7276
  /*--- Toolbar (internal) ---*/
6831
7277
  ["EV_ZOOM_IN", 0],