@yuneta/gobj-ui 7.15.0 → 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.
package/README.md CHANGED
@@ -708,6 +708,56 @@ so it survived that — two light islands over a dark canvas.
708
708
  **New keys for consumers: `actual size`, `zoom level`** (both tooltips, so a
709
709
  host that has not defined them shows the key on hover and nothing else breaks).
710
710
 
711
+ ### Selecting several nodes, and moving them together
712
+
713
+ In **edition** mode the graph has a real selection, not just "the node you
714
+ clicked":
715
+
716
+ | gesture | what it does |
717
+ |---|---|
718
+ | click a node | selects it **and opens it**: resize handles, ports, popovers |
719
+ | **shift + click** | adds that node to the selection, or takes it out |
720
+ | **shift + drag on the canvas** | rubber band: the selection becomes what it enclosed |
721
+ | drag any selected node | **moves the whole selection**, as one undo |
722
+ | click the canvas | clears it |
723
+
724
+ Three decisions are worth knowing, because each one is where this could have
725
+ gone wrong:
726
+
727
+ - **G6's `selected` element state IS the selection.** `drag-element` decides
728
+ what a drag moves by asking the graph for it
729
+ (`getElementDataByState('node', 'selected')`), so a set kept anywhere else
730
+ would be a second truth the drag never consults — the ring would say five and
731
+ one would move. It also batches the move, so a group drag is one history
732
+ entry rather than one per node.
733
+
734
+ - **The ring is painted into the card's own html**, and it had to be. A state
735
+ style paints on a node's KEY SHAPE, and every node here is an `html` node
736
+ whose key shape is a DOM element — the same reason the amber highlight had
737
+ never appeared before `7.3.0`. Selecting with `brush-select` and nothing else
738
+ would have selected correctly and shown **nothing**. The ring is blue and
739
+ drawn OUTSIDE the amber halo, so a node that is both a find match and
740
+ selected wears both; one function composes them (`ring_shadow`), because
741
+ before it each repaint wrote its own flag and erased the other's.
742
+
743
+ - **The gesture is G6's, the result is an event.** `brush-select` gets an
744
+ `onSelect` that sends `EV_BRUSH_SELECT` with the ids, and the action does the
745
+ work — so a marquee shows up in the `machine` trace like every other action.
746
+ Shift+click is not G6's `click-select` at all: this gclass already owns
747
+ `EV_NODE_CLICK`, and adding a second selection owner outside the FSM is how
748
+ the two end up disagreeing.
749
+
750
+ Panning gives way while **Shift** is held (`drag-canvas` takes an `enable`
751
+ predicate), or the canvas would pan under the rubber band — G6 binds
752
+ `drag-canvas` straight to the drag events, and its own docs warn that the two
753
+ gestures cannot both be a plain drag.
754
+
755
+ **A marquee selects, it does not open.** Even when it encloses exactly one
756
+ node, the handles and ports stay away: `_selected_node_id` means *the node
757
+ opened for editing*, and only a click sets it. Everything that hangs off a
758
+ single node reads that field, so a multiple selection puts all of it away by
759
+ construction rather than by a check in twenty places.
760
+
711
761
  ### Finding a node in the graph
712
762
 
713
763
  `C_YUI_TREEDB_GRAPH` carries a find box in the middle of its toolbar. It
@@ -53747,6 +53747,7 @@ ensure_drag_canvas_patch();
53747
53747
  var GCLASS_NAME$1 = "C_G6_NODES_TREE";
53748
53748
  var HIGHLIGHT_COLOR = "#f0a020";
53749
53749
  var HIGHLIGHT_HALO = "rgba(240,160,32,0.35)";
53750
+ var SELECT_RING = "rgba(59,130,246,0.95)";
53750
53751
  /***************************************************************
53751
53752
  * Internal layout and operation mode definitions
53752
53753
  ***************************************************************/
@@ -53858,6 +53859,7 @@ var PRIVATE_DATA$1 = {
53858
53859
  _link_saved_styles: [],
53859
53860
  _focus_topic: null,
53860
53861
  _focus_ids: [],
53862
+ _selected_paint_ids: [],
53861
53863
  _pending_focus_topic: null,
53862
53864
  _pending_find: null,
53863
53865
  _layout_asked: "",
@@ -54378,9 +54380,24 @@ function configure_behaviour(gobj) {
54378
54380
  case "edition":
54379
54381
  priv.edit_mode = true;
54380
54382
  behaviors = [
54381
- "drag-canvas",
54383
+ {
54384
+ type: "drag-canvas",
54385
+ key: "drag-canvas",
54386
+ enable: (event) => !event.shiftKey
54387
+ },
54382
54388
  "zoom-canvas",
54383
- "drag-element"
54389
+ "drag-element",
54390
+ {
54391
+ type: "brush-select",
54392
+ key: "brush-select",
54393
+ trigger: ["shift"],
54394
+ enableElements: ["node"],
54395
+ immediately: false,
54396
+ onSelect: (states) => {
54397
+ let ids = Object.keys(states || {}).filter((id) => (states[id] || []).includes("selected"));
54398
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_BRUSH_SELECT", { ids }, gobj);
54399
+ }
54400
+ }
54384
54401
  ];
54385
54402
  break;
54386
54403
  case "operation":
@@ -55495,6 +55512,81 @@ function perform_history_op(gobj, is_redo) {
55495
55512
  sync_history_to_backend(gobj, cmd ? cmd.original : null);
55496
55513
  }
55497
55514
  }
55515
+ /************************************************************
55516
+ * The selection.
55517
+ *
55518
+ * G6's `selected` element state IS the selection: `drag-element`
55519
+ * reads it (`getElementDataByState`) to decide what a drag moves,
55520
+ * so keeping the set anywhere else would be a second truth the
55521
+ * drag does not consult. What this gclass keeps is what is
55522
+ * PAINTED -- an html node draws no state style at all (its key
55523
+ * shape is a DOM element), so the ring lives in the card's own
55524
+ * html, exactly like the find highlight, and the painted set
55525
+ * exists to diff the repaint.
55526
+ ************************************************************/
55527
+ function selected_node_ids(gobj) {
55528
+ let graph = gobj.priv.graph;
55529
+ if (!graph) return [];
55530
+ let data;
55531
+ try {
55532
+ data = graph.getElementDataByState("node", "selected") || [];
55533
+ } catch (e) {
55534
+ return [];
55535
+ }
55536
+ return data.map((nd) => nd.id);
55537
+ }
55538
+ /************************************************************
55539
+ * Move the ring to these nodes and off the ones that had it.
55540
+ ************************************************************/
55541
+ function paint_selection(gobj, ids) {
55542
+ let priv = gobj.priv;
55543
+ let prev = priv._selected_paint_ids || [];
55544
+ let next = ids || [];
55545
+ priv._selected_paint_ids = next;
55546
+ repaint_cards(gobj, /* @__PURE__ */ new Set([...prev, ...next]));
55547
+ }
55548
+ /************************************************************
55549
+ * Select a SET of nodes: what a marquee and a shift-click do.
55550
+ *
55551
+ * It leaves `_selected_node_id` null even for a set of one,
55552
+ * because that field is not "the selection", it is "the node
55553
+ * opened for editing" -- the resize handles, the ports and the
55554
+ * popovers hang off it, and none of them means anything over a
55555
+ * set. Clicking a node is what opens one (`select_node`); a
55556
+ * marquee selects, it does not open.
55557
+ ************************************************************/
55558
+ function set_selection(gobj, ids) {
55559
+ let graph = gobj.priv.graph;
55560
+ let next = [];
55561
+ for (let id of ids || []) try {
55562
+ if (graph.getNodeData(id)) next.push(id);
55563
+ } catch (e) {}
55564
+ deselect_node(gobj);
55565
+ if (!next.length) return;
55566
+ history_pause(gobj);
55567
+ try {
55568
+ let states = {};
55569
+ for (let id of next) states[id] = ["selected"];
55570
+ graph.setElementState(states);
55571
+ } catch (e) {
55572
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: cannot set the selection: ${e}`);
55573
+ }
55574
+ paint_selection(gobj, next);
55575
+ graph_draw(gobj).then(() => {
55576
+ history_resume(gobj);
55577
+ });
55578
+ }
55579
+ /************************************************************
55580
+ * Add a node to the selection, or take it out of it.
55581
+ ************************************************************/
55582
+ function toggle_in_selection(gobj, node_id) {
55583
+ let priv = gobj.priv;
55584
+ let current = new Set(selected_node_ids(gobj));
55585
+ for (let id of priv._selected_paint_ids || []) current.add(id);
55586
+ if (current.has(node_id)) current.delete(node_id);
55587
+ else current.add(node_id);
55588
+ set_selection(gobj, [...current]);
55589
+ }
55498
55590
  function select_node(gobj, node_id) {
55499
55591
  let priv = gobj.priv;
55500
55592
  let graph = priv.graph;
@@ -55504,6 +55596,7 @@ function select_node(gobj, node_id) {
55504
55596
  graph.setElementState(node_id, ["selected"]);
55505
55597
  } catch (e) {}
55506
55598
  priv._selected_node_id = node_id;
55599
+ paint_selection(gobj, [node_id]);
55507
55600
  show_resize_handles(gobj);
55508
55601
  show_node_icon(gobj);
55509
55602
  graph_draw(gobj).then(() => {
@@ -55519,12 +55612,17 @@ function deselect_node(gobj) {
55519
55612
  hide_node_popover(gobj);
55520
55613
  hide_delete_confirm(gobj);
55521
55614
  hide_unlink_confirm(gobj);
55522
- if (priv._selected_node_id) {
55615
+ let clearing = /* @__PURE__ */ new Set([...selected_node_ids(gobj), ...priv._selected_paint_ids || []]);
55616
+ if (priv._selected_node_id) clearing.add(priv._selected_node_id);
55617
+ if (clearing.size) {
55523
55618
  history_pause(gobj);
55524
55619
  try {
55525
- graph.setElementState(priv._selected_node_id, []);
55620
+ let states = {};
55621
+ for (let id of clearing) states[id] = [];
55622
+ graph.setElementState(states);
55526
55623
  } catch (e) {}
55527
55624
  priv._selected_node_id = null;
55625
+ paint_selection(gobj, []);
55528
55626
  graph_draw(gobj).then(() => {
55529
55627
  history_resume(gobj);
55530
55628
  });
@@ -56811,33 +56909,36 @@ function refresh_minimap(gobj) {
56811
56909
  * same three-way choice — it lived inline in the theme refresh and is
56812
56910
  * shared now. Returns null for a node that carries no desc.
56813
56911
  ************************************************************/
56814
- function node_innerHTML_of(nd, theme, highlight) {
56912
+ function node_innerHTML_of(nd, theme, highlight, selected) {
56815
56913
  if (!nd || !nd.data || !nd.data.desc) return null;
56816
56914
  let desc = nd.data.desc;
56817
56915
  let record = nd.data.record || {};
56818
56916
  let label = node_label(desc, record);
56819
56917
  switch (desc.node_treedb_type) {
56820
- case "child": return build_chip_innerHTML(desc.color, theme, record.icon, label, record.id, highlight);
56821
- case "extended": return build_node_innerHTML(desc.color, theme, record.icon, label, desc.topic_name, true, record.id, highlight);
56822
- case "hierarchical": return build_node_innerHTML(desc.color, theme, record.icon, label, desc.topic_name, false, record.id, highlight);
56918
+ case "child": return build_chip_innerHTML(desc.color, theme, record.icon, label, record.id, highlight, selected);
56919
+ case "extended": return build_node_innerHTML(desc.color, theme, record.icon, label, desc.topic_name, true, record.id, highlight, selected);
56920
+ case "hierarchical": return build_node_innerHTML(desc.color, theme, record.icon, label, desc.topic_name, false, record.id, highlight, selected);
56823
56921
  }
56824
56922
  return null;
56825
56923
  }
56826
56924
  /************************************************************
56827
- * Repaint the cards whose highlight state CHANGES: the ones that had it
56828
- * and lose it, plus the ones that gain it. Only those — a treedb graph
56829
- * is redrawn per keystroke of the find box otherwise.
56925
+ * Repaint these cards with the flags they carry RIGHT NOW.
56926
+ *
56927
+ * One place, because the flags are independent and each used to
56928
+ * be written by whoever repainted last: a find that repainted its
56929
+ * matches erased the selection ring off them, and a selection
56930
+ * repainted over a match erased the amber. Both are read here
56931
+ * from where they live.
56830
56932
  ************************************************************/
56831
- function apply_node_highlight(gobj, prev_ids, next_ids) {
56933
+ function repaint_cards(gobj, ids) {
56832
56934
  let priv = gobj.priv;
56833
56935
  let graph = priv.graph;
56834
- if (!graph) return;
56835
- let next = new Set(next_ids || []);
56836
- let touched = /* @__PURE__ */ new Set([...prev_ids || [], ...next]);
56837
- if (touched.size === 0) return;
56936
+ if (!graph || !ids || !ids.size) return;
56937
+ let focus = new Set(priv._focus_ids || []);
56938
+ let selected = new Set(priv._selected_paint_ids || []);
56838
56939
  let updates = [];
56839
- for (let id of touched) {
56840
- let html = node_innerHTML_of(graph.getNodeData(id), priv.theme, next.has(id));
56940
+ for (let id of ids) {
56941
+ let html = node_innerHTML_of(graph.getNodeData(id), priv.theme, focus.has(id), selected.has(id));
56841
56942
  if (html !== null) updates.push({
56842
56943
  id,
56843
56944
  style: { innerHTML: html }
@@ -56848,19 +56949,32 @@ function apply_node_highlight(gobj, prev_ids, next_ids) {
56848
56949
  graph.updateNodeData(updates);
56849
56950
  graph.draw();
56850
56951
  } catch (e) {
56851
- (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: cannot repaint the highlight: ${e}`);
56952
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: cannot repaint the cards: ${e}`);
56852
56953
  }
56853
56954
  }
56955
+ /************************************************************
56956
+ * Repaint the cards whose highlight state CHANGES: the ones that had it
56957
+ * and lose it, plus the ones that gain it. Only those — a treedb graph
56958
+ * is redrawn per keystroke of the find box otherwise.
56959
+ ************************************************************/
56960
+ function apply_node_highlight(gobj, prev_ids, next_ids) {
56961
+ if (!gobj.priv.graph) return;
56962
+ let next = new Set(next_ids || []);
56963
+ let touched = /* @__PURE__ */ new Set([...prev_ids || [], ...next]);
56964
+ if (touched.size === 0) return;
56965
+ repaint_cards(gobj, touched);
56966
+ }
56854
56967
  function refresh_html_nodes_theme(gobj, theme) {
56855
56968
  let priv = gobj.priv;
56856
56969
  let graph = priv.graph;
56857
56970
  if (!graph) return;
56858
56971
  let nodes = graph.getData().nodes || [];
56859
56972
  let highlighted = new Set(priv._focus_ids || []);
56973
+ let selected = new Set(priv._selected_paint_ids || []);
56860
56974
  let updates = [];
56861
56975
  for (let i = 0; i < nodes.length; i++) {
56862
56976
  let id = nodes[i].id;
56863
- let html = node_innerHTML_of(graph.getNodeData(id), theme, highlighted.has(id));
56977
+ let html = node_innerHTML_of(graph.getNodeData(id), theme, highlighted.has(id), selected.has(id));
56864
56978
  if (html !== null) updates.push({
56865
56979
  id,
56866
56980
  style: { innerHTML: html }
@@ -56890,12 +57004,29 @@ function refresh_default_edges_theme(gobj, theme) {
56890
57004
  if (updates.length > 0) graph.updateEdgeData(updates);
56891
57005
  }
56892
57006
  /************************************************************
57007
+ * The rings a card can wear, composed into one `box-shadow`.
57008
+ *
57009
+ * A node can be a find match and be selected at the same time, so
57010
+ * neither ring may be written by overwriting the other: the amber
57011
+ * halo hugs the card and the blue selection ring is drawn outside
57012
+ * it, which is also the order that reads correctly when only one
57013
+ * of them is on.
57014
+ ************************************************************/
57015
+ function ring_shadow(highlight, selected, base_shadow) {
57016
+ let rings = [];
57017
+ if (highlight) rings.push(`0 0 0 4px ${HIGHLIGHT_HALO}`);
57018
+ if (selected) rings.push(`0 0 0 ${highlight ? "7px" : "3px"} ${SELECT_RING}`);
57019
+ if (base_shadow) rings.push(base_shadow);
57020
+ if (!rings.length) return "";
57021
+ return `box-shadow: ${rings.join(", ")};`;
57022
+ }
57023
+ /************************************************************
56893
57024
  * Build innerHTML for pure-child (leaf) nodes: a compact
56894
57025
  * chip-card. Same colour/typography family as the entity card
56895
57026
  * but lighter (1px border, no shadow, single line). The name is
56896
57027
  * always legible (ellipsis + native title tooltip on overflow).
56897
57028
  ************************************************************/
56898
- function build_chip_innerHTML(color, theme, icon, label, key, highlight) {
57029
+ function build_chip_innerHTML(color, theme, icon, label, key, highlight, selected) {
56899
57030
  let title = key || label;
56900
57031
  let dark = theme === "dark";
56901
57032
  let bg = dark ? `color-mix(in srgb, ${color} 30%, #2c3542)` : `color-mix(in srgb, ${color} 10%, ${dark ? "#1b2230" : "#ffffff"})`;
@@ -56914,7 +57045,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight) {
56914
57045
  height: 100%;
56915
57046
  background: ${bg};
56916
57047
  border: ${highlight ? "3px" : "1px"} solid ${border};
56917
- ${highlight ? `box-shadow: 0 0 0 4px ${HIGHLIGHT_HALO};` : ""}
57048
+ ${ring_shadow(highlight, selected, "")}
56918
57049
  border-radius: 8px;
56919
57050
  color: ${text_color};
56920
57051
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
@@ -56940,7 +57071,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight) {
56940
57071
  * colour is kept (per-topic differentiation) but softened via
56941
57072
  * color-mix instead of a harsh saturated fill. Theme-aware.
56942
57073
  ************************************************************/
56943
- function build_node_innerHTML(color, theme, icon, label, topic_name, structural, key, highlight) {
57074
+ function build_node_innerHTML(color, theme, icon, label, topic_name, structural, key, highlight, selected) {
56944
57075
  let title = key || label;
56945
57076
  let dark = theme === "dark";
56946
57077
  let surface = dark ? "#1b2230" : "#ffffff";
@@ -56982,7 +57113,7 @@ function build_node_innerHTML(color, theme, icon, label, topic_name, structural,
56982
57113
  background: ${bg};
56983
57114
  border: ${highlight ? "3px" : "1.5px"} ${border_style} ${border};
56984
57115
  border-radius: 10px;
56985
- box-shadow: ${highlight ? `0 0 0 4px ${HIGHLIGHT_HALO}, ${shadow}` : shadow};
57116
+ ${ring_shadow(highlight, selected, shadow)}
56986
57117
  color: ${title_color};
56987
57118
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
56988
57119
  display: flex;
@@ -57903,6 +58034,17 @@ function ac_node_drag_end(gobj, event, kw, src) {
57903
58034
  return 0;
57904
58035
  }
57905
58036
  /************************************************************
58037
+ * The rubber band let go: these are the nodes it enclosed.
58038
+ *
58039
+ * They arrive in the kw because `onSelect` runs BEFORE G6 writes
58040
+ * the state, so asking the graph here would answer the previous
58041
+ * selection.
58042
+ ************************************************************/
58043
+ function ac_brush_select(gobj, event, kw, src) {
58044
+ set_selection(gobj, kw && kw.ids || []);
58045
+ return 0;
58046
+ }
58047
+ /************************************************************
57906
58048
  * Node click - publish vertex clicked event
57907
58049
  ************************************************************/
57908
58050
  function ac_node_click(gobj, event, kw, src) {
@@ -57917,7 +58059,8 @@ function ac_node_click(gobj, event, kw, src) {
57917
58059
  topic_name: nodedata.data.desc.topic_name,
57918
58060
  record: nodedata.data.record
57919
58061
  });
57920
- if (priv.edit_mode) {
58062
+ if (priv.edit_mode && kw.evt.shiftKey) toggle_in_selection(gobj, node_id);
58063
+ else if (priv.edit_mode) {
57921
58064
  let containerRect = priv.$container.getBoundingClientRect();
57922
58065
  let canvasPoint = graph.getCanvasByViewport([kw.evt.client.x - containerRect.left, kw.evt.client.y - containerRect.top]);
57923
58066
  let port_key = detect_port_click(gobj, node_id, canvasPoint[0], canvasPoint[1]);
@@ -58144,6 +58287,11 @@ function create_gclass$1(gclass_name) {
58144
58287
  ac_node_drag_end,
58145
58288
  null
58146
58289
  ],
58290
+ [
58291
+ "EV_BRUSH_SELECT",
58292
+ ac_brush_select,
58293
+ null
58294
+ ],
58147
58295
  [
58148
58296
  "EV_ZOOM_IN",
58149
58297
  ac_zoom_in,
@@ -58252,6 +58400,7 @@ function create_gclass$1(gclass_name) {
58252
58400
  ["EV_NODE_CONTEXT_MENU", 0],
58253
58401
  ["EV_CANVAS_CLICK", 0],
58254
58402
  ["EV_NODE_DRAG_END", 0],
58403
+ ["EV_BRUSH_SELECT", 0],
58255
58404
  ["EV_ZOOM_IN", 0],
58256
58405
  ["EV_ZOOM_OUT", 0],
58257
58406
  ["EV_ZOOM_RESET", 0],
@@ -53734,6 +53734,7 @@ ensure_drag_canvas_patch();
53734
53734
  var GCLASS_NAME$1 = "C_G6_NODES_TREE";
53735
53735
  var HIGHLIGHT_COLOR = "#f0a020";
53736
53736
  var HIGHLIGHT_HALO = "rgba(240,160,32,0.35)";
53737
+ var SELECT_RING = "rgba(59,130,246,0.95)";
53737
53738
  /***************************************************************
53738
53739
  * Internal layout and operation mode definitions
53739
53740
  ***************************************************************/
@@ -53845,6 +53846,7 @@ var PRIVATE_DATA$1 = {
53845
53846
  _link_saved_styles: [],
53846
53847
  _focus_topic: null,
53847
53848
  _focus_ids: [],
53849
+ _selected_paint_ids: [],
53848
53850
  _pending_focus_topic: null,
53849
53851
  _pending_find: null,
53850
53852
  _layout_asked: "",
@@ -54365,9 +54367,24 @@ function configure_behaviour(gobj) {
54365
54367
  case "edition":
54366
54368
  priv.edit_mode = true;
54367
54369
  behaviors = [
54368
- "drag-canvas",
54370
+ {
54371
+ type: "drag-canvas",
54372
+ key: "drag-canvas",
54373
+ enable: (event) => !event.shiftKey
54374
+ },
54369
54375
  "zoom-canvas",
54370
- "drag-element"
54376
+ "drag-element",
54377
+ {
54378
+ type: "brush-select",
54379
+ key: "brush-select",
54380
+ trigger: ["shift"],
54381
+ enableElements: ["node"],
54382
+ immediately: false,
54383
+ onSelect: (states) => {
54384
+ let ids = Object.keys(states || {}).filter((id) => (states[id] || []).includes("selected"));
54385
+ gobj_send_event(gobj, "EV_BRUSH_SELECT", { ids }, gobj);
54386
+ }
54387
+ }
54371
54388
  ];
54372
54389
  break;
54373
54390
  case "operation":
@@ -55482,6 +55499,81 @@ function perform_history_op(gobj, is_redo) {
55482
55499
  sync_history_to_backend(gobj, cmd ? cmd.original : null);
55483
55500
  }
55484
55501
  }
55502
+ /************************************************************
55503
+ * The selection.
55504
+ *
55505
+ * G6's `selected` element state IS the selection: `drag-element`
55506
+ * reads it (`getElementDataByState`) to decide what a drag moves,
55507
+ * so keeping the set anywhere else would be a second truth the
55508
+ * drag does not consult. What this gclass keeps is what is
55509
+ * PAINTED -- an html node draws no state style at all (its key
55510
+ * shape is a DOM element), so the ring lives in the card's own
55511
+ * html, exactly like the find highlight, and the painted set
55512
+ * exists to diff the repaint.
55513
+ ************************************************************/
55514
+ function selected_node_ids(gobj) {
55515
+ let graph = gobj.priv.graph;
55516
+ if (!graph) return [];
55517
+ let data;
55518
+ try {
55519
+ data = graph.getElementDataByState("node", "selected") || [];
55520
+ } catch (e) {
55521
+ return [];
55522
+ }
55523
+ return data.map((nd) => nd.id);
55524
+ }
55525
+ /************************************************************
55526
+ * Move the ring to these nodes and off the ones that had it.
55527
+ ************************************************************/
55528
+ function paint_selection(gobj, ids) {
55529
+ let priv = gobj.priv;
55530
+ let prev = priv._selected_paint_ids || [];
55531
+ let next = ids || [];
55532
+ priv._selected_paint_ids = next;
55533
+ repaint_cards(gobj, /* @__PURE__ */ new Set([...prev, ...next]));
55534
+ }
55535
+ /************************************************************
55536
+ * Select a SET of nodes: what a marquee and a shift-click do.
55537
+ *
55538
+ * It leaves `_selected_node_id` null even for a set of one,
55539
+ * because that field is not "the selection", it is "the node
55540
+ * opened for editing" -- the resize handles, the ports and the
55541
+ * popovers hang off it, and none of them means anything over a
55542
+ * set. Clicking a node is what opens one (`select_node`); a
55543
+ * marquee selects, it does not open.
55544
+ ************************************************************/
55545
+ function set_selection(gobj, ids) {
55546
+ let graph = gobj.priv.graph;
55547
+ let next = [];
55548
+ for (let id of ids || []) try {
55549
+ if (graph.getNodeData(id)) next.push(id);
55550
+ } catch (e) {}
55551
+ deselect_node(gobj);
55552
+ if (!next.length) return;
55553
+ history_pause(gobj);
55554
+ try {
55555
+ let states = {};
55556
+ for (let id of next) states[id] = ["selected"];
55557
+ graph.setElementState(states);
55558
+ } catch (e) {
55559
+ log_error(`${gobj_short_name(gobj)}: cannot set the selection: ${e}`);
55560
+ }
55561
+ paint_selection(gobj, next);
55562
+ graph_draw(gobj).then(() => {
55563
+ history_resume(gobj);
55564
+ });
55565
+ }
55566
+ /************************************************************
55567
+ * Add a node to the selection, or take it out of it.
55568
+ ************************************************************/
55569
+ function toggle_in_selection(gobj, node_id) {
55570
+ let priv = gobj.priv;
55571
+ let current = new Set(selected_node_ids(gobj));
55572
+ for (let id of priv._selected_paint_ids || []) current.add(id);
55573
+ if (current.has(node_id)) current.delete(node_id);
55574
+ else current.add(node_id);
55575
+ set_selection(gobj, [...current]);
55576
+ }
55485
55577
  function select_node(gobj, node_id) {
55486
55578
  let priv = gobj.priv;
55487
55579
  let graph = priv.graph;
@@ -55491,6 +55583,7 @@ function select_node(gobj, node_id) {
55491
55583
  graph.setElementState(node_id, ["selected"]);
55492
55584
  } catch (e) {}
55493
55585
  priv._selected_node_id = node_id;
55586
+ paint_selection(gobj, [node_id]);
55494
55587
  show_resize_handles(gobj);
55495
55588
  show_node_icon(gobj);
55496
55589
  graph_draw(gobj).then(() => {
@@ -55506,12 +55599,17 @@ function deselect_node(gobj) {
55506
55599
  hide_node_popover(gobj);
55507
55600
  hide_delete_confirm(gobj);
55508
55601
  hide_unlink_confirm(gobj);
55509
- if (priv._selected_node_id) {
55602
+ let clearing = /* @__PURE__ */ new Set([...selected_node_ids(gobj), ...priv._selected_paint_ids || []]);
55603
+ if (priv._selected_node_id) clearing.add(priv._selected_node_id);
55604
+ if (clearing.size) {
55510
55605
  history_pause(gobj);
55511
55606
  try {
55512
- graph.setElementState(priv._selected_node_id, []);
55607
+ let states = {};
55608
+ for (let id of clearing) states[id] = [];
55609
+ graph.setElementState(states);
55513
55610
  } catch (e) {}
55514
55611
  priv._selected_node_id = null;
55612
+ paint_selection(gobj, []);
55515
55613
  graph_draw(gobj).then(() => {
55516
55614
  history_resume(gobj);
55517
55615
  });
@@ -56798,33 +56896,36 @@ function refresh_minimap(gobj) {
56798
56896
  * same three-way choice — it lived inline in the theme refresh and is
56799
56897
  * shared now. Returns null for a node that carries no desc.
56800
56898
  ************************************************************/
56801
- function node_innerHTML_of(nd, theme, highlight) {
56899
+ function node_innerHTML_of(nd, theme, highlight, selected) {
56802
56900
  if (!nd || !nd.data || !nd.data.desc) return null;
56803
56901
  let desc = nd.data.desc;
56804
56902
  let record = nd.data.record || {};
56805
56903
  let label = node_label(desc, record);
56806
56904
  switch (desc.node_treedb_type) {
56807
- case "child": return build_chip_innerHTML(desc.color, theme, record.icon, label, record.id, highlight);
56808
- case "extended": return build_node_innerHTML(desc.color, theme, record.icon, label, desc.topic_name, true, record.id, highlight);
56809
- case "hierarchical": return build_node_innerHTML(desc.color, theme, record.icon, label, desc.topic_name, false, record.id, highlight);
56905
+ case "child": return build_chip_innerHTML(desc.color, theme, record.icon, label, record.id, highlight, selected);
56906
+ case "extended": return build_node_innerHTML(desc.color, theme, record.icon, label, desc.topic_name, true, record.id, highlight, selected);
56907
+ case "hierarchical": return build_node_innerHTML(desc.color, theme, record.icon, label, desc.topic_name, false, record.id, highlight, selected);
56810
56908
  }
56811
56909
  return null;
56812
56910
  }
56813
56911
  /************************************************************
56814
- * Repaint the cards whose highlight state CHANGES: the ones that had it
56815
- * and lose it, plus the ones that gain it. Only those — a treedb graph
56816
- * is redrawn per keystroke of the find box otherwise.
56912
+ * Repaint these cards with the flags they carry RIGHT NOW.
56913
+ *
56914
+ * One place, because the flags are independent and each used to
56915
+ * be written by whoever repainted last: a find that repainted its
56916
+ * matches erased the selection ring off them, and a selection
56917
+ * repainted over a match erased the amber. Both are read here
56918
+ * from where they live.
56817
56919
  ************************************************************/
56818
- function apply_node_highlight(gobj, prev_ids, next_ids) {
56920
+ function repaint_cards(gobj, ids) {
56819
56921
  let priv = gobj.priv;
56820
56922
  let graph = priv.graph;
56821
- if (!graph) return;
56822
- let next = new Set(next_ids || []);
56823
- let touched = /* @__PURE__ */ new Set([...prev_ids || [], ...next]);
56824
- if (touched.size === 0) return;
56923
+ if (!graph || !ids || !ids.size) return;
56924
+ let focus = new Set(priv._focus_ids || []);
56925
+ let selected = new Set(priv._selected_paint_ids || []);
56825
56926
  let updates = [];
56826
- for (let id of touched) {
56827
- let html = node_innerHTML_of(graph.getNodeData(id), priv.theme, next.has(id));
56927
+ for (let id of ids) {
56928
+ let html = node_innerHTML_of(graph.getNodeData(id), priv.theme, focus.has(id), selected.has(id));
56828
56929
  if (html !== null) updates.push({
56829
56930
  id,
56830
56931
  style: { innerHTML: html }
@@ -56835,19 +56936,32 @@ function apply_node_highlight(gobj, prev_ids, next_ids) {
56835
56936
  graph.updateNodeData(updates);
56836
56937
  graph.draw();
56837
56938
  } catch (e) {
56838
- log_error(`${gobj_short_name(gobj)}: cannot repaint the highlight: ${e}`);
56939
+ log_error(`${gobj_short_name(gobj)}: cannot repaint the cards: ${e}`);
56839
56940
  }
56840
56941
  }
56942
+ /************************************************************
56943
+ * Repaint the cards whose highlight state CHANGES: the ones that had it
56944
+ * and lose it, plus the ones that gain it. Only those — a treedb graph
56945
+ * is redrawn per keystroke of the find box otherwise.
56946
+ ************************************************************/
56947
+ function apply_node_highlight(gobj, prev_ids, next_ids) {
56948
+ if (!gobj.priv.graph) return;
56949
+ let next = new Set(next_ids || []);
56950
+ let touched = /* @__PURE__ */ new Set([...prev_ids || [], ...next]);
56951
+ if (touched.size === 0) return;
56952
+ repaint_cards(gobj, touched);
56953
+ }
56841
56954
  function refresh_html_nodes_theme(gobj, theme) {
56842
56955
  let priv = gobj.priv;
56843
56956
  let graph = priv.graph;
56844
56957
  if (!graph) return;
56845
56958
  let nodes = graph.getData().nodes || [];
56846
56959
  let highlighted = new Set(priv._focus_ids || []);
56960
+ let selected = new Set(priv._selected_paint_ids || []);
56847
56961
  let updates = [];
56848
56962
  for (let i = 0; i < nodes.length; i++) {
56849
56963
  let id = nodes[i].id;
56850
- let html = node_innerHTML_of(graph.getNodeData(id), theme, highlighted.has(id));
56964
+ let html = node_innerHTML_of(graph.getNodeData(id), theme, highlighted.has(id), selected.has(id));
56851
56965
  if (html !== null) updates.push({
56852
56966
  id,
56853
56967
  style: { innerHTML: html }
@@ -56877,12 +56991,29 @@ function refresh_default_edges_theme(gobj, theme) {
56877
56991
  if (updates.length > 0) graph.updateEdgeData(updates);
56878
56992
  }
56879
56993
  /************************************************************
56994
+ * The rings a card can wear, composed into one `box-shadow`.
56995
+ *
56996
+ * A node can be a find match and be selected at the same time, so
56997
+ * neither ring may be written by overwriting the other: the amber
56998
+ * halo hugs the card and the blue selection ring is drawn outside
56999
+ * it, which is also the order that reads correctly when only one
57000
+ * of them is on.
57001
+ ************************************************************/
57002
+ function ring_shadow(highlight, selected, base_shadow) {
57003
+ let rings = [];
57004
+ if (highlight) rings.push(`0 0 0 4px ${HIGHLIGHT_HALO}`);
57005
+ if (selected) rings.push(`0 0 0 ${highlight ? "7px" : "3px"} ${SELECT_RING}`);
57006
+ if (base_shadow) rings.push(base_shadow);
57007
+ if (!rings.length) return "";
57008
+ return `box-shadow: ${rings.join(", ")};`;
57009
+ }
57010
+ /************************************************************
56880
57011
  * Build innerHTML for pure-child (leaf) nodes: a compact
56881
57012
  * chip-card. Same colour/typography family as the entity card
56882
57013
  * but lighter (1px border, no shadow, single line). The name is
56883
57014
  * always legible (ellipsis + native title tooltip on overflow).
56884
57015
  ************************************************************/
56885
- function build_chip_innerHTML(color, theme, icon, label, key, highlight) {
57016
+ function build_chip_innerHTML(color, theme, icon, label, key, highlight, selected) {
56886
57017
  let title = key || label;
56887
57018
  let dark = theme === "dark";
56888
57019
  let bg = dark ? `color-mix(in srgb, ${color} 30%, #2c3542)` : `color-mix(in srgb, ${color} 10%, ${dark ? "#1b2230" : "#ffffff"})`;
@@ -56901,7 +57032,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight) {
56901
57032
  height: 100%;
56902
57033
  background: ${bg};
56903
57034
  border: ${highlight ? "3px" : "1px"} solid ${border};
56904
- ${highlight ? `box-shadow: 0 0 0 4px ${HIGHLIGHT_HALO};` : ""}
57035
+ ${ring_shadow(highlight, selected, "")}
56905
57036
  border-radius: 8px;
56906
57037
  color: ${text_color};
56907
57038
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
@@ -56927,7 +57058,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight) {
56927
57058
  * colour is kept (per-topic differentiation) but softened via
56928
57059
  * color-mix instead of a harsh saturated fill. Theme-aware.
56929
57060
  ************************************************************/
56930
- function build_node_innerHTML(color, theme, icon, label, topic_name, structural, key, highlight) {
57061
+ function build_node_innerHTML(color, theme, icon, label, topic_name, structural, key, highlight, selected) {
56931
57062
  let title = key || label;
56932
57063
  let dark = theme === "dark";
56933
57064
  let surface = dark ? "#1b2230" : "#ffffff";
@@ -56969,7 +57100,7 @@ function build_node_innerHTML(color, theme, icon, label, topic_name, structural,
56969
57100
  background: ${bg};
56970
57101
  border: ${highlight ? "3px" : "1.5px"} ${border_style} ${border};
56971
57102
  border-radius: 10px;
56972
- box-shadow: ${highlight ? `0 0 0 4px ${HIGHLIGHT_HALO}, ${shadow}` : shadow};
57103
+ ${ring_shadow(highlight, selected, shadow)}
56973
57104
  color: ${title_color};
56974
57105
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
56975
57106
  display: flex;
@@ -57890,6 +58021,17 @@ function ac_node_drag_end(gobj, event, kw, src) {
57890
58021
  return 0;
57891
58022
  }
57892
58023
  /************************************************************
58024
+ * The rubber band let go: these are the nodes it enclosed.
58025
+ *
58026
+ * They arrive in the kw because `onSelect` runs BEFORE G6 writes
58027
+ * the state, so asking the graph here would answer the previous
58028
+ * selection.
58029
+ ************************************************************/
58030
+ function ac_brush_select(gobj, event, kw, src) {
58031
+ set_selection(gobj, kw && kw.ids || []);
58032
+ return 0;
58033
+ }
58034
+ /************************************************************
57893
58035
  * Node click - publish vertex clicked event
57894
58036
  ************************************************************/
57895
58037
  function ac_node_click(gobj, event, kw, src) {
@@ -57904,7 +58046,8 @@ function ac_node_click(gobj, event, kw, src) {
57904
58046
  topic_name: nodedata.data.desc.topic_name,
57905
58047
  record: nodedata.data.record
57906
58048
  });
57907
- if (priv.edit_mode) {
58049
+ if (priv.edit_mode && kw.evt.shiftKey) toggle_in_selection(gobj, node_id);
58050
+ else if (priv.edit_mode) {
57908
58051
  let containerRect = priv.$container.getBoundingClientRect();
57909
58052
  let canvasPoint = graph.getCanvasByViewport([kw.evt.client.x - containerRect.left, kw.evt.client.y - containerRect.top]);
57910
58053
  let port_key = detect_port_click(gobj, node_id, canvasPoint[0], canvasPoint[1]);
@@ -58131,6 +58274,11 @@ function create_gclass$1(gclass_name) {
58131
58274
  ac_node_drag_end,
58132
58275
  null
58133
58276
  ],
58277
+ [
58278
+ "EV_BRUSH_SELECT",
58279
+ ac_brush_select,
58280
+ null
58281
+ ],
58134
58282
  [
58135
58283
  "EV_ZOOM_IN",
58136
58284
  ac_zoom_in,
@@ -58239,6 +58387,7 @@ function create_gclass$1(gclass_name) {
58239
58387
  ["EV_NODE_CONTEXT_MENU", 0],
58240
58388
  ["EV_CANVAS_CLICK", 0],
58241
58389
  ["EV_NODE_DRAG_END", 0],
58390
+ ["EV_BRUSH_SELECT", 0],
58242
58391
  ["EV_ZOOM_IN", 0],
58243
58392
  ["EV_ZOOM_OUT", 0],
58244
58393
  ["EV_ZOOM_RESET", 0],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.15.0",
3
+ "version": "7.16.0",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -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)
@@ -1182,9 +1195,46 @@ function configure_behaviour(gobj)
1182
1195
  case "edition":
1183
1196
  priv.edit_mode = true;
1184
1197
  behaviors = [
1185
- "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
+ },
1186
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`. */
1187
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
+ },
1188
1238
  ];
1189
1239
  break;
1190
1240
  case "operation":
@@ -2948,6 +2998,126 @@ function perform_history_op(gobj, is_redo)
2948
2998
  }
2949
2999
  }
2950
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
+
2951
3121
  function select_node(gobj, node_id)
2952
3122
  {
2953
3123
  let priv = gobj.priv;
@@ -2962,6 +3132,7 @@ function select_node(gobj, node_id)
2962
3132
  graph.setElementState(node_id, ['selected']);
2963
3133
  } catch(e) {}
2964
3134
  priv._selected_node_id = node_id;
3135
+ paint_selection(gobj, [node_id]);
2965
3136
 
2966
3137
  // Show resize handles and properties icon
2967
3138
  show_resize_handles(gobj);
@@ -2984,12 +3155,28 @@ function deselect_node(gobj)
2984
3155
  hide_delete_confirm(gobj);
2985
3156
  hide_unlink_confirm(gobj);
2986
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
+ ]);
2987
3165
  if(priv._selected_node_id) {
3166
+ clearing.add(priv._selected_node_id);
3167
+ }
3168
+
3169
+ if(clearing.size) {
2988
3170
  history_pause(gobj);
2989
3171
  try {
2990
- graph.setElementState(priv._selected_node_id, []);
3172
+ let states = {};
3173
+ for(let id of clearing) {
3174
+ states[id] = [];
3175
+ }
3176
+ graph.setElementState(states);
2991
3177
  } catch(e) {}
2992
3178
  priv._selected_node_id = null;
3179
+ paint_selection(gobj, []);
2993
3180
 
2994
3181
  graph_draw(gobj).then(() => {
2995
3182
  history_resume(gobj);
@@ -4793,7 +4980,7 @@ function refresh_minimap(gobj)
4793
4980
  * same three-way choice — it lived inline in the theme refresh and is
4794
4981
  * shared now. Returns null for a node that carries no desc.
4795
4982
  ************************************************************/
4796
- function node_innerHTML_of(nd, theme, highlight)
4983
+ function node_innerHTML_of(nd, theme, highlight, selected)
4797
4984
  {
4798
4985
  if(!nd || !nd.data || !nd.data.desc) {
4799
4986
  return null;
@@ -4805,45 +4992,49 @@ function node_innerHTML_of(nd, theme, highlight)
4805
4992
  switch(desc.node_treedb_type) {
4806
4993
  case 'child':
4807
4994
  return build_chip_innerHTML(
4808
- desc.color, theme, record.icon, label, record.id, highlight
4995
+ desc.color, theme, record.icon, label, record.id,
4996
+ highlight, selected
4809
4997
  );
4810
4998
  case 'extended':
4811
4999
  return build_node_innerHTML(
4812
5000
  desc.color, theme, record.icon, label,
4813
- desc.topic_name, true, record.id, highlight
5001
+ desc.topic_name, true, record.id, highlight, selected
4814
5002
  );
4815
5003
  case 'hierarchical':
4816
5004
  return build_node_innerHTML(
4817
5005
  desc.color, theme, record.icon, label,
4818
- desc.topic_name, false, record.id, highlight
5006
+ desc.topic_name, false, record.id, highlight, selected
4819
5007
  );
4820
5008
  }
4821
5009
  return null;
4822
5010
  }
4823
5011
 
4824
5012
  /************************************************************
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.
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.
4828
5020
  ************************************************************/
4829
- function apply_node_highlight(gobj, prev_ids, next_ids)
5021
+ function repaint_cards(gobj, ids)
4830
5022
  {
4831
5023
  let priv = gobj.priv;
4832
5024
  let graph = priv.graph;
4833
- if(!graph) {
4834
- return;
4835
- }
4836
5025
 
4837
- let next = new Set(next_ids || []);
4838
- let touched = new Set([...(prev_ids || []), ...next]);
4839
- if(touched.size === 0) {
5026
+ if(!graph || !ids || !ids.size) {
4840
5027
  return;
4841
5028
  }
4842
5029
 
5030
+ let focus = new Set(priv._focus_ids || []);
5031
+ let selected = new Set(priv._selected_paint_ids || []);
5032
+
4843
5033
  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));
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
+ );
4847
5038
  if(html !== null) {
4848
5039
  updates.push({id: id, style: {innerHTML: html}});
4849
5040
  }
@@ -4851,14 +5042,37 @@ function apply_node_highlight(gobj, prev_ids, next_ids)
4851
5042
  if(!updates.length) {
4852
5043
  return;
4853
5044
  }
5045
+
4854
5046
  try {
4855
5047
  graph.updateNodeData(updates);
4856
5048
  graph.draw();
4857
5049
  } catch(e) {
4858
- log_error(`${gobj_short_name(gobj)}: cannot repaint the highlight: ${e}`);
5050
+ log_error(`${gobj_short_name(gobj)}: cannot repaint the cards: ${e}`);
4859
5051
  }
4860
5052
  }
4861
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
+
4862
5076
  function refresh_html_nodes_theme(gobj, theme)
4863
5077
  {
4864
5078
  let priv = gobj.priv;
@@ -4871,10 +5085,13 @@ function refresh_html_nodes_theme(gobj, theme)
4871
5085
  * rebuilt every card without it would silently CLEAR the focus or the
4872
5086
  * find that is on screen. Carry it across. */
4873
5087
  let highlighted = new Set(priv._focus_ids || []);
5088
+ let selected = new Set(priv._selected_paint_ids || []);
4874
5089
  let updates = [];
4875
5090
  for(let i = 0; i < nodes.length; i++) {
4876
5091
  let id = nodes[i].id;
4877
- 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
+ );
4878
5095
  if(html !== null) {
4879
5096
  updates.push({id: id, style: {innerHTML: html}});
4880
5097
  }
@@ -4913,13 +5130,42 @@ function refresh_default_edges_theme(gobj, theme)
4913
5130
  }
4914
5131
  }
4915
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
+
4916
5162
  /************************************************************
4917
5163
  * Build innerHTML for pure-child (leaf) nodes: a compact
4918
5164
  * chip-card. Same colour/typography family as the entity card
4919
5165
  * but lighter (1px border, no shadow, single line). The name is
4920
5166
  * always legible (ellipsis + native title tooltip on overflow).
4921
5167
  ************************************************************/
4922
- function build_chip_innerHTML(color, theme, icon, label, key, highlight)
5168
+ function build_chip_innerHTML(color, theme, icon, label, key, highlight, selected)
4923
5169
  {
4924
5170
  let title = key || label;
4925
5171
  let dark = (theme === "dark");
@@ -4950,7 +5196,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight)
4950
5196
  height: 100%;
4951
5197
  background: ${bg};
4952
5198
  border: ${highlight? "3px" : "1px"} solid ${border};
4953
- ${highlight? `box-shadow: 0 0 0 4px ${HIGHLIGHT_HALO};` : ""}
5199
+ ${ring_shadow(highlight, selected, "")}
4954
5200
  border-radius: 8px;
4955
5201
  color: ${text_color};
4956
5202
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
@@ -4977,7 +5223,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight)
4977
5223
  * colour is kept (per-topic differentiation) but softened via
4978
5224
  * color-mix instead of a harsh saturated fill. Theme-aware.
4979
5225
  ************************************************************/
4980
- 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)
4981
5227
  {
4982
5228
  let title = key || label;
4983
5229
  let dark = (theme === "dark");
@@ -5040,7 +5286,7 @@ function build_node_innerHTML(color, theme, icon, label, topic_name, structural,
5040
5286
  background: ${bg};
5041
5287
  border: ${highlight? "3px" : "1.5px"} ${border_style} ${border};
5042
5288
  border-radius: 10px;
5043
- box-shadow: ${highlight? `0 0 0 4px ${HIGHLIGHT_HALO}, ${shadow}` : shadow};
5289
+ ${ring_shadow(highlight, selected, shadow)}
5044
5290
  color: ${title_color};
5045
5291
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
5046
5292
  display: flex;
@@ -6477,6 +6723,20 @@ function ac_node_drag_end(gobj, event, kw, src)
6477
6723
  return 0;
6478
6724
  }
6479
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
+
6480
6740
  /************************************************************
6481
6741
  * Node click - publish vertex clicked event
6482
6742
  ************************************************************/
@@ -6495,7 +6755,13 @@ function ac_node_click(gobj, event, kw, src)
6495
6755
  record: nodedata.data.record
6496
6756
  });
6497
6757
 
6498
- 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) {
6499
6765
  // Check if click hits a port
6500
6766
  // Convert client coords to viewport (container-relative) then to canvas
6501
6767
  let containerRect = priv.$container.getBoundingClientRect();
@@ -6782,6 +7048,7 @@ function create_gclass(gclass_name)
6782
7048
  ["EV_NODE_CONTEXT_MENU", ac_node_context_menu, null],
6783
7049
  ["EV_CANVAS_CLICK", ac_canvas_click, null],
6784
7050
  ["EV_NODE_DRAG_END", ac_node_drag_end, null],
7051
+ ["EV_BRUSH_SELECT", ac_brush_select, null],
6785
7052
 
6786
7053
  /*--- Toolbar events ---*/
6787
7054
  ["EV_ZOOM_IN", ac_zoom_in, null],
@@ -6826,6 +7093,7 @@ function create_gclass(gclass_name)
6826
7093
  ["EV_NODE_CONTEXT_MENU", 0],
6827
7094
  ["EV_CANVAS_CLICK", 0],
6828
7095
  ["EV_NODE_DRAG_END", 0],
7096
+ ["EV_BRUSH_SELECT", 0],
6829
7097
 
6830
7098
  /*--- Toolbar (internal) ---*/
6831
7099
  ["EV_ZOOM_IN", 0],