@yuneta/gobj-ui 7.14.3 → 7.16.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.
@@ -105,11 +105,22 @@ import {yui_theme_now, yui_watch_theme} from "./yui_theme.js";
105
105
 
106
106
  /***************************************************************
107
107
  * YuiToolbar — G6 Toolbar subclass that adds per-item className
108
- * and disabled support.
108
+ * and disabled support, plus three item kinds G6 does not have.
109
109
  *
110
110
  * Each item in getItems() may carry:
111
111
  * className — CSS class added to the div (e.g. 'EV_SAVE_GRAPH')
112
112
  * disabled — boolean; sets the 'disabled' attribute initially
113
+ * text — render this text instead of a sprite symbol; a
114
+ * label is the only readable way to say `1:1`, and
115
+ * every editor that offers actual-size writes it
116
+ * readout — a text item that REPORTS instead of acting (the
117
+ * zoom level); it is not a button
118
+ * separator — a group divider, not an item
119
+ *
120
+ * The base class fires onClick only for elements whose class list
121
+ * contains `g6-toolbar-item`, so a separator and a readout carry
122
+ * their own class and are inert by construction, with no guard in
123
+ * the click handler.
113
124
  *
114
125
  * With className set to the event name, the lib_graph.js state
115
126
  * functions (set_submit_state, disableElements, …) work on toolbar
@@ -122,14 +133,36 @@ class YuiToolbar extends Toolbar
122
133
  {
123
134
  const items = await this.options.getItems();
124
135
  return items.map((item) => {
136
+ if(item.separator) {
137
+ return `<div class="g6-toolbar-sep" aria-hidden="true"></div>`;
138
+ }
139
+
125
140
  const extra_class = item.className ? ` ${item.className}` : '';
126
- const disabled = item.disabled ? ' disabled' : '';
141
+ const title = item.title ?? '';
142
+
143
+ if(item.readout) {
144
+ return (
145
+ `<div class="g6-toolbar-readout${extra_class}"` +
146
+ ` title="${title}" aria-label="${title}">` +
147
+ `${item.text ?? ''}` +
148
+ `</div>`
149
+ );
150
+ }
151
+
152
+ const disabled = item.disabled ? ' disabled' : '';
153
+ const is_text = (item.text !== undefined);
154
+ const kind_class = is_text ? ' g6-toolbar-item-text' : '';
155
+ const body = is_text
156
+ ? `<span class="g6-toolbar-text">${item.text}</span>`
157
+ : `<svg aria-hidden="true" focusable="false">` +
158
+ `<use xlink:href="#${item.id}"></use>` +
159
+ `</svg>`;
160
+
127
161
  return (
128
- `<div class="g6-toolbar-item${extra_class}"` +
129
- ` value="${item.value}" title="${item.title ?? ''}"${disabled}>` +
130
- `<svg aria-hidden="true" focusable="false">` +
131
- `<use xlink:href="#${item.id}"></use>` +
132
- `</svg>` +
162
+ `<div class="g6-toolbar-item${kind_class}${extra_class}"` +
163
+ ` value="${item.value}" title="${title}"` +
164
+ ` aria-label="${title}"${disabled}>` +
165
+ `${body}` +
133
166
  `</div>`
134
167
  );
135
168
  }).join('');
@@ -151,6 +184,15 @@ const GCLASS_NAME = "C_G6_NODES_TREE";
151
184
  const HIGHLIGHT_COLOR = "#f0a020";
152
185
  const HIGHLIGHT_HALO = "rgba(240,160,32,0.35)";
153
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
+
154
196
  /***************************************************************
155
197
  * Internal layout and operation mode definitions
156
198
  ***************************************************************/
@@ -291,6 +333,10 @@ let PRIVATE_DATA = {
291
333
  _link_saved_styles: [], // saved port styles to restore on cancel
292
334
  _focus_topic: null, // topic currently focused (EV_FOCUS_TOPIC)
293
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)
294
340
  _pending_focus_topic: null, // focus requested before data was loaded
295
341
  _pending_find: null, // find requested before data was loaded
296
342
  _layout_asked: "", // layout the host asked for at create (see mt_create)
@@ -732,6 +778,7 @@ function configure_events(gobj)
732
778
  update_edge_icon_position(gobj);
733
779
  update_node_icon_position(gobj);
734
780
  update_link_icon_position(gobj);
781
+ update_zoom_readout(gobj);
735
782
  });
736
783
 
737
784
  // Re-render G6 toolbars when language changes
@@ -863,6 +910,79 @@ function update_toolbar(gobj)
863
910
  }
864
911
  }
865
912
 
913
+ /************************************************************
914
+ * The floating toolbars follow the theme.
915
+ *
916
+ * They used to be pinned to a light background in BOTH themes,
917
+ * with the icon colour pinned dark so it survived that -- two
918
+ * light islands sitting over a dark canvas. The tokens do the
919
+ * flipping now; the fallbacks are the old forced-light values, so
920
+ * a host without Bulma gets exactly what it had.
921
+ ************************************************************/
922
+ function toolbar_style(extra)
923
+ {
924
+ return Object.assign({
925
+ backgroundColor: 'var(--bulma-scheme-main, #f5f5f5)',
926
+ color: 'var(--bulma-text-strong, #333)',
927
+ padding: '8px',
928
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
929
+ borderRadius: '8px',
930
+ border: '1px solid var(--bulma-border-weak, #e8e8e8)',
931
+ opacity: '0.85',
932
+ }, extra || {});
933
+ }
934
+
935
+ /************************************************************
936
+ * The zoom level as a percentage, the way every editor that
937
+ * shows one writes it. Empty while the graph cannot be asked --
938
+ * the toolbar is built before the first draw, and a viewport
939
+ * that does not exist yet has no zoom to report.
940
+ ************************************************************/
941
+ function zoom_percent_text(gobj)
942
+ {
943
+ let graph = gobj.priv.graph;
944
+
945
+ if(!graph || typeof graph.getZoom !== "function") {
946
+ return "";
947
+ }
948
+
949
+ let zoom;
950
+ try {
951
+ zoom = graph.getZoom();
952
+ } catch(e) {
953
+ return "";
954
+ }
955
+ if(!zoom) {
956
+ return "";
957
+ }
958
+
959
+ return `${Math.round(zoom * 100)}%`;
960
+ }
961
+
962
+ /************************************************************
963
+ * Repaint the zoom readout in place.
964
+ *
965
+ * The text node is patched instead of re-rendering the plugin:
966
+ * the readout follows the wheel, and updatePlugin() rebuilds the
967
+ * whole toolbar's innerHTML -- which would also drop the disabled
968
+ * state of the edit buttons on every notch of the wheel.
969
+ ************************************************************/
970
+ function update_zoom_readout(gobj)
971
+ {
972
+ let $container = gobj_read_attr(gobj, "$container");
973
+
974
+ if(!$container) {
975
+ return;
976
+ }
977
+
978
+ let $readout = $container.querySelector(".G6_ZOOM_LEVEL");
979
+ if(!$readout) {
980
+ return; /* no toolbar (with_toolbar false): nobody to report to */
981
+ }
982
+
983
+ $readout.textContent = zoom_percent_text(gobj);
984
+ }
985
+
866
986
  function configure_toolbar(gobj)
867
987
  {
868
988
  let priv = gobj.priv;
@@ -880,28 +1000,37 @@ function configure_toolbar(gobj)
880
1000
  type: 'yui-toolbar',
881
1001
  className: 'g6-toolbar-large',
882
1002
  position: toolbar_position,
883
- style: {
884
- backgroundColor: '#f5f5f5',
885
- /* Forced-light bg in both themes → pin dark icon
886
- * color (currentColor) so it stays visible in dark. */
887
- color: '#333',
888
- padding: '8px',
889
- boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
890
- borderRadius: '8px',
891
- border: '1px solid #e8e8e8',
892
- opacity: '0.85',
893
- marginTop: '12px',
1003
+ style: toolbar_style({
1004
+ marginTop: '12px',
894
1005
  marginLeft: '12px',
895
- },
1006
+ }),
896
1007
  getItems: () => {
897
1008
  let items = [
898
1009
  { id: 'g6-icon-zoom-in', value: 'zoom-in', className: 'EV_ZOOM_IN', title: t('zoom in') },
899
1010
  { id: 'g6-icon-zoom-out', value: 'zoom-out', className: 'EV_ZOOM_OUT', title: t('zoom out') },
1011
+ /* What the two buttons above change. Every editor
1012
+ * that offers a zoom shows the number; without it
1013
+ * `1:1` is a jump to a value nobody was told. */
1014
+ { readout: true, className: 'G6_ZOOM_LEVEL',
1015
+ text: zoom_percent_text(gobj), title: t('zoom level') },
1016
+ { separator: true },
900
1017
  { id: 'g6-icon-fit', value: 'auto-fit', className: 'EV_AUTO_FIT', title: t('auto fit') },
901
- { id: 'g6-icon-home', value: 'reset', className: 'EV_ZOOM_RESET', title: t('reset zoom') },
1018
+ /* `1:1`, not a house. The action is `zoomTo(1)`: it
1019
+ * sets the SCALE and leaves the camera where it was,
1020
+ * which is neither of the two things a house means
1021
+ * anywhere -- a map's initial extent, an editor's
1022
+ * starting view. Sitting under `fit`, the house read
1023
+ * as a second way to get the whole graph back, and
1024
+ * answered with the same corner of it at 100%.
1025
+ * Actual size is WRITTEN in every editor that offers
1026
+ * it, never drawn: there is no glyph for it. */
1027
+ { text: '1:1', value: 'reset', className: 'EV_ZOOM_RESET', title: t('actual size') },
902
1028
  ];
903
1029
 
904
1030
  if(gobj_read_bool_attr(gobj, "with_fullscreen")) {
1031
+ /* Full screen is a WINDOW control, not a camera one:
1032
+ * it goes in its own group. */
1033
+ items.push({ separator: true });
905
1034
  if(priv.is_fullscreen) {
906
1035
  items.push(
907
1036
  { id: 'g6-icon-fullscreen-exit', value: 'exit-fullscreen', className: 'EV_EXIT_FULLSCREEN', title: t('exit full screen') }
@@ -976,18 +1105,7 @@ function configure_toolbar_edit(gobj)
976
1105
  type: 'yui-toolbar',
977
1106
  className: 'g6-toolbar-large',
978
1107
  position: 'left-top',
979
- style: {
980
- backgroundColor: '#f5f5f5',
981
- /* Toolbar bg is forced light in BOTH themes; icons
982
- * use currentColor, so pin a dark color or in dark
983
- * theme they'd be light-on-light (invisible). */
984
- color: '#333',
985
- padding: '8px',
986
- boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
987
- borderRadius: '8px',
988
- border: '1px solid #e8e8e8',
989
- opacity: '0.85',
990
- },
1108
+ style: toolbar_style(),
991
1109
  getItems: () => {
992
1110
  return [
993
1111
  { id: 'g6-icon-plus', value: 'create-node', className: 'EV_CREATE_NODE_BTN color_create_state', title: t('create node') },
@@ -1077,9 +1195,46 @@ function configure_behaviour(gobj)
1077
1195
  case "edition":
1078
1196
  priv.edit_mode = true;
1079
1197
  behaviors = [
1080
- "drag-canvas",
1198
+ /* Panning gives way while Shift is held: that is the
1199
+ * marquee's gesture, and G6 binds drag-canvas straight
1200
+ * to the drag events, so without this the canvas pans
1201
+ * under the rubber band. */
1202
+ {
1203
+ type: "drag-canvas",
1204
+ key: "drag-canvas",
1205
+ enable: (event) => !event.shiftKey,
1206
+ },
1081
1207
  "zoom-canvas",
1208
+ /* Moves EVERY node in the `selected` state, not just
1209
+ * the one under the pointer, and wraps the whole move
1210
+ * in one history batch -- so a group move is one drag
1211
+ * and one undo. Nothing to configure: `selected` is
1212
+ * already its default `state`. */
1082
1213
  "drag-element",
1214
+ /* Shift+drag on the canvas: the rubber band. The
1215
+ * GESTURE is G6's, its RESULT enters the machine --
1216
+ * `onSelect` fires before G6 writes the state, so the
1217
+ * ids travel with the event and the action does not
1218
+ * have to race it. */
1219
+ {
1220
+ type: "brush-select",
1221
+ key: "brush-select",
1222
+ trigger: ["shift"],
1223
+ enableElements: ["node"],
1224
+ /* Left at G6's default (false): the set is read at
1225
+ * pointerup, not on every pointermove. Each answer
1226
+ * repaints the cards it touches, and doing that per
1227
+ * frame of a rubber band over a hundred nodes is
1228
+ * paid for nothing -- the band already shows what
1229
+ * it covers. */
1230
+ immediately: false,
1231
+ onSelect: (states) => {
1232
+ let ids = Object.keys(states || {}).filter(
1233
+ (id) => (states[id] || []).includes("selected")
1234
+ );
1235
+ gobj_send_event(gobj, "EV_BRUSH_SELECT", {ids: ids}, gobj);
1236
+ },
1237
+ },
1083
1238
  ];
1084
1239
  break;
1085
1240
  case "operation":
@@ -2843,6 +2998,126 @@ function perform_history_op(gobj, is_redo)
2843
2998
  }
2844
2999
  }
2845
3000
 
3001
+ /************************************************************
3002
+ * The selection.
3003
+ *
3004
+ * G6's `selected` element state IS the selection: `drag-element`
3005
+ * reads it (`getElementDataByState`) to decide what a drag moves,
3006
+ * so keeping the set anywhere else would be a second truth the
3007
+ * drag does not consult. What this gclass keeps is what is
3008
+ * PAINTED -- an html node draws no state style at all (its key
3009
+ * shape is a DOM element), so the ring lives in the card's own
3010
+ * html, exactly like the find highlight, and the painted set
3011
+ * exists to diff the repaint.
3012
+ ************************************************************/
3013
+ function selected_node_ids(gobj)
3014
+ {
3015
+ let graph = gobj.priv.graph;
3016
+
3017
+ if(!graph) {
3018
+ return [];
3019
+ }
3020
+
3021
+ let data;
3022
+ try {
3023
+ data = graph.getElementDataByState('node', 'selected') || [];
3024
+ } catch(e) {
3025
+ return []; /* asked before there is a graph to ask */
3026
+ }
3027
+
3028
+ return data.map((nd) => nd.id);
3029
+ }
3030
+
3031
+ /************************************************************
3032
+ * Move the ring to these nodes and off the ones that had it.
3033
+ ************************************************************/
3034
+ function paint_selection(gobj, ids)
3035
+ {
3036
+ let priv = gobj.priv;
3037
+ let prev = priv._selected_paint_ids || [];
3038
+ let next = ids || [];
3039
+
3040
+ priv._selected_paint_ids = next;
3041
+
3042
+ repaint_cards(gobj, new Set([...prev, ...next]));
3043
+ }
3044
+
3045
+ /************************************************************
3046
+ * Select a SET of nodes: what a marquee and a shift-click do.
3047
+ *
3048
+ * It leaves `_selected_node_id` null even for a set of one,
3049
+ * because that field is not "the selection", it is "the node
3050
+ * opened for editing" -- the resize handles, the ports and the
3051
+ * popovers hang off it, and none of them means anything over a
3052
+ * set. Clicking a node is what opens one (`select_node`); a
3053
+ * marquee selects, it does not open.
3054
+ ************************************************************/
3055
+ function set_selection(gobj, ids)
3056
+ {
3057
+ let priv = gobj.priv;
3058
+ let graph = priv.graph;
3059
+
3060
+ let next = [];
3061
+ for(let id of (ids || [])) {
3062
+ try {
3063
+ if(graph.getNodeData(id)) {
3064
+ next.push(id);
3065
+ }
3066
+ } catch(e) {
3067
+ /* An id the graph does not have: a brush answers with what
3068
+ * it enclosed, and a node can be gone by the time we ask. */
3069
+ }
3070
+ }
3071
+
3072
+ deselect_node(gobj); /* the state, the paint and the affordances */
3073
+
3074
+ if(!next.length) {
3075
+ return;
3076
+ }
3077
+
3078
+ history_pause(gobj);
3079
+ try {
3080
+ let states = {};
3081
+ for(let id of next) {
3082
+ states[id] = ['selected'];
3083
+ }
3084
+ graph.setElementState(states);
3085
+ } catch(e) {
3086
+ log_error(`${gobj_short_name(gobj)}: cannot set the selection: ${e}`);
3087
+ }
3088
+
3089
+ paint_selection(gobj, next);
3090
+
3091
+ graph_draw(gobj).then(() => {
3092
+ history_resume(gobj);
3093
+ });
3094
+ }
3095
+
3096
+ /************************************************************
3097
+ * Add a node to the selection, or take it out of it.
3098
+ ************************************************************/
3099
+ function toggle_in_selection(gobj, node_id)
3100
+ {
3101
+ let priv = gobj.priv;
3102
+
3103
+ /* The union of the two views of the same thing. A brush clears
3104
+ * G6's state on pointerdown without telling anybody, so reading
3105
+ * only the state could drop a card that is on screen wearing a
3106
+ * ring. */
3107
+ let current = new Set(selected_node_ids(gobj));
3108
+ for(let id of (priv._selected_paint_ids || [])) {
3109
+ current.add(id);
3110
+ }
3111
+
3112
+ if(current.has(node_id)) {
3113
+ current.delete(node_id);
3114
+ } else {
3115
+ current.add(node_id);
3116
+ }
3117
+
3118
+ set_selection(gobj, [...current]);
3119
+ }
3120
+
2846
3121
  function select_node(gobj, node_id)
2847
3122
  {
2848
3123
  let priv = gobj.priv;
@@ -2857,6 +3132,7 @@ function select_node(gobj, node_id)
2857
3132
  graph.setElementState(node_id, ['selected']);
2858
3133
  } catch(e) {}
2859
3134
  priv._selected_node_id = node_id;
3135
+ paint_selection(gobj, [node_id]);
2860
3136
 
2861
3137
  // Show resize handles and properties icon
2862
3138
  show_resize_handles(gobj);
@@ -2879,12 +3155,28 @@ function deselect_node(gobj)
2879
3155
  hide_delete_confirm(gobj);
2880
3156
  hide_unlink_confirm(gobj);
2881
3157
 
3158
+ /* Both views of the selection, because either can hold an id the
3159
+ * other lost: G6's state is what a drag moves, the painted set is
3160
+ * what the reader can see. */
3161
+ let clearing = new Set([
3162
+ ...selected_node_ids(gobj),
3163
+ ...(priv._selected_paint_ids || [])
3164
+ ]);
2882
3165
  if(priv._selected_node_id) {
3166
+ clearing.add(priv._selected_node_id);
3167
+ }
3168
+
3169
+ if(clearing.size) {
2883
3170
  history_pause(gobj);
2884
3171
  try {
2885
- graph.setElementState(priv._selected_node_id, []);
3172
+ let states = {};
3173
+ for(let id of clearing) {
3174
+ states[id] = [];
3175
+ }
3176
+ graph.setElementState(states);
2886
3177
  } catch(e) {}
2887
3178
  priv._selected_node_id = null;
3179
+ paint_selection(gobj, []);
2888
3180
 
2889
3181
  graph_draw(gobj).then(() => {
2890
3182
  history_resume(gobj);
@@ -4688,7 +4980,7 @@ function refresh_minimap(gobj)
4688
4980
  * same three-way choice — it lived inline in the theme refresh and is
4689
4981
  * shared now. Returns null for a node that carries no desc.
4690
4982
  ************************************************************/
4691
- function node_innerHTML_of(nd, theme, highlight)
4983
+ function node_innerHTML_of(nd, theme, highlight, selected)
4692
4984
  {
4693
4985
  if(!nd || !nd.data || !nd.data.desc) {
4694
4986
  return null;
@@ -4700,45 +4992,49 @@ function node_innerHTML_of(nd, theme, highlight)
4700
4992
  switch(desc.node_treedb_type) {
4701
4993
  case 'child':
4702
4994
  return build_chip_innerHTML(
4703
- desc.color, theme, record.icon, label, record.id, highlight
4995
+ desc.color, theme, record.icon, label, record.id,
4996
+ highlight, selected
4704
4997
  );
4705
4998
  case 'extended':
4706
4999
  return build_node_innerHTML(
4707
5000
  desc.color, theme, record.icon, label,
4708
- desc.topic_name, true, record.id, highlight
5001
+ desc.topic_name, true, record.id, highlight, selected
4709
5002
  );
4710
5003
  case 'hierarchical':
4711
5004
  return build_node_innerHTML(
4712
5005
  desc.color, theme, record.icon, label,
4713
- desc.topic_name, false, record.id, highlight
5006
+ desc.topic_name, false, record.id, highlight, selected
4714
5007
  );
4715
5008
  }
4716
5009
  return null;
4717
5010
  }
4718
5011
 
4719
5012
  /************************************************************
4720
- * Repaint the cards whose highlight state CHANGES: the ones that had it
4721
- * and lose it, plus the ones that gain it. Only those — a treedb graph
4722
- * is redrawn per keystroke of the find box otherwise.
5013
+ * Repaint these cards with the flags they carry RIGHT NOW.
5014
+ *
5015
+ * One place, because the flags are independent and each used to
5016
+ * be written by whoever repainted last: a find that repainted its
5017
+ * matches erased the selection ring off them, and a selection
5018
+ * repainted over a match erased the amber. Both are read here
5019
+ * from where they live.
4723
5020
  ************************************************************/
4724
- function apply_node_highlight(gobj, prev_ids, next_ids)
5021
+ function repaint_cards(gobj, ids)
4725
5022
  {
4726
5023
  let priv = gobj.priv;
4727
5024
  let graph = priv.graph;
4728
- if(!graph) {
4729
- return;
4730
- }
4731
5025
 
4732
- let next = new Set(next_ids || []);
4733
- let touched = new Set([...(prev_ids || []), ...next]);
4734
- if(touched.size === 0) {
5026
+ if(!graph || !ids || !ids.size) {
4735
5027
  return;
4736
5028
  }
4737
5029
 
5030
+ let focus = new Set(priv._focus_ids || []);
5031
+ let selected = new Set(priv._selected_paint_ids || []);
5032
+
4738
5033
  let updates = [];
4739
- for(let id of touched) {
4740
- let nd = graph.getNodeData(id);
4741
- let html = node_innerHTML_of(nd, priv.theme, next.has(id));
5034
+ for(let id of ids) {
5035
+ let html = node_innerHTML_of(
5036
+ graph.getNodeData(id), priv.theme, focus.has(id), selected.has(id)
5037
+ );
4742
5038
  if(html !== null) {
4743
5039
  updates.push({id: id, style: {innerHTML: html}});
4744
5040
  }
@@ -4746,14 +5042,37 @@ function apply_node_highlight(gobj, prev_ids, next_ids)
4746
5042
  if(!updates.length) {
4747
5043
  return;
4748
5044
  }
5045
+
4749
5046
  try {
4750
5047
  graph.updateNodeData(updates);
4751
5048
  graph.draw();
4752
5049
  } catch(e) {
4753
- log_error(`${gobj_short_name(gobj)}: cannot repaint the highlight: ${e}`);
5050
+ log_error(`${gobj_short_name(gobj)}: cannot repaint the cards: ${e}`);
4754
5051
  }
4755
5052
  }
4756
5053
 
5054
+ /************************************************************
5055
+ * Repaint the cards whose highlight state CHANGES: the ones that had it
5056
+ * and lose it, plus the ones that gain it. Only those — a treedb graph
5057
+ * is redrawn per keystroke of the find box otherwise.
5058
+ ************************************************************/
5059
+ function apply_node_highlight(gobj, prev_ids, next_ids)
5060
+ {
5061
+ let priv = gobj.priv;
5062
+ let graph = priv.graph;
5063
+ if(!graph) {
5064
+ return;
5065
+ }
5066
+
5067
+ let next = new Set(next_ids || []);
5068
+ let touched = new Set([...(prev_ids || []), ...next]);
5069
+ if(touched.size === 0) {
5070
+ return;
5071
+ }
5072
+
5073
+ repaint_cards(gobj, touched);
5074
+ }
5075
+
4757
5076
  function refresh_html_nodes_theme(gobj, theme)
4758
5077
  {
4759
5078
  let priv = gobj.priv;
@@ -4766,10 +5085,13 @@ function refresh_html_nodes_theme(gobj, theme)
4766
5085
  * rebuilt every card without it would silently CLEAR the focus or the
4767
5086
  * find that is on screen. Carry it across. */
4768
5087
  let highlighted = new Set(priv._focus_ids || []);
5088
+ let selected = new Set(priv._selected_paint_ids || []);
4769
5089
  let updates = [];
4770
5090
  for(let i = 0; i < nodes.length; i++) {
4771
5091
  let id = nodes[i].id;
4772
- let html = node_innerHTML_of(graph.getNodeData(id), theme, highlighted.has(id));
5092
+ let html = node_innerHTML_of(
5093
+ graph.getNodeData(id), theme, highlighted.has(id), selected.has(id)
5094
+ );
4773
5095
  if(html !== null) {
4774
5096
  updates.push({id: id, style: {innerHTML: html}});
4775
5097
  }
@@ -4808,13 +5130,42 @@ function refresh_default_edges_theme(gobj, theme)
4808
5130
  }
4809
5131
  }
4810
5132
 
5133
+ /************************************************************
5134
+ * The rings a card can wear, composed into one `box-shadow`.
5135
+ *
5136
+ * A node can be a find match and be selected at the same time, so
5137
+ * neither ring may be written by overwriting the other: the amber
5138
+ * halo hugs the card and the blue selection ring is drawn outside
5139
+ * it, which is also the order that reads correctly when only one
5140
+ * of them is on.
5141
+ ************************************************************/
5142
+ function ring_shadow(highlight, selected, base_shadow)
5143
+ {
5144
+ let rings = [];
5145
+
5146
+ if(highlight) {
5147
+ rings.push(`0 0 0 4px ${HIGHLIGHT_HALO}`);
5148
+ }
5149
+ if(selected) {
5150
+ rings.push(`0 0 0 ${highlight? "7px" : "3px"} ${SELECT_RING}`);
5151
+ }
5152
+ if(base_shadow) {
5153
+ rings.push(base_shadow);
5154
+ }
5155
+ if(!rings.length) {
5156
+ return "";
5157
+ }
5158
+
5159
+ return `box-shadow: ${rings.join(", ")};`;
5160
+ }
5161
+
4811
5162
  /************************************************************
4812
5163
  * Build innerHTML for pure-child (leaf) nodes: a compact
4813
5164
  * chip-card. Same colour/typography family as the entity card
4814
5165
  * but lighter (1px border, no shadow, single line). The name is
4815
5166
  * always legible (ellipsis + native title tooltip on overflow).
4816
5167
  ************************************************************/
4817
- function build_chip_innerHTML(color, theme, icon, label, key, highlight)
5168
+ function build_chip_innerHTML(color, theme, icon, label, key, highlight, selected)
4818
5169
  {
4819
5170
  let title = key || label;
4820
5171
  let dark = (theme === "dark");
@@ -4845,7 +5196,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight)
4845
5196
  height: 100%;
4846
5197
  background: ${bg};
4847
5198
  border: ${highlight? "3px" : "1px"} solid ${border};
4848
- ${highlight? `box-shadow: 0 0 0 4px ${HIGHLIGHT_HALO};` : ""}
5199
+ ${ring_shadow(highlight, selected, "")}
4849
5200
  border-radius: 8px;
4850
5201
  color: ${text_color};
4851
5202
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
@@ -4872,7 +5223,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight)
4872
5223
  * colour is kept (per-topic differentiation) but softened via
4873
5224
  * color-mix instead of a harsh saturated fill. Theme-aware.
4874
5225
  ************************************************************/
4875
- function build_node_innerHTML(color, theme, icon, label, topic_name, structural, key, highlight)
5226
+ function build_node_innerHTML(color, theme, icon, label, topic_name, structural, key, highlight, selected)
4876
5227
  {
4877
5228
  let title = key || label;
4878
5229
  let dark = (theme === "dark");
@@ -4935,7 +5286,7 @@ function build_node_innerHTML(color, theme, icon, label, topic_name, structural,
4935
5286
  background: ${bg};
4936
5287
  border: ${highlight? "3px" : "1.5px"} ${border_style} ${border};
4937
5288
  border-radius: 10px;
4938
- box-shadow: ${highlight? `0 0 0 4px ${HIGHLIGHT_HALO}, ${shadow}` : shadow};
5289
+ ${ring_shadow(highlight, selected, shadow)}
4939
5290
  color: ${title_color};
4940
5291
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
4941
5292
  display: flex;
@@ -6372,6 +6723,20 @@ function ac_node_drag_end(gobj, event, kw, src)
6372
6723
  return 0;
6373
6724
  }
6374
6725
 
6726
+ /************************************************************
6727
+ * The rubber band let go: these are the nodes it enclosed.
6728
+ *
6729
+ * They arrive in the kw because `onSelect` runs BEFORE G6 writes
6730
+ * the state, so asking the graph here would answer the previous
6731
+ * selection.
6732
+ ************************************************************/
6733
+ function ac_brush_select(gobj, event, kw, src)
6734
+ {
6735
+ set_selection(gobj, (kw && kw.ids) || []);
6736
+
6737
+ return 0;
6738
+ }
6739
+
6375
6740
  /************************************************************
6376
6741
  * Node click - publish vertex clicked event
6377
6742
  ************************************************************/
@@ -6390,7 +6755,13 @@ function ac_node_click(gobj, event, kw, src)
6390
6755
  record: nodedata.data.record
6391
6756
  });
6392
6757
 
6393
- if(priv.edit_mode) {
6758
+ if(priv.edit_mode && kw.evt.shiftKey) {
6759
+ /* Shift+click extends the selection, the way it does
6760
+ * everywhere. It never looks for a port: a port is a
6761
+ * one-node affordance, and this gesture is about the
6762
+ * set. */
6763
+ toggle_in_selection(gobj, node_id);
6764
+ } else if(priv.edit_mode) {
6394
6765
  // Check if click hits a port
6395
6766
  // Convert client coords to viewport (container-relative) then to canvas
6396
6767
  let containerRect = priv.$container.getBoundingClientRect();
@@ -6677,6 +7048,7 @@ function create_gclass(gclass_name)
6677
7048
  ["EV_NODE_CONTEXT_MENU", ac_node_context_menu, null],
6678
7049
  ["EV_CANVAS_CLICK", ac_canvas_click, null],
6679
7050
  ["EV_NODE_DRAG_END", ac_node_drag_end, null],
7051
+ ["EV_BRUSH_SELECT", ac_brush_select, null],
6680
7052
 
6681
7053
  /*--- Toolbar events ---*/
6682
7054
  ["EV_ZOOM_IN", ac_zoom_in, null],
@@ -6721,6 +7093,7 @@ function create_gclass(gclass_name)
6721
7093
  ["EV_NODE_CONTEXT_MENU", 0],
6722
7094
  ["EV_CANVAS_CLICK", 0],
6723
7095
  ["EV_NODE_DRAG_END", 0],
7096
+ ["EV_BRUSH_SELECT", 0],
6724
7097
 
6725
7098
  /*--- Toolbar (internal) ---*/
6726
7099
  ["EV_ZOOM_IN", 0],