@yuneta/gobj-ui 7.21.1 → 7.22.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
@@ -439,6 +439,20 @@ the order of arrival: the viewer opens on the **tree** (`view_mode` default).
439
439
  | `tree` | *where is this value, and what is around it* | the lazy view; the only one that can drill; the default |
440
440
  | `graph` | *what shape is this* | a hosted `C_YUI_JSON_GRAPH` child (AntV/G6) |
441
441
 
442
+ The graph carries **its own** toolbar, and since `7.22.0` that toolbar has the
443
+ same two facilities the tree has: a **find box** (rate-limited, `EV_FIND_NODES`)
444
+ that highlights matching rows and outlines the cards they are in and says how
445
+ many matched, and **expand-all / collapse-all** (`EV_EXPAND_ALL` /
446
+ `EV_COLLAPSE_ALL`) that fold every card but the root, marking each cut with
447
+ `▸ N` so the shape stays legible and you can see where the rest went. A find
448
+ does not move the camera — a viewport that jumps on every keystroke is
449
+ unusable — which is why the count is there.
450
+
451
+ Note **where the highlight lives**: baked into the card's own markup, never set
452
+ as a G6 node state. The key shape of an `html` node is a DOM element and G6
453
+ paints no state style on it, so setting `active` selects correctly and shows
454
+ nothing.
455
+
442
456
  - **Neither text nor graph is lazy.** Both show what the client currently
443
457
  holds, `__collapsed__` sentinels included, because that is honestly what it
444
458
  has. Drill in the tree and they grow with it.
@@ -9492,7 +9492,14 @@ var PRIVATE_DATA$11 = {
9492
9492
  theme: null,
9493
9493
  theme_observer: null,
9494
9494
  resize_observer: null,
9495
- _resize_raf: 0
9495
+ _resize_raf: 0,
9496
+ search: "",
9497
+ collapsed: null,
9498
+ match_count: 0,
9499
+ find_timer: null,
9500
+ $find_input: null,
9501
+ $find_result: null,
9502
+ $find_count: null
9496
9503
  };
9497
9504
  var __gclass__$11 = null;
9498
9505
  var __layout_registered__$1 = false;
@@ -9561,6 +9568,8 @@ function type_color(type, dark) {
9561
9568
  ***************************************************************/
9562
9569
  function mt_create$11(gobj) {
9563
9570
  let priv = gobj.priv;
9571
+ priv.collapsed = /* @__PURE__ */ new Set();
9572
+ priv.search = "";
9564
9573
  priv.canvas_id = "json-canvas-" + (0, _yuneta_gobj_js.clean_name)((0, _yuneta_gobj_js.gobj_name)(gobj));
9565
9574
  (0, _yuneta_gobj_js.gobj_write_str_attr)(gobj, "canvas_id", priv.canvas_id);
9566
9575
  let subscriber = (0, _yuneta_gobj_js.gobj_read_pointer_attr)(gobj, "subscriber");
@@ -9590,6 +9599,10 @@ function mt_stop$11(gobj) {}
9590
9599
  * Framework Method: Destroy
9591
9600
  ***************************************************************/
9592
9601
  function mt_destroy$11(gobj) {
9602
+ if (gobj.priv.find_timer) {
9603
+ clearTimeout(gobj.priv.find_timer);
9604
+ gobj.priv.find_timer = null;
9605
+ }
9593
9606
  let priv = gobj.priv;
9594
9607
  if (priv.theme_observer) {
9595
9608
  priv.theme_observer.disconnect();
@@ -9646,6 +9659,9 @@ function build_ui$10(gobj) {
9646
9659
  ]]
9647
9660
  ]);
9648
9661
  (0, _yuneta_gobj_js.gobj_write_attr)(gobj, "$container", $container);
9662
+ priv.$find_input = $container.querySelector(".JSON_GRAPH_FIND_INPUT");
9663
+ priv.$find_result = $container.querySelector(".JSON_GRAPH_FIND_RESULT");
9664
+ priv.$find_count = $container.querySelector(".JSON_GRAPH_FIND_COUNT");
9649
9665
  (0, _yuneta_gobj_js.refresh_language)($container, i18next.t);
9650
9666
  }
9651
9667
  /************************************************************
@@ -9659,12 +9675,84 @@ function destroy_ui$6(gobj) {
9659
9675
  }
9660
9676
  }
9661
9677
  /************************************************************
9678
+ * One fold button. `open` rotates the chevron to the open
9679
+ * state, mirroring the per-node toggle of the lazy tree.
9680
+ ************************************************************/
9681
+ function graph_fold_button(gobj, event_name, label_key, open) {
9682
+ let icon_style = "font-size:1.5em; color:inherit;" + (open ? " transform: rotate(90deg);" : "");
9683
+ return [
9684
+ "button",
9685
+ {
9686
+ class: `button ${event_name}`,
9687
+ type: "button",
9688
+ style: {
9689
+ height: (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "wide"),
9690
+ width: "2.5em"
9691
+ },
9692
+ title: (0, i18next.t)(label_key),
9693
+ "data-i18n-title": label_key,
9694
+ "aria-label": (0, i18next.t)(label_key),
9695
+ "data-i18n-aria-label": label_key
9696
+ },
9697
+ ["i", {
9698
+ style: icon_style,
9699
+ class: "yi-chevron-right"
9700
+ }],
9701
+ { click: (evt) => {
9702
+ evt.stopPropagation();
9703
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, event_name, {}, gobj);
9704
+ } }
9705
+ ];
9706
+ }
9707
+ /************************************************************
9662
9708
  * Toolbar
9663
9709
  ************************************************************/
9664
9710
  function make_toolbar$3(gobj) {
9665
- gobj.priv;
9711
+ let priv = gobj.priv;
9666
9712
  let toolbar_wide = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "wide");
9667
- let left_items = [];
9713
+ let left_items = [[
9714
+ "div",
9715
+ {
9716
+ class: "JSON_GRAPH_FIND control has-icons-left",
9717
+ style: "margin-right:.5rem; max-width:12rem; min-width:7rem;"
9718
+ },
9719
+ [["input", {
9720
+ class: "JSON_GRAPH_FIND_INPUT input",
9721
+ type: "text",
9722
+ placeholder: (0, i18next.t)("search"),
9723
+ "data-i18n-placeholder": "search",
9724
+ "aria-label": (0, i18next.t)("search"),
9725
+ "data-i18n-aria-label": "search"
9726
+ }], [
9727
+ "span",
9728
+ { class: "icon is-left" },
9729
+ [["i", { class: "yi-magnifying-glass" }]]
9730
+ ]],
9731
+ { input: (evt) => {
9732
+ evt.stopPropagation();
9733
+ let text = evt.target.value.trim();
9734
+ if (priv.find_timer) clearTimeout(priv.find_timer);
9735
+ priv.find_timer = setTimeout(function() {
9736
+ priv.find_timer = null;
9737
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_FIND_NODES", { text }, gobj);
9738
+ }, 250);
9739
+ } }
9740
+ ], [
9741
+ "div",
9742
+ {
9743
+ class: "JSON_GRAPH_FIND_RESULT is-hidden",
9744
+ style: "display:flex; align-items:center; gap:.3rem; margin-right:.5rem; font-size:.85rem;"
9745
+ },
9746
+ [[
9747
+ "span",
9748
+ { class: "JSON_GRAPH_FIND_COUNT" },
9749
+ ""
9750
+ ], [
9751
+ "span",
9752
+ { i18n: "matches" },
9753
+ "matches"
9754
+ ]]
9755
+ ]];
9668
9756
  let center_items = [];
9669
9757
  for (let item of [
9670
9758
  [
@@ -9722,6 +9810,7 @@ function make_toolbar$3(gobj) {
9722
9810
  } }
9723
9811
  ]);
9724
9812
  }
9813
+ let right_items = [graph_fold_button(gobj, "EV_EXPAND_ALL", "expand all", true), graph_fold_button(gobj, "EV_COLLAPSE_ALL", "collapse all", false)];
9725
9814
  const $toolbar = yui_toolbar({}, [
9726
9815
  [
9727
9816
  "div",
@@ -9736,7 +9825,7 @@ function make_toolbar$3(gobj) {
9736
9825
  [
9737
9826
  "div",
9738
9827
  { class: "yui-horizontal-toolbar-section right" },
9739
- []
9828
+ right_items
9740
9829
  ]
9741
9830
  ]);
9742
9831
  (0, _yuneta_gobj_js.refresh_language)($toolbar, i18next.t);
@@ -9787,7 +9876,7 @@ function build_graph$3(gobj) {
9787
9876
  /************************************************************
9788
9877
  * Build cell value HTML (matching old mx_json_viewer colors)
9789
9878
  ************************************************************/
9790
- function build_cell_html(key, value, type, dark) {
9879
+ function build_cell_html(key, value, type, dark, matched) {
9791
9880
  let color = type_color(type, dark);
9792
9881
  let key_color = json_card_style(GROUP_COLORS.dict, dark).key;
9793
9882
  let display_value = "";
@@ -9812,7 +9901,28 @@ function build_cell_html(key, value, type, dark) {
9812
9901
  break;
9813
9902
  default: display_value = String(value);
9814
9903
  }
9815
- return `<span style="color:${key_color}">• ${(0, _yuneta_gobj_js.escapeHtml)(String(key))}: </span><span style="color:${color}">${display_value}</span>`;
9904
+ let row = `<span style="color:${key_color}">• ${(0, _yuneta_gobj_js.escapeHtml)(String(key))}: </span><span style="color:${color}">${display_value}</span>`;
9905
+ if (matched) row = `<span style="background:${dark ? "#7a5d00" : "#ffe082"}; border-radius:2px; padding:0 2px;">${row}</span>`;
9906
+ return row;
9907
+ }
9908
+ /************************************************************
9909
+ * Does this key/value pair match the find term?
9910
+ *
9911
+ * Scalars are matched on what the card SHOWS. A complex value
9912
+ * is not: its contents are a card of their own and match there,
9913
+ * so matching the `{3}` summary too would light up every
9914
+ * ancestor of every hit.
9915
+ ************************************************************/
9916
+ function cell_matches(key, value, type, needle) {
9917
+ if ((0, _yuneta_gobj_js.empty_string)(needle)) return false;
9918
+ if (String(key).toLowerCase().includes(needle)) return true;
9919
+ switch (type) {
9920
+ case "string":
9921
+ case "number":
9922
+ case "boolean": return String(value).toLowerCase().includes(needle);
9923
+ case "null": return "null".includes(needle);
9924
+ default: return false;
9925
+ }
9816
9926
  }
9817
9927
  /************************************************************
9818
9928
  * Get JSON type string
@@ -9858,12 +9968,21 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id) {
9858
9968
  let is_dict = (0, _yuneta_gobj_js.is_object)(kw);
9859
9969
  let is_list = (0, _yuneta_gobj_js.is_array)(kw);
9860
9970
  if (!is_dict && !is_list) return null;
9971
+ let priv = gobj.priv;
9972
+ let needle = priv.search || "";
9861
9973
  let dark = yui_is_dark();
9862
9974
  let lines = [];
9863
9975
  let pending_complex = [];
9976
+ let card_matched = false;
9864
9977
  let entries = is_dict ? Object.entries(kw) : kw.map((v, i) => [i, v]);
9865
9978
  for (let [key, value] of entries) {
9866
- let html = build_cell_html(key, value, get_json_type(value), dark);
9979
+ let type = get_json_type(value);
9980
+ let matched = cell_matches(key, value, type, needle);
9981
+ if (matched) {
9982
+ card_matched = true;
9983
+ priv.match_count++;
9984
+ }
9985
+ let html = build_cell_html(key, value, type, dark, matched);
9867
9986
  lines.push(html);
9868
9987
  if ((0, _yuneta_gobj_js.is_object)(value) && (0, _yuneta_gobj_js.json_object_size)(value) > 0 || (0, _yuneta_gobj_js.is_array)(value) && value.length > 0) pending_complex.push({
9869
9988
  key,
@@ -9871,14 +9990,18 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id) {
9871
9990
  });
9872
9991
  }
9873
9992
  let label = get_group_label(path) || (is_dict ? "{}" : "[]");
9993
+ let folded = priv.collapsed.has(path) && pending_complex.length > 0;
9994
+ if (folded) lines.push(`<span style="color:${type_color("number", dark)}; font-weight:bold;">&#9656; ${pending_complex.length}</span>`);
9874
9995
  let colors = json_card_style(is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark);
9996
+ let border_color = card_matched ? dark ? "#ffb300" : "#ff8f00" : colors.border;
9997
+ let border_width = card_matched ? 2 : 1;
9875
9998
  let content_html = lines.map((line) => `<div style="font-family:monospace; font-size:13px; padding:1px 6px; white-space:nowrap;">${line}</div>`).join("");
9876
9999
  let min_width = Math.max(180, label.length * 9);
9877
10000
  let node_html = `
9878
10001
  <div style="
9879
10002
  min-width: ${min_width}px;
9880
10003
  background: ${colors.bg};
9881
- border: 1px ${is_dict ? "dashed" : "solid"} ${colors.border};
10004
+ border: ${border_width}px ${is_dict ? "dashed" : "solid"} ${border_color};
9882
10005
  border-radius: ${is_dict ? "6px" : "0"};
9883
10006
  opacity: 0.9;
9884
10007
  overflow: hidden;
@@ -9916,6 +10039,7 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id) {
9916
10039
  lineWidth: 1
9917
10040
  }
9918
10041
  });
10042
+ if (folded) return group_id;
9919
10043
  for (let pending of pending_complex) build_json_nodes(gobj, add_segment(path, pending.key), pending.value, nodes, edges, group_id);
9920
10044
  return group_id;
9921
10045
  }
@@ -9932,6 +10056,7 @@ function load_json(gobj) {
9932
10056
  let path = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "path") || "`";
9933
10057
  if ((0, _yuneta_gobj_js.empty_string)(path)) path = "`";
9934
10058
  priv.node_counter = 0;
10059
+ priv.match_count = 0;
9935
10060
  let nodes = [];
9936
10061
  let edges = [];
9937
10062
  build_json_nodes(gobj, path, json, nodes, edges, null);
@@ -9948,6 +10073,42 @@ function load_json(gobj) {
9948
10073
  }
9949
10074
  }
9950
10075
  /************************************************************
10076
+ * Every path that HAS a branch to fold, in the same recursion
10077
+ * build_json_nodes uses — so "collapse all" can never name a
10078
+ * card that does not exist. The root is excluded: folding it
10079
+ * would leave an empty canvas.
10080
+ ************************************************************/
10081
+ function collect_foldable_paths(value, path, out, is_root) {
10082
+ let is_dict = (0, _yuneta_gobj_js.is_object)(value);
10083
+ let is_list = (0, _yuneta_gobj_js.is_array)(value);
10084
+ if (!is_dict && !is_list) return false;
10085
+ let has_branch = false;
10086
+ let entries = is_dict ? Object.entries(value) : value.map((v, i) => [i, v]);
10087
+ for (let [key, child] of entries) if ((0, _yuneta_gobj_js.is_object)(child) && (0, _yuneta_gobj_js.json_object_size)(child) > 0 || (0, _yuneta_gobj_js.is_array)(child) && child.length > 0) {
10088
+ has_branch = true;
10089
+ collect_foldable_paths(child, add_segment(path, key), out, false);
10090
+ }
10091
+ if (has_branch && !is_root) out.push(path);
10092
+ return has_branch;
10093
+ }
10094
+ /************************************************************
10095
+ * Show the find count, or hide the chip when there is no
10096
+ * term. Zero is a RESULT and must show: a graph that did not
10097
+ * move looks the same whether nothing matched or the match
10098
+ * was already on screen.
10099
+ ************************************************************/
10100
+ function update_find_result(gobj) {
10101
+ let priv = gobj.priv;
10102
+ if (!priv.$find_result || !priv.$find_count) return;
10103
+ if ((0, _yuneta_gobj_js.empty_string)(priv.search)) {
10104
+ priv.$find_result.classList.add("is-hidden");
10105
+ priv.$find_count.textContent = "";
10106
+ return;
10107
+ }
10108
+ priv.$find_count.textContent = String(priv.match_count);
10109
+ priv.$find_result.classList.remove("is-hidden");
10110
+ }
10111
+ /************************************************************
9951
10112
  * Clear and reload.
9952
10113
  * {preserve_view: true}
9953
10114
  * keep the current zoom and translate (no fitView) —
@@ -10069,6 +10230,49 @@ function ac_refresh$5(gobj, event, kw, src) {
10069
10230
  }
10070
10231
  /************************************************************
10071
10232
  * {theme: "dark"|"light"} — the app switched theme.
10233
+ * EV_FIND_NODES { text }
10234
+ *
10235
+ * The camera does NOT move: a find is a way of reading what is
10236
+ * already on screen, and a viewport that jumps on every
10237
+ * keystroke is unusable. The count says whether there is
10238
+ * anything to look for elsewhere.
10239
+ ************************************************************/
10240
+ function ac_find_nodes$2(gobj, event, kw, src) {
10241
+ let priv = gobj.priv;
10242
+ priv.search = (kw && kw.text || "").trim().toLowerCase();
10243
+ refresh_json(gobj, { preserve_view: true });
10244
+ update_find_result(gobj);
10245
+ return 0;
10246
+ }
10247
+ /************************************************************
10248
+ * EV_EXPAND_ALL
10249
+ ************************************************************/
10250
+ function ac_expand_all$2(gobj, event, kw, src) {
10251
+ let priv = gobj.priv;
10252
+ if (priv.collapsed.size === 0) return 0;
10253
+ priv.collapsed.clear();
10254
+ refresh_json(gobj);
10255
+ update_find_result(gobj);
10256
+ return 0;
10257
+ }
10258
+ /************************************************************
10259
+ * EV_COLLAPSE_ALL — fold every card but the root.
10260
+ ************************************************************/
10261
+ function ac_collapse_all$2(gobj, event, kw, src) {
10262
+ let priv = gobj.priv;
10263
+ let json_data = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "json_data");
10264
+ if (!json_data) return 0;
10265
+ let paths = [];
10266
+ let path = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "path") || "`";
10267
+ if ((0, _yuneta_gobj_js.empty_string)(path)) path = "`";
10268
+ collect_foldable_paths(json_data, path, paths, true);
10269
+ priv.collapsed = new Set(paths);
10270
+ refresh_json(gobj);
10271
+ update_find_result(gobj);
10272
+ return 0;
10273
+ }
10274
+ /************************************************************
10275
+ *
10072
10276
  ************************************************************/
10073
10277
  function ac_theme$3(gobj, event, kw, src) {
10074
10278
  let priv = gobj.priv;
@@ -10209,6 +10413,21 @@ function create_gclass$11(gclass_name) {
10209
10413
  ac_refresh$5,
10210
10414
  null
10211
10415
  ],
10416
+ [
10417
+ "EV_FIND_NODES",
10418
+ ac_find_nodes$2,
10419
+ null
10420
+ ],
10421
+ [
10422
+ "EV_EXPAND_ALL",
10423
+ ac_expand_all$2,
10424
+ null
10425
+ ],
10426
+ [
10427
+ "EV_COLLAPSE_ALL",
10428
+ ac_collapse_all$2,
10429
+ null
10430
+ ],
10212
10431
  [
10213
10432
  "EV_THEME",
10214
10433
  ac_theme$3,
@@ -10258,6 +10477,9 @@ function create_gclass$11(gclass_name) {
10258
10477
  const event_types = [
10259
10478
  ["EV_LOAD_DATA", 0],
10260
10479
  ["EV_REFRESH", 0],
10480
+ ["EV_FIND_NODES", 0],
10481
+ ["EV_EXPAND_ALL", 0],
10482
+ ["EV_COLLAPSE_ALL", 0],
10261
10483
  ["EV_THEME", 0],
10262
10484
  ["EV_ZOOM_IN", 0],
10263
10485
  ["EV_ZOOM_OUT", 0],
@@ -9487,7 +9487,14 @@ var PRIVATE_DATA$11 = {
9487
9487
  theme: null,
9488
9488
  theme_observer: null,
9489
9489
  resize_observer: null,
9490
- _resize_raf: 0
9490
+ _resize_raf: 0,
9491
+ search: "",
9492
+ collapsed: null,
9493
+ match_count: 0,
9494
+ find_timer: null,
9495
+ $find_input: null,
9496
+ $find_result: null,
9497
+ $find_count: null
9491
9498
  };
9492
9499
  var __gclass__$11 = null;
9493
9500
  var __layout_registered__$1 = false;
@@ -9556,6 +9563,8 @@ function type_color(type, dark) {
9556
9563
  ***************************************************************/
9557
9564
  function mt_create$11(gobj) {
9558
9565
  let priv = gobj.priv;
9566
+ priv.collapsed = /* @__PURE__ */ new Set();
9567
+ priv.search = "";
9559
9568
  priv.canvas_id = "json-canvas-" + clean_name(gobj_name(gobj));
9560
9569
  gobj_write_str_attr(gobj, "canvas_id", priv.canvas_id);
9561
9570
  let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
@@ -9585,6 +9594,10 @@ function mt_stop$11(gobj) {}
9585
9594
  * Framework Method: Destroy
9586
9595
  ***************************************************************/
9587
9596
  function mt_destroy$11(gobj) {
9597
+ if (gobj.priv.find_timer) {
9598
+ clearTimeout(gobj.priv.find_timer);
9599
+ gobj.priv.find_timer = null;
9600
+ }
9588
9601
  let priv = gobj.priv;
9589
9602
  if (priv.theme_observer) {
9590
9603
  priv.theme_observer.disconnect();
@@ -9641,6 +9654,9 @@ function build_ui$10(gobj) {
9641
9654
  ]]
9642
9655
  ]);
9643
9656
  gobj_write_attr(gobj, "$container", $container);
9657
+ priv.$find_input = $container.querySelector(".JSON_GRAPH_FIND_INPUT");
9658
+ priv.$find_result = $container.querySelector(".JSON_GRAPH_FIND_RESULT");
9659
+ priv.$find_count = $container.querySelector(".JSON_GRAPH_FIND_COUNT");
9644
9660
  refresh_language($container, t);
9645
9661
  }
9646
9662
  /************************************************************
@@ -9654,12 +9670,84 @@ function destroy_ui$6(gobj) {
9654
9670
  }
9655
9671
  }
9656
9672
  /************************************************************
9673
+ * One fold button. `open` rotates the chevron to the open
9674
+ * state, mirroring the per-node toggle of the lazy tree.
9675
+ ************************************************************/
9676
+ function graph_fold_button(gobj, event_name, label_key, open) {
9677
+ let icon_style = "font-size:1.5em; color:inherit;" + (open ? " transform: rotate(90deg);" : "");
9678
+ return [
9679
+ "button",
9680
+ {
9681
+ class: `button ${event_name}`,
9682
+ type: "button",
9683
+ style: {
9684
+ height: gobj_read_attr(gobj, "wide"),
9685
+ width: "2.5em"
9686
+ },
9687
+ title: t(label_key),
9688
+ "data-i18n-title": label_key,
9689
+ "aria-label": t(label_key),
9690
+ "data-i18n-aria-label": label_key
9691
+ },
9692
+ ["i", {
9693
+ style: icon_style,
9694
+ class: "yi-chevron-right"
9695
+ }],
9696
+ { click: (evt) => {
9697
+ evt.stopPropagation();
9698
+ gobj_send_event(gobj, event_name, {}, gobj);
9699
+ } }
9700
+ ];
9701
+ }
9702
+ /************************************************************
9657
9703
  * Toolbar
9658
9704
  ************************************************************/
9659
9705
  function make_toolbar$3(gobj) {
9660
- gobj.priv;
9706
+ let priv = gobj.priv;
9661
9707
  let toolbar_wide = gobj_read_attr(gobj, "wide");
9662
- let left_items = [];
9708
+ let left_items = [[
9709
+ "div",
9710
+ {
9711
+ class: "JSON_GRAPH_FIND control has-icons-left",
9712
+ style: "margin-right:.5rem; max-width:12rem; min-width:7rem;"
9713
+ },
9714
+ [["input", {
9715
+ class: "JSON_GRAPH_FIND_INPUT input",
9716
+ type: "text",
9717
+ placeholder: t("search"),
9718
+ "data-i18n-placeholder": "search",
9719
+ "aria-label": t("search"),
9720
+ "data-i18n-aria-label": "search"
9721
+ }], [
9722
+ "span",
9723
+ { class: "icon is-left" },
9724
+ [["i", { class: "yi-magnifying-glass" }]]
9725
+ ]],
9726
+ { input: (evt) => {
9727
+ evt.stopPropagation();
9728
+ let text = evt.target.value.trim();
9729
+ if (priv.find_timer) clearTimeout(priv.find_timer);
9730
+ priv.find_timer = setTimeout(function() {
9731
+ priv.find_timer = null;
9732
+ gobj_send_event(gobj, "EV_FIND_NODES", { text }, gobj);
9733
+ }, 250);
9734
+ } }
9735
+ ], [
9736
+ "div",
9737
+ {
9738
+ class: "JSON_GRAPH_FIND_RESULT is-hidden",
9739
+ style: "display:flex; align-items:center; gap:.3rem; margin-right:.5rem; font-size:.85rem;"
9740
+ },
9741
+ [[
9742
+ "span",
9743
+ { class: "JSON_GRAPH_FIND_COUNT" },
9744
+ ""
9745
+ ], [
9746
+ "span",
9747
+ { i18n: "matches" },
9748
+ "matches"
9749
+ ]]
9750
+ ]];
9663
9751
  let center_items = [];
9664
9752
  for (let item of [
9665
9753
  [
@@ -9717,6 +9805,7 @@ function make_toolbar$3(gobj) {
9717
9805
  } }
9718
9806
  ]);
9719
9807
  }
9808
+ let right_items = [graph_fold_button(gobj, "EV_EXPAND_ALL", "expand all", true), graph_fold_button(gobj, "EV_COLLAPSE_ALL", "collapse all", false)];
9720
9809
  const $toolbar = yui_toolbar({}, [
9721
9810
  [
9722
9811
  "div",
@@ -9731,7 +9820,7 @@ function make_toolbar$3(gobj) {
9731
9820
  [
9732
9821
  "div",
9733
9822
  { class: "yui-horizontal-toolbar-section right" },
9734
- []
9823
+ right_items
9735
9824
  ]
9736
9825
  ]);
9737
9826
  refresh_language($toolbar, t);
@@ -9782,7 +9871,7 @@ function build_graph$3(gobj) {
9782
9871
  /************************************************************
9783
9872
  * Build cell value HTML (matching old mx_json_viewer colors)
9784
9873
  ************************************************************/
9785
- function build_cell_html(key, value, type, dark) {
9874
+ function build_cell_html(key, value, type, dark, matched) {
9786
9875
  let color = type_color(type, dark);
9787
9876
  let key_color = json_card_style(GROUP_COLORS.dict, dark).key;
9788
9877
  let display_value = "";
@@ -9807,7 +9896,28 @@ function build_cell_html(key, value, type, dark) {
9807
9896
  break;
9808
9897
  default: display_value = String(value);
9809
9898
  }
9810
- return `<span style="color:${key_color}">• ${escapeHtml(String(key))}: </span><span style="color:${color}">${display_value}</span>`;
9899
+ let row = `<span style="color:${key_color}">• ${escapeHtml(String(key))}: </span><span style="color:${color}">${display_value}</span>`;
9900
+ if (matched) row = `<span style="background:${dark ? "#7a5d00" : "#ffe082"}; border-radius:2px; padding:0 2px;">${row}</span>`;
9901
+ return row;
9902
+ }
9903
+ /************************************************************
9904
+ * Does this key/value pair match the find term?
9905
+ *
9906
+ * Scalars are matched on what the card SHOWS. A complex value
9907
+ * is not: its contents are a card of their own and match there,
9908
+ * so matching the `{3}` summary too would light up every
9909
+ * ancestor of every hit.
9910
+ ************************************************************/
9911
+ function cell_matches(key, value, type, needle) {
9912
+ if (empty_string(needle)) return false;
9913
+ if (String(key).toLowerCase().includes(needle)) return true;
9914
+ switch (type) {
9915
+ case "string":
9916
+ case "number":
9917
+ case "boolean": return String(value).toLowerCase().includes(needle);
9918
+ case "null": return "null".includes(needle);
9919
+ default: return false;
9920
+ }
9811
9921
  }
9812
9922
  /************************************************************
9813
9923
  * Get JSON type string
@@ -9853,12 +9963,21 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id) {
9853
9963
  let is_dict = is_object(kw);
9854
9964
  let is_list = is_array(kw);
9855
9965
  if (!is_dict && !is_list) return null;
9966
+ let priv = gobj.priv;
9967
+ let needle = priv.search || "";
9856
9968
  let dark = yui_is_dark();
9857
9969
  let lines = [];
9858
9970
  let pending_complex = [];
9971
+ let card_matched = false;
9859
9972
  let entries = is_dict ? Object.entries(kw) : kw.map((v, i) => [i, v]);
9860
9973
  for (let [key, value] of entries) {
9861
- let html = build_cell_html(key, value, get_json_type(value), dark);
9974
+ let type = get_json_type(value);
9975
+ let matched = cell_matches(key, value, type, needle);
9976
+ if (matched) {
9977
+ card_matched = true;
9978
+ priv.match_count++;
9979
+ }
9980
+ let html = build_cell_html(key, value, type, dark, matched);
9862
9981
  lines.push(html);
9863
9982
  if (is_object(value) && json_object_size(value) > 0 || is_array(value) && value.length > 0) pending_complex.push({
9864
9983
  key,
@@ -9866,14 +9985,18 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id) {
9866
9985
  });
9867
9986
  }
9868
9987
  let label = get_group_label(path) || (is_dict ? "{}" : "[]");
9988
+ let folded = priv.collapsed.has(path) && pending_complex.length > 0;
9989
+ if (folded) lines.push(`<span style="color:${type_color("number", dark)}; font-weight:bold;">&#9656; ${pending_complex.length}</span>`);
9869
9990
  let colors = json_card_style(is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark);
9991
+ let border_color = card_matched ? dark ? "#ffb300" : "#ff8f00" : colors.border;
9992
+ let border_width = card_matched ? 2 : 1;
9870
9993
  let content_html = lines.map((line) => `<div style="font-family:monospace; font-size:13px; padding:1px 6px; white-space:nowrap;">${line}</div>`).join("");
9871
9994
  let min_width = Math.max(180, label.length * 9);
9872
9995
  let node_html = `
9873
9996
  <div style="
9874
9997
  min-width: ${min_width}px;
9875
9998
  background: ${colors.bg};
9876
- border: 1px ${is_dict ? "dashed" : "solid"} ${colors.border};
9999
+ border: ${border_width}px ${is_dict ? "dashed" : "solid"} ${border_color};
9877
10000
  border-radius: ${is_dict ? "6px" : "0"};
9878
10001
  opacity: 0.9;
9879
10002
  overflow: hidden;
@@ -9911,6 +10034,7 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id) {
9911
10034
  lineWidth: 1
9912
10035
  }
9913
10036
  });
10037
+ if (folded) return group_id;
9914
10038
  for (let pending of pending_complex) build_json_nodes(gobj, add_segment(path, pending.key), pending.value, nodes, edges, group_id);
9915
10039
  return group_id;
9916
10040
  }
@@ -9927,6 +10051,7 @@ function load_json(gobj) {
9927
10051
  let path = gobj_read_str_attr(gobj, "path") || "`";
9928
10052
  if (empty_string(path)) path = "`";
9929
10053
  priv.node_counter = 0;
10054
+ priv.match_count = 0;
9930
10055
  let nodes = [];
9931
10056
  let edges = [];
9932
10057
  build_json_nodes(gobj, path, json, nodes, edges, null);
@@ -9943,6 +10068,42 @@ function load_json(gobj) {
9943
10068
  }
9944
10069
  }
9945
10070
  /************************************************************
10071
+ * Every path that HAS a branch to fold, in the same recursion
10072
+ * build_json_nodes uses — so "collapse all" can never name a
10073
+ * card that does not exist. The root is excluded: folding it
10074
+ * would leave an empty canvas.
10075
+ ************************************************************/
10076
+ function collect_foldable_paths(value, path, out, is_root) {
10077
+ let is_dict = is_object(value);
10078
+ let is_list = is_array(value);
10079
+ if (!is_dict && !is_list) return false;
10080
+ let has_branch = false;
10081
+ let entries = is_dict ? Object.entries(value) : value.map((v, i) => [i, v]);
10082
+ for (let [key, child] of entries) if (is_object(child) && json_object_size(child) > 0 || is_array(child) && child.length > 0) {
10083
+ has_branch = true;
10084
+ collect_foldable_paths(child, add_segment(path, key), out, false);
10085
+ }
10086
+ if (has_branch && !is_root) out.push(path);
10087
+ return has_branch;
10088
+ }
10089
+ /************************************************************
10090
+ * Show the find count, or hide the chip when there is no
10091
+ * term. Zero is a RESULT and must show: a graph that did not
10092
+ * move looks the same whether nothing matched or the match
10093
+ * was already on screen.
10094
+ ************************************************************/
10095
+ function update_find_result(gobj) {
10096
+ let priv = gobj.priv;
10097
+ if (!priv.$find_result || !priv.$find_count) return;
10098
+ if (empty_string(priv.search)) {
10099
+ priv.$find_result.classList.add("is-hidden");
10100
+ priv.$find_count.textContent = "";
10101
+ return;
10102
+ }
10103
+ priv.$find_count.textContent = String(priv.match_count);
10104
+ priv.$find_result.classList.remove("is-hidden");
10105
+ }
10106
+ /************************************************************
9946
10107
  * Clear and reload.
9947
10108
  * {preserve_view: true}
9948
10109
  * keep the current zoom and translate (no fitView) —
@@ -10064,6 +10225,49 @@ function ac_refresh$5(gobj, event, kw, src) {
10064
10225
  }
10065
10226
  /************************************************************
10066
10227
  * {theme: "dark"|"light"} — the app switched theme.
10228
+ * EV_FIND_NODES { text }
10229
+ *
10230
+ * The camera does NOT move: a find is a way of reading what is
10231
+ * already on screen, and a viewport that jumps on every
10232
+ * keystroke is unusable. The count says whether there is
10233
+ * anything to look for elsewhere.
10234
+ ************************************************************/
10235
+ function ac_find_nodes$2(gobj, event, kw, src) {
10236
+ let priv = gobj.priv;
10237
+ priv.search = (kw && kw.text || "").trim().toLowerCase();
10238
+ refresh_json(gobj, { preserve_view: true });
10239
+ update_find_result(gobj);
10240
+ return 0;
10241
+ }
10242
+ /************************************************************
10243
+ * EV_EXPAND_ALL
10244
+ ************************************************************/
10245
+ function ac_expand_all$2(gobj, event, kw, src) {
10246
+ let priv = gobj.priv;
10247
+ if (priv.collapsed.size === 0) return 0;
10248
+ priv.collapsed.clear();
10249
+ refresh_json(gobj);
10250
+ update_find_result(gobj);
10251
+ return 0;
10252
+ }
10253
+ /************************************************************
10254
+ * EV_COLLAPSE_ALL — fold every card but the root.
10255
+ ************************************************************/
10256
+ function ac_collapse_all$2(gobj, event, kw, src) {
10257
+ let priv = gobj.priv;
10258
+ let json_data = gobj_read_attr(gobj, "json_data");
10259
+ if (!json_data) return 0;
10260
+ let paths = [];
10261
+ let path = gobj_read_str_attr(gobj, "path") || "`";
10262
+ if (empty_string(path)) path = "`";
10263
+ collect_foldable_paths(json_data, path, paths, true);
10264
+ priv.collapsed = new Set(paths);
10265
+ refresh_json(gobj);
10266
+ update_find_result(gobj);
10267
+ return 0;
10268
+ }
10269
+ /************************************************************
10270
+ *
10067
10271
  ************************************************************/
10068
10272
  function ac_theme$3(gobj, event, kw, src) {
10069
10273
  let priv = gobj.priv;
@@ -10204,6 +10408,21 @@ function create_gclass$11(gclass_name) {
10204
10408
  ac_refresh$5,
10205
10409
  null
10206
10410
  ],
10411
+ [
10412
+ "EV_FIND_NODES",
10413
+ ac_find_nodes$2,
10414
+ null
10415
+ ],
10416
+ [
10417
+ "EV_EXPAND_ALL",
10418
+ ac_expand_all$2,
10419
+ null
10420
+ ],
10421
+ [
10422
+ "EV_COLLAPSE_ALL",
10423
+ ac_collapse_all$2,
10424
+ null
10425
+ ],
10207
10426
  [
10208
10427
  "EV_THEME",
10209
10428
  ac_theme$3,
@@ -10253,6 +10472,9 @@ function create_gclass$11(gclass_name) {
10253
10472
  const event_types = [
10254
10473
  ["EV_LOAD_DATA", 0],
10255
10474
  ["EV_REFRESH", 0],
10475
+ ["EV_FIND_NODES", 0],
10476
+ ["EV_EXPAND_ALL", 0],
10477
+ ["EV_COLLAPSE_ALL", 0],
10256
10478
  ["EV_THEME", 0],
10257
10479
  ["EV_ZOOM_IN", 0],
10258
10480
  ["EV_ZOOM_OUT", 0],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.21.1",
3
+ "version": "7.22.0",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -47,28 +47,34 @@
47
47
  }
48
48
 
49
49
  /*
50
- * Who YIELDS when the toolbar runs out of room. The search box does:
51
- * it grows to `max-width` when there is space and shrinks to nothing
52
- * when there is not, while the buttons keep their size. A narrow
53
- * search box still accepts what you type; a button pushed past the
54
- * edge can only be reached by scrolling the toolbar, and the view
55
- * switch is the last control that should be hiding.
50
+ * Who YIELDS when the toolbar runs out of room and how far.
56
51
  *
57
- * Measured at 390px: without this the search took 294 of 320 visible
58
- * pixels and every button sat outside.
52
+ * The search box yields, and the buttons keep their size: a button
53
+ * pushed past the edge is harder to reach than a narrower input. But
54
+ * it yields down to a FLOOR, not to nothing. Letting it shrink freely
55
+ * did make everything fit on a phone, and the result was worse than
56
+ * the overflow it cured: the box collapsed to 58px — the magnifier and
57
+ * no room to read what you typed — and, because nothing overflowed any
58
+ * more, `yui_toolbar` stopped offering its scroll arrows, so the row
59
+ * had no way to show you anything else either.
60
+ *
61
+ * With the floor the toolbar overflows again on a narrow screen, which
62
+ * is the state yui_toolbar is built for: the container is
63
+ * `overflow-x: auto` and the arrows appear.
59
64
  */
60
- .C_YUI_JSON .yui-horizontal-toolbar-section.left {
61
- flex: 1 1 auto;
62
- min-width: 0;
65
+ /* Neither section shrinks below its content. With `min-width: 0` here
66
+ * the left section shrank to 70px while the search box inside it kept
67
+ * its floor — so the input was painted UNDERNEATH the buttons, and the
68
+ * container, whose own children all fitted, reported no overflow to
69
+ * scroll. Sized to content, the container overflows honestly. */
70
+ .C_YUI_JSON .yui-horizontal-toolbar-section.left,
71
+ .C_YUI_JSON .yui-horizontal-toolbar-section.right {
72
+ flex: 0 0 auto;
63
73
  }
64
74
 
65
75
  .C_YUI_JSON .JSON_SEARCH_CONTROL {
66
76
  flex: 1 1 auto;
67
- min-width: 0;
68
- }
69
-
70
- .C_YUI_JSON .yui-horizontal-toolbar-section.right {
71
- flex: 0 0 auto;
77
+ min-width: 11rem;
72
78
  }
73
79
 
74
80
  .C_YUI_JSON .JSON_TREE {
@@ -96,6 +96,15 @@ let PRIVATE_DATA = {
96
96
  theme_observer: null, // MutationObserver on <html data-theme>
97
97
  resize_observer: null, // ResizeObserver on the canvas mount element
98
98
  _resize_raf: 0, // rAF id debouncing resize -> EV_RESIZE
99
+
100
+ /*---------------- find + collapse ----------------*/
101
+ search: "", // current find term, lower-cased
102
+ collapsed: null, // Set<string> of paths whose children are hidden
103
+ match_count: 0, // matches of the last find, for the count chip
104
+ find_timer: null, // rate-limits the find box
105
+ $find_input: null,
106
+ $find_result: null,
107
+ $find_count: null,
99
108
  };
100
109
 
101
110
  let __gclass__ = null;
@@ -180,6 +189,9 @@ function mt_create(gobj)
180
189
  {
181
190
  let priv = gobj.priv;
182
191
 
192
+ priv.collapsed = new Set();
193
+ priv.search = "";
194
+
183
195
  let name = clean_name(gobj_name(gobj));
184
196
  priv.canvas_id = "json-canvas-" + name;
185
197
  gobj_write_str_attr(gobj, "canvas_id", priv.canvas_id);
@@ -233,6 +245,10 @@ function mt_stop(gobj)
233
245
  ***************************************************************/
234
246
  function mt_destroy(gobj)
235
247
  {
248
+ if(gobj.priv.find_timer) {
249
+ clearTimeout(gobj.priv.find_timer);
250
+ gobj.priv.find_timer = null;
251
+ }
236
252
  let priv = gobj.priv;
237
253
 
238
254
  if(priv.theme_observer) {
@@ -286,6 +302,11 @@ function build_ui(gobj)
286
302
  );
287
303
 
288
304
  gobj_write_attr(gobj, "$container", $container);
305
+
306
+ priv.$find_input = $container.querySelector('.JSON_GRAPH_FIND_INPUT');
307
+ priv.$find_result = $container.querySelector('.JSON_GRAPH_FIND_RESULT');
308
+ priv.$find_count = $container.querySelector('.JSON_GRAPH_FIND_COUNT');
309
+
289
310
  refresh_language($container, t);
290
311
  }
291
312
 
@@ -303,6 +324,30 @@ function destroy_ui(gobj)
303
324
  }
304
325
  }
305
326
 
327
+ /************************************************************
328
+ * One fold button. `open` rotates the chevron to the open
329
+ * state, mirroring the per-node toggle of the lazy tree.
330
+ ************************************************************/
331
+ function graph_fold_button(gobj, event_name, label_key, open)
332
+ {
333
+ let icon_style = 'font-size:1.5em; color:inherit;' +
334
+ (open? ' transform: rotate(90deg);': '');
335
+
336
+ return ['button', {class: `button ${event_name}`,
337
+ type: 'button',
338
+ style: {height: gobj_read_attr(gobj, "wide"), width: '2.5em'},
339
+ title: t(label_key), 'data-i18n-title': label_key,
340
+ 'aria-label': t(label_key), 'data-i18n-aria-label': label_key},
341
+ ['i', {style: icon_style, class: 'yi-chevron-right'}],
342
+ {
343
+ click: (evt) => {
344
+ evt.stopPropagation();
345
+ gobj_send_event(gobj, event_name, {}, gobj);
346
+ }
347
+ }
348
+ ];
349
+ }
350
+
306
351
  /************************************************************
307
352
  * Toolbar
308
353
  ************************************************************/
@@ -311,7 +356,62 @@ function make_toolbar(gobj)
311
356
  let priv = gobj.priv;
312
357
  let toolbar_wide = gobj_read_attr(gobj, "wide");
313
358
 
314
- let left_items = [];
359
+ /*
360
+ * Left: find a node.
361
+ *
362
+ * A graph of a large document has no other way in — the only way to
363
+ * locate a key was to read every card. Same shape as the treedb
364
+ * graph's box: a rate-limited input into EV_FIND_NODES, and a count
365
+ * that SAYS how many matched, because a graph that did not move
366
+ * looks the same whether nothing matched or the match was already
367
+ * on screen.
368
+ */
369
+ let left_items = [
370
+ ['div', {class: 'JSON_GRAPH_FIND control has-icons-left',
371
+ style: 'margin-right:.5rem; max-width:12rem; min-width:7rem;'}, [
372
+ ['input', {
373
+ class: 'JSON_GRAPH_FIND_INPUT input',
374
+ type: 'text',
375
+ /* A placeholder is not a text node, so the data-i18n
376
+ * walk cannot reach it: it needs its own key. */
377
+ placeholder: t('search'),
378
+ 'data-i18n-placeholder': 'search',
379
+ 'aria-label': t('search'),
380
+ 'data-i18n-aria-label': 'search'
381
+ }],
382
+ ['span', {class: 'icon is-left'}, [['i', {class: 'yi-magnifying-glass'}]]]
383
+ ], {
384
+ /* Rate-limited, not delayed for effect: every keystroke
385
+ * REBUILDS the cards — an html node paints no G6 state, so
386
+ * the highlight has to live in the card's own markup — and
387
+ * the first letter typed can match most of a document.
388
+ * Input plumbing: the action still crosses the FSM, just
389
+ * not once per keystroke. */
390
+ input: (evt) => {
391
+ evt.stopPropagation();
392
+ let text = evt.target.value.trim();
393
+ if(priv.find_timer) {
394
+ clearTimeout(priv.find_timer);
395
+ }
396
+ priv.find_timer = setTimeout(function() {
397
+ priv.find_timer = null;
398
+ gobj_send_event(gobj, "EV_FIND_NODES", {text: text}, gobj);
399
+ }, 250);
400
+ }
401
+ }],
402
+ /* Two spans, not one string: the number is DATA and "matches"
403
+ * is the word, so a language switch re-translates the half that
404
+ * is a word. Spaced with CSS because createElement2 trims text
405
+ * nodes. `display:flex` inline and NOT the `is-flex` helper —
406
+ * both Bulma helpers carry !important, so `is-hidden is-flex` on
407
+ * one element is decided by stylesheet order. */
408
+ ['div', {class: 'JSON_GRAPH_FIND_RESULT is-hidden',
409
+ style: 'display:flex; align-items:center; gap:.3rem; ' +
410
+ 'margin-right:.5rem; font-size:.85rem;'}, [
411
+ ['span', {class: 'JSON_GRAPH_FIND_COUNT'}, ''],
412
+ ['span', {i18n: 'matches'}, 'matches']
413
+ ]]
414
+ ];
315
415
  let center_items = [];
316
416
 
317
417
  let c_icons = [
@@ -347,10 +447,24 @@ function make_toolbar(gobj)
347
447
  );
348
448
  }
349
449
 
450
+ /*
451
+ * Right: fold the tree.
452
+ *
453
+ * A JSON graph of anything real is mostly cards nobody is looking
454
+ * at. "Collapse all" leaves the root and marks every cut with a
455
+ * count, so the shape stays legible and you can see WHERE the rest
456
+ * went; "expand all" puts it back. Same chevron as the lazy tree,
457
+ * rotated to point down for the open state.
458
+ */
459
+ let right_items = [
460
+ graph_fold_button(gobj, "EV_EXPAND_ALL", "expand all", true),
461
+ graph_fold_button(gobj, "EV_COLLAPSE_ALL", "collapse all", false),
462
+ ];
463
+
350
464
  const $toolbar = yui_toolbar({}, [
351
465
  ['div', {class: 'yui-horizontal-toolbar-section left'}, left_items],
352
466
  ['div', {class: 'yui-horizontal-toolbar-section center'}, center_items],
353
- ['div', {class: 'yui-horizontal-toolbar-section right'}, []],
467
+ ['div', {class: 'yui-horizontal-toolbar-section right'}, right_items],
354
468
  ]);
355
469
 
356
470
  refresh_language($toolbar, t);
@@ -440,7 +554,7 @@ function build_graph(gobj)
440
554
  /************************************************************
441
555
  * Build cell value HTML (matching old mx_json_viewer colors)
442
556
  ************************************************************/
443
- function build_cell_html(key, value, type, dark)
557
+ function build_cell_html(key, value, type, dark, matched)
444
558
  {
445
559
  let color = type_color(type, dark);
446
560
  let key_color = json_card_style(GROUP_COLORS.dict, dark).key;
@@ -470,7 +584,46 @@ function build_cell_html(key, value, type, dark)
470
584
  break;
471
585
  }
472
586
 
473
- return `<span style="color:${key_color}">• ${escapeHtml(String(key))}: </span><span style="color:${color}">${display_value}</span>`;
587
+ let row = `<span style="color:${key_color}">• ${escapeHtml(String(key))}: </span>` +
588
+ `<span style="color:${color}">${display_value}</span>`;
589
+
590
+ if(matched) {
591
+ /* The highlight is baked into the card's MARKUP and not set as
592
+ * a G6 node state: the key shape of an `html` node is a DOM
593
+ * element, and G6 paints no state style on it. Setting
594
+ * 'active' here would select correctly and show nothing. */
595
+ row = `<span style="background:${dark? "#7a5d00": "#ffe082"}; ` +
596
+ `border-radius:2px; padding:0 2px;">${row}</span>`;
597
+ }
598
+ return row;
599
+ }
600
+
601
+ /************************************************************
602
+ * Does this key/value pair match the find term?
603
+ *
604
+ * Scalars are matched on what the card SHOWS. A complex value
605
+ * is not: its contents are a card of their own and match there,
606
+ * so matching the `{3}` summary too would light up every
607
+ * ancestor of every hit.
608
+ ************************************************************/
609
+ function cell_matches(key, value, type, needle)
610
+ {
611
+ if(empty_string(needle)) {
612
+ return false;
613
+ }
614
+ if(String(key).toLowerCase().includes(needle)) {
615
+ return true;
616
+ }
617
+ switch(type) {
618
+ case "string":
619
+ case "number":
620
+ case "boolean":
621
+ return String(value).toLowerCase().includes(needle);
622
+ case "null":
623
+ return "null".includes(needle);
624
+ default:
625
+ return false;
626
+ }
474
627
  }
475
628
 
476
629
  /************************************************************
@@ -541,15 +694,23 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
541
694
  * The theme is read live as the card is drawn (like the gobj-tree's
542
695
  * cards): ac_theme rebuilds on a switch, so this is always current.
543
696
  */
697
+ let priv = gobj.priv;
698
+ let needle = priv.search || "";
544
699
  let dark = yui_is_dark();
545
700
  let lines = [];
546
701
  let pending_complex = [];
702
+ let card_matched = false;
547
703
 
548
704
  let entries = is_dict ? Object.entries(kw) : kw.map((v, i) => [i, v]);
549
705
 
550
706
  for(let [key, value] of entries) {
551
707
  let type = get_json_type(value);
552
- let html = build_cell_html(key, value, type, dark);
708
+ let matched = cell_matches(key, value, type, needle);
709
+ if(matched) {
710
+ card_matched = true;
711
+ priv.match_count++;
712
+ }
713
+ let html = build_cell_html(key, value, type, dark, matched);
553
714
  lines.push(html);
554
715
 
555
716
  if((is_object(value) && json_object_size(value) > 0) ||
@@ -559,9 +720,27 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
559
720
  }
560
721
 
561
722
  let label = get_group_label(path) || (is_dict ? "{}" : "[]");
723
+
724
+ /*
725
+ * Folded: keep the card, drop the branch, and SAY how much went
726
+ * with it. A cut that is not marked reads as a document that ends
727
+ * there — the same rule the lazy tree follows for a `__collapsed__`
728
+ * sentinel. The badge is a glyph and a number on purpose: the card
729
+ * is an innerHTML string, where a word could carry no i18n key and
730
+ * would sit there in English forever.
731
+ */
732
+ let folded = priv.collapsed.has(path) && pending_complex.length > 0;
733
+ if(folded) {
734
+ lines.push(
735
+ `<span style="color:${type_color("number", dark)}; font-weight:bold;">` +
736
+ `&#9656; ${pending_complex.length}</span>`
737
+ );
738
+ }
562
739
  let colors = json_card_style(
563
740
  is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark
564
741
  );
742
+ let border_color = card_matched? (dark? "#ffb300": "#ff8f00"): colors.border;
743
+ let border_width = card_matched? 2: 1;
565
744
 
566
745
  /*
567
746
  * Build node HTML content
@@ -576,7 +755,7 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
576
755
  <div style="
577
756
  min-width: ${min_width}px;
578
757
  background: ${colors.bg};
579
- border: 1px ${is_dict ? 'dashed' : 'solid'} ${colors.border};
758
+ border: ${border_width}px ${is_dict ? 'dashed' : 'solid'} ${border_color};
580
759
  border-radius: ${is_dict ? '6px' : '0'};
581
760
  opacity: 0.9;
582
761
  overflow: hidden;
@@ -623,8 +802,11 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
623
802
  }
624
803
 
625
804
  /*
626
- * Recurse into complex children
805
+ * Recurse into complex children — unless this card is folded.
627
806
  */
807
+ if(folded) {
808
+ return group_id;
809
+ }
628
810
  for(let pending of pending_complex) {
629
811
  build_json_nodes(
630
812
  gobj,
@@ -663,6 +845,7 @@ function load_json(gobj)
663
845
  }
664
846
 
665
847
  priv.node_counter = 0;
848
+ priv.match_count = 0;
666
849
 
667
850
  let nodes = [];
668
851
  let edges = [];
@@ -682,6 +865,57 @@ function load_json(gobj)
682
865
  }
683
866
  }
684
867
 
868
+ /************************************************************
869
+ * Every path that HAS a branch to fold, in the same recursion
870
+ * build_json_nodes uses — so "collapse all" can never name a
871
+ * card that does not exist. The root is excluded: folding it
872
+ * would leave an empty canvas.
873
+ ************************************************************/
874
+ function collect_foldable_paths(value, path, out, is_root)
875
+ {
876
+ let is_dict = is_object(value);
877
+ let is_list = is_array(value);
878
+ if(!is_dict && !is_list) {
879
+ return false;
880
+ }
881
+
882
+ let has_branch = false;
883
+ let entries = is_dict ? Object.entries(value) : value.map((v, i) => [i, v]);
884
+ for(let [key, child] of entries) {
885
+ if((is_object(child) && json_object_size(child) > 0) ||
886
+ (is_array(child) && child.length > 0)) {
887
+ has_branch = true;
888
+ collect_foldable_paths(child, add_segment(path, key), out, false);
889
+ }
890
+ }
891
+
892
+ if(has_branch && !is_root) {
893
+ out.push(path);
894
+ }
895
+ return has_branch;
896
+ }
897
+
898
+ /************************************************************
899
+ * Show the find count, or hide the chip when there is no
900
+ * term. Zero is a RESULT and must show: a graph that did not
901
+ * move looks the same whether nothing matched or the match
902
+ * was already on screen.
903
+ ************************************************************/
904
+ function update_find_result(gobj)
905
+ {
906
+ let priv = gobj.priv;
907
+ if(!priv.$find_result || !priv.$find_count) {
908
+ return;
909
+ }
910
+ if(empty_string(priv.search)) {
911
+ priv.$find_result.classList.add("is-hidden");
912
+ priv.$find_count.textContent = "";
913
+ return;
914
+ }
915
+ priv.$find_count.textContent = String(priv.match_count);
916
+ priv.$find_result.classList.remove("is-hidden");
917
+ }
918
+
685
919
  /************************************************************
686
920
  * Clear and reload.
687
921
  * {preserve_view: true}
@@ -863,6 +1097,66 @@ function ac_refresh(gobj, event, kw, src)
863
1097
 
864
1098
  /************************************************************
865
1099
  * {theme: "dark"|"light"} — the app switched theme.
1100
+ * EV_FIND_NODES { text }
1101
+ *
1102
+ * The camera does NOT move: a find is a way of reading what is
1103
+ * already on screen, and a viewport that jumps on every
1104
+ * keystroke is unusable. The count says whether there is
1105
+ * anything to look for elsewhere.
1106
+ ************************************************************/
1107
+ function ac_find_nodes(gobj, event, kw, src)
1108
+ {
1109
+ let priv = gobj.priv;
1110
+
1111
+ priv.search = ((kw && kw.text) || "").trim().toLowerCase();
1112
+ refresh_json(gobj, {preserve_view: true});
1113
+ update_find_result(gobj);
1114
+ return 0;
1115
+ }
1116
+
1117
+ /************************************************************
1118
+ * EV_EXPAND_ALL
1119
+ ************************************************************/
1120
+ function ac_expand_all(gobj, event, kw, src)
1121
+ {
1122
+ let priv = gobj.priv;
1123
+
1124
+ if(priv.collapsed.size === 0) {
1125
+ return 0; // nothing folded; a relayout would only move the camera
1126
+ }
1127
+ priv.collapsed.clear();
1128
+ refresh_json(gobj);
1129
+ update_find_result(gobj);
1130
+ return 0;
1131
+ }
1132
+
1133
+ /************************************************************
1134
+ * EV_COLLAPSE_ALL — fold every card but the root.
1135
+ ************************************************************/
1136
+ function ac_collapse_all(gobj, event, kw, src)
1137
+ {
1138
+ let priv = gobj.priv;
1139
+
1140
+ let json_data = gobj_read_attr(gobj, "json_data");
1141
+ if(!json_data) {
1142
+ return 0;
1143
+ }
1144
+
1145
+ let paths = [];
1146
+ let path = gobj_read_str_attr(gobj, "path") || "`";
1147
+ if(empty_string(path)) {
1148
+ path = "`";
1149
+ }
1150
+ collect_foldable_paths(json_data, path, paths, true);
1151
+
1152
+ priv.collapsed = new Set(paths);
1153
+ refresh_json(gobj);
1154
+ update_find_result(gobj);
1155
+ return 0;
1156
+ }
1157
+
1158
+ /************************************************************
1159
+ *
866
1160
  ************************************************************/
867
1161
  function ac_theme(gobj, event, kw, src)
868
1162
  {
@@ -1054,6 +1348,9 @@ function create_gclass(gclass_name)
1054
1348
  ["ST_IDLE", [
1055
1349
  ["EV_LOAD_DATA", ac_load_data, null],
1056
1350
  ["EV_REFRESH", ac_refresh, null],
1351
+ ["EV_FIND_NODES", ac_find_nodes, null],
1352
+ ["EV_EXPAND_ALL", ac_expand_all, null],
1353
+ ["EV_COLLAPSE_ALL", ac_collapse_all, null],
1057
1354
  ["EV_THEME", ac_theme, null],
1058
1355
  ["EV_ZOOM_IN", ac_zoom_in, null],
1059
1356
  ["EV_ZOOM_OUT", ac_zoom_out, null],
@@ -1072,6 +1369,9 @@ function create_gclass(gclass_name)
1072
1369
  const event_types = [
1073
1370
  ["EV_LOAD_DATA", 0],
1074
1371
  ["EV_REFRESH", 0],
1372
+ ["EV_FIND_NODES", 0],
1373
+ ["EV_EXPAND_ALL", 0],
1374
+ ["EV_COLLAPSE_ALL", 0],
1075
1375
  ["EV_THEME", 0],
1076
1376
  ["EV_ZOOM_IN", 0],
1077
1377
  ["EV_ZOOM_OUT", 0],