@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.
- package/README.md +43 -1
- package/dist/gobj-ui.cjs.js +773 -125
- package/dist/gobj-ui.es.js +773 -125
- package/package.json +1 -1
- package/src/c_yui_form.js +52 -1
- package/src/c_yui_treedb_topic_with_form.css +67 -0
- package/src/c_yui_treedb_topic_with_form.js +401 -9
- package/src/c_yui_treedb_topics.js +80 -0
- package/src/yui_asset.css +51 -0
- package/src/yui_asset.js +223 -3
- package/src/yui_asset.test.js +18 -0
- package/src/yui_file_field.css +28 -0
- package/src/yui_file_field.js +45 -1
|
@@ -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
|
|
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(
|
|
374
|
+
if(kind === "video") {
|
|
164
375
|
el = createElement2(["video", {class: cls, controls: "", preload: "metadata"}]);
|
|
165
|
-
} else if(
|
|
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
|
};
|
package/src/yui_asset.test.js
CHANGED
|
@@ -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
|
*/
|
package/src/yui_file_field.css
CHANGED
|
@@ -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
|
+
}
|
package/src/yui_file_field.js
CHANGED
|
@@ -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) {
|