@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.
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) {