@yuneta/gobj-ui 7.18.3 → 7.19.1

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
@@ -719,6 +719,41 @@ so it survived that — two light islands over a dark canvas.
719
719
  (all tooltips, so a host that has not defined them shows the key on hover and
720
720
  nothing else breaks).
721
721
 
722
+ ### Tabs opened at runtime, and the two decisions their url costs
723
+
724
+ `yui_tab_routes.js`. A workspace whose tabs are opened by the operator —
725
+ `/<ws>/<home>/<id>`, with whatever the tab is showing below it — pays for that
726
+ url twice, and both apps in this family learned the same two lessons, one of
727
+ them the hard way.
728
+
729
+ **`yui_tab_split_subpath(subpath)` → `{id, tail}`.** On a cold load the tab's
730
+ route does not exist yet: it is registered when the tab is opened, so a reload
731
+ on `/<ws>/<home>/<id>/<tail>` resolves only as far as the workspace home and the
732
+ shell hands the WHOLE rest over as the subpath — `<id>/<tail>`, not `<id>`.
733
+ Reading all of it as the id matches nothing, and an app that then falls back to
734
+ its first tab **answers a reload with somebody else's default**. It hides well:
735
+ a bare tab route survives, because there the subpath IS the id, so only the deep
736
+ case breaks and only for whoever reloads on one.
737
+
738
+ Only the id segment is decoded. These ids are composite (`<node>`+`0x1F`+
739
+ `<yuno>`, `<conn>`+`0x1F`+`<treedb>`) and reach the url percent-encoded, so
740
+ decoding the whole tail first would turn an encoded slash inside an id into a
741
+ separator and cut it in two.
742
+
743
+ **`yui_tab_position_plan(prev_base, base, subpath, remembered)` →
744
+ `{record, replay}`.** A tab's nav item is a FIXED route —
745
+ `yui_shell_set_submenu()` registers it, and that route is where the view is
746
+ mounted — so the position inside a tab cannot travel in the item and has to be
747
+ replayed when the tab is entered again. "Entered again" is the whole subtlety:
748
+ arriving at the root of the tab you were ALREADY in is the way OUT of whatever
749
+ was open, and replaying the position there would make that button do nothing.
750
+
751
+ **What is NOT here: the wiring.** One host restores on its transport's
752
+ `EV_ON_OPEN`, another normalizes the route as it arrives, and both are right for
753
+ what they know about when their tabs become real. These are the decisions, not
754
+ the plumbing — which is also why they are pure and tested rather than three
755
+ lines inside an action.
756
+
722
757
  ### Selecting several nodes, and moving them together
723
758
 
724
759
  In **edition** mode the graph has a real selection, not just "the node you
@@ -57017,6 +57017,19 @@ function refresh_minimap(gobj) {
57017
57017
  graph_add_plugin(gobj, "minimap", {
57018
57018
  size: [200, 140],
57019
57019
  position: "bottom-left",
57020
+ containerStyle: {
57021
+ top: "auto",
57022
+ bottom: "12px",
57023
+ left: "12px",
57024
+ background: "var(--bulma-scheme-main, #fff)",
57025
+ border: "1px solid var(--bulma-border-weak, #ddd)",
57026
+ borderRadius: "6px",
57027
+ boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)"
57028
+ },
57029
+ maskStyle: {
57030
+ border: "2px solid var(--bulma-link, #3b82f6)",
57031
+ background: "rgba(59, 130, 246, 0.12)"
57032
+ },
57020
57033
  shape: (id, element_type, element) => {
57021
57034
  if (element_type !== "node") return element;
57022
57035
  try {
@@ -57004,6 +57004,19 @@ function refresh_minimap(gobj) {
57004
57004
  graph_add_plugin(gobj, "minimap", {
57005
57005
  size: [200, 140],
57006
57006
  position: "bottom-left",
57007
+ containerStyle: {
57008
+ top: "auto",
57009
+ bottom: "12px",
57010
+ left: "12px",
57011
+ background: "var(--bulma-scheme-main, #fff)",
57012
+ border: "1px solid var(--bulma-border-weak, #ddd)",
57013
+ borderRadius: "6px",
57014
+ boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)"
57015
+ },
57016
+ maskStyle: {
57017
+ border: "2px solid var(--bulma-link, #3b82f6)",
57018
+ background: "rgba(59, 130, 246, 0.12)"
57019
+ },
57007
57020
  shape: (id, element_type, element) => {
57008
57021
  if (element_type !== "node") return element;
57009
57022
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.18.3",
3
+ "version": "7.19.1",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -5166,6 +5166,43 @@ function refresh_minimap(gobj)
5166
5166
  {
5167
5167
  size: [200, 140],
5168
5168
  position: "bottom-left",
5169
+ /*
5170
+ * The anchor has to be CSS, not the pixels G6 computes.
5171
+ *
5172
+ * `createPluginCanvas` turns `position` into `left`/`top`
5173
+ * in PIXELS, once, from the canvas size at creation
5174
+ * (`xRatio * (W - width)`). The container then grows --
5175
+ * full screen, a window resize, a panel closing -- and the
5176
+ * minimap stays at the `top` it was given: measured going
5177
+ * full screen, `top` was still `511px` over a container
5178
+ * that had become 768 tall, which put it floating halfway
5179
+ * up the left edge, on top of the graph it is there to
5180
+ * explain.
5181
+ *
5182
+ * `containerStyle` is merged AFTER those pixels, so
5183
+ * `top: auto` + `bottom` is a real anchor the browser
5184
+ * keeps for every size the container ever takes.
5185
+ *
5186
+ * And it follows the theme: G6's default is a `#fff` box
5187
+ * with a `#ddd` border in BOTH themes, which over a
5188
+ * near-black canvas is the brightest thing on screen.
5189
+ */
5190
+ containerStyle: {
5191
+ top: "auto",
5192
+ bottom: "12px",
5193
+ left: "12px",
5194
+ background: "var(--bulma-scheme-main, #fff)",
5195
+ border: "1px solid var(--bulma-border-weak, #ddd)",
5196
+ borderRadius: "6px",
5197
+ boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
5198
+ },
5199
+ /* The viewport rectangle: G6 washes it in black, which is
5200
+ * invisible over a dark minimap. A link-coloured outline
5201
+ * reads on both. */
5202
+ maskStyle: {
5203
+ border: "2px solid var(--bulma-link, #3b82f6)",
5204
+ background: "rgba(59, 130, 246, 0.12)",
5205
+ },
5169
5206
  shape: (id, element_type, element) => {
5170
5207
  if(element_type !== "node") {
5171
5208
  return element; /* edges clone themselves fine */
package/src/lib_graph.css CHANGED
@@ -95,6 +95,17 @@
95
95
  min-width: 20px;
96
96
  }
97
97
  .g6-toolbar.g6-toolbar-large .g6-toolbar-text {
98
+ /*
99
+ * The click has to land on the ITEM, not on this.
100
+ *
101
+ * G6's toolbar fires `onClick` only when the pressed element's own
102
+ * class list contains `g6-toolbar-item` -- which is why its CSS
103
+ * gives the icon `<svg>` `pointer-events: none`. A text glyph
104
+ * needs the same rule for the same reason: without it the press
105
+ * lands on this span, the class does not match, and the button is
106
+ * simply dead. `1:1` shipped that way.
107
+ */
108
+ pointer-events: none;
98
109
  display: block;
99
110
  line-height: 20px;
100
111
  font-size: 0.8125rem;
@@ -0,0 +1,146 @@
1
+ /***********************************************************************
2
+ * yui_tab_routes.js
3
+ *
4
+ * A workspace whose tabs are opened at RUNTIME, and the two
5
+ * decisions its url costs.
6
+ *
7
+ * The shape is always the same: a workspace home `/<ws>/<home>`
8
+ * with one tab per thing the operator has opened, at
9
+ * `/<ws>/<home>/<id>`, and whatever the tab is showing below
10
+ * that. The agent console does it per node, the treedb browser
11
+ * per (connection, treedb) — and both learned the same two
12
+ * lessons, one of them the hard way.
13
+ *
14
+ * 1. ON A COLD LOAD THE TAB'S ROUTE DOES NOT EXIST YET.
15
+ *
16
+ * It is registered when the tab is opened, so a reload on
17
+ * `/<ws>/<home>/<id>/<what the tab was showing>` resolves only as
18
+ * far as the workspace home, and the shell hands the WHOLE rest
19
+ * over as the subpath: `<id>/<tail>`, not `<id>`. Reading all of
20
+ * it as the id matches nothing, and an app that then falls back
21
+ * to its first tab answers a reload with somebody else's default
22
+ * — which is the one thing a reload must not do. It also hides
23
+ * itself well: a BARE tab route survives, because there the
24
+ * subpath IS the id.
25
+ *
26
+ * `yui_tab_split_subpath()` is that split. Only the id segment is
27
+ * decoded, and that is not a detail: these ids are composite
28
+ * (`<node>` + 0x1F + `<yuno>`, `<conn>` + 0x1F + `<treedb>`) and
29
+ * reach the url percent-encoded, so decoding the whole tail first
30
+ * would turn an encoded slash inside an id into a separator and
31
+ * cut it in two.
32
+ *
33
+ * 2. A TAB'S NAV ITEM IS A FIXED ROUTE.
34
+ *
35
+ * `yui_shell_set_submenu()` registers it in the shell's item
36
+ * index, and that route is where the tab's view is MOUNTED and
37
+ * what a deep link resolves to. So the position inside a tab
38
+ * cannot travel in the item — moving it would move the mount —
39
+ * and has to be replayed when the tab is entered again.
40
+ *
41
+ * "Entered again" is the whole subtlety, and it is why
42
+ * `yui_tab_position_plan()` is a function with tests rather than
43
+ * three lines inside an action: arriving at the root of the tab
44
+ * you were ALREADY in is the way OUT of whatever was open (the
45
+ * view's own back button), and replaying the position there would
46
+ * make that button do nothing.
47
+ *
48
+ * WHAT IS NOT HERE: the wiring. One host restores on its
49
+ * transport's `EV_ON_OPEN`, another normalizes the route as it
50
+ * arrives, and both are right for what they know about when their
51
+ * tabs become real. These are the decisions, not the plumbing.
52
+ *
53
+ * Copyright (c) 2026, ArtGins.
54
+ * All Rights Reserved.
55
+ ***********************************************************************/
56
+
57
+ /***************************************************************
58
+ * Decode one url segment, or hand it back as it came.
59
+ *
60
+ * A malformed percent escape throws, and an id that cannot be
61
+ * decoded is still better read raw than not read at all: it
62
+ * simply will not match an open tab, which is the honest answer.
63
+ ***************************************************************/
64
+ function yui_tab_decode_id(segment)
65
+ {
66
+ try {
67
+ return decodeURIComponent(String(segment || ""));
68
+ } catch(e) {
69
+ return String(segment || "");
70
+ }
71
+ }
72
+
73
+ /***************************************************************
74
+ * yui_tab_split_subpath(subpath) -> {id, tail}
75
+ *
76
+ * subpath what the shell left below the workspace home
77
+ *
78
+ * `id` the tab's id, decoded
79
+ * `tail` what the tab was showing, untouched — it is the
80
+ * tab's own business and travels with it
81
+ ***************************************************************/
82
+ function yui_tab_split_subpath(subpath)
83
+ {
84
+ let sub = String(subpath || "");
85
+
86
+ if(!sub) {
87
+ return {id: "", tail: ""};
88
+ }
89
+
90
+ let slash = sub.indexOf("/");
91
+ let raw_id = (slash < 0) ? sub : sub.slice(0, slash);
92
+ let tail = (slash < 0) ? "" : sub.slice(slash + 1);
93
+
94
+ return {id: yui_tab_decode_id(raw_id), tail: tail};
95
+ }
96
+
97
+ /***************************************************************
98
+ * yui_tab_position_plan(prev_base, base, subpath, remembered)
99
+ *
100
+ * prev_base the tab base the previous route resolved to
101
+ * base the tab base this route resolves to
102
+ * subpath what is left of the url below `base`
103
+ * remembered the position last recorded for THIS tab
104
+ *
105
+ * -> {record, replay}
106
+ *
107
+ * `record` is the position to remember (null = leave it), and
108
+ * `replay` the route to normalize to (null = stay). Never both:
109
+ * a position is either being made or being restored.
110
+ ***************************************************************/
111
+ function yui_tab_position_plan(prev_base, base, subpath, remembered)
112
+ {
113
+ let inner = String(subpath || "");
114
+ let here = String(base || "");
115
+
116
+ if(!here) {
117
+ return {record: null, replay: null};
118
+ }
119
+
120
+ /* Somewhere inside the tab: that IS the position. */
121
+ if(inner) {
122
+ return {record: `${here}/${inner}`, replay: null};
123
+ }
124
+
125
+ /* At the root of the tab we were already in: the operator walked
126
+ * UP, on purpose, and that is a position too. */
127
+ if(prev_base === here) {
128
+ return {record: here, replay: null};
129
+ }
130
+
131
+ /* Entering the tab again: go back to where it was left, if that is
132
+ * anywhere other than the root it would land on anyway. */
133
+ let back = String(remembered || "");
134
+ if(back && back !== here) {
135
+ return {record: null, replay: back};
136
+ }
137
+
138
+ return {record: null, replay: null};
139
+ }
140
+
141
+
142
+ export {
143
+ yui_tab_decode_id,
144
+ yui_tab_split_subpath,
145
+ yui_tab_position_plan,
146
+ };
@@ -0,0 +1,138 @@
1
+ /***********************************************************************
2
+ * yui_tab_routes.test.js
3
+ *
4
+ * The two decisions a runtime-opened tab costs its url.
5
+ *
6
+ * The split is here because of what it cost to find: a reload on
7
+ * a deep tab route answered with another tab's default for as
8
+ * long as nobody reloaded on one.
9
+ *
10
+ * The position plan is here because the case that makes it
11
+ * non-trivial reads like a duplicate of the one above it: the
12
+ * root of the tab you are already in is not the same event as
13
+ * the root of a tab you are coming back to, and reading them as
14
+ * one breaks the view's own way out of a topic.
15
+ ***********************************************************************/
16
+ import { describe, test, expect } from "vitest";
17
+ import {
18
+ yui_tab_decode_id,
19
+ yui_tab_split_subpath,
20
+ yui_tab_position_plan,
21
+ } from "./yui_tab_routes.js";
22
+
23
+ const US = String.fromCharCode(31); /* the composite-id separator */
24
+
25
+
26
+ describe("yui_tab_split_subpath", () => {
27
+ test("a bare tab tail is all id", () => {
28
+ expect(yui_tab_split_subpath("wattyzer")).toEqual({id: "wattyzer", tail: ""});
29
+ });
30
+
31
+ test("a deep tail keeps the id and hands the rest back", () => {
32
+ expect(yui_tab_split_subpath("wattyzer/treedb_authzs/__graphs__"))
33
+ .toEqual({id: "wattyzer", tail: "treedb_authzs/__graphs__"});
34
+ });
35
+
36
+ test("a composite id survives the url it travelled in", () => {
37
+ /* 0x1F joins the two halves of these ids and reaches the url
38
+ * percent-encoded. */
39
+ expect(yui_tab_split_subpath("yunovatios-controlador%1F1630/treedb_authzs/__graphs__"))
40
+ .toEqual({id: "yunovatios-controlador" + US + "1630",
41
+ tail: "treedb_authzs/__graphs__"});
42
+ });
43
+
44
+ test("an encoded slash inside the id stays inside the id", () => {
45
+ /* Which is why the WHOLE tail must not be decoded first: that
46
+ * would turn this %2F into a separator and cut the id in two. */
47
+ expect(yui_tab_split_subpath("a%2Fb/treedb_x"))
48
+ .toEqual({id: "a/b", tail: "treedb_x"});
49
+ });
50
+
51
+ test("a malformed escape is taken as it came, not thrown", () => {
52
+ expect(yui_tab_split_subpath("%E0%A4%A/treedb_x"))
53
+ .toEqual({id: "%E0%A4%A", tail: "treedb_x"});
54
+ });
55
+
56
+ test("nothing in, nothing out", () => {
57
+ expect(yui_tab_split_subpath("")).toEqual({id: "", tail: ""});
58
+ expect(yui_tab_split_subpath(null)).toEqual({id: "", tail: ""});
59
+ expect(yui_tab_split_subpath(undefined)).toEqual({id: "", tail: ""});
60
+ });
61
+ });
62
+
63
+ describe("yui_tab_decode_id", () => {
64
+ test("decodes, and survives what cannot be decoded", () => {
65
+ expect(yui_tab_decode_id("a%2Fb")).toBe("a/b");
66
+ expect(yui_tab_decode_id("%E0%A4%A")).toBe("%E0%A4%A");
67
+ expect(yui_tab_decode_id("")).toBe("");
68
+ expect(yui_tab_decode_id(null)).toBe("");
69
+ });
70
+ });
71
+
72
+ const A = "/topics/db/conn1%1Fdb1";
73
+ const B = "/topics/db/conn1%1Fdb2";
74
+
75
+
76
+ describe("inside a tab", () => {
77
+ test("a position is recorded, and nothing is replayed", () => {
78
+ expect(yui_tab_position_plan(A, A, "users", "")).toEqual(
79
+ {record: `${A}/users`, replay: null});
80
+ });
81
+
82
+ test("a deeper position is recorded whole", () => {
83
+ expect(yui_tab_position_plan(A, A, "users/info", `${A}/users`)).toEqual(
84
+ {record: `${A}/users/info`, replay: null});
85
+ });
86
+
87
+ test("arriving deep from ANOTHER tab records too — the url won", () => {
88
+ expect(yui_tab_position_plan(B, A, "users", `${A}/roles`)).toEqual(
89
+ {record: `${A}/users`, replay: null});
90
+ });
91
+ });
92
+
93
+ describe("the root of the tab you were already in", () => {
94
+ test("is the way OUT of a topic, so it records and never replays", () => {
95
+ /* The view's own "back to Topics" button lands here. Replaying
96
+ the position would make that button do nothing at all. */
97
+ expect(yui_tab_position_plan(A, A, "", `${A}/users`)).toEqual(
98
+ {record: A, replay: null});
99
+ });
100
+ });
101
+
102
+ describe("the root of a tab you are coming back to", () => {
103
+ test("replays what was left open", () => {
104
+ expect(yui_tab_position_plan(B, A, "", `${A}/users`)).toEqual(
105
+ {record: null, replay: `${A}/users`});
106
+ });
107
+
108
+ test("...including from OUTSIDE the tabs — the picker, the settings", () => {
109
+ /* The caller says "no tab" with an empty prev_base. It must, or a
110
+ * return from Select reads as the walk UP that records the root
111
+ * (and the tab comes back on its cards, its position gone). */
112
+ expect(yui_tab_position_plan("", A, "", `${A}/users`)).toEqual(
113
+ {record: null, replay: `${A}/users`});
114
+ });
115
+
116
+ test("...and does nothing when the tab was left at its own root", () => {
117
+ expect(yui_tab_position_plan(B, A, "", A)).toEqual({record: null, replay: null});
118
+ });
119
+
120
+ test("...or when it has never been visited", () => {
121
+ expect(yui_tab_position_plan(B, A, "", "")).toEqual({record: null, replay: null});
122
+ expect(yui_tab_position_plan("", A, "", undefined)).toEqual({record: null, replay: null});
123
+ });
124
+ });
125
+
126
+ describe("edges", () => {
127
+ test("no base is no decision", () => {
128
+ expect(yui_tab_position_plan(A, "", "users", A)).toEqual({record: null, replay: null});
129
+ });
130
+
131
+ test("record and replay are never both set", () => {
132
+ for(const args of [[A, A, "users", A], [A, A, "", A], [B, A, "", `${A}/x`],
133
+ [B, A, "", ""], ["", A, "", ""]]) {
134
+ const plan = yui_tab_position_plan(...args);
135
+ expect(!!(plan.record && plan.replay)).toBe(false);
136
+ }
137
+ });
138
+ });