@yuneta/gobj-ui 7.23.157 → 7.23.159

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.
@@ -6090,6 +6090,299 @@ function yui_tabulator_relocalize(table, t) {
6090
6090
  }
6091
6091
  }
6092
6092
  //#endregion
6093
+ //#region src/yui_asset.js
6094
+ var BLOB_ATTR = "data-yui-blob";
6095
+ function ref_to_id(ref) {
6096
+ if (!ref) return null;
6097
+ if (typeof ref === "string") {
6098
+ if (ref.indexOf("^") < 0) return ref;
6099
+ const parts = ref.split("^");
6100
+ return parts.length >= 2 && parts[1] ? parts[1] : null;
6101
+ }
6102
+ if (typeof ref === "object" && typeof ref.id === "string") return ref.id;
6103
+ return null;
6104
+ }
6105
+ /***************************************************************************
6106
+ * Every asset id a column names, in order. Always an array, possibly empty.
6107
+ ***************************************************************************/
6108
+ function yui_asset_ids(ref) {
6109
+ if (Array.isArray(ref)) return ref.map(ref_to_id).filter((id) => !!id);
6110
+ const id = ref_to_id(ref);
6111
+ return id ? [id] : [];
6112
+ }
6113
+ /***************************************************************************
6114
+ * The first asset id a column names, or null.
6115
+ ***************************************************************************/
6116
+ function yui_asset_id(ref) {
6117
+ const ids = yui_asset_ids(ref);
6118
+ return ids.length ? ids[0] : null;
6119
+ }
6120
+ /***************************************************************************
6121
+ * Turn a `get-asset` answer into something an element can load.
6122
+ *
6123
+ * Returns null when the answer carries neither -- which the caller must
6124
+ * treat as "missing", never as an empty src: an <img src=""> reloads the
6125
+ * PAGE in some browsers.
6126
+ ***************************************************************************/
6127
+ function yui_asset_src(answer) {
6128
+ if (!answer) return null;
6129
+ if (answer.mode === "url" && answer.url) return answer.url;
6130
+ if (answer.mode === "inline" && answer.content64) return `data:${answer.content_type || "application/octet-stream"};base64,${answer.content64}`;
6131
+ return null;
6132
+ }
6133
+ /***************************************************************************
6134
+ * The marker for an asset that is not there.
6135
+ *
6136
+ * A missing image used to leave a broken box and no word about it, which
6137
+ * is indistinguishable from a slow one and from a bug. This says so, and
6138
+ * it says WHICH: `detail` is data (a name, a path) and is never
6139
+ * translated, while the label carries its i18n key so a language change
6140
+ * re-translates it.
6141
+ ***************************************************************************/
6142
+ function yui_asset_missing(detail, opts = {}) {
6143
+ const key = opts.key || "asset not available";
6144
+ const children = [["span", { class: "ASSET_MISSING_ICON yi-triangle-exclamation" }], [
6145
+ "span",
6146
+ {
6147
+ class: "ASSET_MISSING_LABEL",
6148
+ i18n: key
6149
+ },
6150
+ key
6151
+ ]];
6152
+ if (detail) children.push([
6153
+ "span",
6154
+ { class: "ASSET_MISSING_DETAIL" },
6155
+ String(detail)
6156
+ ]);
6157
+ const attrs = { class: `ASSET_MISSING ${opts.class || ""}`.trim() };
6158
+ if (detail) attrs.title = String(detail);
6159
+ else attrs["data-i18n-title"] = key;
6160
+ return createElement2([
6161
+ "div",
6162
+ attrs,
6163
+ children
6164
+ ]);
6165
+ }
6166
+ /***************************************************************************
6167
+ * What KIND of thing a content type is, as far as showing it goes:
6168
+ *
6169
+ * "image" | "video" | "audio" the browser draws it in an element
6170
+ * "pdf" the browser's own viewer, in a frame
6171
+ * "other" nothing here can draw it: a card
6172
+ * saying what it is, and a way to open
6173
+ *
6174
+ * The content type is the one the BACKEND stored (treedb checks it
6175
+ * against the bytes), never the extension of a name a person typed.
6176
+ ***************************************************************************/
6177
+ function yui_asset_kind(content_type) {
6178
+ const ct = String(content_type || "").toLowerCase().split(";")[0].trim();
6179
+ if (ct.startsWith("image/")) return "image";
6180
+ if (ct.startsWith("video/")) return "video";
6181
+ if (ct.startsWith("audio/")) return "audio";
6182
+ if (ct === "application/pdf") return "pdf";
6183
+ return "other";
6184
+ }
6185
+ /***************************************************************************
6186
+ * A `blob:` url for inline bytes, or null.
6187
+ *
6188
+ * A frame and a new tab need a blob and not a data url: Firefox refuses to
6189
+ * NAVIGATE to a data url (a new tab opened on one stays blank), and its
6190
+ * PDF viewer does not run on one inside a frame.
6191
+ ***************************************************************************/
6192
+ function base64_to_blob_url(content64, content_type) {
6193
+ try {
6194
+ const binary = atob(content64);
6195
+ const bytes = new Uint8Array(binary.length);
6196
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
6197
+ const blob = new Blob([bytes], { type: content_type || "application/octet-stream" });
6198
+ return URL.createObjectURL(blob);
6199
+ } catch (e) {
6200
+ log_error(`cannot decode the bytes of an asset: ${e && e.message ? e.message : e}`);
6201
+ return null;
6202
+ }
6203
+ }
6204
+ /***************************************************************************
6205
+ * Where a frame or a link can take an asset:
6206
+ *
6207
+ * -> {href, blob} `blob` true when href is a blob url THIS call
6208
+ * created, which the caller must mark (BLOB_ATTR)
6209
+ * so that yui_asset_release() revokes it
6210
+ * -> null nothing to open
6211
+ ***************************************************************************/
6212
+ function yui_asset_href(answer) {
6213
+ if (!answer) return null;
6214
+ if (answer.mode === "url" && answer.url) return {
6215
+ href: answer.url,
6216
+ blob: false
6217
+ };
6218
+ if (answer.mode === "inline" && answer.content64) {
6219
+ const href = base64_to_blob_url(answer.content64, answer.content_type);
6220
+ return href ? {
6221
+ href,
6222
+ blob: true
6223
+ } : null;
6224
+ }
6225
+ return null;
6226
+ }
6227
+ /***************************************************************************
6228
+ * The answer a picked `File` would have had from the backend.
6229
+ *
6230
+ * A file a person picked and has not saved yet is shown BEFORE it exists
6231
+ * as an asset, so it goes through the same element as a stored one: a
6232
+ * `url` answer whose url is a blob url over the File itself -- which
6233
+ * reads nothing until the browser draws it.
6234
+ *
6235
+ * `blob: true` says the url is owned: whoever drops the element revokes
6236
+ * it with yui_asset_release().
6237
+ ***************************************************************************/
6238
+ function yui_asset_file_answer(file) {
6239
+ if (!file) return null;
6240
+ let url;
6241
+ try {
6242
+ url = URL.createObjectURL(file);
6243
+ } catch (e) {
6244
+ log_error(`cannot preview the picked file: ${e && e.message ? e.message : e}`);
6245
+ return null;
6246
+ }
6247
+ return {
6248
+ mode: "url",
6249
+ url,
6250
+ content_type: file.type || "",
6251
+ size: file.size,
6252
+ original_name: file.name || "",
6253
+ blob: true
6254
+ };
6255
+ }
6256
+ /***************************************************************************
6257
+ * Revoke every blob url this module created under `$root`, $root included.
6258
+ *
6259
+ * Call it on whatever element is about to be dropped: a preview being
6260
+ * replaced, a dialog being closed. A blob url that is never revoked keeps
6261
+ * its bytes in memory for the life of the page.
6262
+ ***************************************************************************/
6263
+ function yui_asset_release($root) {
6264
+ if (!$root || !$root.querySelectorAll) return;
6265
+ const marked = Array.from($root.querySelectorAll(`[${BLOB_ATTR}]`));
6266
+ if ($root.hasAttribute && $root.hasAttribute(BLOB_ATTR)) marked.push($root);
6267
+ marked.forEach(($el) => {
6268
+ const url = $el.getAttribute(BLOB_ATTR);
6269
+ $el.removeAttribute(BLOB_ATTR);
6270
+ if (url) URL.revokeObjectURL(url);
6271
+ });
6272
+ }
6273
+ /***************************************************************************
6274
+ * The link that opens an asset in a new tab, or null when there is
6275
+ * nothing to open. A control: named for the eye and for a reader, both
6276
+ * through their keys so a language change re-translates them.
6277
+ ***************************************************************************/
6278
+ function yui_asset_open_link(answer, opts = {}) {
6279
+ const target = yui_asset_href(answer);
6280
+ if (!target) return null;
6281
+ const key = "open in a new tab";
6282
+ const $a = createElement2([
6283
+ "a",
6284
+ {
6285
+ class: `ASSET_OPEN ${opts.class || ""}`.trim(),
6286
+ target: "_blank",
6287
+ rel: "noopener",
6288
+ title: t(key),
6289
+ "data-i18n-title": key,
6290
+ "aria-label": t(key),
6291
+ "data-i18n-aria-label": key
6292
+ },
6293
+ [["span", { class: "ASSET_OPEN_ICON yi-link" }], [
6294
+ "span",
6295
+ {
6296
+ class: "ASSET_OPEN_LABEL",
6297
+ i18n: key
6298
+ },
6299
+ key
6300
+ ]]
6301
+ ]);
6302
+ $a.href = target.href;
6303
+ if (target.blob) $a.setAttribute(BLOB_ATTR, target.href);
6304
+ return $a;
6305
+ }
6306
+ /***************************************************************************
6307
+ * The element for one asset.
6308
+ *
6309
+ * The KIND comes from the content type the backend stored, not from the
6310
+ * name a person typed: video and audio are assets too, and an <img> whose
6311
+ * src is a film shows the broken box this module exists to remove.
6312
+ *
6313
+ * `detail` is what the missing marker shows -- pass the original name or
6314
+ * the source path, the thing a person can act on.
6315
+ *
6316
+ * A PDF goes in a frame, where the browser's own viewer draws it. Any
6317
+ * other kind gets a card: what it is, and a link to open it.
6318
+ * TODO: draw more kinds in place (plain text, office documents); today
6319
+ * they are only opened in a new tab, where the browser does what it can.
6320
+ ***************************************************************************/
6321
+ function yui_asset_element(answer, opts = {}) {
6322
+ const src = yui_asset_src(answer);
6323
+ if (!src) return yui_asset_missing(opts.detail, opts);
6324
+ const kind = yui_asset_kind(answer.content_type);
6325
+ const cls = `ASSET_MEDIA ${opts.class || ""}`.trim();
6326
+ if (kind === "pdf") {
6327
+ const target = yui_asset_href(answer);
6328
+ if (!target) return yui_asset_missing(opts.detail, opts);
6329
+ const $frame = createElement2(["iframe", {
6330
+ class: `${cls} ASSET_DOCUMENT`,
6331
+ title: opts.detail || answer.content_type || ""
6332
+ }]);
6333
+ $frame.src = target.href;
6334
+ if (target.blob) $frame.setAttribute(BLOB_ATTR, target.href);
6335
+ return $frame;
6336
+ }
6337
+ if (kind === "other") {
6338
+ const children = [[
6339
+ "span",
6340
+ { class: "ASSET_OTHER_TYPE" },
6341
+ String(answer.content_type || "?")
6342
+ ]];
6343
+ if (opts.detail) children.push([
6344
+ "span",
6345
+ { class: "ASSET_OTHER_DETAIL" },
6346
+ String(opts.detail)
6347
+ ]);
6348
+ const $card = createElement2([
6349
+ "div",
6350
+ { class: `${cls} ASSET_OTHER` },
6351
+ children
6352
+ ]);
6353
+ const $open = yui_asset_open_link(answer);
6354
+ if ($open) $card.appendChild($open);
6355
+ return $card;
6356
+ }
6357
+ let el;
6358
+ if (kind === "video") el = createElement2(["video", {
6359
+ class: cls,
6360
+ controls: "",
6361
+ preload: "metadata"
6362
+ }]);
6363
+ else if (kind === "audio") el = createElement2(["audio", {
6364
+ class: cls,
6365
+ controls: "",
6366
+ preload: "metadata"
6367
+ }]);
6368
+ else {
6369
+ el = createElement2(["img", {
6370
+ class: cls,
6371
+ loading: "lazy"
6372
+ }]);
6373
+ el.alt = opts.alt || opts.detail || "";
6374
+ }
6375
+ el.src = src;
6376
+ if (answer.blob) el.setAttribute(BLOB_ATTR, src);
6377
+ el.onerror = () => {
6378
+ log_error(`asset could not be loaded: ${opts.detail || src}`);
6379
+ const $marker = yui_asset_missing(opts.detail, opts);
6380
+ if (el.parentNode) el.parentNode.replaceChild($marker, el);
6381
+ yui_asset_release(el);
6382
+ };
6383
+ return el;
6384
+ }
6385
+ //#endregion
6093
6386
  //#region src/yui_file_field.js
6094
6387
  /***********************************************************************
6095
6388
  * yui_file_field.js
@@ -6326,6 +6619,10 @@ function yui_file_control(gobj, { name, value, readonly, accept, on_pick }) {
6326
6619
  const $name = createElement2(["span", { class: "FILE_NAME" }]);
6327
6620
  const $size = createElement2(["span", { class: "FILE_SIZE" }]);
6328
6621
  const $state = createElement2(["span", { class: "FILE_STATE" }]);
6622
+ const $preview = createElement2(["div", {
6623
+ class: "FILE_PREVIEW",
6624
+ hidden: ""
6625
+ }]);
6329
6626
  const $control = createElement2([
6330
6627
  "div",
6331
6628
  { class: "FILE_FIELD" },
@@ -6335,11 +6632,13 @@ function yui_file_control(gobj, { name, value, readonly, accept, on_pick }) {
6335
6632
  $name,
6336
6633
  $size,
6337
6634
  $state,
6338
- $clear
6635
+ $clear,
6636
+ $preview
6339
6637
  ]
6340
6638
  ]);
6341
6639
  $control.yui_file = null;
6342
6640
  $control.yui_file_value = value || "";
6641
+ $control.yui_file_stored = null;
6343
6642
  const render = () => {
6344
6643
  const picked = $control.yui_file;
6345
6644
  const kept = $control.yui_file_value;
@@ -6360,6 +6659,24 @@ function yui_file_control(gobj, { name, value, readonly, accept, on_pick }) {
6360
6659
  }
6361
6660
  $clear.hidden = readonly || !picked && !kept;
6362
6661
  $pick.hidden = !!readonly;
6662
+ yui_asset_release($preview);
6663
+ $preview.replaceChildren();
6664
+ let $media = null;
6665
+ if (picked) {
6666
+ const answer = yui_asset_file_answer(picked);
6667
+ if (answer) $media = yui_asset_element(answer, {
6668
+ detail: picked.name || "",
6669
+ class: "FILE_PREVIEW_MEDIA"
6670
+ });
6671
+ } else if (kept) {
6672
+ const stored = $control.yui_file_stored;
6673
+ if (stored && stored.id === kept) $media = yui_asset_element(stored.answer, {
6674
+ detail: stored.error || kept,
6675
+ class: "FILE_PREVIEW_MEDIA"
6676
+ });
6677
+ }
6678
+ if ($media) $preview.appendChild($media);
6679
+ $preview.hidden = !$media;
6363
6680
  };
6364
6681
  if (!readonly) {
6365
6682
  $pick.addEventListener("click", () => {
@@ -6388,120 +6705,6 @@ function yui_file_control(gobj, { name, value, readonly, accept, on_pick }) {
6388
6705
  };
6389
6706
  }
6390
6707
  //#endregion
6391
- //#region src/yui_asset.js
6392
- function ref_to_id(ref) {
6393
- if (!ref) return null;
6394
- if (typeof ref === "string") {
6395
- if (ref.indexOf("^") < 0) return ref;
6396
- const parts = ref.split("^");
6397
- return parts.length >= 2 && parts[1] ? parts[1] : null;
6398
- }
6399
- if (typeof ref === "object" && typeof ref.id === "string") return ref.id;
6400
- return null;
6401
- }
6402
- /***************************************************************************
6403
- * Every asset id a column names, in order. Always an array, possibly empty.
6404
- ***************************************************************************/
6405
- function yui_asset_ids(ref) {
6406
- if (Array.isArray(ref)) return ref.map(ref_to_id).filter((id) => !!id);
6407
- const id = ref_to_id(ref);
6408
- return id ? [id] : [];
6409
- }
6410
- /***************************************************************************
6411
- * The first asset id a column names, or null.
6412
- ***************************************************************************/
6413
- function yui_asset_id(ref) {
6414
- const ids = yui_asset_ids(ref);
6415
- return ids.length ? ids[0] : null;
6416
- }
6417
- /***************************************************************************
6418
- * Turn a `get-asset` answer into something an element can load.
6419
- *
6420
- * Returns null when the answer carries neither -- which the caller must
6421
- * treat as "missing", never as an empty src: an <img src=""> reloads the
6422
- * PAGE in some browsers.
6423
- ***************************************************************************/
6424
- function yui_asset_src(answer) {
6425
- if (!answer) return null;
6426
- if (answer.mode === "url" && answer.url) return answer.url;
6427
- if (answer.mode === "inline" && answer.content64) return `data:${answer.content_type || "application/octet-stream"};base64,${answer.content64}`;
6428
- return null;
6429
- }
6430
- /***************************************************************************
6431
- * The marker for an asset that is not there.
6432
- *
6433
- * A missing image used to leave a broken box and no word about it, which
6434
- * is indistinguishable from a slow one and from a bug. This says so, and
6435
- * it says WHICH: `detail` is data (a name, a path) and is never
6436
- * translated, while the label carries its i18n key so a language change
6437
- * re-translates it.
6438
- ***************************************************************************/
6439
- function yui_asset_missing(detail, opts = {}) {
6440
- const key = opts.key || "asset not available";
6441
- const children = [["span", { class: "ASSET_MISSING_ICON yi-triangle-exclamation" }], [
6442
- "span",
6443
- {
6444
- class: "ASSET_MISSING_LABEL",
6445
- i18n: key
6446
- },
6447
- key
6448
- ]];
6449
- if (detail) children.push([
6450
- "span",
6451
- { class: "ASSET_MISSING_DETAIL" },
6452
- String(detail)
6453
- ]);
6454
- const attrs = { class: `ASSET_MISSING ${opts.class || ""}`.trim() };
6455
- if (detail) attrs.title = String(detail);
6456
- else attrs["data-i18n-title"] = key;
6457
- return createElement2([
6458
- "div",
6459
- attrs,
6460
- children
6461
- ]);
6462
- }
6463
- /***************************************************************************
6464
- * The element for one asset.
6465
- *
6466
- * The KIND comes from the content type the backend stored, not from the
6467
- * name a person typed: video and audio are assets too, and an <img> whose
6468
- * src is a film shows the broken box this module exists to remove.
6469
- *
6470
- * `detail` is what the missing marker shows -- pass the original name or
6471
- * the source path, the thing a person can act on.
6472
- ***************************************************************************/
6473
- function yui_asset_element(answer, opts = {}) {
6474
- const src = yui_asset_src(answer);
6475
- if (!src) return yui_asset_missing(opts.detail, opts);
6476
- const ct = (answer.content_type || "").toLowerCase();
6477
- const cls = `ASSET_MEDIA ${opts.class || ""}`.trim();
6478
- let el;
6479
- if (ct.startsWith("video/")) el = createElement2(["video", {
6480
- class: cls,
6481
- controls: "",
6482
- preload: "metadata"
6483
- }]);
6484
- else if (ct.startsWith("audio/")) el = createElement2(["audio", {
6485
- class: cls,
6486
- controls: "",
6487
- preload: "metadata"
6488
- }]);
6489
- else {
6490
- el = createElement2(["img", {
6491
- class: cls,
6492
- loading: "lazy"
6493
- }]);
6494
- el.alt = opts.alt || opts.detail || "";
6495
- }
6496
- el.src = src;
6497
- el.onerror = () => {
6498
- log_error(`asset could not be loaded: ${opts.detail || src}`);
6499
- const $marker = yui_asset_missing(opts.detail, opts);
6500
- if (el.parentNode) el.parentNode.replaceChild($marker, el);
6501
- };
6502
- return el;
6503
- }
6504
- //#endregion
6505
6708
  //#region src/yui_inputs.js
6506
6709
  /***************************************************************
6507
6710
  * The ✕ shows only when there is something to clear AND the field
@@ -7171,6 +7374,7 @@ function mt_create$17(gobj) {
7171
7374
  * and subscriptions are already deleted.
7172
7375
  ************************************************************/
7173
7376
  function mt_destroy$17(gobj) {
7377
+ yui_asset_release(gobj_read_attr(gobj, "$container"));
7174
7378
  destroy_ui$10(gobj);
7175
7379
  }
7176
7380
  /************************************************************
@@ -9319,6 +9523,43 @@ function ac_clear_record(gobj, event, kw, src) {
9319
9523
  return 0;
9320
9524
  }
9321
9525
  /************************************************************
9526
+ * From the host: what it fetched for the asset a `file` column
9527
+ * names -- the `get-asset` answer, or why there is none.
9528
+ * {
9529
+ * name: the column
9530
+ * id: the asset id the answer is for
9531
+ * answer: the get-asset answer, or null
9532
+ * error: why there is no answer, when there is none
9533
+ * }
9534
+ *
9535
+ * Kept on the control and drawn only while the column still names
9536
+ * THAT id: an answer landing after the person picked another file
9537
+ * or cleared the column must not bring the old picture back.
9538
+ ************************************************************/
9539
+ function ac_set_file_preview(gobj, event, kw, src) {
9540
+ let $container = gobj_read_attr(gobj, "$container");
9541
+ let $form = $container ? $container.querySelector("form") : null;
9542
+ if (!$form) {
9543
+ log_error(`${gobj_short_name(gobj)}: no form to show the file preview in`);
9544
+ return -1;
9545
+ }
9546
+ let $file = null;
9547
+ $form.querySelectorAll(".FILE_FIELD").forEach(($el) => {
9548
+ if ($el.dataset.treedb_name === kw.name) $file = $el;
9549
+ });
9550
+ if (!$file) {
9551
+ log_error(`${gobj_short_name(gobj)}: no file column '${kw.name}' in the form`);
9552
+ return -1;
9553
+ }
9554
+ $file.yui_file_stored = {
9555
+ id: kw.id || "",
9556
+ answer: kw.answer || null,
9557
+ error: kw.error || ""
9558
+ };
9559
+ if ($file.yui_file_render) $file.yui_file_render();
9560
+ return 0;
9561
+ }
9562
+ /************************************************************
9322
9563
  * From internal form button for new rows
9323
9564
  * or from load_tabulator_data() to load data from backend
9324
9565
  ************************************************************/
@@ -9512,6 +9753,11 @@ function create_gclass$17(gclass_name) {
9512
9753
  ac_paste_record,
9513
9754
  null
9514
9755
  ],
9756
+ [
9757
+ "EV_SET_FILE_PREVIEW",
9758
+ ac_set_file_preview,
9759
+ null
9760
+ ],
9515
9761
  [
9516
9762
  "EV_WINDOW_MOVED",
9517
9763
  null,
@@ -9557,6 +9803,7 @@ function create_gclass$17(gclass_name) {
9557
9803
  ["EV_RECORD_CHANGED", 0],
9558
9804
  ["EV_COPY_RECORD", 0],
9559
9805
  ["EV_PASTE_RECORD", 0],
9806
+ ["EV_SET_FILE_PREVIEW", 0],
9560
9807
  ["EV_WINDOW_MOVED", 0],
9561
9808
  ["EV_WINDOW_RESIZED", 0],
9562
9809
  ["EV_WINDOW_TO_CLOSE", 0],
@@ -22754,6 +23001,7 @@ var attrs_table$6 = [
22754
23001
  SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
22755
23002
  SDATA(data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, "Remote yuno for data fetching"),
22756
23003
  SDATA(data_type_t.DTP_STRING, "treedb_name", 0, null, "Remote TreeDB service name"),
23004
+ SDATA(data_type_t.DTP_STRING, "assets_service", 0, "assets", "Remote service that answers `get-asset` (a C_ASSETS) for the bytes of the treedb's `file` columns: the form's preview and the table's file popup ask it. Empty: nothing is asked, and a file cell opens a marker saying the asset is not available"),
22757
23005
  SDATA(data_type_t.DTP_JSON, "descs", 0, null, "Description of topics"),
22758
23006
  SDATA(data_type_t.DTP_BOOLEAN, "with_remote_paging", 0, false, "Each topic table pulls its rows a PAGE at a time (`nodes` from/limit) instead of loading the topic whole. ⚠️ DO NOT TURN THIS ON: a partial topic BREAKS LINKING -- the link picker of a record is built from the rows the PARENT topic's table holds, which with paging is one page, so the right parent is usually not offered and an existing link the form cannot match is dropped on save. Off in every consumer until linking asks the backend instead of the sibling table"),
22759
23007
  SDATA(data_type_t.DTP_INTEGER, "page_size", 0, 0, "Rows per page of each topic table (the DISPLAY; the fetch is whole). 0 leaves the table's own default"),
@@ -24073,6 +24321,24 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
24073
24321
  else gobj_send_event(jv, "EV_SET_JSON", { json: data }, gobj);
24074
24322
  return 0;
24075
24323
  }
24324
+ if (command === "get-asset") {
24325
+ let asset_topic = kw_get_str(gobj, kw_command, "topic_name", "", 0);
24326
+ let req_id = kw_get_str(gobj, kw_command, "req_id", "", 0);
24327
+ let gobj_topic_form = gobj_find_child(gobj, { __gobj_name__: `${gobj_name(gobj)}?${asset_topic}` });
24328
+ if (!gobj_topic_form || !req_id) {
24329
+ log_error(`${gobj_short_name(gobj)}: get-asset answer for nobody: topic '${asset_topic}', req_id '${req_id}'`);
24330
+ return 0;
24331
+ }
24332
+ if (result < 0) gobj_send_event(gobj_topic_form, "EV_ASSET_FAILED", {
24333
+ req_id,
24334
+ error: comment || "get-asset failed"
24335
+ }, gobj);
24336
+ else gobj_send_event(gobj_topic_form, "EV_ASSET_LOADED", {
24337
+ req_id,
24338
+ answer: data
24339
+ }, gobj);
24340
+ return 0;
24341
+ }
24076
24342
  if (result < 0) {
24077
24343
  if (command === "descs") show_load_error$1(gobj, t(comment));
24078
24344
  else {
@@ -24354,6 +24620,48 @@ function ac_request_page(gobj, event, kw, src) {
24354
24620
  return 0;
24355
24621
  }
24356
24622
  /********************************************
24623
+ * Event from formtable: the bytes of one asset a `file` column names,
24624
+ * for the form's preview or the table's file popup.
24625
+ *
24626
+ * Asked to the ASSETS service and not to the treedb: storing is the
24627
+ * treedb's, the way out is C_ASSETS's (a signed url when a web server
24628
+ * sits in front of the store, the bytes inline when there is none). The
24629
+ * answer goes back down to the table that asked, by `req_id`.
24630
+ *
24631
+ * A read: a read-only treedb still answers it.
24632
+ ********************************************/
24633
+ function ac_request_asset(gobj, event, kw, src) {
24634
+ if (!kw.req_id || !kw.asset_id) {
24635
+ log_error(`${gobj_short_name(gobj)}: an asset request with no id`);
24636
+ return -1;
24637
+ }
24638
+ let assets_service = gobj_read_str_attr(gobj, "assets_service");
24639
+ let gobj_remote_yuno = gobj_read_pointer_attr(gobj, "gobj_remote_yuno");
24640
+ if (!assets_service || !gobj_remote_yuno) {
24641
+ gobj_send_event(src, "EV_ASSET_FAILED", {
24642
+ req_id: kw.req_id,
24643
+ error: assets_service ? "cannot reach the backend" : "no assets service"
24644
+ }, gobj);
24645
+ return 0;
24646
+ }
24647
+ let ret = gobj_command(gobj_remote_yuno, "get-asset", {
24648
+ service: assets_service,
24649
+ asset_id: kw.asset_id,
24650
+ __md_command__: {
24651
+ topic_name: kw.topic_name || gobj_read_attr(src, "topic_name"),
24652
+ req_id: kw.req_id
24653
+ }
24654
+ }, gobj);
24655
+ if (ret) {
24656
+ log_error(ret);
24657
+ gobj_send_event(src, "EV_ASSET_FAILED", {
24658
+ req_id: kw.req_id,
24659
+ error: "cannot reach the backend"
24660
+ }, gobj);
24661
+ }
24662
+ return 0;
24663
+ }
24664
+ /********************************************
24357
24665
  * Event from formtable: ONE field of one record, edited in place.
24358
24666
  *
24359
24667
  * Written as a PARTIAL update and, deliberately, with NO `autolink`.
@@ -24539,6 +24847,11 @@ function create_gclass$6(gclass_name) {
24539
24847
  ac_request_page,
24540
24848
  null
24541
24849
  ],
24850
+ [
24851
+ "EV_REQUEST_ASSET",
24852
+ ac_request_asset,
24853
+ null
24854
+ ],
24542
24855
  [
24543
24856
  "EV_DELETE_RECORD",
24544
24857
  ac_delete_record,
@@ -24619,6 +24932,7 @@ function create_gclass$6(gclass_name) {
24619
24932
  ["EV_UPDATE_RECORD", 0],
24620
24933
  ["EV_UPDATE_FIELD", 0],
24621
24934
  ["EV_REQUEST_PAGE", 0],
24935
+ ["EV_REQUEST_ASSET", 0],
24622
24936
  ["EV_DELETE_RECORD", 0],
24623
24937
  ["EV_RECORD_WRITTEN", event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS],
24624
24938
  ["EV_REFRESH_TOPIC", 0],
@@ -25424,7 +25738,12 @@ var PRIVATE_DATA$5 = {
25424
25738
  schema_win: null,
25425
25739
  cell_json_gobj: null,
25426
25740
  cell_json_modal: null,
25427
- cell_json_win: null
25741
+ cell_json_win: null,
25742
+ cell_file_box: null,
25743
+ cell_file_modal: null,
25744
+ cell_file_win: null,
25745
+ _pending_assets: null,
25746
+ _asset_seq: 0
25428
25747
  };
25429
25748
  var __gclass__$5 = null;
25430
25749
  function is_mobile$1() {
@@ -25557,6 +25876,8 @@ function mt_stop$5(gobj) {
25557
25876
  close_form_dialog(gobj);
25558
25877
  close_schema_dialog(gobj);
25559
25878
  close_cell_json_dialog(gobj);
25879
+ close_cell_file_dialog(gobj);
25880
+ drop_pending_assets(gobj);
25560
25881
  table__destroy(gobj);
25561
25882
  }
25562
25883
  /***************************************************************
@@ -25566,6 +25887,8 @@ function mt_destroy$5(gobj) {
25566
25887
  close_form_dialog(gobj);
25567
25888
  close_schema_dialog(gobj);
25568
25889
  close_cell_json_dialog(gobj);
25890
+ close_cell_file_dialog(gobj);
25891
+ drop_pending_assets(gobj);
25569
25892
  destroy_ui$2(gobj);
25570
25893
  }
25571
25894
  /************************************************************
@@ -26210,6 +26533,20 @@ function create_tabulator(gobj) {
26210
26533
  break;
26211
26534
  case "file":
26212
26535
  colFormatter = file_cell_formatter;
26536
+ cellClick = function(e, cell) {
26537
+ if (!e.target.closest(".FILE_CELL_LINK")) return;
26538
+ e.stopPropagation();
26539
+ let pkey = desc.pkey || "id";
26540
+ let row_id = cell.getData()[pkey];
26541
+ if (row_id === void 0 || row_id === null) {
26542
+ log_error(`${gobj_short_name(gobj)}: row without pkey '${pkey}', cannot open the file of '${cell.getField()}'`);
26543
+ return;
26544
+ }
26545
+ gobj_send_event(gobj, "EV_SHOW_CELL_FILE", {
26546
+ row_id,
26547
+ col_id: cell.getField()
26548
+ }, gobj);
26549
+ };
26213
26550
  break;
26214
26551
  case "object":
26215
26552
  case "dict":
@@ -26480,13 +26817,21 @@ function table__destroy(gobj) {
26480
26817
  * What a `file` column shows in a table cell: the asset it names.
26481
26818
  *
26482
26819
  * A sha256 in full is 64 characters of noise in a column, so it is
26483
- * shortened and the whole of it is the tooltip. Empty is SAID, not left
26484
- * blank -- a blank cell and a cell whose asset failed to load look the
26485
- * same, and only one of them is normal.
26820
+ * shortened and the whole of it goes to the popup the click opens. Empty
26821
+ * is SAID, not left blank -- a blank cell and a cell whose asset failed
26822
+ * to load look the same, and only one of them is normal.
26823
+ *
26824
+ * The value arrives already turned into ids joined by ", "
26825
+ * (transform__treedb_value_2_table_value), and an array column names
26826
+ * several: the first is shown and the rest counted.
26486
26827
  ************************************************************/
26828
+ function file_cell_ids(value) {
26829
+ if (is_array(value)) return yui_asset_ids(value);
26830
+ return String(value || "").split(",").map((s) => s.trim()).filter((s) => !!s).flatMap((s) => yui_asset_ids(s));
26831
+ }
26487
26832
  function file_cell_formatter(cell, formatterParams, onRendered) {
26488
- const id = yui_asset_id(cell.getValue());
26489
- if (!id) return createElement2([
26833
+ const ids = file_cell_ids(cell.getValue());
26834
+ if (!ids.length) return createElement2([
26490
26835
  "span",
26491
26836
  {
26492
26837
  class: "FILE_CELL is-empty",
@@ -26494,13 +26839,26 @@ function file_cell_formatter(cell, formatterParams, onRendered) {
26494
26839
  },
26495
26840
  "no file"
26496
26841
  ]);
26497
- return createElement2([
26842
+ const children = [["span", { class: "FILE_CELL_ICON icon yi-eye" }], [
26843
+ "span",
26844
+ { class: "FILE_CELL_ID" },
26845
+ yui_file_id_label(ids[0])
26846
+ ]];
26847
+ if (ids.length > 1) children.push([
26498
26848
  "span",
26849
+ { class: "FILE_CELL_MORE" },
26850
+ `+${ids.length - 1}`
26851
+ ]);
26852
+ return createElement2([
26853
+ "a",
26499
26854
  {
26500
- class: "FILE_CELL",
26501
- title: id
26855
+ class: "FILE_CELL FILE_CELL_LINK",
26856
+ title: t("show file"),
26857
+ "data-i18n-title": "show file",
26858
+ "aria-label": t("show file"),
26859
+ "data-i18n-aria-label": "show file"
26502
26860
  },
26503
- yui_file_id_label(id)
26861
+ children
26504
26862
  ]);
26505
26863
  }
26506
26864
  function icon_formatter(cell) {
@@ -26764,6 +27122,7 @@ function open_form_dialog(gobj, mode, record) {
26764
27122
  });
26765
27123
  gobj_start(form);
26766
27124
  refresh_language($body, t);
27125
+ request_form_previews(gobj, record);
26767
27126
  let $with_focus = $body.querySelector(".with-focus:not([readonly])");
26768
27127
  if (!$with_focus) $with_focus = $body.querySelector("input.input:not([readonly]), textarea, select");
26769
27128
  if ($with_focus) $with_focus.focus();
@@ -27011,6 +27370,225 @@ function close_cell_json_dialog(gobj) {
27011
27370
  teardown_cell_json_child(gobj);
27012
27371
  }
27013
27372
  /************************************************************
27373
+ * Ask the host for the bytes of ONE asset.
27374
+ *
27375
+ * Same contract as the page requests: the transport belongs to the
27376
+ * HOST, so the request goes up as an event and the answer comes back
27377
+ * down as one, correlated by a `req_id` the host echoes. What is parked
27378
+ * here is WHERE the answer goes -- the form's preview of a column, or a
27379
+ * slot of the file popup -- and the watchdog, because a request whose
27380
+ * answer never lands would leave a "loading" on screen for ever.
27381
+ *
27382
+ * `target` is kept here and never travels: it holds a gobj and a DOM
27383
+ * node, and a kw is plain json.
27384
+ ************************************************************/
27385
+ var ASSET_TIMEOUT_MS = 3e4;
27386
+ function request_asset(gobj, asset_id, target) {
27387
+ let priv = gobj.priv;
27388
+ if (!priv._pending_assets) priv._pending_assets = {};
27389
+ let req_id = `a${++priv._asset_seq}`;
27390
+ let timer = window.setTimeout(function() {
27391
+ gobj_send_event(gobj, "EV_ASSET_FAILED", {
27392
+ req_id,
27393
+ error: "asset request timed out"
27394
+ }, gobj);
27395
+ }, ASSET_TIMEOUT_MS);
27396
+ priv._pending_assets[req_id] = Object.assign({
27397
+ id: asset_id,
27398
+ timer
27399
+ }, target);
27400
+ gobj_publish_event(gobj, "EV_REQUEST_ASSET", {
27401
+ topic_name: gobj_read_str_attr(gobj, "topic_name"),
27402
+ req_id,
27403
+ asset_id
27404
+ });
27405
+ }
27406
+ /************************************************************
27407
+ * Take a parked asset request out, however it ended. Null when
27408
+ * it was already settled (a late answer after a timeout).
27409
+ ************************************************************/
27410
+ function take_pending_asset(gobj, req_id) {
27411
+ let priv = gobj.priv;
27412
+ let pend = priv._pending_assets ? priv._pending_assets[req_id] : null;
27413
+ if (!pend) return null;
27414
+ delete priv._pending_assets[req_id];
27415
+ if (pend.timer) window.clearTimeout(pend.timer);
27416
+ return pend;
27417
+ }
27418
+ /************************************************************
27419
+ * Forget every parked asset request: the view is going away,
27420
+ * and a watchdog firing into a stopped gobj is an error for
27421
+ * nothing.
27422
+ ************************************************************/
27423
+ function drop_pending_assets(gobj) {
27424
+ let priv = gobj.priv;
27425
+ if (priv._pending_assets) {
27426
+ for (const pend of Object.values(priv._pending_assets)) if (pend.timer) window.clearTimeout(pend.timer);
27427
+ }
27428
+ priv._pending_assets = null;
27429
+ }
27430
+ /************************************************************
27431
+ * Ask for the asset of every `file` column the record names,
27432
+ * for the form's preview. One per column: the form shows the
27433
+ * asset the column keeps, which is the first it names.
27434
+ ************************************************************/
27435
+ function request_form_previews(gobj, record) {
27436
+ let priv = gobj.priv;
27437
+ if (!record || !priv.form) return;
27438
+ let desc = gobj_read_attr(gobj, "desc");
27439
+ for (let col of desc.cols) {
27440
+ if (!col.id || col.id.charAt(0) === "_") continue;
27441
+ const field_desc = treedb_get_field_desc(col);
27442
+ if (field_desc.type !== "file" || field_desc.is_hidden) continue;
27443
+ let ids = file_cell_ids(record[col.id]);
27444
+ if (!ids.length) continue;
27445
+ request_asset(gobj, ids[0], {
27446
+ target: "form",
27447
+ form: priv.form,
27448
+ col: col.id
27449
+ });
27450
+ }
27451
+ }
27452
+ /************************************************************
27453
+ * Show what a `file` cell names -- every asset, when the
27454
+ * column is an array -- in the standardized adaptive dialog.
27455
+ *
27456
+ * Unlike the JSON cell this is NOT offline: the row holds the
27457
+ * ids and the bytes are the backend's, so each slot opens as
27458
+ * "loading" and one `get-asset` per id fills it.
27459
+ ************************************************************/
27460
+ function open_cell_file_dialog(gobj, row_id, col_id) {
27461
+ close_cell_file_dialog(gobj);
27462
+ let priv = gobj.priv;
27463
+ let tabulator = gobj_read_attr(gobj, "tabulator");
27464
+ if (!tabulator) {
27465
+ log_error(`${gobj_short_name(gobj)}: no table, no file cell to show`);
27466
+ return;
27467
+ }
27468
+ let row = tabulator.getRow(row_id);
27469
+ if (!row) {
27470
+ log_error(`${gobj_short_name(gobj)}: row '${row_id}' not found in the table`);
27471
+ return;
27472
+ }
27473
+ let ids = file_cell_ids(row.getData()[col_id]);
27474
+ if (!ids.length) {
27475
+ log_error(`${gobj_short_name(gobj)}: row '${row_id}' col '${col_id}' names no file`);
27476
+ return;
27477
+ }
27478
+ let $items = ids.map((id) => createElement2([
27479
+ "div",
27480
+ { class: "CELL_FILE_ITEM" },
27481
+ [[
27482
+ "div",
27483
+ { class: "CELL_FILE_SLOT" },
27484
+ [[
27485
+ "span",
27486
+ {
27487
+ class: "CELL_FILE_LOADING",
27488
+ i18n: "loading"
27489
+ },
27490
+ "loading"
27491
+ ]]
27492
+ ], [
27493
+ "div",
27494
+ { class: "CELL_FILE_CAPTION" },
27495
+ [[
27496
+ "span",
27497
+ { class: "CELL_FILE_ID" },
27498
+ id
27499
+ ]]
27500
+ ]]
27501
+ ]));
27502
+ let $box = createElement2([
27503
+ "div",
27504
+ { class: "TREEDB_CELL_FILE" },
27505
+ $items
27506
+ ]);
27507
+ let presented = present_json_popup(gobj, $box, {
27508
+ name: "filewin_" + clean_name(gobj_name(gobj)),
27509
+ logical_class: "TREEDB_CELL_FILE",
27510
+ title_prefix: String(row_id),
27511
+ title: col_id,
27512
+ icon: "yi-eye",
27513
+ forget_window: function() {
27514
+ priv.cell_file_win = null;
27515
+ },
27516
+ on_close: function() {
27517
+ teardown_cell_file(gobj);
27518
+ }
27519
+ });
27520
+ if (!presented) return;
27521
+ priv.cell_file_box = $box;
27522
+ priv.cell_file_modal = presented.modal;
27523
+ priv.cell_file_win = presented.win;
27524
+ ids.forEach((id, i) => {
27525
+ request_asset(gobj, id, {
27526
+ target: "popup",
27527
+ box: $box,
27528
+ slot: i
27529
+ });
27530
+ });
27531
+ }
27532
+ /************************************************************
27533
+ * Fill one slot of the file popup with what came back.
27534
+ ************************************************************/
27535
+ function fill_cell_file_slot(gobj, pend, answer, error) {
27536
+ let priv = gobj.priv;
27537
+ if (!priv.cell_file_box || priv.cell_file_box !== pend.box) return;
27538
+ let $item = pend.box.querySelectorAll(".CELL_FILE_ITEM")[pend.slot];
27539
+ let $slot = $item ? $item.querySelector(".CELL_FILE_SLOT") : null;
27540
+ let $caption = $item ? $item.querySelector(".CELL_FILE_CAPTION") : null;
27541
+ if (!$slot || !$caption) {
27542
+ log_error(`${gobj_short_name(gobj)}: file popup has no slot ${pend.slot}`);
27543
+ return;
27544
+ }
27545
+ yui_asset_release($slot);
27546
+ $slot.replaceChildren(yui_asset_element(error ? null : answer, {
27547
+ detail: error || pend.id,
27548
+ alt: pend.id,
27549
+ class: "CELL_FILE_MEDIA"
27550
+ }));
27551
+ refresh_language($slot, t);
27552
+ if (!error && answer) {
27553
+ let meta = [answer.content_type, yui_file_size_label(answer.size)].filter((s) => !!s).join(" · ");
27554
+ if (meta) $caption.appendChild(createElement2([
27555
+ "span",
27556
+ { class: "CELL_FILE_META" },
27557
+ meta
27558
+ ]));
27559
+ let $open = yui_asset_open_link(answer);
27560
+ if ($open) $caption.appendChild($open);
27561
+ }
27562
+ }
27563
+ /************************************************************
27564
+ * Retire the file popup. Called from its on_close -- the
27565
+ * shell or the window has already removed the DOM. The blob
27566
+ * urls it made are revoked; answers still in flight for it
27567
+ * land on nobody (fill_cell_file_slot checks the box).
27568
+ ************************************************************/
27569
+ function teardown_cell_file(gobj) {
27570
+ let priv = gobj.priv;
27571
+ yui_asset_release(priv.cell_file_box);
27572
+ priv.cell_file_box = null;
27573
+ let win = priv.cell_file_win;
27574
+ priv.cell_file_win = null;
27575
+ retire_popup_window(win);
27576
+ priv.cell_file_modal = null;
27577
+ }
27578
+ /************************************************************
27579
+ * Close the file popup (teardown, or a second open).
27580
+ ************************************************************/
27581
+ function close_cell_file_dialog(gobj) {
27582
+ let priv = gobj.priv;
27583
+ if (priv.cell_file_modal) {
27584
+ let modal = priv.cell_file_modal;
27585
+ priv.cell_file_modal = null;
27586
+ modal.close();
27587
+ return;
27588
+ }
27589
+ teardown_cell_file(gobj);
27590
+ }
27591
+ /************************************************************
27014
27592
  * True if the topic's pkey col carries the "rowid" flag.
27015
27593
  ************************************************************/
27016
27594
  function pkey_is_rowid(gobj) {
@@ -27794,6 +28372,57 @@ function ac_show_cell_json(gobj, event, kw, src) {
27794
28372
  return 0;
27795
28373
  }
27796
28374
  /************************************************************
28375
+ * Show the asset(s) one `file` cell names
28376
+ * {
28377
+ * row_id:
28378
+ * col_id:
28379
+ * }
28380
+ ************************************************************/
28381
+ function ac_show_cell_file(gobj, event, kw, src) {
28382
+ open_cell_file_dialog(gobj, kw.row_id, kw.col_id);
28383
+ return 0;
28384
+ }
28385
+ /************************************************************
28386
+ * Hand an asset answer -- or why there is none -- to whoever
28387
+ * asked for it, if it is still there to take it.
28388
+ ************************************************************/
28389
+ function deliver_asset(gobj, pend, answer, error) {
28390
+ let priv = gobj.priv;
28391
+ if (pend.target === "form") {
28392
+ if (!priv.form || priv.form !== pend.form) return;
28393
+ gobj_send_event(priv.form, "EV_SET_FILE_PREVIEW", {
28394
+ name: pend.col,
28395
+ id: pend.id,
28396
+ answer,
28397
+ error
28398
+ }, gobj);
28399
+ return;
28400
+ }
28401
+ fill_cell_file_slot(gobj, pend, answer, error);
28402
+ }
28403
+ /************************************************************
28404
+ * The host answered an asset.
28405
+ * {req_id, answer}
28406
+ ************************************************************/
28407
+ function ac_asset_loaded(gobj, event, kw, src) {
28408
+ let pend = take_pending_asset(gobj, kw.req_id);
28409
+ if (!pend) return 0;
28410
+ deliver_asset(gobj, pend, kw.answer || null, "");
28411
+ return 0;
28412
+ }
28413
+ /************************************************************
28414
+ * The host could not answer it, or nobody did in time.
28415
+ * {req_id, error}
28416
+ ************************************************************/
28417
+ function ac_asset_failed(gobj, event, kw, src) {
28418
+ let why = kw && kw.error || "asset request failed";
28419
+ let pend = take_pending_asset(gobj, kw.req_id);
28420
+ log_error(`${gobj_short_name(gobj)}: asset '${pend ? pend.id : kw.req_id}': ${why}`);
28421
+ if (!pend) return 0;
28422
+ deliver_asset(gobj, pend, null, why);
28423
+ return 0;
28424
+ }
28425
+ /************************************************************
27797
28426
  * One cell of one record changed. Publish it UP as a field write;
27798
28427
  * the host owns the treedb command and its options.
27799
28428
  ************************************************************/
@@ -28297,6 +28926,21 @@ function create_gclass$5(gclass_name) {
28297
28926
  ac_show_cell_json,
28298
28927
  null
28299
28928
  ],
28929
+ [
28930
+ "EV_SHOW_CELL_FILE",
28931
+ ac_show_cell_file,
28932
+ null
28933
+ ],
28934
+ [
28935
+ "EV_ASSET_LOADED",
28936
+ ac_asset_loaded,
28937
+ null
28938
+ ],
28939
+ [
28940
+ "EV_ASSET_FAILED",
28941
+ ac_asset_failed,
28942
+ null
28943
+ ],
28300
28944
  [
28301
28945
  "EV_CHANGE_LOCALE",
28302
28946
  ac_change_locale,
@@ -28397,6 +29041,10 @@ function create_gclass$5(gclass_name) {
28397
29041
  ["EV_UNSELECT_ROWS", event_flag_t.EVF_OUTPUT_EVENT],
28398
29042
  ["EV_SHOW_HOOK_DATA", event_flag_t.EVF_OUTPUT_EVENT],
28399
29043
  ["EV_SHOW_CELL_JSON", 0],
29044
+ ["EV_SHOW_CELL_FILE", 0],
29045
+ ["EV_ASSET_LOADED", 0],
29046
+ ["EV_ASSET_FAILED", 0],
29047
+ ["EV_REQUEST_ASSET", event_flag_t.EVF_OUTPUT_EVENT],
28400
29048
  ["EV_CHANGE_LOCALE", 0],
28401
29049
  ["EV_REFRESH", 0],
28402
29050
  ["EV_SHOW_SCHEMA", 0],
@@ -56715,8 +57363,9 @@ var PRIVATE_DATA$4 = {
56715
57363
  _selected_paint_ids: [],
56716
57364
  _pending_focus_topic: null,
56717
57365
  _pending_focus_all: false,
56718
- _find_hidden_matches: 0,
56719
- _pending_find: null,
57366
+ _find_term: "",
57367
+ _find_current: "",
57368
+ _find_found: null,
56720
57369
  _layout_asked: "",
56721
57370
  _nodes_total: 0,
56722
57371
  _nodes_placed: 0,
@@ -58671,6 +59320,14 @@ function graph_focus_topic(gobj, topic, reveal_all) {
58671
59320
  }
58672
59321
  let model = priv._fold_model;
58673
59322
  let state = priv._fold_state;
59323
+ if (empty_string(topic) && priv._find_term) {
59324
+ priv._focus_topic = null;
59325
+ return;
59326
+ }
59327
+ if (!empty_string(topic)) {
59328
+ priv._find_term = "";
59329
+ priv._find_current = "";
59330
+ }
58674
59331
  let keys = [];
58675
59332
  if (!empty_string(topic)) {
58676
59333
  for (let node of model.nodes.values()) if (node.topic_name === topic) keys.push(node.key);
@@ -58686,17 +59343,17 @@ function graph_focus_topic(gobj, topic, reveal_all) {
58686
59343
  }
58687
59344
  priv._focus_topic = topic || null;
58688
59345
  reconcile_fold(gobj, {}).then(() => {
58689
- apply_focus_ids(gobj, keys, "focus topic");
59346
+ apply_focus_ids(gobj, keys, "focus topic", true);
58690
59347
  });
58691
59348
  }
58692
59349
  /************************************************************
58693
- * Paint the highlight on these records (by model key) and put
58694
- * the first of them in the middle. Shared by the find and the
58695
- * topic focus, after the reconcile that brought them on screen.
58696
- * Keys that are still off screen are skipped: a match beyond the
58697
- * page a reveal opened is counted, not painted.
59350
+ * Paint the highlight on these records (by model key) and, with
59351
+ * `center`, put the first of them in the middle. Shared by the
59352
+ * find and the topic focus. Keys that are not on screen are
59353
+ * skipped: a match beyond the page a reveal opened is counted,
59354
+ * not painted.
58698
59355
  ************************************************************/
58699
- function apply_focus_ids(gobj, keys, what) {
59356
+ function apply_focus_ids(gobj, keys, what, center) {
58700
59357
  let priv = gobj.priv;
58701
59358
  let graph = priv.graph;
58702
59359
  if (!graph) return;
@@ -58721,7 +59378,7 @@ function apply_focus_ids(gobj, keys, what) {
58721
59378
  try {
58722
59379
  if (typeof graph.setElementState === "function") graph.setElementState(states);
58723
59380
  apply_node_highlight(gobj, prev_ids, ids);
58724
- if (ids.length) yui_graph_center_on(graph, ids[0]);
59381
+ if (center && ids.length) yui_graph_center_on(graph, ids[0]);
58725
59382
  } catch (e) {
58726
59383
  log_error(`${gobj_short_name(gobj)}: ${what} apply failed: ${e}`);
58727
59384
  }
@@ -58732,61 +59389,139 @@ function node_id_of_key(gobj, key) {
58732
59389
  return build_node_name(gobj, key.slice(0, i), key.slice(i + 1));
58733
59390
  }
58734
59391
  /************************************************************
58735
- * Find the nodes whose name, id or topic contains `term`, highlight
58736
- * them with the same amber 'active' state the topic focus uses, and
58737
- * centre the viewport on them. Returns how many matched, which is the
58738
- * half the toolbar needs: a search that finds nothing must SAY so,
58739
- * because a graph that did not move is also what a graph looks like
58740
- * when the match is off-screen.
59392
+ * The find box: highlight the cards ON SCREEN whose name, id or
59393
+ * topic contains `term`, and change nothing else -- no group is
59394
+ * opened, no layout runs, the camera does not move. The graph the
59395
+ * reader arranged is the graph they are searching; emptying the
59396
+ * box takes the amber off and leaves it exactly as it was.
59397
+ *
59398
+ * It used to search the whole treedb and unfold a page of what it
59399
+ * found, which re-laid the graph out on every keystroke and left
59400
+ * the unfolded groups behind when the box was cleared. Opening up
59401
+ * to a topic is the legend's focus (graph_focus_topic); this only
59402
+ * LOOKS.
59403
+ *
59404
+ * The term is kept and repainted after every reconcile (see
59405
+ * reconcile_fold_now): a group opened by hand shows its matches
59406
+ * already lit, and the count follows what is on screen.
58741
59407
  *
58742
59408
  * It shares the highlight slot with graph_focus_topic on purpose —
58743
- * two amber sets at once would say nothing about either. An empty
58744
- * term clears it. Matching reads the node's LABEL and not only its
58745
- * id: on a topic keyed by rowid or by a qualified path, the id is a
58746
- * counter and the name a human knows is elsewhere (see node_label).
59409
+ * two amber sets at once would say nothing about either. Matching
59410
+ * reads the node's LABEL and not only its id: on a topic keyed by
59411
+ * rowid or by a qualified path, the id is a counter and the name a
59412
+ * human knows is elsewhere (see node_label).
58747
59413
  ************************************************************/
58748
59414
  function graph_find_nodes(gobj, term) {
58749
59415
  let priv = gobj.priv;
58750
- if (!priv.graph || !priv.graph_rendered || !priv._fold_model) {
58751
- priv._pending_find = term;
58752
- return 0;
59416
+ let next = empty_string(term) ? "" : String(term);
59417
+ let was = priv._find_term;
59418
+ priv._find_term = next;
59419
+ priv._find_current = "";
59420
+ priv._find_found = null;
59421
+ let ready = priv.graph && priv.graph_rendered && priv._fold_model;
59422
+ if (!next) {
59423
+ if (was && ready && !priv._focus_topic) apply_focus_ids(gobj, [], "find", false);
59424
+ publish_find_result(gobj);
59425
+ return;
58753
59426
  }
59427
+ priv._focus_topic = null;
59428
+ if (!ready) return;
59429
+ refresh_find(gobj, fold_visible_set(priv._fold_model, priv._fold_state));
59430
+ }
59431
+ /************************************************************
59432
+ * Repaint the find's highlight over the `visible` set and publish
59433
+ * the count. A no-op while no term is live, so the topic focus
59434
+ * keeps the slot.
59435
+ ************************************************************/
59436
+ function refresh_find(gobj, visible) {
59437
+ let priv = gobj.priv;
59438
+ if (!priv._find_term) return;
59439
+ let found = find_matches(gobj, priv._find_term, visible);
59440
+ apply_focus_ids(gobj, found.keys, "find", false);
59441
+ if (priv._find_current && (priv._focus_ids || []).indexOf(priv._find_current) < 0) priv._find_current = "";
59442
+ priv._find_found = found;
59443
+ publish_find_result(gobj);
59444
+ }
59445
+ /************************************************************
59446
+ * The records matching `term`: the keys ON SCREEN, and how many
59447
+ * more match that are NOT -- folded away in the fold model, or in
59448
+ * a hidden topic. Those two are only counted: "0" alone would read
59449
+ * as "does not exist" when it means "is not on screen".
59450
+ ************************************************************/
59451
+ function find_matches(gobj, term, visible) {
59452
+ let priv = gobj.priv;
58754
59453
  let model = priv._fold_model;
58755
- let state = priv._fold_state;
58756
- let keys = [];
58757
- let hidden_matches = 0;
58758
- if (!empty_string(term)) {
58759
- let needle = String(term).toLowerCase();
58760
- let matches = (topic_name, record) => {
58761
- let label = "";
58762
- try {
58763
- label = node_label(priv.descs[topic_name] || {}, record) || "";
58764
- } catch (e) {
58765
- label = "";
58766
- }
58767
- return [
58768
- label,
58769
- record.id,
58770
- topic_name
58771
- ].filter((v) => typeof v === "string").join(" ").toLowerCase().includes(needle);
58772
- };
58773
- for (let node of model.nodes.values()) if (matches(node.topic_name, node.record || {})) keys.push(node.key);
58774
- for (let topic_name of model.hidden) for (let record of priv.records[topic_name] || []) if (is_object(record) && matches(topic_name, record)) hidden_matches++;
59454
+ let found = {
59455
+ keys: [],
59456
+ folded: 0,
59457
+ hidden: 0
59458
+ };
59459
+ if (!model || empty_string(term)) return found;
59460
+ let needle = String(term).toLowerCase();
59461
+ let matches = (topic_name, record) => {
59462
+ let label = "";
59463
+ try {
59464
+ label = node_label(priv.descs[topic_name] || {}, record) || "";
59465
+ } catch (e) {
59466
+ label = "";
59467
+ }
59468
+ return [
59469
+ label,
59470
+ record.id,
59471
+ topic_name
59472
+ ].filter((v) => typeof v === "string").join(" ").toLowerCase().includes(needle);
59473
+ };
59474
+ for (let node of model.nodes.values()) if (matches(node.topic_name, node.record || {})) {
59475
+ if (visible.has(node.key)) found.keys.push(node.key);
59476
+ else found.folded++;
58775
59477
  }
58776
- priv._find_hidden_matches = hidden_matches;
58777
- let visible = fold_visible_set(model, state);
58778
- let revealed = 0;
58779
- for (let key of keys) {
58780
- if (visible.has(key)) continue;
58781
- if (revealed >= state.page_size) break;
58782
- fold_reveal(model, state, key);
58783
- revealed++;
59478
+ for (let topic_name of model.hidden) for (let record of priv.records[topic_name] || []) if (is_object(record) && matches(topic_name, record)) found.hidden++;
59479
+ return found;
59480
+ }
59481
+ /************************************************************
59482
+ * The lit cards in reading order: top to bottom, then left to
59483
+ * right. The order Enter walks, and the one the "k/N" counts in.
59484
+ ************************************************************/
59485
+ function find_order(gobj) {
59486
+ let priv = gobj.priv;
59487
+ let graph = priv.graph;
59488
+ let ids = (priv._focus_ids || []).slice();
59489
+ if (!graph) return ids;
59490
+ let pos = {};
59491
+ for (let id of ids) {
59492
+ let p = null;
59493
+ try {
59494
+ p = graph.getElementPosition(id);
59495
+ } catch (e) {
59496
+ p = null;
59497
+ }
59498
+ pos[id] = p || [0, 0];
58784
59499
  }
58785
- priv._focus_topic = null;
58786
- reconcile_fold(gobj, {}).then(() => {
58787
- apply_focus_ids(gobj, keys, "find");
58788
- });
58789
- return keys.length;
59500
+ ids.sort((a, b) => pos[a][1] - pos[b][1] || pos[a][0] - pos[b][0]);
59501
+ return ids;
59502
+ }
59503
+ /************************************************************
59504
+ * Enter in the find box: centre the next lit card (`back`: the
59505
+ * previous one). The one camera move of the find, and the reader
59506
+ * asks for it: the highlight alone cannot take anybody to a match
59507
+ * that is outside the frame.
59508
+ ************************************************************/
59509
+ function graph_find_step(gobj, back) {
59510
+ let priv = gobj.priv;
59511
+ let graph = priv.graph;
59512
+ if (!graph || !priv._find_term) return;
59513
+ let ids = find_order(gobj);
59514
+ if (ids.length === 0) return;
59515
+ let i = ids.indexOf(priv._find_current);
59516
+ if (i < 0) i = back ? ids.length - 1 : 0;
59517
+ else i = (i + (back ? ids.length - 1 : 1)) % ids.length;
59518
+ priv._find_current = ids[i];
59519
+ try {
59520
+ yui_graph_center_on(graph, ids[i]);
59521
+ } catch (e) {
59522
+ log_error(`${gobj_short_name(gobj)}: find step failed: ${e}`);
59523
+ }
59524
+ publish_find_result(gobj);
58790
59525
  }
58791
59526
  async function graph_zoom_in(gobj) {
58792
59527
  let graph = gobj.priv.graph;
@@ -62678,6 +63413,7 @@ async function reconcile_fold_now(gobj, opts) {
62678
63413
  history_resume(gobj);
62679
63414
  }
62680
63415
  publish_legend_state(gobj, visible);
63416
+ refresh_find(gobj, visible);
62681
63417
  return changed;
62682
63418
  }
62683
63419
  /************************************************************
@@ -63059,11 +63795,6 @@ function ac_load_data(gobj, event, kw, src) {
63059
63795
  priv._pending_focus_all = false;
63060
63796
  graph_focus_topic(gobj, ft, all);
63061
63797
  }
63062
- if (priv._pending_find !== null) {
63063
- let term = priv._pending_find;
63064
- priv._pending_find = null;
63065
- publish_find_result(gobj, term, graph_find_nodes(gobj, term));
63066
- }
63067
63798
  });
63068
63799
  }
63069
63800
  return 0;
@@ -63632,23 +64363,45 @@ function ac_node_dblclick(gobj, event, kw, src) {
63632
64363
  return 0;
63633
64364
  }
63634
64365
  /************************************************************
63635
- * Tell whoever asked how many nodes the term matched. The count is
63636
- * the answer to the question the box asks; without it "nothing moved"
63637
- * and "nothing matched" look the same.
64366
+ * Tell whoever asked how many cards the term lit. The count is the
64367
+ * answer to the question the box asks; without it "nothing lit" and
64368
+ * "nothing matched" look the same.
64369
+ *
64370
+ * matches lit cards (on screen)
64371
+ * folded_matches matches folded away, not on screen
64372
+ * hidden_matches matches in hidden topics
64373
+ * current 1-based place of the card Enter centred on
64374
+ * among the lit ones (reading order), 0 = none
63638
64375
  ************************************************************/
63639
- function publish_find_result(gobj, term, matches) {
64376
+ function publish_find_result(gobj) {
64377
+ let priv = gobj.priv;
64378
+ let found = priv._find_found || {
64379
+ keys: [],
64380
+ folded: 0,
64381
+ hidden: 0
64382
+ };
64383
+ let current = 0;
64384
+ if (priv._find_term && priv._find_current) current = find_order(gobj).indexOf(priv._find_current) + 1;
63640
64385
  gobj_publish_event(gobj, "EV_FIND_RESULT", {
63641
- term,
63642
- matches,
63643
- hidden_matches: gobj.priv._find_hidden_matches || 0
64386
+ term: priv._find_term,
64387
+ matches: priv._find_term ? (priv._focus_ids || []).length : 0,
64388
+ folded_matches: found.folded,
64389
+ hidden_matches: found.hidden,
64390
+ current
63644
64391
  });
63645
64392
  }
63646
64393
  /************************************************************
63647
64394
  *
63648
64395
  ************************************************************/
63649
64396
  function ac_find_nodes$1(gobj, event, kw, src) {
63650
- let term = kw && kw.text || "";
63651
- publish_find_result(gobj, term, graph_find_nodes(gobj, term));
64397
+ graph_find_nodes(gobj, kw && kw.text || "");
64398
+ return 0;
64399
+ }
64400
+ /************************************************************
64401
+ * Enter in the find box: {back: true} for Shift+Enter.
64402
+ ************************************************************/
64403
+ function ac_find_next$1(gobj, event, kw, src) {
64404
+ graph_find_step(gobj, !!(kw && kw.back));
63652
64405
  return 0;
63653
64406
  }
63654
64407
  function ac_center(gobj, event, kw, src) {
@@ -64109,6 +64862,11 @@ function create_gclass$4(gclass_name) {
64109
64862
  ac_find_nodes$1,
64110
64863
  null
64111
64864
  ],
64865
+ [
64866
+ "EV_FIND_NEXT",
64867
+ ac_find_next$1,
64868
+ null
64869
+ ],
64112
64870
  [
64113
64871
  "EV_TOGGLE_FOLD",
64114
64872
  ac_toggle_fold,
@@ -64264,6 +65022,7 @@ function create_gclass$4(gclass_name) {
64264
65022
  ["EV_ZOOM_SELECTION", 0],
64265
65023
  ["EV_FOCUS_TOPIC", 0],
64266
65024
  ["EV_FIND_NODES", 0],
65025
+ ["EV_FIND_NEXT", 0],
64267
65026
  ["EV_FIND_RESULT", event_flag_t.EVF_OUTPUT_EVENT],
64268
65027
  ["EV_LAYOUT_AUTOSET", event_flag_t.EVF_OUTPUT_EVENT],
64269
65028
  ["EV_CAMERA_CHANGED", event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS],
@@ -65056,19 +65815,34 @@ function make_toolbar(gobj) {
65056
65815
  type: "text",
65057
65816
  placeholder: t("search"),
65058
65817
  "data-i18n-placeholder": "search",
65059
- "aria-label": t("search"),
65060
- "data-i18n-aria-label": "search"
65818
+ title: t("find on screen"),
65819
+ "data-i18n-title": "find on screen",
65820
+ "aria-label": t("find on screen"),
65821
+ "data-i18n-aria-label": "find on screen"
65061
65822
  },
65062
65823
  [],
65063
- { input: (evt) => {
65064
- evt.stopPropagation();
65065
- let text = evt.target.value.trim();
65066
- if (priv.find_timer) clearTimeout(priv.find_timer);
65067
- priv.find_timer = setTimeout(function() {
65068
- priv.find_timer = null;
65069
- gobj_send_event(gobj, "EV_FIND_NODES", { text }, gobj);
65070
- }, 250);
65071
- } }
65824
+ {
65825
+ input: (evt) => {
65826
+ evt.stopPropagation();
65827
+ let text = evt.target.value.trim();
65828
+ if (priv.find_timer) clearTimeout(priv.find_timer);
65829
+ priv.find_timer = setTimeout(function() {
65830
+ priv.find_timer = null;
65831
+ gobj_send_event(gobj, "EV_FIND_NODES", { text }, gobj);
65832
+ }, 250);
65833
+ },
65834
+ keydown: (evt) => {
65835
+ if (evt.key !== "Enter" || evt.isComposing) return;
65836
+ evt.preventDefault();
65837
+ evt.stopPropagation();
65838
+ if (priv.find_timer) {
65839
+ clearTimeout(priv.find_timer);
65840
+ priv.find_timer = null;
65841
+ gobj_send_event(gobj, "EV_FIND_NODES", { text: evt.target.value.trim() }, gobj);
65842
+ }
65843
+ gobj_send_event(gobj, "EV_FIND_NEXT", { back: evt.shiftKey }, gobj);
65844
+ }
65845
+ }
65072
65846
  ]);
65073
65847
  let $find_control = createElement2([
65074
65848
  "div",
@@ -66397,6 +67171,14 @@ function ac_find_nodes(gobj, event, kw, src) {
66397
67171
  return 0;
66398
67172
  }
66399
67173
  /************************************************************
67174
+ * Enter in the find box: forward the step down to the graph child.
67175
+ ************************************************************/
67176
+ function ac_find_next(gobj, event, kw, src) {
67177
+ let priv = gobj.priv;
67178
+ if (priv.gobj_nodes_tree) gobj_send_event(priv.gobj_nodes_tree, "EV_FIND_NEXT", { back: !!(kw && kw.back) }, gobj);
67179
+ return 0;
67180
+ }
67181
+ /************************************************************
66400
67182
  * One press of the fold stepper (EV_EXPAND_LEVEL /
66401
67183
  * EV_COLLAPSE_LEVEL): the level moves by one, is SAVED (the
66402
67184
  * reader's, per treedb) and reaches the G6 child, which owns
@@ -66480,12 +67262,33 @@ function refresh_find_result(gobj) {
66480
67262
  return 0;
66481
67263
  }
66482
67264
  let text = t("matches", { count: kw && kw.matches || 0 });
67265
+ let current = kw && kw.current || 0;
67266
+ if (current > 0) text = `${current}/${text}`;
67267
+ let extra = [];
67268
+ let folded = kw && kw.folded_matches || 0;
67269
+ if (folded > 0) extra.push(`+${folded} ${t("not shown")}`);
66483
67270
  let hidden = kw && kw.hidden_matches || 0;
66484
- if (hidden > 0) text += ` (+${hidden} ${t("hidden topics")})`;
67271
+ if (hidden > 0) extra.push(`+${hidden} ${t("hidden topics")}`);
67272
+ if (extra.length) text += ` (${extra.join(", ")})`;
66485
67273
  $count.textContent = text;
66486
67274
  $result.classList.remove("is-hidden");
66487
67275
  return 0;
66488
67276
  }
67277
+ function clear_find_box(gobj) {
67278
+ let priv = gobj.priv;
67279
+ if (priv.find_timer) {
67280
+ clearTimeout(priv.find_timer);
67281
+ priv.find_timer = null;
67282
+ }
67283
+ let $container = gobj_read_attr(gobj, "$container");
67284
+ let $input = $container ? $container.querySelector(".GRAPH_FIND_INPUT") : null;
67285
+ if ($input) {
67286
+ $input.value = "";
67287
+ refresh_clear($input);
67288
+ }
67289
+ priv._find_result = null;
67290
+ refresh_find_result(gobj);
67291
+ }
66489
67292
  function ac_set_focus_topic(gobj, event, kw, src) {
66490
67293
  let priv = gobj.priv;
66491
67294
  priv.focus_topic = kw && kw.topic || "";
@@ -66496,6 +67299,7 @@ function ac_set_focus_topic(gobj, event, kw, src) {
66496
67299
  set_legend_list_attr(gobj, "hidden_topics", "EV_SET_HIDDEN_TOPICS", hidden);
66497
67300
  }
66498
67301
  }
67302
+ if (priv.focus_topic) clear_find_box(gobj);
66499
67303
  if (priv.gobj_nodes_tree) gobj_send_event(priv.gobj_nodes_tree, "EV_FOCUS_TOPIC", {
66500
67304
  topic: priv.focus_topic,
66501
67305
  reveal: kw && kw.reveal || "page"
@@ -66673,6 +67477,11 @@ function create_gclass$3(gclass_name) {
66673
67477
  ac_find_nodes,
66674
67478
  null
66675
67479
  ],
67480
+ [
67481
+ "EV_FIND_NEXT",
67482
+ ac_find_next,
67483
+ null
67484
+ ],
66676
67485
  [
66677
67486
  "EV_EXPAND_LEVEL",
66678
67487
  ac_fold_level_step,
@@ -66771,6 +67580,7 @@ function create_gclass$3(gclass_name) {
66771
67580
  ["EV_SET_OPERATION_MODE", 0],
66772
67581
  ["EV_SET_FOCUS_TOPIC", 0],
66773
67582
  ["EV_FIND_NODES", 0],
67583
+ ["EV_FIND_NEXT", 0],
66774
67584
  ["EV_EXPAND_LEVEL", 0],
66775
67585
  ["EV_COLLAPSE_LEVEL", 0],
66776
67586
  ["EV_SET_NODE_MODE", 0],