fb-slides 0.8.0 → 0.9.1

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.
@@ -0,0 +1,556 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Dropping files into assets/ — the other half of the edit drawer.
3
+ //
4
+ // While the drawer is open, dragging files anywhere over the deck raises a
5
+ // sheet of folders: assets/ itself, every subfolder it already has, and one
6
+ // that asks for a new name. Dropping on one saves the files there — the folder
7
+ // is created if it is new — and the drawer lists what landed, each row a path
8
+ // with two buttons: copy it, or write the markup for it into the slide.
9
+ //
10
+ // The list under the folder picker is that folder, as it is on disk: what was
11
+ // just dropped and what has been there since last month, each row a path to
12
+ // copy, to write into the slide, or to delete. The paths are relative, so they
13
+ // work in dev and in the built deck alike. Deleting asks for the word to be
14
+ // typed — it is the only thing here a reload cannot undo.
15
+ //
16
+ // Only the dev server answers /api/assets, so on a static host the sheet never
17
+ // appears — the drawer it belongs to is not there either.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const API_HEADERS = { 'x-fb-slides': '1' };
21
+
22
+ // The folder target that asks for a name instead of being one: `*` is not a
23
+ // character any folder here may carry, so it can never collide with a real one.
24
+ const NEW = '*new*';
25
+
26
+ // Typed, not clicked: deleting is the one thing in this drawer that leaves no
27
+ // way back, and a button alone is too easy to hit on the way to something else.
28
+ const WORD = 'CONFIRM';
29
+
30
+ const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg|bmp|ico)$/i;
31
+ const VIDEO = /\.(mp4|webm|mov|m4v|ogv)$/i;
32
+ const AUDIO = /\.(mp3|m4a|wav|ogg|aac|flac)$/i;
33
+
34
+ // What the slide would say about a file of this kind — the block picker's own
35
+ // markup, with the path already in it. Anything else becomes a plain link: a
36
+ // PDF or a zip is something to hand out, not something to render.
37
+ const blockFor = (path, name) => {
38
+ if (IMAGE.test(path)) return `![](${path})`;
39
+ if (VIDEO.test(path)) {
40
+ return (
41
+ `<video src="${path}" controls muted playsinline preload="metadata"\n` +
42
+ ` style="width: 78%; aspect-ratio: 16 / 9;"></video>`
43
+ );
44
+ }
45
+ if (AUDIO.test(path)) return `<audio src="${path}" controls></audio>`;
46
+ return `[${name}](${path})`;
47
+ };
48
+
49
+ export const DROP_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
50
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
51
+ <path d="M12 15V4" /><path d="M8 8l4-4 4 4" />
52
+ <path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" />
53
+ </svg>`;
54
+
55
+ const COPY_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
56
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
57
+ <rect x="9" y="9" width="11" height="11" rx="2" />
58
+ <path d="M5 15H4a1 1 0 0 1-1-1V5a2 2 0 0 1 2-2h9a1 1 0 0 1 1 1v1" />
59
+ </svg>`;
60
+
61
+ const INSERT_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
62
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
63
+ <path d="M20 5v6a3 3 0 0 1-3 3H5" /><path d="M9 10l-4 4 4 4" />
64
+ </svg>`;
65
+
66
+ const CLOSE_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
67
+ stroke-linecap="round" aria-hidden="true">
68
+ <path d="M6 6l12 12" /><path d="M18 6L6 18" />
69
+ </svg>`;
70
+
71
+ const TRASH_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
72
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
73
+ <path d="M4 7h16" /><path d="M10 5h4" />
74
+ <path d="M6.5 7l.8 12a1 1 0 0 0 1 1h7.4a1 1 0 0 0 1-1l.8-12" />
75
+ </svg>`;
76
+
77
+ const hasFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
78
+
79
+ // A 511-byte SVG rounding to `0 KB` is a row that looks like a broken file.
80
+ const bytes = (size) =>
81
+ size < 1024
82
+ ? `${size} B`
83
+ : size < 1024 * 1024
84
+ ? `${Math.round(size / 1024)} KB`
85
+ : `${(size / 1048576).toFixed(1)} MB`;
86
+
87
+ // One file, streamed. XHR rather than fetch for the one thing fetch cannot do:
88
+ // tell you how far a 60 MB screen recording has got.
89
+ const upload = (file, dir, onProgress) =>
90
+ new Promise((done, fail) => {
91
+ const request = new XMLHttpRequest();
92
+ request.open('POST', '/api/assets');
93
+ request.setRequestHeader('x-fb-slides', '1');
94
+ // A filename is not latin-1 and a header is: encoded here, decoded there.
95
+ request.setRequestHeader('x-fb-name', encodeURIComponent(file.name));
96
+ request.setRequestHeader('x-fb-dir', encodeURIComponent(dir));
97
+ request.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
98
+ request.upload.addEventListener('progress', (event) => {
99
+ if (event.lengthComputable) onProgress(event.loaded / event.total);
100
+ });
101
+ request.addEventListener('load', () => {
102
+ let answer = {};
103
+ try {
104
+ answer = JSON.parse(request.responseText);
105
+ } catch {
106
+ /* a body that is not JSON is a failure whatever it says */
107
+ }
108
+ if (request.status === 200 && answer.path) done(answer);
109
+ else fail(new Error(answer.error ?? `the server said ${request.status}`));
110
+ });
111
+ request.addEventListener('error', () => fail(new Error('the server is not answering')));
112
+ request.send(file);
113
+ });
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // `paste` writes a block into the slide the way the picker does; `active` says
117
+ // whether the drawer is open, which is the only time a drop means this.
118
+ // ---------------------------------------------------------------------------
119
+
120
+ export const createAssets = ({ paste, active }) => {
121
+ // ---- the drawer panel -------------------------------------------------
122
+
123
+ const element = document.createElement('div');
124
+ element.className = 'deck-assets';
125
+ element.hidden = true;
126
+ element.innerHTML = `
127
+ <header>
128
+ <span class="deck-assets-title">Assets</span>
129
+ <button type="button" class="deck-assets-close" aria-label="Close">${CLOSE_ICON}</button>
130
+ </header>
131
+ <div class="deck-assets-where">
132
+ <label for="deck-assets-dir">folder</label>
133
+ <select id="deck-assets-dir" class="deck-assets-dir"></select>
134
+ </div>
135
+ <div class="deck-assets-name-row" hidden>
136
+ <input class="deck-assets-new" type="text" spellcheck="false" autocomplete="off"
137
+ placeholder="new/folder" aria-label="New folder" />
138
+ <button type="button" class="deck-assets-go">Save here</button>
139
+ </div>
140
+ <p class="deck-assets-waiting" hidden></p>
141
+ <button type="button" class="deck-assets-zone">
142
+ Drop files anywhere on the deck<small>— or click to choose them</small>
143
+ </button>
144
+ <input type="file" class="deck-assets-input" multiple hidden />
145
+ <ul class="deck-assets-list"></ul>
146
+ <div class="deck-assets-confirm" hidden>
147
+ <p>Delete <code></code> from disk?<br />There is no undo, and any slide
148
+ pointing at it will come up empty.</p>
149
+ <input class="deck-assets-word" type="text" spellcheck="false" autocomplete="off"
150
+ placeholder="${WORD}" aria-label="Type ${WORD} to delete" />
151
+ <div>
152
+ <button type="button" class="deck-assets-cancel">Cancel</button>
153
+ <button type="button" class="deck-assets-confirm-delete" disabled>Delete</button>
154
+ </div>
155
+ </div>
156
+ `;
157
+
158
+ const select = element.querySelector('.deck-assets-dir');
159
+ const nameRow = element.querySelector('.deck-assets-name-row');
160
+ const nameInput = element.querySelector('.deck-assets-new');
161
+ const goButton = element.querySelector('.deck-assets-go');
162
+ const waiting = element.querySelector('.deck-assets-waiting');
163
+ const zone = element.querySelector('.deck-assets-zone');
164
+ const fileInput = element.querySelector('.deck-assets-input');
165
+ const list = element.querySelector('.deck-assets-list');
166
+ const confirmBox = element.querySelector('.deck-assets-confirm');
167
+ const wordInput = confirmBox.querySelector('.deck-assets-word');
168
+ const confirmButton = confirmBox.querySelector('.deck-assets-confirm-delete');
169
+
170
+ // ---- the full-page sheet ----------------------------------------------
171
+
172
+ const overlay = document.createElement('div');
173
+ overlay.id = 'deck-drop';
174
+ overlay.hidden = true;
175
+ overlay.innerHTML = `
176
+ <div class="deck-drop-sheet">
177
+ <h2>Drop into <code class="deck-drop-root">assets/</code></h2>
178
+ <ul class="deck-drop-targets"></ul>
179
+ </div>
180
+ `;
181
+ const targets = overlay.querySelector('.deck-drop-targets');
182
+ const rootLabel = overlay.querySelector('.deck-drop-root');
183
+
184
+ // ---- state ------------------------------------------------------------
185
+
186
+ let root = 'assets'; // the folder as the browser writes it
187
+ let dirs = []; // its subfolders, as the server last listed them
188
+ let chosen = ''; // where the next drop goes, remembered between drops
189
+ let pending = null; // files waiting for a folder that has yet to be named
190
+ let asking = null; // the path the delete question is standing over
191
+ let files = []; // the chosen folder, as the server last listed it
192
+ let failures = []; // uploads that did not land, kept under the listing
193
+ const fresh = new Set(); // what this session put there — worth pointing at
194
+
195
+ // The root, what the server last listed, and — however that listing raced
196
+ // with the upload that made it — the folder this drop actually went to.
197
+ const folders = () => {
198
+ const all = new Set(['', ...dirs]);
199
+ if (chosen) all.add(chosen);
200
+ return [...all];
201
+ };
202
+
203
+ const drawSelect = () => {
204
+ select.innerHTML = folders()
205
+ .map((dir) => `<option value="${dir}">${root}/${dir ? `${dir}/` : ''}</option>`)
206
+ .join('');
207
+ select.insertAdjacentHTML('beforeend', `<option value="${NEW}">+ new folder…</option>`);
208
+ select.value = pending ? NEW : folders().includes(chosen) ? chosen : '';
209
+ nameRow.hidden = select.value !== NEW;
210
+ goButton.hidden = !pending;
211
+ };
212
+
213
+ const drawTargets = () => {
214
+ rootLabel.textContent = `${root}/`;
215
+ targets.innerHTML = folders()
216
+ .map(
217
+ (dir) =>
218
+ `<li data-dir="${dir}"${dir === chosen ? ' class="is-last"' : ''}>` +
219
+ `${dir ? `${dir}/` : `${root}/`}</li>`,
220
+ )
221
+ .join('');
222
+ targets.insertAdjacentHTML('beforeend', `<li data-dir="${NEW}" class="is-new">+ new folder…</li>`);
223
+ };
224
+
225
+ // The folder as it is right now — its subfolders to aim at, and its files to
226
+ // list. Called on open, on picking another folder, and after every write.
227
+ const refresh = async () => {
228
+ const where = pending ? '' : chosen;
229
+ try {
230
+ const response = await fetch(`/api/assets?dir=${encodeURIComponent(where)}`, {
231
+ headers: API_HEADERS,
232
+ cache: 'no-cache',
233
+ });
234
+ if (response.ok) {
235
+ const answer = await response.json();
236
+ root = answer.dir ?? root;
237
+ dirs = Array.isArray(answer.dirs) ? answer.dirs : [];
238
+ files = Array.isArray(answer.files) ? answer.files : [];
239
+ }
240
+ } catch {
241
+ /* no listing: assets/ itself is always there to drop into */
242
+ files = [];
243
+ }
244
+ drawSelect();
245
+ drawTargets();
246
+ drawFiles();
247
+ };
248
+
249
+ // ---- open and close ---------------------------------------------------
250
+
251
+ const open = () => {
252
+ element.hidden = false;
253
+ refresh();
254
+ };
255
+
256
+ const closeConfirm = () => {
257
+ asking = null;
258
+ confirmBox.hidden = true;
259
+ wordInput.value = '';
260
+ confirmButton.disabled = true;
261
+ };
262
+
263
+ const close = () => {
264
+ element.hidden = true;
265
+ pending = null;
266
+ nameRow.hidden = true;
267
+ waiting.hidden = true;
268
+ closeConfirm();
269
+ };
270
+
271
+ // ---- the rows ---------------------------------------------------------
272
+
273
+ const copy = async (text, button) => {
274
+ try {
275
+ await navigator.clipboard.writeText(text);
276
+ button.classList.add('is-done');
277
+ setTimeout(() => button.classList.remove('is-done'), 900);
278
+ } catch {
279
+ // No clipboard permission: select the path instead, so ⌘C still works.
280
+ const range = document.createRange();
281
+ range.selectNodeContents(button.closest('li').querySelector('code'));
282
+ const selection = getSelection();
283
+ selection.removeAllRanges();
284
+ selection.addRange(range);
285
+ }
286
+ };
287
+
288
+ // The folder, one row per file: the path, and the three things worth doing
289
+ // with it. Rebuilt whole on every refresh — a listing is cheap, and the
290
+ // alternative is keeping rows in step with a folder two other things write to.
291
+ const drawFiles = () => {
292
+ list.innerHTML = '';
293
+
294
+ if (!files.length && !failures.length) {
295
+ const empty = document.createElement('li');
296
+ empty.className = 'deck-assets-none';
297
+ empty.textContent = pending ? 'The folder will be made when you name it.' : 'Nothing in this folder yet.';
298
+ list.append(empty);
299
+ }
300
+
301
+ for (const file of files) {
302
+ const row = document.createElement('li');
303
+ if (fresh.has(file.path)) row.classList.add('is-fresh');
304
+ row.innerHTML =
305
+ `<code></code>` +
306
+ `<span class="deck-assets-size"></span>` +
307
+ `<span class="deck-assets-acts">` +
308
+ `<button type="button" class="deck-assets-copy" title="Copy the path">${COPY_ICON}</button>` +
309
+ `<button type="button" class="deck-assets-insert" title="Write it into the slide">${INSERT_ICON}</button>` +
310
+ `<button type="button" class="deck-assets-trash" title="Delete the file">${TRASH_ICON}</button>` +
311
+ `</span>`;
312
+ row.querySelector('code').textContent = file.path;
313
+ // The drawer is narrow and a path can be long: the row truncates, and
314
+ // hovering it says the whole thing.
315
+ row.title = file.path;
316
+ row.querySelector('.deck-assets-size').textContent = bytes(file.size);
317
+ row.querySelector('.deck-assets-copy').addEventListener('click', (event) =>
318
+ copy(file.path, event.currentTarget),
319
+ );
320
+ row.querySelector('.deck-assets-insert').addEventListener('click', () => {
321
+ paste(blockFor(file.path, file.name));
322
+ close();
323
+ });
324
+ row.querySelector('.deck-assets-trash').addEventListener('click', () => askDelete(file.path));
325
+ list.append(row);
326
+ }
327
+
328
+ // What did not land stays under the listing rather than vanishing with the
329
+ // refresh that follows the upload.
330
+ for (const failure of failures) {
331
+ const row = document.createElement('li');
332
+ row.className = 'is-failed';
333
+ row.innerHTML = `<span class="deck-assets-file"></span><span class="deck-assets-pct"></span>`;
334
+ row.querySelector('.deck-assets-file').textContent = failure.name;
335
+ row.querySelector('.deck-assets-pct').textContent = failure.message;
336
+ list.append(row);
337
+ }
338
+ };
339
+
340
+ // While an upload is in flight it has a row of its own, under the listing,
341
+ // counting up. The refresh that follows replaces it with the real thing.
342
+ const addRow = (file) => {
343
+ const row = document.createElement('li');
344
+ row.innerHTML = `<span class="deck-assets-file"></span><span class="deck-assets-pct"></span>`;
345
+ row.querySelector('.deck-assets-file').textContent = file.name;
346
+ const pct = row.querySelector('.deck-assets-pct');
347
+ pct.textContent = bytes(file.size);
348
+ list.append(row);
349
+ row.scrollIntoView({ block: 'nearest' });
350
+ return {
351
+ progress: (share) => {
352
+ pct.textContent = `${Math.round(share * 100)}%`;
353
+ },
354
+ };
355
+ };
356
+
357
+ // One at a time: a folder of screenshots dropped at once should not open ten
358
+ // sockets, and the rows then fill in the order they were dropped.
359
+ const send = async (dropped, dir) => {
360
+ if (!dropped.length) return;
361
+ chosen = dir;
362
+ failures = [];
363
+ open();
364
+ for (const file of dropped) {
365
+ const row = addRow(file);
366
+ try {
367
+ const answer = await upload(file, dir, row.progress);
368
+ // The folder as the server spells it — `talk 2026` was typed, and what
369
+ // exists on disk is `talk-2026`. The list should say the latter.
370
+ chosen = answer.dir;
371
+ fresh.add(answer.path);
372
+ } catch (error) {
373
+ failures.push({ name: file.name, message: error.message });
374
+ }
375
+ }
376
+ await refresh();
377
+ };
378
+
379
+ const askDelete = (path) => {
380
+ asking = path;
381
+ confirmBox.querySelector('code').textContent = path;
382
+ wordInput.value = '';
383
+ confirmButton.disabled = true;
384
+ confirmBox.hidden = false;
385
+ wordInput.focus();
386
+ };
387
+
388
+ const doDelete = async () => {
389
+ if (!asking || confirmButton.disabled) return;
390
+ const path = asking;
391
+ confirmButton.disabled = true;
392
+ try {
393
+ const response = await fetch(`/api/assets?path=${encodeURIComponent(path)}`, {
394
+ method: 'DELETE',
395
+ headers: API_HEADERS,
396
+ });
397
+ if (!response.ok) throw new Error((await response.json()).error ?? response.status);
398
+ fresh.delete(path);
399
+ closeConfirm();
400
+ refresh();
401
+ } catch (error) {
402
+ confirmBox.querySelector('code').textContent = error.message;
403
+ confirmButton.disabled = false;
404
+ }
405
+ };
406
+
407
+ // A drop that named no folder yet: hold the files, ask, and send on the answer.
408
+ const askName = (dropped) => {
409
+ pending = dropped;
410
+ open();
411
+ drawSelect();
412
+ nameRow.hidden = false;
413
+ goButton.hidden = false;
414
+ waiting.hidden = false;
415
+ waiting.textContent = `${dropped.length} file${dropped.length === 1 ? '' : 's'} waiting for a folder name.`;
416
+ nameInput.focus();
417
+ nameInput.select();
418
+ };
419
+
420
+ const confirmName = () => {
421
+ const dir = nameInput.value.trim().replace(/^\/+|\/+$/g, '');
422
+ if (!dir) return nameInput.focus();
423
+ const held = pending;
424
+ pending = null;
425
+ nameInput.value = '';
426
+ nameRow.hidden = true;
427
+ goButton.hidden = true;
428
+ waiting.hidden = true;
429
+ if (held) send(held, dir);
430
+ else {
431
+ chosen = dir;
432
+ refresh();
433
+ }
434
+ };
435
+
436
+ const drop = (dropped, dir) => {
437
+ if (!dropped.length) return;
438
+ if (dir === NEW) askName(dropped);
439
+ else send(dropped, dir);
440
+ };
441
+
442
+ // ---- wiring: the panel -------------------------------------------------
443
+
444
+ element.querySelector('.deck-assets-close').addEventListener('click', close);
445
+
446
+ select.addEventListener('change', () => {
447
+ if (select.value === NEW) {
448
+ nameRow.hidden = false;
449
+ nameInput.focus();
450
+ return;
451
+ }
452
+ nameRow.hidden = true;
453
+ chosen = select.value;
454
+ failures = [];
455
+ refresh();
456
+ });
457
+
458
+ wordInput.addEventListener('input', () => {
459
+ confirmButton.disabled = wordInput.value.trim() !== WORD;
460
+ });
461
+ wordInput.addEventListener('keydown', (event) => {
462
+ if (event.key !== 'Enter') return;
463
+ event.preventDefault();
464
+ doDelete();
465
+ });
466
+ confirmBox.querySelector('.deck-assets-cancel').addEventListener('click', closeConfirm);
467
+ confirmButton.addEventListener('click', doDelete);
468
+
469
+ goButton.addEventListener('click', confirmName);
470
+ nameInput.addEventListener('keydown', (event) => {
471
+ if (event.key !== 'Enter') return;
472
+ event.preventDefault();
473
+ confirmName();
474
+ });
475
+
476
+ zone.addEventListener('click', () => {
477
+ if (select.value === NEW && !nameInput.value.trim()) return nameInput.focus();
478
+ fileInput.click();
479
+ });
480
+ fileInput.addEventListener('change', () => {
481
+ const picked = [...fileInput.files];
482
+ fileInput.value = '';
483
+ drop(picked, select.value === NEW ? nameInput.value.trim() : select.value);
484
+ });
485
+
486
+ // ---- wiring: the sheet -------------------------------------------------
487
+ // `dragover` is the only event a drag repeats — every few milliseconds, for
488
+ // as long as it is over the page. So the sheet is raised by it and dropped by
489
+ // its absence: a watchdog restarted on each one, firing shortly after the
490
+ // last. Counting dragenter against dragleave is the usual way, and it is the
491
+ // one that goes wrong here — the sheet appears under the cursor mid-drag,
492
+ // which is itself a change of target, and the pair that follows does not
493
+ // arrive in a fixed order.
494
+
495
+ let watchdog = 0;
496
+
497
+ const show = () => {
498
+ clearTimeout(watchdog);
499
+ watchdog = setTimeout(hide, 240);
500
+ if (!overlay.hidden) return;
501
+ drawTargets();
502
+ overlay.hidden = false;
503
+ refresh();
504
+ };
505
+
506
+ const hide = () => {
507
+ clearTimeout(watchdog);
508
+ watchdog = 0;
509
+ overlay.hidden = true;
510
+ for (const target of targets.children) target.classList.remove('is-over');
511
+ };
512
+
513
+ const over = (event) => {
514
+ if (!active() || !hasFiles(event)) return;
515
+ // Without this the browser takes the drop and navigates to the file.
516
+ event.preventDefault();
517
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
518
+ show();
519
+ const under = event.target.closest?.('[data-dir]');
520
+ for (const target of targets.children) target.classList.toggle('is-over', target === under);
521
+ };
522
+
523
+ // Both, so the sheet is up on the first frame of the drag rather than on the
524
+ // first repeat — they do the same thing, and `show` is idempotent.
525
+ document.addEventListener('dragenter', over);
526
+ document.addEventListener('dragover', over);
527
+ // A drag that ends anywhere else — Escape, or a drop outside the window.
528
+ document.addEventListener('dragend', hide);
529
+
530
+ document.addEventListener('drop', (event) => {
531
+ if (!active() || !hasFiles(event)) return;
532
+ event.preventDefault();
533
+ const target = event.target.closest?.('[data-dir]');
534
+ hide();
535
+ drop([...event.dataTransfer.files], target?.dataset.dir ?? chosen);
536
+ });
537
+
538
+ document.body.append(overlay);
539
+
540
+ return {
541
+ element,
542
+ open,
543
+ close,
544
+ isOpen: () => !element.hidden,
545
+ hideSheet: hide,
546
+ // One layer per press, innermost first — the drawer asks this before it
547
+ // decides whether Escape was meant for it.
548
+ escape: () => {
549
+ if (!confirmBox.hidden) return closeConfirm(), true;
550
+ if (!element.hidden) return close(), true;
551
+ return false;
552
+ },
553
+ };
554
+ };
555
+
556
+ export default createAssets;