@yuneta/gobj-ui 7.23.44 → 7.23.46

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
@@ -1364,6 +1364,59 @@ only while there is somewhere to go back to, spent when used, and dropped by a
1364
1364
  refresh. Undoing is another write, like the drag. The host must define the key
1365
1365
  `"undo the order"`.
1366
1366
 
1367
+ ### The bytes a node owns but cannot hold — `yui_asset_*`
1368
+
1369
+ A treedb node often owns something that is not JSON: a photo, a plan, a
1370
+ clip. Those bytes cannot live in the treedb — it is held in memory and
1371
+ timeranger2 rewrites the whole record on every update — so the SDK's
1372
+ `C_ASSETS` keeps them in a directory it owns and the node names one with an
1373
+ **fkey**.
1374
+
1375
+ `get-asset` answers in one of two shapes, and the **backend** decides which:
1376
+
1377
+ ```json
1378
+ {"mode": "url", "url": "/assets/ab/cd/<id>.jpg?e=<expires>&s=<token>"}
1379
+ {"mode": "inline", "content_type": "image/jpeg", "content64": "..."}
1380
+ ```
1381
+
1382
+ It signs a URL when a web server sits in front of the store and hands over
1383
+ the bytes when there is none, so a consumer has **one** code path and a node
1384
+ with no web server still shows its images instead of showing nothing.
1385
+
1386
+ This module is the two ends of that, and **it does not talk to the
1387
+ backend** — asking is an action and belongs in the view's own FSM:
1388
+
1389
+ | | |
1390
+ |---|---|
1391
+ | `yui_asset_id(ref)` / `yui_asset_ids(ref)` | the id(s) a column names. Reads a single-valued fkey (`"assets^<id>^as_foto"`), an array fkey, and an expanded ref (`{id}`). An empty column answers nothing rather than throwing |
1392
+ | `yui_asset_src(answer)` | the two shapes into one `src`. `null` when the answer carries neither — never an empty string, because `<img src="">` reloads the page in some browsers |
1393
+ | `yui_asset_element(answer, opts)` | the element, picked from the **content type the backend stored**: `<img>`, `<video>` or `<audio>`. Video and audio are assets too, and an `<img>` whose src is a film shows the broken box this exists to remove |
1394
+ | `yui_asset_missing(detail, opts)` | the marker, for when there is nothing to show |
1395
+
1396
+ **A missing asset is now said out loud.** It used to leave a broken box and
1397
+ no word about it, which is indistinguishable from a slow one and from a bug
1398
+ — 47 such holes in one day on `artgins.ytreedb.com` before anybody noticed.
1399
+ `yui_asset_element()` wires `onerror` so the dead element is **replaced** by
1400
+ the marker, whatever the reason: an expired signature, a blob gone from the
1401
+ store, an unsupported codec.
1402
+
1403
+ The marker's label carries its i18n key (`asset not available`, overridable
1404
+ with `opts.key`), so it follows a language change; `opts.detail` is DATA — a
1405
+ name or a path, the thing a person can act on — and is never translated.
1406
+
1407
+ ```js
1408
+ import {yui_asset_id, yui_asset_element} from "@yuneta/gobj-ui/src/yui_asset.js";
1409
+ import "@yuneta/gobj-ui/src/yui_asset.css";
1410
+
1411
+ // in the view's FSM, not in a DOM callback:
1412
+ const id = yui_asset_id(device.foto);
1413
+ if(id) {
1414
+ gobj_send_event(gobj, "EV_ASK_ASSET", {asset_id: id, slot: "foto"}, gobj);
1415
+ }
1416
+ // ...and when the answer arrives, in the action:
1417
+ $box.appendChild(yui_asset_element(answer, {detail: device.foto_name}));
1418
+ ```
1419
+
1367
1420
  ### Frontend view — `setup_frontend_view`
1368
1421
 
1369
1422
  `setup_frontend_view(self)` opens the **gobj tree of the app's own yuno** in a
@@ -54352,6 +54352,7 @@ function create_popover_base(left, top, className, borderColor, minWidth) {
54352
54352
  popover.style.cssText = "position:absolute;left:" + left + "px;top:" + top + "px;background:var(--bulma-scheme-main, #fff);color:var(--bulma-text-strong, #1A1A1A);border:1px solid " + borderColor + ";border-radius:6px;padding:12px;z-index:100;pointer-events:all;box-shadow:0 4px 12px rgba(0,0,0,0.15);min-width:" + minWidth + "px;font-size:13px;";
54353
54353
  popover.addEventListener("click", (e) => e.stopPropagation());
54354
54354
  popover.addEventListener("pointerdown", (e) => e.stopPropagation());
54355
+ popover.addEventListener("wheel", (e) => e.stopPropagation());
54355
54356
  return popover;
54356
54357
  }
54357
54358
  /************************************************************
@@ -55801,6 +55802,7 @@ function show_node_detail_popover(gobj, node_id) {
55801
55802
  popover.style.maxWidth = "320px";
55802
55803
  popover.style.maxHeight = "60%";
55803
55804
  popover.style.overflow = "auto";
55805
+ popover.style.padding = "0 12px 12px";
55804
55806
  popover.style.fontFamily = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
55805
55807
  let rows = "";
55806
55808
  for (let key of Object.keys(record)) {
@@ -55811,9 +55813,9 @@ function show_node_detail_popover(gobj, node_id) {
55811
55813
  else if (typeof v === "object") vs = JSON.stringify(v);
55812
55814
  else vs = String(v);
55813
55815
  if (vs.length > 240) vs = vs.slice(0, 240) + "…";
55814
- rows += `<div style="display:flex;gap:10px;padding:5px 0;border-top:1px solid ${rowbd};"><div style="flex:0 0 38%;color:${sub};font-size:12px;word-break:break-word;">${(0, _yuneta_gobj_js.escapeHtml)(key)}</div><div style="flex:1 1 auto;font-size:12px;word-break:break-word;">${(0, _yuneta_gobj_js.escapeHtml)(vs)}</div></div>`;
55816
+ rows += `<div class="g6-detail-row" style="border-top:1px solid ${rowbd};"><div class="g6-detail-key" style="color:${sub};">${(0, _yuneta_gobj_js.escapeHtml)(key)}</div><div class="g6-detail-val">${(0, _yuneta_gobj_js.escapeHtml)(vs)}</div></div>`;
55815
55817
  }
55816
- popover.innerHTML = `<div style="display:flex;align-items:flex-start;gap:8px;margin-bottom:6px;"><div style="flex:1 1 auto;min-width:0;"><div style="font-size:14px;font-weight:600;word-break:break-word;">${(0, _yuneta_gobj_js.escapeHtml)(id)}</div><div style="font-size:11px;color:${sub};margin-top:2px;">${(0, _yuneta_gobj_js.escapeHtml)(topic)}</div></div><div class="g6-detail-close" style="flex:0 0 auto;cursor:pointer;font-size:16px;line-height:1;color:${sub};padding:0 2px;">×</div></div>` + (rows || `<div style="font-size:12px;color:${sub};">${(0, _yuneta_gobj_js.escapeHtml)((0, i18next.t)("no fields"))}</div>`);
55818
+ popover.innerHTML = `<div class="g6-detail-head" style="background:${bg};"><div style="flex:1 1 auto;min-width:0;"><div class="g6-detail-id">${(0, _yuneta_gobj_js.escapeHtml)(id)}</div><div class="g6-detail-topic" style="color:${sub};">${(0, _yuneta_gobj_js.escapeHtml)(topic)}</div></div><div class="g6-detail-close" role="button" tabindex="0" aria-label="${(0, _yuneta_gobj_js.escapeHtml)((0, i18next.t)("close"))}" title="${(0, _yuneta_gobj_js.escapeHtml)((0, i18next.t)("close"))}" style="color:${sub};">×</div></div>` + (rows || `<div class="g6-detail-empty" style="color:${sub};">${(0, _yuneta_gobj_js.escapeHtml)((0, i18next.t)("no fields"))}</div>`);
55817
55819
  let close_el = popover.querySelector(".g6-detail-close");
55818
55820
  if (close_el) close_el.addEventListener("click", (e) => {
55819
55821
  e.stopPropagation();
@@ -67041,6 +67043,118 @@ function register_c_yui_period() {
67041
67043
  return create_gclass(GCLASS_NAME);
67042
67044
  }
67043
67045
  //#endregion
67046
+ //#region src/yui_asset.js
67047
+ function ref_to_id(ref) {
67048
+ if (!ref) return null;
67049
+ if (typeof ref === "string") {
67050
+ const parts = ref.split("^");
67051
+ return parts.length >= 2 ? parts[1] : null;
67052
+ }
67053
+ if (typeof ref === "object" && typeof ref.id === "string") return ref.id;
67054
+ return null;
67055
+ }
67056
+ /***************************************************************************
67057
+ * Every asset id a column names, in order. Always an array, possibly empty.
67058
+ ***************************************************************************/
67059
+ function yui_asset_ids(ref) {
67060
+ if (Array.isArray(ref)) return ref.map(ref_to_id).filter((id) => !!id);
67061
+ const id = ref_to_id(ref);
67062
+ return id ? [id] : [];
67063
+ }
67064
+ /***************************************************************************
67065
+ * The first asset id a column names, or null.
67066
+ ***************************************************************************/
67067
+ function yui_asset_id(ref) {
67068
+ const ids = yui_asset_ids(ref);
67069
+ return ids.length ? ids[0] : null;
67070
+ }
67071
+ /***************************************************************************
67072
+ * Turn a `get-asset` answer into something an element can load.
67073
+ *
67074
+ * Returns null when the answer carries neither -- which the caller must
67075
+ * treat as "missing", never as an empty src: an <img src=""> reloads the
67076
+ * PAGE in some browsers.
67077
+ ***************************************************************************/
67078
+ function yui_asset_src(answer) {
67079
+ if (!answer) return null;
67080
+ if (answer.mode === "url" && answer.url) return answer.url;
67081
+ if (answer.mode === "inline" && answer.content64) return `data:${answer.content_type || "application/octet-stream"};base64,${answer.content64}`;
67082
+ return null;
67083
+ }
67084
+ /***************************************************************************
67085
+ * The marker for an asset that is not there.
67086
+ *
67087
+ * A missing image used to leave a broken box and no word about it, which
67088
+ * is indistinguishable from a slow one and from a bug. This says so, and
67089
+ * it says WHICH: `detail` is data (a name, a path) and is never
67090
+ * translated, while the label carries its i18n key so a language change
67091
+ * re-translates it.
67092
+ ***************************************************************************/
67093
+ function yui_asset_missing(detail, opts = {}) {
67094
+ const key = opts.key || "asset not available";
67095
+ const children = [["span", { class: "ASSET_MISSING_ICON yi-triangle-exclamation" }], [
67096
+ "span",
67097
+ {
67098
+ class: "ASSET_MISSING_LABEL",
67099
+ i18n: key
67100
+ },
67101
+ key
67102
+ ]];
67103
+ if (detail) children.push([
67104
+ "span",
67105
+ { class: "ASSET_MISSING_DETAIL" },
67106
+ String(detail)
67107
+ ]);
67108
+ const attrs = { class: `ASSET_MISSING ${opts.class || ""}`.trim() };
67109
+ if (detail) attrs.title = String(detail);
67110
+ else attrs["data-i18n-title"] = key;
67111
+ return (0, _yuneta_gobj_js.createElement2)([
67112
+ "div",
67113
+ attrs,
67114
+ children
67115
+ ]);
67116
+ }
67117
+ /***************************************************************************
67118
+ * The element for one asset.
67119
+ *
67120
+ * The KIND comes from the content type the backend stored, not from the
67121
+ * name a person typed: video and audio are assets too, and an <img> whose
67122
+ * src is a film shows the broken box this module exists to remove.
67123
+ *
67124
+ * `detail` is what the missing marker shows -- pass the original name or
67125
+ * the source path, the thing a person can act on.
67126
+ ***************************************************************************/
67127
+ function yui_asset_element(answer, opts = {}) {
67128
+ const src = yui_asset_src(answer);
67129
+ if (!src) return yui_asset_missing(opts.detail, opts);
67130
+ const ct = (answer.content_type || "").toLowerCase();
67131
+ const cls = `ASSET_MEDIA ${opts.class || ""}`.trim();
67132
+ let el;
67133
+ if (ct.startsWith("video/")) el = (0, _yuneta_gobj_js.createElement2)(["video", {
67134
+ class: cls,
67135
+ controls: "",
67136
+ preload: "metadata"
67137
+ }]);
67138
+ else if (ct.startsWith("audio/")) el = (0, _yuneta_gobj_js.createElement2)(["audio", {
67139
+ class: cls,
67140
+ controls: "",
67141
+ preload: "metadata"
67142
+ }]);
67143
+ else {
67144
+ el = (0, _yuneta_gobj_js.createElement2)(["img", {
67145
+ class: cls,
67146
+ loading: "lazy"
67147
+ }]);
67148
+ el.alt = opts.alt || opts.detail || "";
67149
+ }
67150
+ el.src = src;
67151
+ el.onerror = () => {
67152
+ const $marker = yui_asset_missing(opts.detail, opts);
67153
+ if (el.parentNode) el.parentNode.replaceChild($marker, el);
67154
+ };
67155
+ return el;
67156
+ }
67157
+ //#endregion
67044
67158
  //#region src/dev_machine_trace.js
67045
67159
  /***********************************************************************
67046
67160
  * dev_machine_trace.js
@@ -68789,6 +68903,11 @@ exports.set_submit_state = set_submit_state;
68789
68903
  exports.setup_dev = setup_dev;
68790
68904
  exports.setup_frontend_view = setup_frontend_view;
68791
68905
  exports.toggleClasses = toggleClasses;
68906
+ exports.yui_asset_element = yui_asset_element;
68907
+ exports.yui_asset_id = yui_asset_id;
68908
+ exports.yui_asset_ids = yui_asset_ids;
68909
+ exports.yui_asset_missing = yui_asset_missing;
68910
+ exports.yui_asset_src = yui_asset_src;
68792
68911
  exports.yui_is_dark = yui_is_dark;
68793
68912
  exports.yui_mount_service_view = yui_mount_service_view;
68794
68913
  exports.yui_shell_close_drawer = yui_shell_close_drawer;
@@ -54347,6 +54347,7 @@ function create_popover_base(left, top, className, borderColor, minWidth) {
54347
54347
  popover.style.cssText = "position:absolute;left:" + left + "px;top:" + top + "px;background:var(--bulma-scheme-main, #fff);color:var(--bulma-text-strong, #1A1A1A);border:1px solid " + borderColor + ";border-radius:6px;padding:12px;z-index:100;pointer-events:all;box-shadow:0 4px 12px rgba(0,0,0,0.15);min-width:" + minWidth + "px;font-size:13px;";
54348
54348
  popover.addEventListener("click", (e) => e.stopPropagation());
54349
54349
  popover.addEventListener("pointerdown", (e) => e.stopPropagation());
54350
+ popover.addEventListener("wheel", (e) => e.stopPropagation());
54350
54351
  return popover;
54351
54352
  }
54352
54353
  /************************************************************
@@ -55796,6 +55797,7 @@ function show_node_detail_popover(gobj, node_id) {
55796
55797
  popover.style.maxWidth = "320px";
55797
55798
  popover.style.maxHeight = "60%";
55798
55799
  popover.style.overflow = "auto";
55800
+ popover.style.padding = "0 12px 12px";
55799
55801
  popover.style.fontFamily = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
55800
55802
  let rows = "";
55801
55803
  for (let key of Object.keys(record)) {
@@ -55806,9 +55808,9 @@ function show_node_detail_popover(gobj, node_id) {
55806
55808
  else if (typeof v === "object") vs = JSON.stringify(v);
55807
55809
  else vs = String(v);
55808
55810
  if (vs.length > 240) vs = vs.slice(0, 240) + "…";
55809
- rows += `<div style="display:flex;gap:10px;padding:5px 0;border-top:1px solid ${rowbd};"><div style="flex:0 0 38%;color:${sub};font-size:12px;word-break:break-word;">${escapeHtml(key)}</div><div style="flex:1 1 auto;font-size:12px;word-break:break-word;">${escapeHtml(vs)}</div></div>`;
55811
+ rows += `<div class="g6-detail-row" style="border-top:1px solid ${rowbd};"><div class="g6-detail-key" style="color:${sub};">${escapeHtml(key)}</div><div class="g6-detail-val">${escapeHtml(vs)}</div></div>`;
55810
55812
  }
55811
- popover.innerHTML = `<div style="display:flex;align-items:flex-start;gap:8px;margin-bottom:6px;"><div style="flex:1 1 auto;min-width:0;"><div style="font-size:14px;font-weight:600;word-break:break-word;">${escapeHtml(id)}</div><div style="font-size:11px;color:${sub};margin-top:2px;">${escapeHtml(topic)}</div></div><div class="g6-detail-close" style="flex:0 0 auto;cursor:pointer;font-size:16px;line-height:1;color:${sub};padding:0 2px;">×</div></div>` + (rows || `<div style="font-size:12px;color:${sub};">${escapeHtml(t("no fields"))}</div>`);
55813
+ popover.innerHTML = `<div class="g6-detail-head" style="background:${bg};"><div style="flex:1 1 auto;min-width:0;"><div class="g6-detail-id">${escapeHtml(id)}</div><div class="g6-detail-topic" style="color:${sub};">${escapeHtml(topic)}</div></div><div class="g6-detail-close" role="button" tabindex="0" aria-label="${escapeHtml(t("close"))}" title="${escapeHtml(t("close"))}" style="color:${sub};">×</div></div>` + (rows || `<div class="g6-detail-empty" style="color:${sub};">${escapeHtml(t("no fields"))}</div>`);
55812
55814
  let close_el = popover.querySelector(".g6-detail-close");
55813
55815
  if (close_el) close_el.addEventListener("click", (e) => {
55814
55816
  e.stopPropagation();
@@ -67036,6 +67038,118 @@ function register_c_yui_period() {
67036
67038
  return create_gclass(GCLASS_NAME);
67037
67039
  }
67038
67040
  //#endregion
67041
+ //#region src/yui_asset.js
67042
+ function ref_to_id(ref) {
67043
+ if (!ref) return null;
67044
+ if (typeof ref === "string") {
67045
+ const parts = ref.split("^");
67046
+ return parts.length >= 2 ? parts[1] : null;
67047
+ }
67048
+ if (typeof ref === "object" && typeof ref.id === "string") return ref.id;
67049
+ return null;
67050
+ }
67051
+ /***************************************************************************
67052
+ * Every asset id a column names, in order. Always an array, possibly empty.
67053
+ ***************************************************************************/
67054
+ function yui_asset_ids(ref) {
67055
+ if (Array.isArray(ref)) return ref.map(ref_to_id).filter((id) => !!id);
67056
+ const id = ref_to_id(ref);
67057
+ return id ? [id] : [];
67058
+ }
67059
+ /***************************************************************************
67060
+ * The first asset id a column names, or null.
67061
+ ***************************************************************************/
67062
+ function yui_asset_id(ref) {
67063
+ const ids = yui_asset_ids(ref);
67064
+ return ids.length ? ids[0] : null;
67065
+ }
67066
+ /***************************************************************************
67067
+ * Turn a `get-asset` answer into something an element can load.
67068
+ *
67069
+ * Returns null when the answer carries neither -- which the caller must
67070
+ * treat as "missing", never as an empty src: an <img src=""> reloads the
67071
+ * PAGE in some browsers.
67072
+ ***************************************************************************/
67073
+ function yui_asset_src(answer) {
67074
+ if (!answer) return null;
67075
+ if (answer.mode === "url" && answer.url) return answer.url;
67076
+ if (answer.mode === "inline" && answer.content64) return `data:${answer.content_type || "application/octet-stream"};base64,${answer.content64}`;
67077
+ return null;
67078
+ }
67079
+ /***************************************************************************
67080
+ * The marker for an asset that is not there.
67081
+ *
67082
+ * A missing image used to leave a broken box and no word about it, which
67083
+ * is indistinguishable from a slow one and from a bug. This says so, and
67084
+ * it says WHICH: `detail` is data (a name, a path) and is never
67085
+ * translated, while the label carries its i18n key so a language change
67086
+ * re-translates it.
67087
+ ***************************************************************************/
67088
+ function yui_asset_missing(detail, opts = {}) {
67089
+ const key = opts.key || "asset not available";
67090
+ const children = [["span", { class: "ASSET_MISSING_ICON yi-triangle-exclamation" }], [
67091
+ "span",
67092
+ {
67093
+ class: "ASSET_MISSING_LABEL",
67094
+ i18n: key
67095
+ },
67096
+ key
67097
+ ]];
67098
+ if (detail) children.push([
67099
+ "span",
67100
+ { class: "ASSET_MISSING_DETAIL" },
67101
+ String(detail)
67102
+ ]);
67103
+ const attrs = { class: `ASSET_MISSING ${opts.class || ""}`.trim() };
67104
+ if (detail) attrs.title = String(detail);
67105
+ else attrs["data-i18n-title"] = key;
67106
+ return createElement2([
67107
+ "div",
67108
+ attrs,
67109
+ children
67110
+ ]);
67111
+ }
67112
+ /***************************************************************************
67113
+ * The element for one asset.
67114
+ *
67115
+ * The KIND comes from the content type the backend stored, not from the
67116
+ * name a person typed: video and audio are assets too, and an <img> whose
67117
+ * src is a film shows the broken box this module exists to remove.
67118
+ *
67119
+ * `detail` is what the missing marker shows -- pass the original name or
67120
+ * the source path, the thing a person can act on.
67121
+ ***************************************************************************/
67122
+ function yui_asset_element(answer, opts = {}) {
67123
+ const src = yui_asset_src(answer);
67124
+ if (!src) return yui_asset_missing(opts.detail, opts);
67125
+ const ct = (answer.content_type || "").toLowerCase();
67126
+ const cls = `ASSET_MEDIA ${opts.class || ""}`.trim();
67127
+ let el;
67128
+ if (ct.startsWith("video/")) el = createElement2(["video", {
67129
+ class: cls,
67130
+ controls: "",
67131
+ preload: "metadata"
67132
+ }]);
67133
+ else if (ct.startsWith("audio/")) el = createElement2(["audio", {
67134
+ class: cls,
67135
+ controls: "",
67136
+ preload: "metadata"
67137
+ }]);
67138
+ else {
67139
+ el = createElement2(["img", {
67140
+ class: cls,
67141
+ loading: "lazy"
67142
+ }]);
67143
+ el.alt = opts.alt || opts.detail || "";
67144
+ }
67145
+ el.src = src;
67146
+ el.onerror = () => {
67147
+ const $marker = yui_asset_missing(opts.detail, opts);
67148
+ if (el.parentNode) el.parentNode.replaceChild($marker, el);
67149
+ };
67150
+ return el;
67151
+ }
67152
+ //#endregion
67039
67153
  //#region src/dev_machine_trace.js
67040
67154
  /***********************************************************************
67041
67155
  * dev_machine_trace.js
@@ -68719,6 +68833,6 @@ function setup_frontend_view(self) {
68719
68833
  return win;
68720
68834
  }
68721
68835
  //#endregion
68722
- export { EditControl, MarkerControl, YUI_PERIODS, YUI_PERIODS_DEFAULT, YUI_ROLLING, addClasses, apply_dev_traces, attach_clear, build_dev_panel, dev_window_was_open, disableElements, enableElements, epoch_to_local_input, epoch_to_ms, expose_view_container, fmt_epoch, getStrokeColor, infer_period, info_traffic, inject_svg_icons, is_current_period, iso_week, local_input_to_epoch, ms_to_epoch, period_bounds, period_bounds_epoch, period_label, period_name, period_shift, period_spec, period_start, refresh_clear, register_c_g6_nodes_tree, register_c_yui_form, register_c_yui_fsm_graph, register_c_yui_gclass, register_c_yui_gobj_tree_js, register_c_yui_json, register_c_yui_json_graph, register_c_yui_map, register_c_yui_nav, register_c_yui_pager, register_c_yui_period, register_c_yui_schema_editor, register_c_yui_service_view, register_c_yui_shell, register_c_yui_treedb_graph, register_c_yui_treedb_schema, register_c_yui_treedb_topic_with_form, register_c_yui_treedb_topics, register_c_yui_uplot, register_c_yui_window, register_c_yui_window_manager, register_c_yui_wizard, removeChildElements, removeClasses, rolling_bounds, safe_locale, set_active_state, set_cancel_state, set_pressed_state, set_submit_state, setup_dev, setup_frontend_view, toggleClasses, yui_is_dark, yui_mount_service_view, yui_shell_close_drawer, yui_shell_close_dropdown, yui_shell_confirm_danger, yui_shell_confirm_ok, yui_shell_confirm_yesno, yui_shell_confirm_yesnocancel, yui_shell_navigate, yui_shell_open_drawer, yui_shell_overlay_dismissed, yui_shell_pop_escape, yui_shell_push_escape, yui_shell_refresh_avatars, yui_shell_register_event_handler, yui_shell_register_overlay, yui_shell_set_avatar_provider, yui_shell_set_connection_state, yui_shell_set_sub_routes, yui_shell_set_toolbar_item_badge, yui_shell_set_toolbar_item_icon, yui_shell_set_translator, yui_shell_show_error, yui_shell_show_info, yui_shell_show_modal, yui_shell_show_route_map, yui_shell_show_warning, yui_shell_toggle_drawer, yui_shell_unpark_route, yui_theme_now, yui_toolbar, yui_watch_theme };
68836
+ export { EditControl, MarkerControl, YUI_PERIODS, YUI_PERIODS_DEFAULT, YUI_ROLLING, addClasses, apply_dev_traces, attach_clear, build_dev_panel, dev_window_was_open, disableElements, enableElements, epoch_to_local_input, epoch_to_ms, expose_view_container, fmt_epoch, getStrokeColor, infer_period, info_traffic, inject_svg_icons, is_current_period, iso_week, local_input_to_epoch, ms_to_epoch, period_bounds, period_bounds_epoch, period_label, period_name, period_shift, period_spec, period_start, refresh_clear, register_c_g6_nodes_tree, register_c_yui_form, register_c_yui_fsm_graph, register_c_yui_gclass, register_c_yui_gobj_tree_js, register_c_yui_json, register_c_yui_json_graph, register_c_yui_map, register_c_yui_nav, register_c_yui_pager, register_c_yui_period, register_c_yui_schema_editor, register_c_yui_service_view, register_c_yui_shell, register_c_yui_treedb_graph, register_c_yui_treedb_schema, register_c_yui_treedb_topic_with_form, register_c_yui_treedb_topics, register_c_yui_uplot, register_c_yui_window, register_c_yui_window_manager, register_c_yui_wizard, removeChildElements, removeClasses, rolling_bounds, safe_locale, set_active_state, set_cancel_state, set_pressed_state, set_submit_state, setup_dev, setup_frontend_view, toggleClasses, yui_asset_element, yui_asset_id, yui_asset_ids, yui_asset_missing, yui_asset_src, yui_is_dark, yui_mount_service_view, yui_shell_close_drawer, yui_shell_close_dropdown, yui_shell_confirm_danger, yui_shell_confirm_ok, yui_shell_confirm_yesno, yui_shell_confirm_yesnocancel, yui_shell_navigate, yui_shell_open_drawer, yui_shell_overlay_dismissed, yui_shell_pop_escape, yui_shell_push_escape, yui_shell_refresh_avatars, yui_shell_register_event_handler, yui_shell_register_overlay, yui_shell_set_avatar_provider, yui_shell_set_connection_state, yui_shell_set_sub_routes, yui_shell_set_toolbar_item_badge, yui_shell_set_toolbar_item_icon, yui_shell_set_translator, yui_shell_show_error, yui_shell_show_info, yui_shell_show_modal, yui_shell_show_route_map, yui_shell_show_warning, yui_shell_toggle_drawer, yui_shell_unpark_route, yui_theme_now, yui_toolbar, yui_watch_theme };
68723
68837
 
68724
68838
  //# sourceMappingURL=gobj-ui.es.js.map
package/index.js CHANGED
@@ -119,6 +119,7 @@ export {
119
119
  export { addClasses, removeClasses, toggleClasses, removeChildElements, disableElements, enableElements, set_submit_state, set_cancel_state, set_active_state, set_pressed_state, getStrokeColor } from "./src/lib_graph.js";
120
120
  export { inject_svg_icons } from "./src/lib_icons.js";
121
121
  export { EditControl, MarkerControl } from "./src/lib_maplibre.js";
122
+ export { yui_asset_id, yui_asset_ids, yui_asset_src, yui_asset_element, yui_asset_missing } from "./src/yui_asset.js";
122
123
  export { yui_toolbar } from "./src/yui_toolbar.js";
123
124
  export { yui_theme_now, yui_is_dark, yui_watch_theme } from "./src/yui_theme.js";
124
125
  export { attach_clear, refresh_clear } from "./src/yui_inputs.js";
@@ -131,6 +132,7 @@ export { setup_frontend_view } from "./src/yui_frontend_view.js";
131
132
  * import "gobj-ui/src/c_yui_shell.css"; // declarative shell
132
133
  * import "gobj-ui/src/c_yui_map.css";
133
134
  * import "gobj-ui/src/yui_toolbar.css";
135
+ * import "gobj-ui/src/yui_asset.css";
134
136
  * import "gobj-ui/src/lib_graph.css";
135
137
  * import "gobj-ui/src/yui_icons.css";
136
138
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.44",
3
+ "version": "7.23.46",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -20,7 +20,7 @@
20
20
  "src/",
21
21
  "vite-plugin-yuneta-html.js"
22
22
  ],
23
- "description": "Yuneta UI Library — the active line: declarative shell (C_YUI_SHELL+NAV+PAGER+WIZARD) + windows, TreeDB views, forms, charts and maps. Consumed by wattyzer and the yunetas JS yunos (gui_agent, gui_treedb). The frozen legacy GClass GUI stack (C_YUI_MAIN/TABS/ROUTING) lives on the v1 branch, npm dist-tag 'legacy' (consumed by estadodelaire and hidraulia).",
23
+ "description": "Yuneta UI Library \u2014 the active line: declarative shell (C_YUI_SHELL+NAV+PAGER+WIZARD) + windows, TreeDB views, forms, charts and maps. Consumed by wattyzer and the yunetas JS yunos (gui_agent, gui_treedb). The frozen legacy GClass GUI stack (C_YUI_MAIN/TABS/ROUTING) lives on the v1 branch, npm dist-tag 'legacy' (consumed by estadodelaire and hidraulia).",
24
24
  "scripts": {
25
25
  "build": "vite build",
26
26
  "test": "vitest run",
@@ -29,7 +29,7 @@
29
29
  "devDependencies": {
30
30
  "@rollup/plugin-terser": "^1.0.0",
31
31
  "@vitest/coverage-v8": "^4.1.10",
32
- "@yuneta/gobj-js": "^7.13.2",
32
+ "@yuneta/gobj-js": "^7.16.1",
33
33
  "vite": "^8.2.1",
34
34
  "vitest": "^4.1.10"
35
35
  },
@@ -3348,6 +3348,25 @@ function create_popover_base(left, top, className, borderColor, minWidth)
3348
3348
  'min-width:' + minWidth + 'px;font-size:13px;';
3349
3349
  popover.addEventListener('click', (e) => e.stopPropagation());
3350
3350
  popover.addEventListener('pointerdown', (e) => e.stopPropagation());
3351
+ /*
3352
+ * AND THE WHEEL, or a popover that overflows cannot be scrolled with
3353
+ * the wheel at all -- the scrollbar has to be dragged.
3354
+ *
3355
+ * G6's zoom behavior binds a wheel listener on the CONTAINER and
3356
+ * calls preventDefault() on every notch, whatever the target is;
3357
+ * then it declines to zoom because the target is not the canvas. So
3358
+ * the gesture is cancelled and nobody uses it: the graph does not
3359
+ * zoom AND the box does not scroll. Measured on a live node: with
3360
+ * the popover overflowing (scrollHeight 674 over 357 visible),
3361
+ * scrollTop stayed at 0 and the zoom readout stayed at 50%.
3362
+ *
3363
+ * Stopping it here -- in the bubble phase, where the click and the
3364
+ * pointerdown above are already stopped -- is enough: G6 sees
3365
+ * nothing and the browser scrolls the box normally. Not
3366
+ * preventDefault: cancelling it ourselves would be the same bug
3367
+ * from our side.
3368
+ */
3369
+ popover.addEventListener('wheel', (e) => e.stopPropagation());
3351
3370
  return popover;
3352
3371
  }
3353
3372
 
@@ -5492,6 +5511,9 @@ function show_node_detail_popover(gobj, node_id)
5492
5511
  popover.style.maxWidth = '320px';
5493
5512
  popover.style.maxHeight = '60%';
5494
5513
  popover.style.overflow = 'auto';
5514
+ /* The sticky head spans the full width, so the side padding moves
5515
+ * to the rows; `create_popover_base` put 12px on the box itself. */
5516
+ popover.style.padding = '0 12px 12px';
5495
5517
  popover.style.fontFamily =
5496
5518
  "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, " +
5497
5519
  "Helvetica, Arial, sans-serif";
@@ -5514,29 +5536,33 @@ function show_node_detail_popover(gobj, node_id)
5514
5536
  vs = vs.slice(0, 240) + '…';
5515
5537
  }
5516
5538
  rows +=
5517
- `<div style="display:flex;gap:10px;padding:5px 0;` +
5518
- `border-top:1px solid ${rowbd};">` +
5519
- `<div style="flex:0 0 38%;color:${sub};font-size:12px;` +
5520
- `word-break:break-word;">${escapeHtml(key)}</div>` +
5521
- `<div style="flex:1 1 auto;font-size:12px;` +
5522
- `word-break:break-word;">${escapeHtml(vs)}</div>` +
5539
+ `<div class="g6-detail-row" style="border-top:1px solid ${rowbd};">` +
5540
+ `<div class="g6-detail-key" style="color:${sub};">` +
5541
+ `${escapeHtml(key)}</div>` +
5542
+ `<div class="g6-detail-val">${escapeHtml(vs)}</div>` +
5523
5543
  `</div>`;
5524
5544
  }
5525
5545
 
5546
+ /*
5547
+ * The head is STICKY: the box scrolls, and with the head scrolling
5548
+ * away the ✕ left the screen -- on a phone, where the box is most
5549
+ * of the screen, that is a popover you cannot close without
5550
+ * scrolling back up for the button. Its background is the
5551
+ * popover's own, or the rows would show through it.
5552
+ */
5526
5553
  popover.innerHTML =
5527
- `<div style="display:flex;align-items:flex-start;gap:8px;` +
5528
- `margin-bottom:6px;">` +
5554
+ `<div class="g6-detail-head" style="background:${bg};">` +
5529
5555
  `<div style="flex:1 1 auto;min-width:0;">` +
5530
- `<div style="font-size:14px;font-weight:600;` +
5531
- `word-break:break-word;">${escapeHtml(id)}</div>` +
5532
- `<div style="font-size:11px;color:${sub};` +
5533
- `margin-top:2px;">${escapeHtml(topic)}</div>` +
5556
+ `<div class="g6-detail-id">${escapeHtml(id)}</div>` +
5557
+ `<div class="g6-detail-topic" style="color:${sub};">` +
5558
+ `${escapeHtml(topic)}</div>` +
5534
5559
  `</div>` +
5535
- `<div class="g6-detail-close" style="flex:0 0 auto;` +
5536
- `cursor:pointer;font-size:16px;line-height:1;` +
5537
- `color:${sub};padding:0 2px;">×</div>` +
5560
+ `<div class="g6-detail-close" role="button" tabindex="0"` +
5561
+ ` aria-label="${escapeHtml(t('close'))}"` +
5562
+ ` title="${escapeHtml(t('close'))}"` +
5563
+ ` style="color:${sub};">×</div>` +
5538
5564
  `</div>` +
5539
- (rows || `<div style="font-size:12px;color:${sub};">` +
5565
+ (rows || `<div class="g6-detail-empty" style="color:${sub};">` +
5540
5566
  `${escapeHtml(t('no fields'))}</div>`);
5541
5567
 
5542
5568
  let close_el = popover.querySelector('.g6-detail-close');
package/src/lib_graph.css CHANGED
@@ -111,6 +111,116 @@
111
111
  background-color: var(--bulma-background, #fff);
112
112
  }
113
113
 
114
+ /*
115
+ * THE NODE DETAIL POPOVER.
116
+ *
117
+ * Sizes live here and not in the inline styles that build it, for one
118
+ * reason: a media query cannot reach an inline style, and this box is
119
+ * read on a phone as much as on a desk.
120
+ *
121
+ * 12px was the size of the rows and 11px of the topic. That is caption
122
+ * size, and this popover exists ONLY to be read -- it is the record of
123
+ * a node, field by field. Same reasoning as the context menu below:
124
+ * 12px is a caption, not content.
125
+ */
126
+ .graph-container .g6-detail-head {
127
+ display: flex;
128
+ align-items: flex-start;
129
+ gap: 8px;
130
+ /* Sticky, so the ✕ never scrolls off a box that is most of the
131
+ * screen on a phone. The 12px of side padding the popover used to
132
+ * carry moved to the rows, so the head reaches its edges. */
133
+ position: sticky;
134
+ top: 0;
135
+ z-index: 1;
136
+ padding: 12px 0 6px;
137
+ }
138
+
139
+ .graph-container .g6-detail-id {
140
+ font-size: 15px;
141
+ font-weight: 600;
142
+ word-break: break-word;
143
+ }
144
+
145
+ .graph-container .g6-detail-topic {
146
+ font-size: 12px;
147
+ margin-top: 2px;
148
+ word-break: break-word;
149
+ }
150
+
151
+ .graph-container .g6-detail-row {
152
+ display: flex;
153
+ gap: 10px;
154
+ padding: 6px 0;
155
+ }
156
+
157
+ .graph-container .g6-detail-key,
158
+ .graph-container .g6-detail-val,
159
+ .graph-container .g6-detail-empty {
160
+ font-size: 13px;
161
+ word-break: break-word;
162
+ }
163
+
164
+ .graph-container .g6-detail-key {
165
+ flex: 0 0 38%;
166
+ }
167
+
168
+ .graph-container .g6-detail-val {
169
+ flex: 1 1 auto;
170
+ }
171
+
172
+ /*
173
+ * The close button was a 13x16 box: a glyph with 2px of side padding
174
+ * and nothing else. That is a mouse target on a good day and not a
175
+ * finger target on any. It is a real box now, and a square one, so the
176
+ * ✕ has somewhere to be.
177
+ */
178
+ .graph-container .g6-detail-close {
179
+ flex: 0 0 auto;
180
+ cursor: pointer;
181
+ font-size: 18px;
182
+ line-height: 1;
183
+ display: flex;
184
+ align-items: center;
185
+ justify-content: center;
186
+ width: 28px;
187
+ height: 28px;
188
+ margin: -4px -4px 0 0;
189
+ border-radius: 4px;
190
+ }
191
+
192
+ .graph-container .g6-detail-close:hover {
193
+ background: var(--bulma-background, rgba(128, 128, 128, 0.15));
194
+ }
195
+
196
+ /*
197
+ * A finger, and the rule the context menu below already follows: the
198
+ * target grows to something a thumb hits, and the text to something
199
+ * read at arm's length.
200
+ */
201
+ @media (pointer: coarse) {
202
+ .graph-container .g6-detail-close {
203
+ width: 44px;
204
+ height: 44px;
205
+ font-size: 22px;
206
+ margin: -8px -8px 0 0;
207
+ }
208
+ .graph-container .g6-detail-id {
209
+ font-size: 16px;
210
+ }
211
+ .graph-container .g6-detail-topic {
212
+ font-size: 13px;
213
+ }
214
+ .graph-container .g6-detail-key,
215
+ .graph-container .g6-detail-val,
216
+ .graph-container .g6-detail-empty {
217
+ font-size: 14px;
218
+ }
219
+ .graph-container .g6-detail-row {
220
+ padding: 8px 0;
221
+ }
222
+ }
223
+
114
224
  /*
115
225
  * G6 toolbar: larger icons (via className option)
116
226
  */
@@ -0,0 +1,43 @@
1
+ /*
2
+ * The marker of an asset that is not there.
3
+ *
4
+ * It takes the SPACE an image would have taken, so a card does not jump
5
+ * when one is missing, and it is quiet enough to sit in a gallery without
6
+ * shouting -- it reports a fact, it is not an error dialog.
7
+ */
8
+ .ASSET_MISSING {
9
+ display: flex;
10
+ flex-direction: column;
11
+ align-items: center;
12
+ justify-content: center;
13
+ gap: 0.25rem;
14
+ min-width: 5rem;
15
+ min-height: 5rem;
16
+ padding: 0.5rem;
17
+ border: 1px dashed var(--bulma-border, #dbdbdb);
18
+ border-radius: var(--bulma-radius, 4px);
19
+ color: var(--bulma-text-weak, #7a7a7a);
20
+ background: var(--bulma-scheme-main-bis, transparent);
21
+ text-align: center;
22
+ }
23
+
24
+ .ASSET_MISSING_ICON {
25
+ font-size: 1.25rem;
26
+ opacity: 0.7;
27
+ }
28
+
29
+ .ASSET_MISSING_LABEL {
30
+ font-size: 0.75rem;
31
+ }
32
+
33
+ /*
34
+ * The detail is a name or a path: it must not stretch the card, and it is
35
+ * the one thing a person can act on, so it stays readable rather than
36
+ * being cut to nothing.
37
+ */
38
+ .ASSET_MISSING_DETAIL {
39
+ font-size: 0.7rem;
40
+ opacity: 0.75;
41
+ max-width: 12rem;
42
+ overflow-wrap: anywhere;
43
+ }
@@ -0,0 +1,180 @@
1
+ /*
2
+ * The bytes a treedb node owns but cannot hold -- a photo, a plan, a clip.
3
+ *
4
+ * A node names its asset with an FKEY, and the BACKEND decides how the
5
+ * bytes travel: a signed url when a web server sits in front of the store,
6
+ * the bytes inline when there is none (the SDK's `C_ASSETS get-asset`).
7
+ * That is why a consumer has ONE code path, and why a node with no web
8
+ * server in front of it still shows its images instead of showing nothing.
9
+ *
10
+ * WHAT THIS MODULE DOES NOT DO IS TALK TO THE BACKEND. Asking is an
11
+ * ACTION and belongs in the view's own FSM; these are the two ends of it:
12
+ * read the id out of the fkey before asking, and turn the answer into an
13
+ * element afterwards.
14
+ *
15
+ * Using Bulma Framework (https://bulma.io)
16
+ */
17
+
18
+ import {
19
+ createElement2
20
+ } from "@yuneta/gobj-js";
21
+
22
+ import "./yui_asset.css";
23
+
24
+ /*
25
+ * A treedb fkey is `topic^id^hook`, and a column holds either one of them
26
+ * (a single-valued fkey, like a device's photo) or a list (an array fkey,
27
+ * like the plans of a bay). An expanded ref -- what `refs` options give
28
+ * back -- is an object carrying the id instead.
29
+ */
30
+ function ref_to_id(ref)
31
+ {
32
+ if(!ref) {
33
+ return null;
34
+ }
35
+ if(typeof ref === "string") {
36
+ const parts = ref.split("^");
37
+ return parts.length >= 2? parts[1]: null;
38
+ }
39
+ if(typeof ref === "object" && typeof ref.id === "string") {
40
+ return ref.id;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /***************************************************************************
46
+ * Every asset id a column names, in order. Always an array, possibly empty.
47
+ ***************************************************************************/
48
+ function yui_asset_ids(ref)
49
+ {
50
+ if(Array.isArray(ref)) {
51
+ return ref.map(ref_to_id).filter((id) => !!id);
52
+ }
53
+ const id = ref_to_id(ref);
54
+ return id? [id]: [];
55
+ }
56
+
57
+ /***************************************************************************
58
+ * The first asset id a column names, or null.
59
+ ***************************************************************************/
60
+ function yui_asset_id(ref)
61
+ {
62
+ const ids = yui_asset_ids(ref);
63
+ return ids.length? ids[0]: null;
64
+ }
65
+
66
+ /***************************************************************************
67
+ * Turn a `get-asset` answer into something an element can load.
68
+ *
69
+ * Returns null when the answer carries neither -- which the caller must
70
+ * treat as "missing", never as an empty src: an <img src=""> reloads the
71
+ * PAGE in some browsers.
72
+ ***************************************************************************/
73
+ function yui_asset_src(answer)
74
+ {
75
+ if(!answer) {
76
+ return null;
77
+ }
78
+ if(answer.mode === "url" && answer.url) {
79
+ return answer.url;
80
+ }
81
+ if(answer.mode === "inline" && answer.content64) {
82
+ const ct = answer.content_type || "application/octet-stream";
83
+ return `data:${ct};base64,${answer.content64}`;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /***************************************************************************
89
+ * The marker for an asset that is not there.
90
+ *
91
+ * A missing image used to leave a broken box and no word about it, which
92
+ * is indistinguishable from a slow one and from a bug. This says so, and
93
+ * it says WHICH: `detail` is data (a name, a path) and is never
94
+ * translated, while the label carries its i18n key so a language change
95
+ * re-translates it.
96
+ ***************************************************************************/
97
+ function yui_asset_missing(detail, opts = {})
98
+ {
99
+ const key = opts.key || "asset not available";
100
+ const children = [
101
+ ["span", {class: "ASSET_MISSING_ICON yi-triangle-exclamation"}],
102
+ /*
103
+ * createElement2() translates the content itself when it is given
104
+ * `i18n`, so the KEY is what goes in -- passing t(key) would
105
+ * translate it twice and, worse, freeze it: refresh_language()
106
+ * only re-translates a node that carries its key.
107
+ */
108
+ ["span", {class: "ASSET_MISSING_LABEL", i18n: key}, key]
109
+ ];
110
+ if(detail) {
111
+ children.push(["span", {class: "ASSET_MISSING_DETAIL"}, String(detail)]);
112
+ }
113
+ /*
114
+ * The tooltip is the DETAIL when there is one -- a name or a path,
115
+ * which is data and must never be translated -- and the label
116
+ * otherwise, through `data-i18n-title` so it follows the language.
117
+ */
118
+ const attrs = {class: `ASSET_MISSING ${opts.class || ""}`.trim()};
119
+ if(detail) {
120
+ attrs.title = String(detail);
121
+ } else {
122
+ attrs["data-i18n-title"] = key;
123
+ }
124
+ return createElement2(["div", attrs, children]);
125
+ }
126
+
127
+ /***************************************************************************
128
+ * The element for one asset.
129
+ *
130
+ * The KIND comes from the content type the backend stored, not from the
131
+ * name a person typed: video and audio are assets too, and an <img> whose
132
+ * src is a film shows the broken box this module exists to remove.
133
+ *
134
+ * `detail` is what the missing marker shows -- pass the original name or
135
+ * the source path, the thing a person can act on.
136
+ ***************************************************************************/
137
+ function yui_asset_element(answer, opts = {})
138
+ {
139
+ const src = yui_asset_src(answer);
140
+ if(!src) {
141
+ return yui_asset_missing(opts.detail, opts);
142
+ }
143
+
144
+ const ct = (answer.content_type || "").toLowerCase();
145
+ const cls = `ASSET_MEDIA ${opts.class || ""}`.trim();
146
+
147
+ let el;
148
+ if(ct.startsWith("video/")) {
149
+ el = createElement2(["video", {class: cls, controls: "", preload: "metadata"}]);
150
+ } else if(ct.startsWith("audio/")) {
151
+ el = createElement2(["audio", {class: cls, controls: "", preload: "metadata"}]);
152
+ } else {
153
+ el = createElement2(["img", {class: cls, loading: "lazy"}]);
154
+ el.alt = opts.alt || opts.detail || "";
155
+ }
156
+ el.src = src;
157
+
158
+ /*
159
+ * A url can 403 (expired signature), a blob can be gone from the
160
+ * store, a codec can be unsupported. Whatever the reason, the element
161
+ * is REPLACED by the marker: leaving the dead one in place is the
162
+ * silent gap this module was written to stop.
163
+ */
164
+ el.onerror = () => {
165
+ const $marker = yui_asset_missing(opts.detail, opts);
166
+ if(el.parentNode) {
167
+ el.parentNode.replaceChild($marker, el);
168
+ }
169
+ };
170
+
171
+ return el;
172
+ }
173
+
174
+ export {
175
+ yui_asset_id,
176
+ yui_asset_ids,
177
+ yui_asset_src,
178
+ yui_asset_element,
179
+ yui_asset_missing
180
+ };
@@ -0,0 +1,79 @@
1
+ /***********************************************************************
2
+ * yui_asset.test.js
3
+ *
4
+ * Unit tests for the pure logic of yui_asset.
5
+ * Run with: npm test
6
+ ***********************************************************************/
7
+ import { test, expect } from "vitest";
8
+ import {
9
+ yui_asset_id,
10
+ yui_asset_ids,
11
+ yui_asset_src,
12
+ } from "./yui_asset.js";
13
+
14
+ /*
15
+ * A treedb fkey is `topic^id^hook`.
16
+ */
17
+ test("reads the id out of a single-valued fkey", () => {
18
+ expect(yui_asset_id("assets^abc123^as_foto")).toBe("abc123");
19
+ expect(yui_asset_ids("assets^abc123^as_foto")).toEqual(["abc123"]);
20
+ });
21
+
22
+ test("reads every id out of an array fkey, in order", () => {
23
+ expect(yui_asset_ids([
24
+ "assets^p1^as_plano",
25
+ "assets^p2^as_plano",
26
+ ])).toEqual(["p1", "p2"]);
27
+ expect(yui_asset_id(["assets^p1^as_plano", "assets^p2^as_plano"])).toBe("p1");
28
+ });
29
+
30
+ test("reads an EXPANDED ref, which is what the refs options give back", () => {
31
+ expect(yui_asset_id({id: "abc123", content_type: "image/jpeg"})).toBe("abc123");
32
+ });
33
+
34
+ /*
35
+ * An empty column is the normal state of a device nobody photographed. It
36
+ * must answer "nothing", never throw and never a half-parsed id.
37
+ */
38
+ test("an empty or malformed column yields nothing, and does not throw", () => {
39
+ expect(yui_asset_ids(null)).toEqual([]);
40
+ expect(yui_asset_ids(undefined)).toEqual([]);
41
+ expect(yui_asset_ids("")).toEqual([]);
42
+ expect(yui_asset_ids([])).toEqual([]);
43
+ expect(yui_asset_id(null)).toBe(null);
44
+ expect(yui_asset_id("not-an-fkey")).toBe(null);
45
+ expect(yui_asset_id({})).toBe(null);
46
+ expect(yui_asset_ids(["assets^ok^as_foto", "rubbish", null])).toEqual(["ok"]);
47
+ });
48
+
49
+ /*
50
+ * The two shapes the backend answers with, and the ONE code path they
51
+ * exist to give the caller.
52
+ */
53
+ test("a signed url is used as it comes", () => {
54
+ expect(yui_asset_src({
55
+ mode: "url",
56
+ url: "/assets/ab/cd/abcd.jpg?e=1&s=tok",
57
+ })).toBe("/assets/ab/cd/abcd.jpg?e=1&s=tok");
58
+ });
59
+
60
+ test("inline bytes become a data url carrying their own type", () => {
61
+ expect(yui_asset_src({
62
+ mode: "inline",
63
+ content_type: "image/jpeg",
64
+ content64: "AAAA",
65
+ })).toBe("data:image/jpeg;base64,AAAA");
66
+ });
67
+
68
+ /*
69
+ * Anything else is MISSING, and must come back as null rather than as an
70
+ * empty string: <img src=""> reloads the page in some browsers, which is a
71
+ * worse failure than the one being reported.
72
+ */
73
+ test("an unusable answer is null, never an empty src", () => {
74
+ expect(yui_asset_src(null)).toBe(null);
75
+ expect(yui_asset_src({})).toBe(null);
76
+ expect(yui_asset_src({mode: "url"})).toBe(null);
77
+ expect(yui_asset_src({mode: "inline", content_type: "image/png"})).toBe(null);
78
+ expect(yui_asset_src({mode: "url", url: ""})).toBe(null);
79
+ });