fb-slides 0.8.0 → 0.9.0

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,513 @@
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
+ // Nothing here is a file browser. The list is what *this* drop produced, which
11
+ // is what you need in the ten seconds after dragging a screenshot in; the paths
12
+ // are relative, so they work in dev and in the built deck alike. A row can also
13
+ // delete its file — the wrong screenshot, dropped a moment ago — and that one
14
+ // asks for the word to be 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
+ const bytes = (size) => (size < 1024 * 1024 ? `${Math.round(size / 1024)} KB` : `${(size / 1048576).toFixed(1)} MB`);
80
+
81
+ // One file, streamed. XHR rather than fetch for the one thing fetch cannot do:
82
+ // tell you how far a 60 MB screen recording has got.
83
+ const upload = (file, dir, onProgress) =>
84
+ new Promise((done, fail) => {
85
+ const request = new XMLHttpRequest();
86
+ request.open('POST', '/api/assets');
87
+ request.setRequestHeader('x-fb-slides', '1');
88
+ // A filename is not latin-1 and a header is: encoded here, decoded there.
89
+ request.setRequestHeader('x-fb-name', encodeURIComponent(file.name));
90
+ request.setRequestHeader('x-fb-dir', encodeURIComponent(dir));
91
+ request.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
92
+ request.upload.addEventListener('progress', (event) => {
93
+ if (event.lengthComputable) onProgress(event.loaded / event.total);
94
+ });
95
+ request.addEventListener('load', () => {
96
+ let answer = {};
97
+ try {
98
+ answer = JSON.parse(request.responseText);
99
+ } catch {
100
+ /* a body that is not JSON is a failure whatever it says */
101
+ }
102
+ if (request.status === 200 && answer.path) done(answer);
103
+ else fail(new Error(answer.error ?? `the server said ${request.status}`));
104
+ });
105
+ request.addEventListener('error', () => fail(new Error('the server is not answering')));
106
+ request.send(file);
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // `paste` writes a block into the slide the way the picker does; `active` says
111
+ // whether the drawer is open, which is the only time a drop means this.
112
+ // ---------------------------------------------------------------------------
113
+
114
+ export const createAssets = ({ paste, active }) => {
115
+ // ---- the drawer panel -------------------------------------------------
116
+
117
+ const element = document.createElement('div');
118
+ element.className = 'deck-assets';
119
+ element.hidden = true;
120
+ element.innerHTML = `
121
+ <header>
122
+ <span class="deck-assets-title">Assets</span>
123
+ <button type="button" class="deck-assets-close" aria-label="Close">${CLOSE_ICON}</button>
124
+ </header>
125
+ <div class="deck-assets-where">
126
+ <label for="deck-assets-dir">folder</label>
127
+ <select id="deck-assets-dir" class="deck-assets-dir"></select>
128
+ </div>
129
+ <div class="deck-assets-name-row" hidden>
130
+ <input class="deck-assets-new" type="text" spellcheck="false" autocomplete="off"
131
+ placeholder="new/folder" aria-label="New folder" />
132
+ <button type="button" class="deck-assets-go">Save here</button>
133
+ </div>
134
+ <p class="deck-assets-waiting" hidden></p>
135
+ <button type="button" class="deck-assets-zone">
136
+ Drop files anywhere on the deck<small>— or click to choose them</small>
137
+ </button>
138
+ <input type="file" class="deck-assets-input" multiple hidden />
139
+ <ul class="deck-assets-list"></ul>
140
+ <div class="deck-assets-confirm" hidden>
141
+ <p>Delete <code></code> from disk?<br />There is no undo, and any slide
142
+ pointing at it will come up empty.</p>
143
+ <input class="deck-assets-word" type="text" spellcheck="false" autocomplete="off"
144
+ placeholder="${WORD}" aria-label="Type ${WORD} to delete" />
145
+ <div>
146
+ <button type="button" class="deck-assets-cancel">Cancel</button>
147
+ <button type="button" class="deck-assets-confirm-delete" disabled>Delete</button>
148
+ </div>
149
+ </div>
150
+ `;
151
+
152
+ const select = element.querySelector('.deck-assets-dir');
153
+ const nameRow = element.querySelector('.deck-assets-name-row');
154
+ const nameInput = element.querySelector('.deck-assets-new');
155
+ const goButton = element.querySelector('.deck-assets-go');
156
+ const waiting = element.querySelector('.deck-assets-waiting');
157
+ const zone = element.querySelector('.deck-assets-zone');
158
+ const fileInput = element.querySelector('.deck-assets-input');
159
+ const list = element.querySelector('.deck-assets-list');
160
+ const confirmBox = element.querySelector('.deck-assets-confirm');
161
+ const wordInput = confirmBox.querySelector('.deck-assets-word');
162
+ const confirmButton = confirmBox.querySelector('.deck-assets-confirm-delete');
163
+
164
+ // ---- the full-page sheet ----------------------------------------------
165
+
166
+ const overlay = document.createElement('div');
167
+ overlay.id = 'deck-drop';
168
+ overlay.hidden = true;
169
+ overlay.innerHTML = `
170
+ <div class="deck-drop-sheet">
171
+ <h2>Drop into <code class="deck-drop-root">assets/</code></h2>
172
+ <ul class="deck-drop-targets"></ul>
173
+ </div>
174
+ `;
175
+ const targets = overlay.querySelector('.deck-drop-targets');
176
+ const rootLabel = overlay.querySelector('.deck-drop-root');
177
+
178
+ // ---- state ------------------------------------------------------------
179
+
180
+ let root = 'assets'; // the folder as the browser writes it
181
+ let dirs = []; // its subfolders, as the server last listed them
182
+ let chosen = ''; // where the next drop goes, remembered between drops
183
+ let pending = null; // files waiting for a folder that has yet to be named
184
+ let asking = null; // { path, row } — the delete question standing over the list
185
+
186
+ // The root, what the server last listed, and — however that listing raced
187
+ // with the upload that made it — the folder this drop actually went to.
188
+ const folders = () => {
189
+ const all = new Set(['', ...dirs]);
190
+ if (chosen) all.add(chosen);
191
+ return [...all];
192
+ };
193
+
194
+ const drawSelect = () => {
195
+ select.innerHTML = folders()
196
+ .map((dir) => `<option value="${dir}">${root}/${dir ? `${dir}/` : ''}</option>`)
197
+ .join('');
198
+ select.insertAdjacentHTML('beforeend', `<option value="${NEW}">+ new folder…</option>`);
199
+ select.value = pending ? NEW : folders().includes(chosen) ? chosen : '';
200
+ nameRow.hidden = select.value !== NEW;
201
+ goButton.hidden = !pending;
202
+ };
203
+
204
+ const drawTargets = () => {
205
+ rootLabel.textContent = `${root}/`;
206
+ targets.innerHTML = folders()
207
+ .map(
208
+ (dir) =>
209
+ `<li data-dir="${dir}"${dir === chosen ? ' class="is-last"' : ''}>` +
210
+ `${dir ? `${dir}/` : `${root}/`}</li>`,
211
+ )
212
+ .join('');
213
+ targets.insertAdjacentHTML('beforeend', `<li data-dir="${NEW}" class="is-new">+ new folder…</li>`);
214
+ };
215
+
216
+ // The folder list as it is right now: a drop is often the second one, and the
217
+ // folder the first one made should be there to aim at.
218
+ const refresh = async () => {
219
+ try {
220
+ const response = await fetch('/api/assets', { headers: API_HEADERS, cache: 'no-cache' });
221
+ if (response.ok) {
222
+ const answer = await response.json();
223
+ root = answer.dir ?? root;
224
+ dirs = Array.isArray(answer.dirs) ? answer.dirs : [];
225
+ }
226
+ } catch {
227
+ /* no listing: assets/ itself is always there to drop into */
228
+ }
229
+ drawSelect();
230
+ drawTargets();
231
+ };
232
+
233
+ // ---- open and close ---------------------------------------------------
234
+
235
+ const open = () => {
236
+ element.hidden = false;
237
+ refresh();
238
+ };
239
+
240
+ const closeConfirm = () => {
241
+ asking = null;
242
+ confirmBox.hidden = true;
243
+ wordInput.value = '';
244
+ confirmButton.disabled = true;
245
+ };
246
+
247
+ const close = () => {
248
+ element.hidden = true;
249
+ pending = null;
250
+ nameRow.hidden = true;
251
+ waiting.hidden = true;
252
+ closeConfirm();
253
+ };
254
+
255
+ // ---- the rows ---------------------------------------------------------
256
+
257
+ const copy = async (text, button) => {
258
+ try {
259
+ await navigator.clipboard.writeText(text);
260
+ button.classList.add('is-done');
261
+ setTimeout(() => button.classList.remove('is-done'), 900);
262
+ } catch {
263
+ // No clipboard permission: select the path instead, so ⌘C still works.
264
+ const range = document.createRange();
265
+ range.selectNodeContents(button.closest('li').querySelector('code'));
266
+ const selection = getSelection();
267
+ selection.removeAllRanges();
268
+ selection.addRange(range);
269
+ }
270
+ };
271
+
272
+ const addRow = (file) => {
273
+ const row = document.createElement('li');
274
+ row.innerHTML = `<span class="deck-assets-file"></span><span class="deck-assets-pct"></span>`;
275
+ row.querySelector('.deck-assets-file').textContent = file.name;
276
+ const pct = row.querySelector('.deck-assets-pct');
277
+ pct.textContent = bytes(file.size);
278
+ list.append(row);
279
+ row.scrollIntoView({ block: 'nearest' });
280
+
281
+ return {
282
+ progress: (share) => {
283
+ pct.textContent = `${Math.round(share * 100)}%`;
284
+ },
285
+ failed: (message) => {
286
+ row.classList.add('is-failed');
287
+ pct.textContent = message;
288
+ },
289
+ done: ({ path, name }) => {
290
+ row.classList.add('is-done');
291
+ row.innerHTML =
292
+ `<code></code>` +
293
+ `<span class="deck-assets-acts">` +
294
+ `<button type="button" class="deck-assets-copy" title="Copy the path">${COPY_ICON}</button>` +
295
+ `<button type="button" class="deck-assets-insert" title="Write it into the slide">${INSERT_ICON}</button>` +
296
+ `<button type="button" class="deck-assets-trash" title="Delete the file">${TRASH_ICON}</button>` +
297
+ `</span>`;
298
+ row.querySelector('code').textContent = path;
299
+ row.querySelector('.deck-assets-copy').addEventListener('click', (event) =>
300
+ copy(path, event.currentTarget),
301
+ );
302
+ row.querySelector('.deck-assets-insert').addEventListener('click', () => {
303
+ paste(blockFor(path, name));
304
+ close();
305
+ });
306
+ row.querySelector('.deck-assets-trash').addEventListener('click', () => askDelete(path, row));
307
+ },
308
+ };
309
+ };
310
+
311
+ // One at a time: a folder of screenshots dropped at once should not open ten
312
+ // sockets, and the rows then fill in the order they were dropped.
313
+ const send = async (files, dir) => {
314
+ if (!files.length) return;
315
+ chosen = dir;
316
+ open();
317
+ for (const file of files) {
318
+ const row = addRow(file);
319
+ try {
320
+ const answer = await upload(file, dir, row.progress);
321
+ row.done(answer);
322
+ // The folder as the server spells it — `talk 2026` was typed, and what
323
+ // exists on disk is `talk-2026`. The list should say the latter.
324
+ chosen = answer.dir;
325
+ if (answer.dir && !dirs.includes(answer.dir)) {
326
+ dirs = [...dirs, answer.dir].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
327
+ }
328
+ drawSelect();
329
+ drawTargets();
330
+ } catch (error) {
331
+ row.failed(error.message);
332
+ }
333
+ }
334
+ };
335
+
336
+ const askDelete = (path, row) => {
337
+ asking = { path, row };
338
+ confirmBox.querySelector('code').textContent = path;
339
+ wordInput.value = '';
340
+ confirmButton.disabled = true;
341
+ confirmBox.hidden = false;
342
+ wordInput.focus();
343
+ };
344
+
345
+ const doDelete = async () => {
346
+ if (!asking || confirmButton.disabled) return;
347
+ const { path, row } = asking;
348
+ confirmButton.disabled = true;
349
+ try {
350
+ const response = await fetch(`/api/assets?path=${encodeURIComponent(path)}`, {
351
+ method: 'DELETE',
352
+ headers: API_HEADERS,
353
+ });
354
+ if (!response.ok) throw new Error((await response.json()).error ?? response.status);
355
+ row.remove();
356
+ closeConfirm();
357
+ // The folder may have been the file's only reason to exist; the list says
358
+ // what is there now either way.
359
+ refresh();
360
+ } catch (error) {
361
+ confirmBox.querySelector('code').textContent = error.message;
362
+ confirmButton.disabled = false;
363
+ }
364
+ };
365
+
366
+ // A drop that named no folder yet: hold the files, ask, and send on the answer.
367
+ const askName = (files) => {
368
+ pending = files;
369
+ open();
370
+ drawSelect();
371
+ nameRow.hidden = false;
372
+ goButton.hidden = false;
373
+ waiting.hidden = false;
374
+ waiting.textContent = `${files.length} file${files.length === 1 ? '' : 's'} waiting for a folder name.`;
375
+ nameInput.focus();
376
+ nameInput.select();
377
+ };
378
+
379
+ const confirmName = () => {
380
+ const dir = nameInput.value.trim().replace(/^\/+|\/+$/g, '');
381
+ if (!dir) return nameInput.focus();
382
+ const files = pending;
383
+ pending = null;
384
+ nameInput.value = '';
385
+ nameRow.hidden = true;
386
+ goButton.hidden = true;
387
+ waiting.hidden = true;
388
+ if (files) send(files, dir);
389
+ else {
390
+ chosen = dir;
391
+ drawSelect();
392
+ }
393
+ };
394
+
395
+ const drop = (files, dir) => {
396
+ if (!files.length) return;
397
+ if (dir === NEW) askName(files);
398
+ else send(files, dir);
399
+ };
400
+
401
+ // ---- wiring: the panel -------------------------------------------------
402
+
403
+ element.querySelector('.deck-assets-close').addEventListener('click', close);
404
+
405
+ select.addEventListener('change', () => {
406
+ if (select.value === NEW) {
407
+ nameRow.hidden = false;
408
+ nameInput.focus();
409
+ return;
410
+ }
411
+ nameRow.hidden = true;
412
+ chosen = select.value;
413
+ });
414
+
415
+ wordInput.addEventListener('input', () => {
416
+ confirmButton.disabled = wordInput.value.trim() !== WORD;
417
+ });
418
+ wordInput.addEventListener('keydown', (event) => {
419
+ if (event.key !== 'Enter') return;
420
+ event.preventDefault();
421
+ doDelete();
422
+ });
423
+ confirmBox.querySelector('.deck-assets-cancel').addEventListener('click', closeConfirm);
424
+ confirmButton.addEventListener('click', doDelete);
425
+
426
+ goButton.addEventListener('click', confirmName);
427
+ nameInput.addEventListener('keydown', (event) => {
428
+ if (event.key !== 'Enter') return;
429
+ event.preventDefault();
430
+ confirmName();
431
+ });
432
+
433
+ zone.addEventListener('click', () => {
434
+ if (select.value === NEW && !nameInput.value.trim()) return nameInput.focus();
435
+ fileInput.click();
436
+ });
437
+ fileInput.addEventListener('change', () => {
438
+ const files = [...fileInput.files];
439
+ fileInput.value = '';
440
+ drop(files, select.value === NEW ? nameInput.value.trim() : select.value);
441
+ });
442
+
443
+ // ---- wiring: the sheet -------------------------------------------------
444
+ // `dragover` is the only event a drag repeats — every few milliseconds, for
445
+ // as long as it is over the page. So the sheet is raised by it and dropped by
446
+ // its absence: a watchdog restarted on each one, firing shortly after the
447
+ // last. Counting dragenter against dragleave is the usual way, and it is the
448
+ // one that goes wrong here — the sheet appears under the cursor mid-drag,
449
+ // which is itself a change of target, and the pair that follows does not
450
+ // arrive in a fixed order.
451
+
452
+ let watchdog = 0;
453
+
454
+ const show = () => {
455
+ clearTimeout(watchdog);
456
+ watchdog = setTimeout(hide, 240);
457
+ if (!overlay.hidden) return;
458
+ drawTargets();
459
+ overlay.hidden = false;
460
+ refresh();
461
+ };
462
+
463
+ const hide = () => {
464
+ clearTimeout(watchdog);
465
+ watchdog = 0;
466
+ overlay.hidden = true;
467
+ for (const target of targets.children) target.classList.remove('is-over');
468
+ };
469
+
470
+ const over = (event) => {
471
+ if (!active() || !hasFiles(event)) return;
472
+ // Without this the browser takes the drop and navigates to the file.
473
+ event.preventDefault();
474
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
475
+ show();
476
+ const under = event.target.closest?.('[data-dir]');
477
+ for (const target of targets.children) target.classList.toggle('is-over', target === under);
478
+ };
479
+
480
+ // Both, so the sheet is up on the first frame of the drag rather than on the
481
+ // first repeat — they do the same thing, and `show` is idempotent.
482
+ document.addEventListener('dragenter', over);
483
+ document.addEventListener('dragover', over);
484
+ // A drag that ends anywhere else — Escape, or a drop outside the window.
485
+ document.addEventListener('dragend', hide);
486
+
487
+ document.addEventListener('drop', (event) => {
488
+ if (!active() || !hasFiles(event)) return;
489
+ event.preventDefault();
490
+ const target = event.target.closest?.('[data-dir]');
491
+ hide();
492
+ drop([...event.dataTransfer.files], target?.dataset.dir ?? chosen);
493
+ });
494
+
495
+ document.body.append(overlay);
496
+
497
+ return {
498
+ element,
499
+ open,
500
+ close,
501
+ isOpen: () => !element.hidden,
502
+ hideSheet: hide,
503
+ // One layer per press, innermost first — the drawer asks this before it
504
+ // decides whether Escape was meant for it.
505
+ escape: () => {
506
+ if (!confirmBox.hidden) return closeConfirm(), true;
507
+ if (!element.hidden) return close(), true;
508
+ return false;
509
+ },
510
+ };
511
+ };
512
+
513
+ export default createAssets;