@yuneta/gobj-ui 7.23.158 → 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.
@@ -81,6 +81,7 @@ const attrs_table = [
81
81
  SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
82
82
  SDATA(data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, "Remote yuno for data fetching"),
83
83
  SDATA(data_type_t.DTP_STRING, "treedb_name", 0, null, "Remote TreeDB service name"),
84
+ 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"),
84
85
  SDATA(data_type_t.DTP_JSON, "descs", 0, null, "Description of topics"),
85
86
  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"),
86
87
  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"),
@@ -1883,6 +1884,33 @@ function ac_mt_command_answer(gobj, event, kw, src)
1883
1884
  return 0;
1884
1885
  }
1885
1886
 
1887
+ /*
1888
+ * An asset, for a form's preview or a file popup. Before the generic
1889
+ * error path too: an asset that cannot be served is SHOWN where it
1890
+ * was asked for (the missing marker, with the reason), not raised as
1891
+ * an app-wide modal over the form that asked.
1892
+ */
1893
+ if(command === "get-asset") {
1894
+ let asset_topic = kw_get_str(gobj, kw_command, "topic_name", "", 0);
1895
+ let req_id = kw_get_str(gobj, kw_command, "req_id", "", 0);
1896
+ let gobj_topic_form = gobj_find_child(gobj, {
1897
+ __gobj_name__: `${gobj_name(gobj)}?${asset_topic}`
1898
+ });
1899
+ if(!gobj_topic_form || !req_id) {
1900
+ log_error(`${gobj_short_name(gobj)}: get-asset answer for nobody: ` +
1901
+ `topic '${asset_topic}', req_id '${req_id}'`);
1902
+ return 0;
1903
+ }
1904
+ if(result < 0) {
1905
+ gobj_send_event(gobj_topic_form, "EV_ASSET_FAILED",
1906
+ {req_id: req_id, error: comment || "get-asset failed"}, gobj);
1907
+ } else {
1908
+ gobj_send_event(gobj_topic_form, "EV_ASSET_LOADED",
1909
+ {req_id: req_id, answer: data}, gobj);
1910
+ }
1911
+ return 0;
1912
+ }
1913
+
1886
1914
  if(result < 0) {
1887
1915
  if(command === "descs") {
1888
1916
  /* The schema couldn't load (not a treedb, no authz for it, backend
@@ -2356,6 +2384,56 @@ function ac_request_page(gobj, event, kw, src)
2356
2384
  return 0;
2357
2385
  }
2358
2386
 
2387
+ /********************************************
2388
+ * Event from formtable: the bytes of one asset a `file` column names,
2389
+ * for the form's preview or the table's file popup.
2390
+ *
2391
+ * Asked to the ASSETS service and not to the treedb: storing is the
2392
+ * treedb's, the way out is C_ASSETS's (a signed url when a web server
2393
+ * sits in front of the store, the bytes inline when there is none). The
2394
+ * answer goes back down to the table that asked, by `req_id`.
2395
+ *
2396
+ * A read: a read-only treedb still answers it.
2397
+ ********************************************/
2398
+ function ac_request_asset(gobj, event, kw, src)
2399
+ {
2400
+ if(!kw.req_id || !kw.asset_id) {
2401
+ log_error(`${gobj_short_name(gobj)}: an asset request with no id`);
2402
+ return -1;
2403
+ }
2404
+
2405
+ let assets_service = gobj_read_str_attr(gobj, "assets_service");
2406
+ let gobj_remote_yuno = gobj_read_pointer_attr(gobj, "gobj_remote_yuno");
2407
+ if(!assets_service || !gobj_remote_yuno) {
2408
+ gobj_send_event(src, "EV_ASSET_FAILED", {
2409
+ req_id: kw.req_id,
2410
+ error: assets_service? "cannot reach the backend": "no assets service"
2411
+ }, gobj);
2412
+ return 0;
2413
+ }
2414
+
2415
+ let ret = gobj_command(
2416
+ gobj_remote_yuno,
2417
+ "get-asset",
2418
+ {
2419
+ service: assets_service,
2420
+ asset_id: kw.asset_id,
2421
+ __md_command__: { // Data to be returned
2422
+ topic_name: kw.topic_name || gobj_read_attr(src, "topic_name"),
2423
+ req_id: kw.req_id
2424
+ }
2425
+ },
2426
+ gobj
2427
+ );
2428
+ if(ret) {
2429
+ log_error(ret);
2430
+ /* The command never went out, so no answer will ever come. */
2431
+ gobj_send_event(src, "EV_ASSET_FAILED",
2432
+ {req_id: kw.req_id, error: "cannot reach the backend"}, gobj);
2433
+ }
2434
+ return 0;
2435
+ }
2436
+
2359
2437
  /********************************************
2360
2438
  * Event from formtable: ONE field of one record, edited in place.
2361
2439
  *
@@ -2597,6 +2675,7 @@ function create_gclass(gclass_name)
2597
2675
  ["EV_UPDATE_RECORD", ac_update_record, null],
2598
2676
  ["EV_UPDATE_FIELD", ac_update_field, null],
2599
2677
  ["EV_REQUEST_PAGE", ac_request_page, null],
2678
+ ["EV_REQUEST_ASSET", ac_request_asset, null],
2600
2679
  ["EV_DELETE_RECORD", ac_delete_record, null],
2601
2680
  ["EV_REFRESH_TOPIC", ac_refresh_topic, null],
2602
2681
  ["EV_OPEN_JSON", ac_open_json, null],
@@ -2626,6 +2705,7 @@ function create_gclass(gclass_name)
2626
2705
  ["EV_UPDATE_RECORD", 0],
2627
2706
  ["EV_UPDATE_FIELD", 0],
2628
2707
  ["EV_REQUEST_PAGE", 0],
2708
+ ["EV_REQUEST_ASSET", 0],
2629
2709
  ["EV_DELETE_RECORD", 0],
2630
2710
  ["EV_RECORD_WRITTEN", event_flag_t.EVF_OUTPUT_EVENT|
2631
2711
  event_flag_t.EVF_NO_WARN_SUBS],
package/src/yui_asset.css CHANGED
@@ -41,3 +41,54 @@
41
41
  max-width: 12rem;
42
42
  overflow-wrap: anywhere;
43
43
  }
44
+
45
+ /*
46
+ * A PDF, in the browser's own viewer. It has no height of its own, so it
47
+ * gets one; a host that wants it bigger sizes it through its own class.
48
+ */
49
+ .ASSET_DOCUMENT {
50
+ display: block;
51
+ width: 100%;
52
+ min-height: 24rem;
53
+ border: 1px solid var(--bulma-border, #dbdbdb);
54
+ border-radius: var(--bulma-radius, 4px);
55
+ background: var(--bulma-scheme-main, #fff);
56
+ }
57
+
58
+ /*
59
+ * A kind nothing here can draw: what it is, and the way to open it. The
60
+ * same footprint as the missing marker, and solid where that one is
61
+ * dashed -- this asset IS there.
62
+ */
63
+ .ASSET_OTHER {
64
+ display: flex;
65
+ flex-direction: column;
66
+ align-items: center;
67
+ justify-content: center;
68
+ gap: 0.35rem;
69
+ min-width: 5rem;
70
+ min-height: 5rem;
71
+ padding: 0.75rem;
72
+ border: 1px solid var(--bulma-border, #dbdbdb);
73
+ border-radius: var(--bulma-radius, 4px);
74
+ text-align: center;
75
+ }
76
+
77
+ .ASSET_OTHER_TYPE {
78
+ font-family: monospace;
79
+ font-size: 0.85rem;
80
+ }
81
+
82
+ .ASSET_OTHER_DETAIL {
83
+ font-size: 0.75rem;
84
+ opacity: 0.75;
85
+ max-width: 16rem;
86
+ overflow-wrap: anywhere;
87
+ }
88
+
89
+ .ASSET_OPEN {
90
+ display: inline-flex;
91
+ align-items: center;
92
+ gap: 0.25rem;
93
+ white-space: nowrap;
94
+ }
package/src/yui_asset.js CHANGED
@@ -19,8 +19,17 @@ import {
19
19
  createElement2, log_error
20
20
  } from "@yuneta/gobj-js";
21
21
 
22
+ import {t} from "i18next";
23
+
22
24
  import "./yui_asset.css";
23
25
 
26
+ /*
27
+ * The attribute that marks an element holding a `blob:` url this module
28
+ * created. A blob url keeps its bytes alive until it is revoked, so
29
+ * whoever removes the element calls yui_asset_release() on it.
30
+ */
31
+ const BLOB_ATTR = "data-yui-blob";
32
+
24
33
  /*
25
34
  * A column that holds a link comes back in one of THREE shapes, and which
26
35
  * one is the READER's choice, not the schema's:
@@ -139,6 +148,170 @@ function yui_asset_missing(detail, opts = {})
139
148
  return createElement2(["div", attrs, children]);
140
149
  }
141
150
 
151
+ /***************************************************************************
152
+ * What KIND of thing a content type is, as far as showing it goes:
153
+ *
154
+ * "image" | "video" | "audio" the browser draws it in an element
155
+ * "pdf" the browser's own viewer, in a frame
156
+ * "other" nothing here can draw it: a card
157
+ * saying what it is, and a way to open
158
+ *
159
+ * The content type is the one the BACKEND stored (treedb checks it
160
+ * against the bytes), never the extension of a name a person typed.
161
+ ***************************************************************************/
162
+ function yui_asset_kind(content_type)
163
+ {
164
+ const ct = String(content_type || "").toLowerCase().split(";")[0].trim();
165
+ if(ct.startsWith("image/")) {
166
+ return "image";
167
+ }
168
+ if(ct.startsWith("video/")) {
169
+ return "video";
170
+ }
171
+ if(ct.startsWith("audio/")) {
172
+ return "audio";
173
+ }
174
+ if(ct === "application/pdf") {
175
+ return "pdf";
176
+ }
177
+ return "other";
178
+ }
179
+
180
+ /***************************************************************************
181
+ * A `blob:` url for inline bytes, or null.
182
+ *
183
+ * A frame and a new tab need a blob and not a data url: Firefox refuses to
184
+ * NAVIGATE to a data url (a new tab opened on one stays blank), and its
185
+ * PDF viewer does not run on one inside a frame.
186
+ ***************************************************************************/
187
+ function base64_to_blob_url(content64, content_type)
188
+ {
189
+ try {
190
+ const binary = atob(content64);
191
+ const bytes = new Uint8Array(binary.length);
192
+ for(let i = 0; i < binary.length; i++) {
193
+ bytes[i] = binary.charCodeAt(i);
194
+ }
195
+ const blob = new Blob([bytes], {type: content_type || "application/octet-stream"});
196
+ return URL.createObjectURL(blob);
197
+ } catch(e) {
198
+ log_error(`cannot decode the bytes of an asset: ${e && e.message? e.message: e}`);
199
+ return null;
200
+ }
201
+ }
202
+
203
+ /***************************************************************************
204
+ * Where a frame or a link can take an asset:
205
+ *
206
+ * -> {href, blob} `blob` true when href is a blob url THIS call
207
+ * created, which the caller must mark (BLOB_ATTR)
208
+ * so that yui_asset_release() revokes it
209
+ * -> null nothing to open
210
+ ***************************************************************************/
211
+ function yui_asset_href(answer)
212
+ {
213
+ if(!answer) {
214
+ return null;
215
+ }
216
+ if(answer.mode === "url" && answer.url) {
217
+ return {href: answer.url, blob: false};
218
+ }
219
+ if(answer.mode === "inline" && answer.content64) {
220
+ const href = base64_to_blob_url(answer.content64, answer.content_type);
221
+ return href? {href: href, blob: true}: null;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ /***************************************************************************
227
+ * The answer a picked `File` would have had from the backend.
228
+ *
229
+ * A file a person picked and has not saved yet is shown BEFORE it exists
230
+ * as an asset, so it goes through the same element as a stored one: a
231
+ * `url` answer whose url is a blob url over the File itself -- which
232
+ * reads nothing until the browser draws it.
233
+ *
234
+ * `blob: true` says the url is owned: whoever drops the element revokes
235
+ * it with yui_asset_release().
236
+ ***************************************************************************/
237
+ function yui_asset_file_answer(file)
238
+ {
239
+ if(!file) {
240
+ return null;
241
+ }
242
+ let url;
243
+ try {
244
+ url = URL.createObjectURL(file);
245
+ } catch(e) {
246
+ log_error(`cannot preview the picked file: ${e && e.message? e.message: e}`);
247
+ return null;
248
+ }
249
+ return {
250
+ mode: "url",
251
+ url: url,
252
+ content_type: file.type || "",
253
+ size: file.size,
254
+ original_name: file.name || "",
255
+ blob: true
256
+ };
257
+ }
258
+
259
+ /***************************************************************************
260
+ * Revoke every blob url this module created under `$root`, $root included.
261
+ *
262
+ * Call it on whatever element is about to be dropped: a preview being
263
+ * replaced, a dialog being closed. A blob url that is never revoked keeps
264
+ * its bytes in memory for the life of the page.
265
+ ***************************************************************************/
266
+ function yui_asset_release($root)
267
+ {
268
+ if(!$root || !$root.querySelectorAll) {
269
+ return;
270
+ }
271
+ const marked = Array.from($root.querySelectorAll(`[${BLOB_ATTR}]`));
272
+ if($root.hasAttribute && $root.hasAttribute(BLOB_ATTR)) {
273
+ marked.push($root);
274
+ }
275
+ marked.forEach(($el) => {
276
+ const url = $el.getAttribute(BLOB_ATTR);
277
+ $el.removeAttribute(BLOB_ATTR);
278
+ if(url) {
279
+ URL.revokeObjectURL(url);
280
+ }
281
+ });
282
+ }
283
+
284
+ /***************************************************************************
285
+ * The link that opens an asset in a new tab, or null when there is
286
+ * nothing to open. A control: named for the eye and for a reader, both
287
+ * through their keys so a language change re-translates them.
288
+ ***************************************************************************/
289
+ function yui_asset_open_link(answer, opts = {})
290
+ {
291
+ const target = yui_asset_href(answer);
292
+ if(!target) {
293
+ return null;
294
+ }
295
+ const key = "open in a new tab";
296
+ const $a = createElement2(["a", {
297
+ class: `ASSET_OPEN ${opts.class || ""}`.trim(),
298
+ target: "_blank",
299
+ rel: "noopener",
300
+ title: t(key),
301
+ "data-i18n-title": key,
302
+ "aria-label": t(key),
303
+ "data-i18n-aria-label": key
304
+ }, [
305
+ ["span", {class: "ASSET_OPEN_ICON yi-link"}],
306
+ ["span", {class: "ASSET_OPEN_LABEL", i18n: key}, key]
307
+ ]]);
308
+ $a.href = target.href;
309
+ if(target.blob) {
310
+ $a.setAttribute(BLOB_ATTR, target.href);
311
+ }
312
+ return $a;
313
+ }
314
+
142
315
  /***************************************************************************
143
316
  * The element for one asset.
144
317
  *
@@ -148,6 +321,11 @@ function yui_asset_missing(detail, opts = {})
148
321
  *
149
322
  * `detail` is what the missing marker shows -- pass the original name or
150
323
  * the source path, the thing a person can act on.
324
+ *
325
+ * A PDF goes in a frame, where the browser's own viewer draws it. Any
326
+ * other kind gets a card: what it is, and a link to open it.
327
+ * TODO: draw more kinds in place (plain text, office documents); today
328
+ * they are only opened in a new tab, where the browser does what it can.
151
329
  ***************************************************************************/
152
330
  function yui_asset_element(answer, opts = {})
153
331
  {
@@ -156,19 +334,55 @@ function yui_asset_element(answer, opts = {})
156
334
  return yui_asset_missing(opts.detail, opts);
157
335
  }
158
336
 
159
- const ct = (answer.content_type || "").toLowerCase();
337
+ const kind = yui_asset_kind(answer.content_type);
160
338
  const cls = `ASSET_MEDIA ${opts.class || ""}`.trim();
161
339
 
340
+ if(kind === "pdf") {
341
+ const target = yui_asset_href(answer);
342
+ if(!target) {
343
+ return yui_asset_missing(opts.detail, opts); // Error already logged
344
+ }
345
+ /* The title is the DETAIL (a name, data) or the content type:
346
+ * a frame is announced by its title. */
347
+ const $frame = createElement2(["iframe", {
348
+ class: `${cls} ASSET_DOCUMENT`,
349
+ title: opts.detail || answer.content_type || ""
350
+ }]);
351
+ $frame.src = target.href;
352
+ if(target.blob) {
353
+ $frame.setAttribute(BLOB_ATTR, target.href);
354
+ }
355
+ return $frame;
356
+ }
357
+
358
+ if(kind === "other") {
359
+ const children = [
360
+ ["span", {class: "ASSET_OTHER_TYPE"}, String(answer.content_type || "?")]
361
+ ];
362
+ if(opts.detail) {
363
+ children.push(["span", {class: "ASSET_OTHER_DETAIL"}, String(opts.detail)]);
364
+ }
365
+ const $card = createElement2(["div", {class: `${cls} ASSET_OTHER`}, children]);
366
+ const $open = yui_asset_open_link(answer);
367
+ if($open) {
368
+ $card.appendChild($open);
369
+ }
370
+ return $card;
371
+ }
372
+
162
373
  let el;
163
- if(ct.startsWith("video/")) {
374
+ if(kind === "video") {
164
375
  el = createElement2(["video", {class: cls, controls: "", preload: "metadata"}]);
165
- } else if(ct.startsWith("audio/")) {
376
+ } else if(kind === "audio") {
166
377
  el = createElement2(["audio", {class: cls, controls: "", preload: "metadata"}]);
167
378
  } else {
168
379
  el = createElement2(["img", {class: cls, loading: "lazy"}]);
169
380
  el.alt = opts.alt || opts.detail || "";
170
381
  }
171
382
  el.src = src;
383
+ if(answer.blob) {
384
+ el.setAttribute(BLOB_ATTR, src);
385
+ }
172
386
 
173
387
  /*
174
388
  * A url can 403 (expired signature), a blob can be gone from the
@@ -191,6 +405,7 @@ function yui_asset_element(answer, opts = {})
191
405
  if(el.parentNode) {
192
406
  el.parentNode.replaceChild($marker, el);
193
407
  }
408
+ yui_asset_release(el);
194
409
  };
195
410
 
196
411
  return el;
@@ -200,6 +415,11 @@ export {
200
415
  yui_asset_id,
201
416
  yui_asset_ids,
202
417
  yui_asset_src,
418
+ yui_asset_kind,
419
+ yui_asset_href,
420
+ yui_asset_file_answer,
421
+ yui_asset_release,
422
+ yui_asset_open_link,
203
423
  yui_asset_element,
204
424
  yui_asset_missing
205
425
  };
@@ -9,8 +9,26 @@ import {
9
9
  yui_asset_id,
10
10
  yui_asset_ids,
11
11
  yui_asset_src,
12
+ yui_asset_kind,
12
13
  } from "./yui_asset.js";
13
14
 
15
+ /*
16
+ * The kind decides the element: an <img> over a PDF is the broken box
17
+ * this module exists to remove, and a parameter on the content type must
18
+ * not turn a PDF into "something nothing can draw".
19
+ */
20
+ test("the kind comes from the stored content type", () => {
21
+ expect(yui_asset_kind("image/jpeg")).toBe("image");
22
+ expect(yui_asset_kind("IMAGE/PNG")).toBe("image");
23
+ expect(yui_asset_kind("video/mp4")).toBe("video");
24
+ expect(yui_asset_kind("audio/mpeg")).toBe("audio");
25
+ expect(yui_asset_kind("application/pdf")).toBe("pdf");
26
+ expect(yui_asset_kind("application/pdf; charset=binary")).toBe("pdf");
27
+ expect(yui_asset_kind("application/zip")).toBe("other");
28
+ expect(yui_asset_kind("")).toBe("other");
29
+ expect(yui_asset_kind(undefined)).toBe("other");
30
+ });
31
+
14
32
  /*
15
33
  * A treedb fkey is `topic^id^hook`.
16
34
  */
@@ -65,3 +65,31 @@
65
65
  opacity: 0.6;
66
66
  font-style: italic;
67
67
  }
68
+
69
+ /*
70
+ * What the column holds, SEEN: the picked file before it is saved, or the
71
+ * stored asset once the host has fetched it. A row of its own under the
72
+ * control, and bounded, so a tall photo does not push the rest of the
73
+ * form off the screen.
74
+ */
75
+ .FILE_FIELD .FILE_PREVIEW {
76
+ flex: 1 1 100%;
77
+ min-width: 0;
78
+ }
79
+
80
+ .FILE_FIELD .FILE_PREVIEW img.FILE_PREVIEW_MEDIA,
81
+ .FILE_FIELD .FILE_PREVIEW video.FILE_PREVIEW_MEDIA {
82
+ display: block;
83
+ max-width: 100%;
84
+ max-height: 14rem;
85
+ object-fit: contain;
86
+ }
87
+
88
+ .FILE_FIELD .FILE_PREVIEW audio.FILE_PREVIEW_MEDIA {
89
+ display: block;
90
+ width: 100%;
91
+ }
92
+
93
+ .FILE_FIELD .FILE_PREVIEW iframe.FILE_PREVIEW_MEDIA {
94
+ min-height: 18rem;
95
+ }
@@ -39,6 +39,10 @@ import {
39
39
  createElement2, log_error
40
40
  } from "@yuneta/gobj-js";
41
41
 
42
+ import {
43
+ yui_asset_element, yui_asset_file_answer, yui_asset_release
44
+ } from "./yui_asset.js";
45
+
42
46
  import "./yui_file_field.css";
43
47
 
44
48
 
@@ -286,13 +290,21 @@ function yui_file_control(gobj, {name, value, readonly, accept, on_pick})
286
290
  const $name = createElement2(["span", {class: "FILE_NAME"}]);
287
291
  const $size = createElement2(["span", {class: "FILE_SIZE"}]);
288
292
  const $state = createElement2(["span", {class: "FILE_STATE"}]);
293
+ const $preview = createElement2(["div", {class: "FILE_PREVIEW", hidden: ""}]);
289
294
 
290
295
  const $control = createElement2(["div", {class: "FILE_FIELD"}, [
291
- $input, $pick, $name, $size, $state, $clear
296
+ $input, $pick, $name, $size, $state, $clear, $preview
292
297
  ]]);
293
298
 
294
299
  $control.yui_file = null;
295
300
  $control.yui_file_value = value || "";
301
+ /*
302
+ * What the HOST fetched for the asset the column names:
303
+ * {id, answer, error}. The form cannot fetch it -- asking the backend
304
+ * is the host's action -- so it is handed in (EV_SET_FILE_PREVIEW)
305
+ * and drawn only while the column still names THAT id.
306
+ */
307
+ $control.yui_file_stored = null;
296
308
 
297
309
  /*
298
310
  * Three things it can be showing, and they are not the same thing:
@@ -325,6 +337,38 @@ function yui_file_control(gobj, {name, value, readonly, accept, on_pick})
325
337
 
326
338
  $clear.hidden = readonly || (!picked && !kept);
327
339
  $pick.hidden = !!readonly;
340
+
341
+ /*
342
+ * The preview follows the same three states. A picked file is
343
+ * shown from the File itself (a blob url, nothing read); a kept
344
+ * id only once the host has handed its answer in -- until then
345
+ * nothing, because a form whose host never fetches must not show
346
+ * a "loading" that never ends.
347
+ */
348
+ yui_asset_release($preview);
349
+ $preview.replaceChildren();
350
+ let $media = null;
351
+ if(picked) {
352
+ const answer = yui_asset_file_answer(picked);
353
+ if(answer) {
354
+ $media = yui_asset_element(answer, {
355
+ detail: picked.name || "",
356
+ class: "FILE_PREVIEW_MEDIA"
357
+ });
358
+ }
359
+ } else if(kept) {
360
+ const stored = $control.yui_file_stored;
361
+ if(stored && stored.id === kept) {
362
+ $media = yui_asset_element(stored.answer, {
363
+ detail: stored.error || kept,
364
+ class: "FILE_PREVIEW_MEDIA"
365
+ });
366
+ }
367
+ }
368
+ if($media) {
369
+ $preview.appendChild($media);
370
+ }
371
+ $preview.hidden = !$media;
328
372
  };
329
373
 
330
374
  if(!readonly) {