@seliseblocks/mailcraft 0.2.14 → 0.2.16
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/CHANGELOG.md +17 -0
- package/DOCS.md +5 -4
- package/README.md +2 -1
- package/README.md.txt +2 -1
- package/dist/mailcraft-editor.bundle.js +71 -71
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/examples/templates/back-in-stock.html +1 -8
- package/examples/templates/cart-left-behind.html +1 -8
- package/examples/templates/meet-nova-launch.html +5 -0
- package/examples/templates/rate-your-headphones.html +1 -8
- package/examples/templates/the-sunday-brief.html +5 -0
- package/examples/templates/your-order-shipped.html +1 -0
- package/examples/vanilla.html +14 -24
- package/package.json +1 -1
- package/src/core/i18n/ar.js +2 -1
- package/src/core/i18n/bg.js +2 -1
- package/src/core/i18n/bn.js +2 -1
- package/src/core/i18n/ca.js +2 -1
- package/src/core/i18n/cs.js +2 -1
- package/src/core/i18n/da.js +2 -1
- package/src/core/i18n/de-CH.js +2 -1
- package/src/core/i18n/de.js +2 -1
- package/src/core/i18n/dz.js +2 -1
- package/src/core/i18n/el.js +2 -1
- package/src/core/i18n/en.js +2 -1
- package/src/core/i18n/es.js +2 -1
- package/src/core/i18n/et.js +2 -1
- package/src/core/i18n/fi.js +2 -1
- package/src/core/i18n/fr.js +2 -1
- package/src/core/i18n/hr.js +2 -1
- package/src/core/i18n/hu.js +2 -1
- package/src/core/i18n/index.js +83 -83
- package/src/core/i18n/it.js +2 -1
- package/src/core/i18n/lt.js +2 -1
- package/src/core/i18n/lv.js +2 -1
- package/src/core/i18n/nb.js +2 -1
- package/src/core/i18n/nl.js +2 -1
- package/src/core/i18n/pl.js +2 -1
- package/src/core/i18n/pt.js +2 -1
- package/src/core/i18n/ro.js +2 -1
- package/src/core/i18n/ru.js +2 -1
- package/src/core/i18n/sk.js +2 -1
- package/src/core/i18n/sl.js +2 -1
- package/src/core/i18n/sv.js +2 -1
- package/src/core/i18n/tr.js +2 -1
- package/src/core/i18n/uk.js +2 -1
- package/src/core/ids.js +1 -1
- package/src/core/layout-style.js +100 -100
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +26 -10
- package/src/render/focus-preserve.js +158 -158
- package/src/render/screenshot.js +95 -7
- package/src/render/story.js +541 -415
- package/src/render/style.js +1 -1
- package/types/index.d.ts +22 -7
package/src/render/story.js
CHANGED
|
@@ -1,415 +1,541 @@
|
|
|
1
|
-
import { icon } from '../core/icons.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
function
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
s.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
function
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
function
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
1
|
+
import { icon } from '../core/icons.js';
|
|
2
|
+
import { transcodeShot } from './screenshot.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Story-style screenshot viewer: the PNG that `render/screenshot.js` produces
|
|
6
|
+
* is a single very tall image, which is exactly the wrong shape for a modal.
|
|
7
|
+
* Rather than hand the user a file and hope they open it, this pages through
|
|
8
|
+
* the capture the way a phone story does -- a segmented progress rail, one
|
|
9
|
+
* screen at a time, auto-advancing -- so the whole template can be reviewed
|
|
10
|
+
* in place. Downloading becomes one of the actions under the card instead of
|
|
11
|
+
* the only thing the button does.
|
|
12
|
+
*
|
|
13
|
+
* Everything animates on `transform`/`opacity` alone (never a
|
|
14
|
+
* layout-affecting property), which is what keeps the advance smooth even on
|
|
15
|
+
* a very long template.
|
|
16
|
+
*
|
|
17
|
+
* The viewer owns no core state: it is opened imperatively and revokes its
|
|
18
|
+
* own object URL, so a re-render of the editor underneath never disturbs it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** How long one screen holds before advancing. */
|
|
22
|
+
const PAGE_MS = 3600;
|
|
23
|
+
/** Slide duration between screens -- long enough to read as a scroll, short enough not to drag. */
|
|
24
|
+
const SLIDE_MS = 560;
|
|
25
|
+
/**
|
|
26
|
+
* Each advance moves 92% of a card height, not 100%: the repeated sliver of
|
|
27
|
+
* the previous screen is what makes the jump legible as "further down the
|
|
28
|
+
* same email" rather than a cut to an unrelated image.
|
|
29
|
+
*/
|
|
30
|
+
const STEP = 0.92;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What the download format toggle cycles through. The capture itself is
|
|
34
|
+
* always PNG (lossless preview, and the clipboard accepts nothing else);
|
|
35
|
+
* a lossy pick re-encodes that one capture at download time. Format
|
|
36
|
+
* acronyms are proper names, so the labels are not translated.
|
|
37
|
+
*/
|
|
38
|
+
const FORMATS = [
|
|
39
|
+
{ id: 'png', label: 'PNG' },
|
|
40
|
+
{ id: 'jpeg', label: 'JPG' },
|
|
41
|
+
{ id: 'webp', label: 'WEBP' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function el(tag, css, attrs = {}, ...kids) {
|
|
45
|
+
const node = document.createElement(tag);
|
|
46
|
+
if (css) node.style.cssText = css;
|
|
47
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
48
|
+
if (v === undefined) continue;
|
|
49
|
+
if (k === 'text') node.textContent = v;
|
|
50
|
+
else node.setAttribute(k, v);
|
|
51
|
+
}
|
|
52
|
+
kids.flat().forEach((c) => { if (c != null && c !== false) node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
|
|
53
|
+
return node;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const GHOST = "border: 1px solid rgba(255,255,255,0.22); background: rgba(255,255,255,0.1); color: #fff; cursor: pointer; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; gap: 7px; font-family: var(--ed-font); font-size: 11.5px; font-weight: 600; transition: background 0.16s, border-color 0.16s, opacity 0.16s;";
|
|
57
|
+
|
|
58
|
+
function ghostBtn(css, label, iconName, onClick, withText) {
|
|
59
|
+
const btn = el('button', GHOST + css, { type: 'button', title: label, 'aria-label': label });
|
|
60
|
+
btn.iconSlot = icon(iconName, 14);
|
|
61
|
+
btn.labelSlot = withText ? el('span', '', { text: label }) : null;
|
|
62
|
+
btn.append(btn.iconSlot);
|
|
63
|
+
if (btn.labelSlot) btn.append(btn.labelSlot);
|
|
64
|
+
btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(255,255,255,0.16)'; btn.style.borderColor = 'rgba(255,255,255,0.4)'; });
|
|
65
|
+
btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(255,255,255,0.07)'; btn.style.borderColor = 'rgba(255,255,255,0.22)'; });
|
|
66
|
+
btn.addEventListener('click', onClick);
|
|
67
|
+
return btn;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `hooks.capture()` -> Promise<Blob>, called on open and on retry;
|
|
72
|
+
* `hooks.download(blob)` and `hooks.copy(blob)` run the host's own save and
|
|
73
|
+
* clipboard flows so the toasts stay consistent with the rest of the editor.
|
|
74
|
+
*
|
|
75
|
+
* (`export const` + function expression, not `export function`: build.js's
|
|
76
|
+
* transform only recognizes `export (const|function|class)` -- either form
|
|
77
|
+
* works, and this file follows screenshot.js.)
|
|
78
|
+
*/
|
|
79
|
+
export const createStoryViewer = function (core, hooks) {
|
|
80
|
+
const t = (key, params) => core.t(key, params);
|
|
81
|
+
|
|
82
|
+
const s = {
|
|
83
|
+
open: false, page: 0, pages: 1, playing: true, done: false, held: false,
|
|
84
|
+
remaining: PAGE_MS, startedAt: 0, timer: 0, token: 0,
|
|
85
|
+
url: '', blob: null, natW: 0, natH: 0, cardW: 0, cardH: 0, showH: 0,
|
|
86
|
+
format: 0, saving: false, onClose: null,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ---- chrome -----------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
// Dim only, no backdrop blur -- a full-screen blur is a many-frame stall on
|
|
92
|
+
// weak GPUs each time the overlay fades in; the deeper dim carries the focus.
|
|
93
|
+
// Deep dim rather than translucent: bright chrome underneath (the export
|
|
94
|
+
// dialog's buttons, a colourful canvas) bled through 0.86 right where the
|
|
95
|
+
// viewer's own header sits and read as clutter.
|
|
96
|
+
const overlay = el('div', 'position: absolute; inset: 0; z-index: 78; display: none; flex-direction: column; align-items: center; justify-content: center; gap: 11px; padding: 22px; box-sizing: border-box; background: rgba(8,10,15,0.94); opacity: 0; transition: opacity 0.22s ease;', { class: 'mc-story' });
|
|
97
|
+
// Clicking the dark surround closes; clicks on the card are the tap zones'
|
|
98
|
+
// business and must not fall through to here.
|
|
99
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
|
100
|
+
|
|
101
|
+
const rail = el('div', 'display: none; gap: 4px; height: 3px; flex: none;');
|
|
102
|
+
const headRow = el('div', 'display: flex; align-items: center; gap: 10px; flex: none;');
|
|
103
|
+
const headText = el('div', 'flex: 1; min-width: 0;');
|
|
104
|
+
const kickerEl = el('div', 'font-family: ui-monospace, monospace; font-size: 8.5px; letter-spacing: 0.16em; text-transform: uppercase; color: rgba(255,255,255,0.5);');
|
|
105
|
+
// The capture's dimensions and page position live under the kicker, where a
|
|
106
|
+
// full header line keeps them legible -- squeezed into the footer next to
|
|
107
|
+
// the buttons they ellipsized on any card narrow enough to matter.
|
|
108
|
+
const metaEl = el('div', 'display: none; margin-top: 4px; font-family: ui-monospace, monospace; font-size: 9px; letter-spacing: 0.09em; color: rgba(255,255,255,0.42); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;');
|
|
109
|
+
headText.append(kickerEl, metaEl);
|
|
110
|
+
const closeBtn = ghostBtn('width: 30px; padding: 0;', t('action.close'), 'x', () => close());
|
|
111
|
+
headRow.append(headText, closeBtn);
|
|
112
|
+
|
|
113
|
+
const card = el('div', 'position: relative; overflow: hidden; border-radius: 16px; background: #fff; flex: none; box-shadow: 0 26px 70px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.09); transform: scale(0.965) translateY(10px); opacity: 0; transition: transform 0.34s cubic-bezier(0.22,0.61,0.36,1), opacity 0.24s ease;');
|
|
114
|
+
// `will-change` keeps the tall capture promoted to its own layer for the
|
|
115
|
+
// life of the viewer -- without it the browser may demote between slides
|
|
116
|
+
// and re-rasterize megapixels on the next one, which is the visible hitch.
|
|
117
|
+
const shot = el('img', 'position: absolute; left: 0; top: 0; width: 100%; display: block; opacity: 0; transform: translate3d(0,0,0); transition: opacity 0.32s ease; will-change: transform;', { alt: '', decoding: 'async' });
|
|
118
|
+
card.appendChild(shot);
|
|
119
|
+
|
|
120
|
+
// Skeleton: shown while the capture renders and cross-faded out when the
|
|
121
|
+
// image arrives, so the card never flashes empty or resizes under the eye.
|
|
122
|
+
const skeleton = el('div', 'position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 11px; background: var(--ed-panel); color: var(--ed-faint); transition: opacity 0.28s ease;');
|
|
123
|
+
// The sweep animates `transform`, not `background-position`: the capture
|
|
124
|
+
// work saturates the main thread exactly while this plays, and a composited
|
|
125
|
+
// transform keeps gliding where a per-frame background repaint stutters.
|
|
126
|
+
const shimmer = el('div', 'position: absolute; top: 0; bottom: 0; left: 0; width: 60%; background: linear-gradient(100deg, transparent, var(--ed-soft), transparent); transform: translateX(-110%); animation: mcStoryShimmer 1.6s linear infinite; will-change: transform;');
|
|
127
|
+
const skelText = el('div', 'position: relative; font-family: var(--ed-font); font-size: 12.5px; font-weight: 600; color: var(--ed-text);');
|
|
128
|
+
const skelHint = el('div', 'position: relative; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.07em; line-height: 1.7; color: var(--ed-faint); max-width: 76%; text-align: center;');
|
|
129
|
+
const retryBtn = el('button', 'position: relative; display: none; border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 13px; border-radius: 8px; align-items: center; gap: 7px; font-family: var(--ed-font); font-size: 11.5px; font-weight: 600;', { type: 'button' });
|
|
130
|
+
retryBtn.append(icon('refresh', 14), el('span', '', { text: t('story.retry') }));
|
|
131
|
+
retryBtn.addEventListener('click', () => load());
|
|
132
|
+
skeleton.append(shimmer, skelText, skelHint, retryBtn);
|
|
133
|
+
card.appendChild(skeleton);
|
|
134
|
+
|
|
135
|
+
// Tap zones, story grammar: the left third steps back, the rest forward.
|
|
136
|
+
// A press-and-hold pauses and resumes on release, so `pointerdown` starts
|
|
137
|
+
// the pause and only a short press counts as a navigating tap.
|
|
138
|
+
const zone = (css, onTap) => {
|
|
139
|
+
const z = el('div', 'position: absolute; top: 0; bottom: 0; ' + css + ' cursor: pointer;');
|
|
140
|
+
let downAt = 0;
|
|
141
|
+
z.addEventListener('pointerdown', () => { downAt = performance.now(); if (s.playing && !s.done) pause(true); });
|
|
142
|
+
const up = () => {
|
|
143
|
+
if (!downAt) return;
|
|
144
|
+
const held = performance.now() - downAt;
|
|
145
|
+
downAt = 0;
|
|
146
|
+
if (held < 220) onTap();
|
|
147
|
+
else if (s.held) play();
|
|
148
|
+
};
|
|
149
|
+
z.addEventListener('pointerup', up);
|
|
150
|
+
z.addEventListener('pointerleave', up);
|
|
151
|
+
return z;
|
|
152
|
+
};
|
|
153
|
+
card.append(
|
|
154
|
+
zone('left: 0; width: 32%;', () => step(-1)),
|
|
155
|
+
zone('left: 32%; right: 0;', () => step(1)),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// One floating action bar with a real hierarchy: Download is the solid
|
|
159
|
+
// primary, Copy a quiet neighbour, play/pause a small transport control,
|
|
160
|
+
// and the format is a segmented PNG/JPG/WEBP picker -- all three values
|
|
161
|
+
// visible with the active one lit, instead of a chip that cycles blind.
|
|
162
|
+
const footer = el('div', 'display: flex; align-items: center; justify-content: center; flex: none;');
|
|
163
|
+
const BAR_BTN = 'border: none; background: transparent; color: rgba(255,255,255,0.92); cursor: pointer; height: 34px; border-radius: 999px; display: flex; align-items: center; justify-content: center; gap: 7px; font-family: var(--ed-font); font-size: 11.5px; font-weight: 600; transition: background 0.16s, color 0.16s, opacity 0.16s;';
|
|
164
|
+
const barBtn = (css, label, iconName, onClick, opts = {}) => {
|
|
165
|
+
const btn = el('button', BAR_BTN + css, { type: 'button', title: label, 'aria-label': label });
|
|
166
|
+
btn.iconSlot = icon(iconName, 14);
|
|
167
|
+
btn.labelSlot = opts.text ? el('span', '', { text: label }) : null;
|
|
168
|
+
btn.append(btn.iconSlot);
|
|
169
|
+
if (btn.labelSlot) btn.append(btn.labelSlot);
|
|
170
|
+
const rest = opts.restBg || 'transparent';
|
|
171
|
+
const hover = opts.hoverBg || 'rgba(255,255,255,0.12)';
|
|
172
|
+
btn.addEventListener('mouseenter', () => { btn.style.background = hover; });
|
|
173
|
+
btn.addEventListener('mouseleave', () => { btn.style.background = rest; });
|
|
174
|
+
btn.addEventListener('click', onClick);
|
|
175
|
+
return btn;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const playBtn = barBtn('width: 34px; padding: 0;', t('story.pause'), 'pause', () => (s.playing ? pause(false) : play()));
|
|
179
|
+
const copyBtn = barBtn('padding: 0 13px;', t('story.copy'), 'copy', () => hooks.copy(s.blob), { text: true });
|
|
180
|
+
|
|
181
|
+
// Format acronyms are proper names, so the segment labels themselves are
|
|
182
|
+
// not translated; the shared tooltip is.
|
|
183
|
+
const segBtns = [];
|
|
184
|
+
const seg = el('div', 'display: flex; align-items: center; gap: 2px; padding: 3px; background: rgba(0,0,0,0.35); border-radius: 999px; flex: none; transition: opacity 0.16s;');
|
|
185
|
+
FORMATS.forEach((fmt, i) => {
|
|
186
|
+
const b = el('button', 'border: none; cursor: pointer; height: 26px; padding: 0 11px; border-radius: 999px; background: transparent; color: rgba(255,255,255,0.55); font-family: ui-monospace, monospace; font-size: 8.5px; font-weight: 700; letter-spacing: 0.09em; transition: background 0.16s, color 0.16s;', { type: 'button', text: fmt.label });
|
|
187
|
+
b.addEventListener('click', () => { s.format = i; syncFormat(); });
|
|
188
|
+
b.addEventListener('mouseenter', () => { if (s.format !== i) { b.style.color = '#fff'; b.style.background = 'rgba(255,255,255,0.1)'; } });
|
|
189
|
+
b.addEventListener('mouseleave', () => syncFormat());
|
|
190
|
+
segBtns.push(b);
|
|
191
|
+
seg.appendChild(b);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const dlBtn = barBtn('padding: 0 16px; font-weight: 700; background: #fff; color: #14171c;', t('story.download', { fmt: FORMATS[0].label }), 'download', () => download(), { text: true, restBg: '#fff', hoverBg: '#dfe5ee' });
|
|
195
|
+
|
|
196
|
+
const bar = el('div', 'display: flex; align-items: center; gap: 6px; padding: 5px; background: rgba(23,26,34,0.92); border: 1px solid rgba(255,255,255,0.14); border-radius: 999px; box-shadow: 0 18px 44px rgba(0,0,0,0.5);');
|
|
197
|
+
bar.append(playBtn, copyBtn, seg, dlBtn);
|
|
198
|
+
footer.append(bar);
|
|
199
|
+
|
|
200
|
+
function syncFormat() {
|
|
201
|
+
const fmt = FORMATS[s.format];
|
|
202
|
+
segBtns.forEach((b, i) => {
|
|
203
|
+
const on = i === s.format;
|
|
204
|
+
b.style.background = on ? '#fff' : 'transparent';
|
|
205
|
+
b.style.color = on ? '#14171c' : 'rgba(255,255,255,0.55)';
|
|
206
|
+
b.title = t('story.format');
|
|
207
|
+
b.setAttribute('aria-label', t('story.format') + ' — ' + FORMATS[i].label);
|
|
208
|
+
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
209
|
+
});
|
|
210
|
+
dlBtn.labelSlot.textContent = t('story.download', { fmt: fmt.label });
|
|
211
|
+
dlBtn.title = t('story.download', { fmt: fmt.label });
|
|
212
|
+
dlBtn.setAttribute('aria-label', dlBtn.title);
|
|
213
|
+
}
|
|
214
|
+
syncFormat();
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* PNG downloads hand the capture over as-is; a lossy pick re-encodes it
|
|
218
|
+
* first (against the template's page colour -- JPEG has no alpha). The
|
|
219
|
+
* `saving` latch keeps a double-click from encoding twice, and an encoder
|
|
220
|
+
* failure falls back to the lossless capture rather than saving nothing.
|
|
221
|
+
*/
|
|
222
|
+
async function download() {
|
|
223
|
+
if (!s.blob || s.saving) return;
|
|
224
|
+
const fmt = FORMATS[s.format];
|
|
225
|
+
let out = s.blob;
|
|
226
|
+
if (fmt.id !== 'png') {
|
|
227
|
+
s.saving = true;
|
|
228
|
+
try { out = await transcodeShot(s.blob, { format: fmt.id }, core.state.doc.theme.bg); }
|
|
229
|
+
catch { out = s.blob; }
|
|
230
|
+
finally { s.saving = false; }
|
|
231
|
+
}
|
|
232
|
+
hooks.download(out);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
overlay.append(rail, headRow, card, footer);
|
|
236
|
+
|
|
237
|
+
// ---- timing -----------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
function fills() { return Array.from(rail.children).map((seg) => seg.firstChild); }
|
|
240
|
+
|
|
241
|
+
/** Paints the rail for the current screen at `fraction`, optionally running the remainder of that screen's fill. */
|
|
242
|
+
function paintRail(fraction, animate) {
|
|
243
|
+
fills().forEach((fill, i) => {
|
|
244
|
+
fill.style.transition = 'none';
|
|
245
|
+
if (i < s.page) { fill.style.transform = 'scaleX(1)'; return; }
|
|
246
|
+
if (i > s.page) { fill.style.transform = 'scaleX(0)'; return; }
|
|
247
|
+
fill.style.transform = 'scaleX(' + fraction + ')';
|
|
248
|
+
if (!animate) return;
|
|
249
|
+
void fill.offsetWidth; // commit the start value before arming the run
|
|
250
|
+
fill.style.transition = 'transform ' + Math.max(0, s.remaining) + 'ms linear';
|
|
251
|
+
fill.style.transform = 'scaleX(1)';
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Time is tracked as "milliseconds still owed on this screen" rather than
|
|
257
|
+
* read back off the animation, so a pause and resume can hand the rail an
|
|
258
|
+
* exact starting fraction instead of sampling a computed transform matrix.
|
|
259
|
+
*/
|
|
260
|
+
function arm() {
|
|
261
|
+
clearTimeout(s.timer);
|
|
262
|
+
if (!s.playing || s.done || s.pages < 2) return;
|
|
263
|
+
s.startedAt = performance.now();
|
|
264
|
+
paintRail(1 - s.remaining / PAGE_MS, true);
|
|
265
|
+
s.timer = setTimeout(() => step(1), s.remaining);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function pause(held) {
|
|
269
|
+
if (!s.playing) return;
|
|
270
|
+
clearTimeout(s.timer);
|
|
271
|
+
s.remaining = Math.max(0, s.remaining - (performance.now() - s.startedAt));
|
|
272
|
+
s.playing = false;
|
|
273
|
+
s.held = !!held; // a hold resumes on release; the button toggle stays paused
|
|
274
|
+
paintRail(1 - s.remaining / PAGE_MS, false);
|
|
275
|
+
syncPlayBtn();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function play() {
|
|
279
|
+
s.held = false;
|
|
280
|
+
if (s.done) { goto(0); return; }
|
|
281
|
+
s.playing = true;
|
|
282
|
+
arm();
|
|
283
|
+
syncPlayBtn();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function syncPlayBtn() {
|
|
287
|
+
const label = s.done ? t('story.replay') : s.playing ? t('story.pause') : t('story.play');
|
|
288
|
+
const name = s.done ? 'refresh' : s.playing ? 'pause' : 'play';
|
|
289
|
+
const next = icon(name, 14);
|
|
290
|
+
playBtn.replaceChild(next, playBtn.iconSlot);
|
|
291
|
+
playBtn.iconSlot = next;
|
|
292
|
+
playBtn.title = label;
|
|
293
|
+
playBtn.setAttribute('aria-label', label);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---- paging -----------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
function offsetFor(page) {
|
|
299
|
+
// Shorter than the card (a very short template): centered, never paged.
|
|
300
|
+
if (s.showH <= s.cardH) return Math.round((s.cardH - s.showH) / 2);
|
|
301
|
+
return -Math.min(Math.round(page * s.cardH * STEP), Math.round(s.showH - s.cardH));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function goto(page, animate) {
|
|
305
|
+
const next = Math.max(0, Math.min(s.pages - 1, page));
|
|
306
|
+
s.page = next;
|
|
307
|
+
s.remaining = PAGE_MS;
|
|
308
|
+
if (s.done) { s.done = false; s.playing = true; } // replaying from the end
|
|
309
|
+
shot.style.transition = animate === false
|
|
310
|
+
? 'opacity 0.32s ease'
|
|
311
|
+
: 'transform ' + SLIDE_MS + 'ms cubic-bezier(0.22,0.61,0.36,1), opacity 0.32s ease';
|
|
312
|
+
shot.style.transform = 'translate3d(0, ' + offsetFor(next) + 'px, 0)';
|
|
313
|
+
renderMeta();
|
|
314
|
+
arm();
|
|
315
|
+
syncPlayBtn();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** One tap/arrow move. Running off the end stops on the last screen rather than looping. */
|
|
319
|
+
function step(dir) {
|
|
320
|
+
if (dir > 0 && s.page >= s.pages - 1) {
|
|
321
|
+
clearTimeout(s.timer);
|
|
322
|
+
s.done = true;
|
|
323
|
+
s.playing = false;
|
|
324
|
+
s.held = false;
|
|
325
|
+
paintRail(1, false);
|
|
326
|
+
syncPlayBtn();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (dir < 0 && s.page === 0) { goto(0); return; }
|
|
330
|
+
goto(s.page + dir);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function renderMeta() {
|
|
334
|
+
metaEl.textContent = s.natW ? t('story.meta', { w: s.natW, h: s.natH, i: s.page + 1, n: s.pages }) : '';
|
|
335
|
+
metaEl.style.display = s.natW ? 'block' : 'none';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---- layout -----------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Sizes the card from the space the shell actually has: as tall as the
|
|
342
|
+
* overlay allows, and no wider than 3/4 of that (capped at 430px), which
|
|
343
|
+
* keeps a 620px-wide email readable instead of shrinking it to a thumbnail.
|
|
344
|
+
* Re-run on resize, and once more when the image's intrinsic size is known.
|
|
345
|
+
*/
|
|
346
|
+
function layout() {
|
|
347
|
+
if (!s.open) return;
|
|
348
|
+
// 44 is the overlay's own padding; 118 covers the chrome around the card:
|
|
349
|
+
// rail (3), the two-line header (~36), the action bar (~46) and the gaps.
|
|
350
|
+
const availH = Math.max(280, overlay.clientHeight - 44 - 118);
|
|
351
|
+
const availW = Math.max(240, overlay.clientWidth - 44);
|
|
352
|
+
s.cardH = availH;
|
|
353
|
+
s.cardW = Math.max(240, Math.min(430, availW, Math.round(availH * 0.75)));
|
|
354
|
+
card.style.width = s.cardW + 'px';
|
|
355
|
+
card.style.height = s.cardH + 'px';
|
|
356
|
+
[rail, headRow, footer].forEach((n) => { n.style.width = s.cardW + 'px'; });
|
|
357
|
+
// On a narrow card the full bar cannot fit; Copy folds to its icon so the
|
|
358
|
+
// primary Download keeps its label.
|
|
359
|
+
const tight = s.cardW < 390;
|
|
360
|
+
copyBtn.labelSlot.style.display = tight ? 'none' : '';
|
|
361
|
+
copyBtn.style.width = tight ? '34px' : '';
|
|
362
|
+
copyBtn.style.padding = tight ? '0' : '0 13px';
|
|
363
|
+
if (!s.natW) return;
|
|
364
|
+
s.showH = s.cardW * (s.natH / s.natW);
|
|
365
|
+
s.pages = s.showH <= s.cardH ? 1 : Math.ceil((s.showH - s.cardH) / (s.cardH * STEP)) + 1;
|
|
366
|
+
buildRail();
|
|
367
|
+
goto(Math.min(s.page, s.pages - 1), false);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function buildRail() {
|
|
371
|
+
rail.innerHTML = '';
|
|
372
|
+
rail.style.display = s.pages > 1 ? 'flex' : 'none';
|
|
373
|
+
for (let i = 0; i < s.pages; i++) {
|
|
374
|
+
const fill = el('div', 'width: 100%; height: 100%; border-radius: 2px; background: #fff; transform: scaleX(0); transform-origin: left center;');
|
|
375
|
+
rail.appendChild(el('div', 'flex: 1; height: 100%; border-radius: 2px; background: rgba(255,255,255,0.24); overflow: hidden;', {}, fill));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- load / open / close ----------------------------------------------
|
|
380
|
+
|
|
381
|
+
function setActionsEnabled(on) {
|
|
382
|
+
[copyBtn, dlBtn, playBtn, ...segBtns].forEach((b) => {
|
|
383
|
+
b.disabled = !on;
|
|
384
|
+
b.style.pointerEvents = on ? '' : 'none';
|
|
385
|
+
});
|
|
386
|
+
// The segmented picker dims as one unit -- fading its segments separately
|
|
387
|
+
// would leave the inset track behind them at full strength.
|
|
388
|
+
[copyBtn, playBtn, dlBtn, seg].forEach((n) => { n.style.opacity = on ? '' : '0.4'; });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function showSkeleton(failed) {
|
|
392
|
+
skeleton.style.display = 'flex';
|
|
393
|
+
skeleton.style.opacity = '1';
|
|
394
|
+
shimmer.style.display = failed ? 'none' : 'block';
|
|
395
|
+
skelText.textContent = failed ? t('story.failed') : t('story.rendering');
|
|
396
|
+
skelHint.textContent = failed ? t('story.failedHint') : t('story.renderingHint');
|
|
397
|
+
retryBtn.style.display = failed ? 'flex' : 'none';
|
|
398
|
+
setActionsEnabled(false);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* `token` guards every async landing: closing (or retrying) bumps it, so a
|
|
403
|
+
* capture that finishes after the user moved on can never paint into the
|
|
404
|
+
* card or leak its object URL.
|
|
405
|
+
*/
|
|
406
|
+
function load() {
|
|
407
|
+
const token = ++s.token;
|
|
408
|
+
releaseUrl();
|
|
409
|
+
Object.assign(s, { blob: null, natW: 0, natH: 0, page: 0, pages: 1, done: false, playing: true, held: false, remaining: PAGE_MS });
|
|
410
|
+
clearTimeout(s.timer);
|
|
411
|
+
shot.style.opacity = '0';
|
|
412
|
+
rail.style.display = 'none';
|
|
413
|
+
renderMeta();
|
|
414
|
+
showSkeleton(false);
|
|
415
|
+
hooks.capture().then((blob) => {
|
|
416
|
+
if (token !== s.token || !s.open) return;
|
|
417
|
+
s.blob = blob;
|
|
418
|
+
s.url = URL.createObjectURL(blob);
|
|
419
|
+
shot.onload = () => {
|
|
420
|
+
if (token !== s.token) return;
|
|
421
|
+
// Decode the megapixels off-screen first: fading in an undecoded
|
|
422
|
+
// image rasterizes it inside the fade and drops its first frames.
|
|
423
|
+
const ready = shot.decode ? shot.decode().catch(() => {}) : Promise.resolve();
|
|
424
|
+
ready.then(() => {
|
|
425
|
+
if (token !== s.token) return;
|
|
426
|
+
s.natW = shot.naturalWidth;
|
|
427
|
+
s.natH = shot.naturalHeight;
|
|
428
|
+
layout();
|
|
429
|
+
shot.style.opacity = '1';
|
|
430
|
+
skeleton.style.opacity = '0';
|
|
431
|
+
setTimeout(() => { if (token === s.token) skeleton.style.display = 'none'; }, 300);
|
|
432
|
+
setActionsEnabled(true);
|
|
433
|
+
// A template that fits on one screen has nothing to advance through:
|
|
434
|
+
// land on "replay" rather than running an invisible timer.
|
|
435
|
+
if (s.pages < 2) { s.done = true; s.playing = false; }
|
|
436
|
+
syncPlayBtn();
|
|
437
|
+
arm();
|
|
438
|
+
});
|
|
439
|
+
};
|
|
440
|
+
shot.src = s.url;
|
|
441
|
+
}, () => {
|
|
442
|
+
if (token !== s.token || !s.open) return;
|
|
443
|
+
showSkeleton(true);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function releaseUrl() {
|
|
448
|
+
if (!s.url) return;
|
|
449
|
+
URL.revokeObjectURL(s.url);
|
|
450
|
+
s.url = '';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Escape and the arrow keys are claimed in the capture phase: the editor's
|
|
455
|
+
* own window-level handler (editor-core `mountKeyboard`) would otherwise
|
|
456
|
+
* close every modal underneath on the same Escape that closes the story.
|
|
457
|
+
*/
|
|
458
|
+
function onKey(e) {
|
|
459
|
+
if (!s.open) return;
|
|
460
|
+
if (e.key === 'Escape') { e.stopPropagation(); close(); return; }
|
|
461
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.stopPropagation(); pause(false); step(1); return; }
|
|
462
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.stopPropagation(); pause(false); step(-1); return; }
|
|
463
|
+
if (e.key === ' ') { e.stopPropagation(); e.preventDefault(); if (s.playing) pause(false); else play(); }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let wheelAt = 0;
|
|
467
|
+
card.addEventListener('wheel', (e) => {
|
|
468
|
+
e.preventDefault();
|
|
469
|
+
const now = performance.now();
|
|
470
|
+
// One screen per gesture: a trackpad fires dozens of small deltas, and
|
|
471
|
+
// acting on each would race the slide against itself.
|
|
472
|
+
if (now - wheelAt < SLIDE_MS * 0.8 || Math.abs(e.deltaY) < 6) return;
|
|
473
|
+
wheelAt = now;
|
|
474
|
+
pause(false);
|
|
475
|
+
step(e.deltaY > 0 ? 1 : -1);
|
|
476
|
+
}, { passive: false });
|
|
477
|
+
|
|
478
|
+
function open(opts) {
|
|
479
|
+
if (s.open) return;
|
|
480
|
+
s.open = true;
|
|
481
|
+
s.onClose = (opts && opts.onClose) || null;
|
|
482
|
+
overlay.style.display = 'flex';
|
|
483
|
+
overlay.removeAttribute('aria-hidden');
|
|
484
|
+
kickerEl.textContent = t('story.kicker');
|
|
485
|
+
void overlay.offsetWidth; // start the fade from 0, not from "already shown"
|
|
486
|
+
overlay.style.opacity = '1';
|
|
487
|
+
card.style.transform = 'scale(1) translateY(0)';
|
|
488
|
+
card.style.opacity = '1';
|
|
489
|
+
layout();
|
|
490
|
+
// The capture blocks the main thread in bursts (render, layout, serialize,
|
|
491
|
+
// rasterize, encode) -- started immediately those bursts land inside the
|
|
492
|
+
// entrance transition and visibly stutter it. The skeleton goes up now;
|
|
493
|
+
// the capture starts once the chrome has finished arriving (0.34s card
|
|
494
|
+
// transition, plus a beat).
|
|
495
|
+
showSkeleton(false);
|
|
496
|
+
renderMeta();
|
|
497
|
+
setTimeout(() => { if (s.open) load(); }, 380);
|
|
498
|
+
window.addEventListener('keydown', onKey, true);
|
|
499
|
+
window.addEventListener('resize', layout);
|
|
500
|
+
closeBtn.focus({ preventScroll: true });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function close() {
|
|
504
|
+
if (!s.open) return;
|
|
505
|
+
s.open = false;
|
|
506
|
+
s.token++;
|
|
507
|
+
clearTimeout(s.timer);
|
|
508
|
+
// Restore whatever the host parked for the viewer (the export dialog) as
|
|
509
|
+
// the fade-out starts, so leaving reads as returning, not as a blank beat.
|
|
510
|
+
const restore = s.onClose;
|
|
511
|
+
s.onClose = null;
|
|
512
|
+
if (restore) restore();
|
|
513
|
+
window.removeEventListener('keydown', onKey, true);
|
|
514
|
+
window.removeEventListener('resize', layout);
|
|
515
|
+
overlay.style.opacity = '0';
|
|
516
|
+
card.style.transform = 'scale(0.965) translateY(10px)';
|
|
517
|
+
card.style.opacity = '0';
|
|
518
|
+
setTimeout(() => {
|
|
519
|
+
if (s.open) return; // reopened during the fade -- leave it as it is
|
|
520
|
+
overlay.style.display = 'none';
|
|
521
|
+
overlay.setAttribute('aria-hidden', 'true');
|
|
522
|
+
shot.removeAttribute('src');
|
|
523
|
+
releaseUrl();
|
|
524
|
+
s.blob = null;
|
|
525
|
+
}, 240);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Re-labels the viewer in place after a `locale`/`.messages` change. */
|
|
529
|
+
function retranslate() {
|
|
530
|
+
closeBtn.title = t('action.close');
|
|
531
|
+
closeBtn.setAttribute('aria-label', t('action.close'));
|
|
532
|
+
copyBtn.labelSlot.textContent = t('story.copy');
|
|
533
|
+
copyBtn.title = t('story.copy');
|
|
534
|
+
syncFormat();
|
|
535
|
+
retryBtn.lastChild.textContent = t('story.retry');
|
|
536
|
+
if (s.open) { kickerEl.textContent = t('story.kicker'); renderMeta(); }
|
|
537
|
+
syncPlayBtn();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return { node: overlay, open, close, retranslate, isOpen: () => s.open };
|
|
541
|
+
};
|