@seliseblocks/mailcraft 0.2.8 → 0.2.9
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 +99 -0
- package/DOCS.md +27 -27
- package/README.md +1 -1
- package/README.md.txt +1 -1
- package/dist/mailcraft-editor.bundle.js +58 -56
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +57 -5
- package/src/core/i18n/index.js +83 -83
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +152 -11
- 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/sanitize.js +61 -0
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +41 -2
- package/src/render/fields.js +28 -2
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +6 -0
- package/src/render/story.js +415 -415
package/src/render/story.js
CHANGED
|
@@ -1,415 +1,415 @@
|
|
|
1
|
-
import { icon } from '../core/icons.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Story-style screenshot viewer: the PNG that `render/screenshot.js` produces
|
|
5
|
-
* is a single very tall image, which is exactly the wrong shape for a modal.
|
|
6
|
-
* Rather than hand the user a file and hope they open it, this pages through
|
|
7
|
-
* the capture the way a phone story does -- a segmented progress rail, one
|
|
8
|
-
* screen at a time, auto-advancing -- so the whole template can be reviewed
|
|
9
|
-
* in place. Downloading becomes one of the actions under the card instead of
|
|
10
|
-
* the only thing the button does.
|
|
11
|
-
*
|
|
12
|
-
* Everything animates on `transform`/`opacity` alone (never a
|
|
13
|
-
* layout-affecting property), which is what keeps the advance smooth even on
|
|
14
|
-
* a very long template.
|
|
15
|
-
*
|
|
16
|
-
* The viewer owns no core state: it is opened imperatively and revokes its
|
|
17
|
-
* own object URL, so a re-render of the editor underneath never disturbs it.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/** How long one screen holds before advancing. */
|
|
21
|
-
const PAGE_MS = 3600;
|
|
22
|
-
/** Slide duration between screens -- long enough to read as a scroll, short enough not to drag. */
|
|
23
|
-
const SLIDE_MS = 560;
|
|
24
|
-
/**
|
|
25
|
-
* Each advance moves 92% of a card height, not 100%: the repeated sliver of
|
|
26
|
-
* the previous screen is what makes the jump legible as "further down the
|
|
27
|
-
* same email" rather than a cut to an unrelated image.
|
|
28
|
-
*/
|
|
29
|
-
const STEP = 0.92;
|
|
30
|
-
|
|
31
|
-
function el(tag, css, attrs = {}, ...kids) {
|
|
32
|
-
const node = document.createElement(tag);
|
|
33
|
-
if (css) node.style.cssText = css;
|
|
34
|
-
for (const [k, v] of Object.entries(attrs)) {
|
|
35
|
-
if (v === undefined) continue;
|
|
36
|
-
if (k === 'text') node.textContent = v;
|
|
37
|
-
else node.setAttribute(k, v);
|
|
38
|
-
}
|
|
39
|
-
kids.flat().forEach((c) => { if (c != null && c !== false) node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
|
|
40
|
-
return node;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
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;";
|
|
44
|
-
|
|
45
|
-
function ghostBtn(css, label, iconName, onClick, withText) {
|
|
46
|
-
const btn = el('button', GHOST + css, { type: 'button', title: label, 'aria-label': label });
|
|
47
|
-
btn.iconSlot = icon(iconName, 14);
|
|
48
|
-
btn.labelSlot = withText ? el('span', '', { text: label }) : null;
|
|
49
|
-
btn.append(btn.iconSlot);
|
|
50
|
-
if (btn.labelSlot) btn.append(btn.labelSlot);
|
|
51
|
-
btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(255,255,255,0.16)'; btn.style.borderColor = 'rgba(255,255,255,0.4)'; });
|
|
52
|
-
btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(255,255,255,0.07)'; btn.style.borderColor = 'rgba(255,255,255,0.22)'; });
|
|
53
|
-
btn.addEventListener('click', onClick);
|
|
54
|
-
return btn;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* `hooks.capture()` -> Promise<Blob>, called on open and on retry;
|
|
59
|
-
* `hooks.download(blob)` and `hooks.copy(blob)` run the host's own save and
|
|
60
|
-
* clipboard flows so the toasts stay consistent with the rest of the editor.
|
|
61
|
-
*
|
|
62
|
-
* (`export const` + function expression, not `export function`: build.js's
|
|
63
|
-
* transform only recognizes `export (const|function|class)` -- either form
|
|
64
|
-
* works, and this file follows screenshot.js.)
|
|
65
|
-
*/
|
|
66
|
-
export const createStoryViewer = function (core, hooks) {
|
|
67
|
-
const t = (key, params) => core.t(key, params);
|
|
68
|
-
|
|
69
|
-
const s = {
|
|
70
|
-
open: false, page: 0, pages: 1, playing: true, done: false, held: false,
|
|
71
|
-
remaining: PAGE_MS, startedAt: 0, timer: 0, token: 0,
|
|
72
|
-
url: '', blob: null, natW: 0, natH: 0, cardW: 0, cardH: 0, showH: 0,
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
// ---- chrome -----------------------------------------------------------
|
|
76
|
-
|
|
77
|
-
// Dim only, no backdrop blur -- a full-screen blur is a many-frame stall on
|
|
78
|
-
// weak GPUs each time the overlay fades in; the deeper dim carries the focus.
|
|
79
|
-
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(9,11,16,0.86); opacity: 0; transition: opacity 0.22s ease;', { class: 'mc-story' });
|
|
80
|
-
// Clicking the dark surround closes; clicks on the card are the tap zones'
|
|
81
|
-
// business and must not fall through to here.
|
|
82
|
-
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
|
83
|
-
|
|
84
|
-
const rail = el('div', 'display: none; gap: 4px; height: 3px; flex: none;');
|
|
85
|
-
const headRow = el('div', 'display: flex; align-items: center; gap: 10px; flex: none;');
|
|
86
|
-
const headText = el('div', 'flex: 1; min-width: 0;');
|
|
87
|
-
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);');
|
|
88
|
-
headText.append(kickerEl);
|
|
89
|
-
const closeBtn = ghostBtn('width: 30px; padding: 0;', t('action.close'), 'x', () => close());
|
|
90
|
-
headRow.append(headText, closeBtn);
|
|
91
|
-
|
|
92
|
-
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;');
|
|
93
|
-
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;', { alt: '' });
|
|
94
|
-
card.appendChild(shot);
|
|
95
|
-
|
|
96
|
-
// Skeleton: shown while the capture renders and cross-faded out when the
|
|
97
|
-
// image arrives, so the card never flashes empty or resizes under the eye.
|
|
98
|
-
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;');
|
|
99
|
-
const shimmer = el('div', 'position: absolute; inset: 0; background: linear-gradient(100deg, transparent 22%, var(--ed-soft) 40%, transparent 58%); background-size: 260% 100%; animation: mcStoryShimmer 1.6s linear infinite;');
|
|
100
|
-
const skelText = el('div', 'position: relative; font-family: var(--ed-font); font-size: 12.5px; font-weight: 600; color: var(--ed-text);');
|
|
101
|
-
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;');
|
|
102
|
-
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' });
|
|
103
|
-
retryBtn.append(icon('refresh', 14), el('span', '', { text: t('story.retry') }));
|
|
104
|
-
retryBtn.addEventListener('click', () => load());
|
|
105
|
-
skeleton.append(shimmer, skelText, skelHint, retryBtn);
|
|
106
|
-
card.appendChild(skeleton);
|
|
107
|
-
|
|
108
|
-
// Tap zones, story grammar: the left third steps back, the rest forward.
|
|
109
|
-
// A press-and-hold pauses and resumes on release, so `pointerdown` starts
|
|
110
|
-
// the pause and only a short press counts as a navigating tap.
|
|
111
|
-
const zone = (css, onTap) => {
|
|
112
|
-
const z = el('div', 'position: absolute; top: 0; bottom: 0; ' + css + ' cursor: pointer;');
|
|
113
|
-
let downAt = 0;
|
|
114
|
-
z.addEventListener('pointerdown', () => { downAt = performance.now(); if (s.playing && !s.done) pause(true); });
|
|
115
|
-
const up = () => {
|
|
116
|
-
if (!downAt) return;
|
|
117
|
-
const held = performance.now() - downAt;
|
|
118
|
-
downAt = 0;
|
|
119
|
-
if (held < 220) onTap();
|
|
120
|
-
else if (s.held) play();
|
|
121
|
-
};
|
|
122
|
-
z.addEventListener('pointerup', up);
|
|
123
|
-
z.addEventListener('pointerleave', up);
|
|
124
|
-
return z;
|
|
125
|
-
};
|
|
126
|
-
card.append(
|
|
127
|
-
zone('left: 0; width: 32%;', () => step(-1)),
|
|
128
|
-
zone('left: 32%; right: 0;', () => step(1)),
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
const footer = el('div', 'display: flex; align-items: center; gap: 8px; flex: none;');
|
|
132
|
-
const metaEl = el('div', 'flex: 1; min-width: 0; font-family: ui-monospace, monospace; font-size: 9px; letter-spacing: 0.09em; color: rgba(255,255,255,0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;');
|
|
133
|
-
const playBtn = ghostBtn('width: 30px; padding: 0;', t('story.pause'), 'pause', () => (s.playing ? pause(false) : play()));
|
|
134
|
-
const copyBtn = ghostBtn('padding: 0 11px;', t('story.copy'), 'copy', () => hooks.copy(s.blob), true);
|
|
135
|
-
const dlBtn = ghostBtn('padding: 0 11px; border-color: rgba(255,255,255,0.4); background: rgba(255,255,255,0.16);', t('action.downloadPng'), 'download', () => hooks.download(s.blob), true);
|
|
136
|
-
footer.append(metaEl, playBtn, copyBtn, dlBtn);
|
|
137
|
-
|
|
138
|
-
overlay.append(rail, headRow, card, footer);
|
|
139
|
-
|
|
140
|
-
// ---- timing -----------------------------------------------------------
|
|
141
|
-
|
|
142
|
-
function fills() { return Array.from(rail.children).map((seg) => seg.firstChild); }
|
|
143
|
-
|
|
144
|
-
/** Paints the rail for the current screen at `fraction`, optionally running the remainder of that screen's fill. */
|
|
145
|
-
function paintRail(fraction, animate) {
|
|
146
|
-
fills().forEach((fill, i) => {
|
|
147
|
-
fill.style.transition = 'none';
|
|
148
|
-
if (i < s.page) { fill.style.transform = 'scaleX(1)'; return; }
|
|
149
|
-
if (i > s.page) { fill.style.transform = 'scaleX(0)'; return; }
|
|
150
|
-
fill.style.transform = 'scaleX(' + fraction + ')';
|
|
151
|
-
if (!animate) return;
|
|
152
|
-
void fill.offsetWidth; // commit the start value before arming the run
|
|
153
|
-
fill.style.transition = 'transform ' + Math.max(0, s.remaining) + 'ms linear';
|
|
154
|
-
fill.style.transform = 'scaleX(1)';
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Time is tracked as "milliseconds still owed on this screen" rather than
|
|
160
|
-
* read back off the animation, so a pause and resume can hand the rail an
|
|
161
|
-
* exact starting fraction instead of sampling a computed transform matrix.
|
|
162
|
-
*/
|
|
163
|
-
function arm() {
|
|
164
|
-
clearTimeout(s.timer);
|
|
165
|
-
if (!s.playing || s.done || s.pages < 2) return;
|
|
166
|
-
s.startedAt = performance.now();
|
|
167
|
-
paintRail(1 - s.remaining / PAGE_MS, true);
|
|
168
|
-
s.timer = setTimeout(() => step(1), s.remaining);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function pause(held) {
|
|
172
|
-
if (!s.playing) return;
|
|
173
|
-
clearTimeout(s.timer);
|
|
174
|
-
s.remaining = Math.max(0, s.remaining - (performance.now() - s.startedAt));
|
|
175
|
-
s.playing = false;
|
|
176
|
-
s.held = !!held; // a hold resumes on release; the button toggle stays paused
|
|
177
|
-
paintRail(1 - s.remaining / PAGE_MS, false);
|
|
178
|
-
syncPlayBtn();
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function play() {
|
|
182
|
-
s.held = false;
|
|
183
|
-
if (s.done) { goto(0); return; }
|
|
184
|
-
s.playing = true;
|
|
185
|
-
arm();
|
|
186
|
-
syncPlayBtn();
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function syncPlayBtn() {
|
|
190
|
-
const label = s.done ? t('story.replay') : s.playing ? t('story.pause') : t('story.play');
|
|
191
|
-
const name = s.done ? 'refresh' : s.playing ? 'pause' : 'play';
|
|
192
|
-
const next = icon(name, 14);
|
|
193
|
-
playBtn.replaceChild(next, playBtn.iconSlot);
|
|
194
|
-
playBtn.iconSlot = next;
|
|
195
|
-
playBtn.title = label;
|
|
196
|
-
playBtn.setAttribute('aria-label', label);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// ---- paging -----------------------------------------------------------
|
|
200
|
-
|
|
201
|
-
function offsetFor(page) {
|
|
202
|
-
// Shorter than the card (a very short template): centered, never paged.
|
|
203
|
-
if (s.showH <= s.cardH) return Math.round((s.cardH - s.showH) / 2);
|
|
204
|
-
return -Math.min(Math.round(page * s.cardH * STEP), Math.round(s.showH - s.cardH));
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function goto(page, animate) {
|
|
208
|
-
const next = Math.max(0, Math.min(s.pages - 1, page));
|
|
209
|
-
s.page = next;
|
|
210
|
-
s.remaining = PAGE_MS;
|
|
211
|
-
if (s.done) { s.done = false; s.playing = true; } // replaying from the end
|
|
212
|
-
shot.style.transition = animate === false
|
|
213
|
-
? 'opacity 0.32s ease'
|
|
214
|
-
: 'transform ' + SLIDE_MS + 'ms cubic-bezier(0.22,0.61,0.36,1), opacity 0.32s ease';
|
|
215
|
-
shot.style.transform = 'translate3d(0, ' + offsetFor(next) + 'px, 0)';
|
|
216
|
-
renderMeta();
|
|
217
|
-
arm();
|
|
218
|
-
syncPlayBtn();
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** One tap/arrow move. Running off the end stops on the last screen rather than looping. */
|
|
222
|
-
function step(dir) {
|
|
223
|
-
if (dir > 0 && s.page >= s.pages - 1) {
|
|
224
|
-
clearTimeout(s.timer);
|
|
225
|
-
s.done = true;
|
|
226
|
-
s.playing = false;
|
|
227
|
-
s.held = false;
|
|
228
|
-
paintRail(1, false);
|
|
229
|
-
syncPlayBtn();
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
if (dir < 0 && s.page === 0) { goto(0); return; }
|
|
233
|
-
goto(s.page + dir);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function renderMeta() {
|
|
237
|
-
metaEl.textContent = s.natW ? t('story.meta', { w: s.natW, h: s.natH, i: s.page + 1, n: s.pages }) : '';
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// ---- layout -----------------------------------------------------------
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Sizes the card from the space the shell actually has: as tall as the
|
|
244
|
-
* overlay allows, and no wider than 3/4 of that (capped at 430px), which
|
|
245
|
-
* keeps a 620px-wide email readable instead of shrinking it to a thumbnail.
|
|
246
|
-
* Re-run on resize, and once more when the image's intrinsic size is known.
|
|
247
|
-
*/
|
|
248
|
-
function layout() {
|
|
249
|
-
if (!s.open) return;
|
|
250
|
-
const availH = Math.max(280, overlay.clientHeight - 44 - 96);
|
|
251
|
-
const availW = Math.max(240, overlay.clientWidth - 44);
|
|
252
|
-
s.cardH = availH;
|
|
253
|
-
s.cardW = Math.max(240, Math.min(430, availW, Math.round(availH * 0.75)));
|
|
254
|
-
card.style.width = s.cardW + 'px';
|
|
255
|
-
card.style.height = s.cardH + 'px';
|
|
256
|
-
[rail, headRow, footer].forEach((n) => { n.style.width = s.cardW + 'px'; });
|
|
257
|
-
if (!s.natW) return;
|
|
258
|
-
s.showH = s.cardW * (s.natH / s.natW);
|
|
259
|
-
s.pages = s.showH <= s.cardH ? 1 : Math.ceil((s.showH - s.cardH) / (s.cardH * STEP)) + 1;
|
|
260
|
-
buildRail();
|
|
261
|
-
goto(Math.min(s.page, s.pages - 1), false);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function buildRail() {
|
|
265
|
-
rail.innerHTML = '';
|
|
266
|
-
rail.style.display = s.pages > 1 ? 'flex' : 'none';
|
|
267
|
-
for (let i = 0; i < s.pages; i++) {
|
|
268
|
-
const fill = el('div', 'width: 100%; height: 100%; border-radius: 2px; background: #fff; transform: scaleX(0); transform-origin: left center;');
|
|
269
|
-
rail.appendChild(el('div', 'flex: 1; height: 100%; border-radius: 2px; background: rgba(255,255,255,0.24); overflow: hidden;', {}, fill));
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// ---- load / open / close ----------------------------------------------
|
|
274
|
-
|
|
275
|
-
function setActionsEnabled(on) {
|
|
276
|
-
[copyBtn, dlBtn, playBtn].forEach((b) => {
|
|
277
|
-
b.disabled = !on;
|
|
278
|
-
b.style.opacity = on ? '' : '0.4';
|
|
279
|
-
b.style.pointerEvents = on ? '' : 'none';
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function showSkeleton(failed) {
|
|
284
|
-
skeleton.style.display = 'flex';
|
|
285
|
-
skeleton.style.opacity = '1';
|
|
286
|
-
shimmer.style.display = failed ? 'none' : 'block';
|
|
287
|
-
skelText.textContent = failed ? t('story.failed') : t('story.rendering');
|
|
288
|
-
skelHint.textContent = failed ? t('story.failedHint') : t('story.renderingHint');
|
|
289
|
-
retryBtn.style.display = failed ? 'flex' : 'none';
|
|
290
|
-
setActionsEnabled(false);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* `token` guards every async landing: closing (or retrying) bumps it, so a
|
|
295
|
-
* capture that finishes after the user moved on can never paint into the
|
|
296
|
-
* card or leak its object URL.
|
|
297
|
-
*/
|
|
298
|
-
function load() {
|
|
299
|
-
const token = ++s.token;
|
|
300
|
-
releaseUrl();
|
|
301
|
-
Object.assign(s, { blob: null, natW: 0, natH: 0, page: 0, pages: 1, done: false, playing: true, held: false, remaining: PAGE_MS });
|
|
302
|
-
clearTimeout(s.timer);
|
|
303
|
-
shot.style.opacity = '0';
|
|
304
|
-
rail.style.display = 'none';
|
|
305
|
-
renderMeta();
|
|
306
|
-
showSkeleton(false);
|
|
307
|
-
hooks.capture().then((blob) => {
|
|
308
|
-
if (token !== s.token || !s.open) return;
|
|
309
|
-
s.blob = blob;
|
|
310
|
-
s.url = URL.createObjectURL(blob);
|
|
311
|
-
shot.onload = () => {
|
|
312
|
-
if (token !== s.token) return;
|
|
313
|
-
s.natW = shot.naturalWidth;
|
|
314
|
-
s.natH = shot.naturalHeight;
|
|
315
|
-
layout();
|
|
316
|
-
shot.style.opacity = '1';
|
|
317
|
-
skeleton.style.opacity = '0';
|
|
318
|
-
setTimeout(() => { if (token === s.token) skeleton.style.display = 'none'; }, 300);
|
|
319
|
-
setActionsEnabled(true);
|
|
320
|
-
// A template that fits on one screen has nothing to advance through:
|
|
321
|
-
// land on "replay" rather than running an invisible timer.
|
|
322
|
-
if (s.pages < 2) { s.done = true; s.playing = false; }
|
|
323
|
-
syncPlayBtn();
|
|
324
|
-
arm();
|
|
325
|
-
};
|
|
326
|
-
shot.src = s.url;
|
|
327
|
-
}, () => {
|
|
328
|
-
if (token !== s.token || !s.open) return;
|
|
329
|
-
showSkeleton(true);
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function releaseUrl() {
|
|
334
|
-
if (!s.url) return;
|
|
335
|
-
URL.revokeObjectURL(s.url);
|
|
336
|
-
s.url = '';
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Escape and the arrow keys are claimed in the capture phase: the editor's
|
|
341
|
-
* own window-level handler (editor-core `mountKeyboard`) would otherwise
|
|
342
|
-
* close every modal underneath on the same Escape that closes the story.
|
|
343
|
-
*/
|
|
344
|
-
function onKey(e) {
|
|
345
|
-
if (!s.open) return;
|
|
346
|
-
if (e.key === 'Escape') { e.stopPropagation(); close(); return; }
|
|
347
|
-
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.stopPropagation(); pause(false); step(1); return; }
|
|
348
|
-
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.stopPropagation(); pause(false); step(-1); return; }
|
|
349
|
-
if (e.key === ' ') { e.stopPropagation(); e.preventDefault(); if (s.playing) pause(false); else play(); }
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
let wheelAt = 0;
|
|
353
|
-
card.addEventListener('wheel', (e) => {
|
|
354
|
-
e.preventDefault();
|
|
355
|
-
const now = performance.now();
|
|
356
|
-
// One screen per gesture: a trackpad fires dozens of small deltas, and
|
|
357
|
-
// acting on each would race the slide against itself.
|
|
358
|
-
if (now - wheelAt < SLIDE_MS * 0.8 || Math.abs(e.deltaY) < 6) return;
|
|
359
|
-
wheelAt = now;
|
|
360
|
-
pause(false);
|
|
361
|
-
step(e.deltaY > 0 ? 1 : -1);
|
|
362
|
-
}, { passive: false });
|
|
363
|
-
|
|
364
|
-
function open() {
|
|
365
|
-
if (s.open) return;
|
|
366
|
-
s.open = true;
|
|
367
|
-
overlay.style.display = 'flex';
|
|
368
|
-
overlay.removeAttribute('aria-hidden');
|
|
369
|
-
kickerEl.textContent = t('story.kicker');
|
|
370
|
-
void overlay.offsetWidth; // start the fade from 0, not from "already shown"
|
|
371
|
-
overlay.style.opacity = '1';
|
|
372
|
-
card.style.transform = 'scale(1) translateY(0)';
|
|
373
|
-
card.style.opacity = '1';
|
|
374
|
-
layout();
|
|
375
|
-
load();
|
|
376
|
-
window.addEventListener('keydown', onKey, true);
|
|
377
|
-
window.addEventListener('resize', layout);
|
|
378
|
-
closeBtn.focus({ preventScroll: true });
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function close() {
|
|
382
|
-
if (!s.open) return;
|
|
383
|
-
s.open = false;
|
|
384
|
-
s.token++;
|
|
385
|
-
clearTimeout(s.timer);
|
|
386
|
-
window.removeEventListener('keydown', onKey, true);
|
|
387
|
-
window.removeEventListener('resize', layout);
|
|
388
|
-
overlay.style.opacity = '0';
|
|
389
|
-
card.style.transform = 'scale(0.965) translateY(10px)';
|
|
390
|
-
card.style.opacity = '0';
|
|
391
|
-
setTimeout(() => {
|
|
392
|
-
if (s.open) return; // reopened during the fade -- leave it as it is
|
|
393
|
-
overlay.style.display = 'none';
|
|
394
|
-
overlay.setAttribute('aria-hidden', 'true');
|
|
395
|
-
shot.removeAttribute('src');
|
|
396
|
-
releaseUrl();
|
|
397
|
-
s.blob = null;
|
|
398
|
-
}, 240);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/** Re-labels the viewer in place after a `locale`/`.messages` change. */
|
|
402
|
-
function retranslate() {
|
|
403
|
-
closeBtn.title = t('action.close');
|
|
404
|
-
closeBtn.setAttribute('aria-label', t('action.close'));
|
|
405
|
-
copyBtn.labelSlot.textContent = t('story.copy');
|
|
406
|
-
copyBtn.title = t('story.copy');
|
|
407
|
-
dlBtn.labelSlot.textContent = t('action.downloadPng');
|
|
408
|
-
dlBtn.title = t('action.downloadPng');
|
|
409
|
-
retryBtn.lastChild.textContent = t('story.retry');
|
|
410
|
-
if (s.open) { kickerEl.textContent = t('story.kicker'); renderMeta(); }
|
|
411
|
-
syncPlayBtn();
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
return { node: overlay, open, close, retranslate, isOpen: () => s.open };
|
|
415
|
-
};
|
|
1
|
+
import { icon } from '../core/icons.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Story-style screenshot viewer: the PNG that `render/screenshot.js` produces
|
|
5
|
+
* is a single very tall image, which is exactly the wrong shape for a modal.
|
|
6
|
+
* Rather than hand the user a file and hope they open it, this pages through
|
|
7
|
+
* the capture the way a phone story does -- a segmented progress rail, one
|
|
8
|
+
* screen at a time, auto-advancing -- so the whole template can be reviewed
|
|
9
|
+
* in place. Downloading becomes one of the actions under the card instead of
|
|
10
|
+
* the only thing the button does.
|
|
11
|
+
*
|
|
12
|
+
* Everything animates on `transform`/`opacity` alone (never a
|
|
13
|
+
* layout-affecting property), which is what keeps the advance smooth even on
|
|
14
|
+
* a very long template.
|
|
15
|
+
*
|
|
16
|
+
* The viewer owns no core state: it is opened imperatively and revokes its
|
|
17
|
+
* own object URL, so a re-render of the editor underneath never disturbs it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** How long one screen holds before advancing. */
|
|
21
|
+
const PAGE_MS = 3600;
|
|
22
|
+
/** Slide duration between screens -- long enough to read as a scroll, short enough not to drag. */
|
|
23
|
+
const SLIDE_MS = 560;
|
|
24
|
+
/**
|
|
25
|
+
* Each advance moves 92% of a card height, not 100%: the repeated sliver of
|
|
26
|
+
* the previous screen is what makes the jump legible as "further down the
|
|
27
|
+
* same email" rather than a cut to an unrelated image.
|
|
28
|
+
*/
|
|
29
|
+
const STEP = 0.92;
|
|
30
|
+
|
|
31
|
+
function el(tag, css, attrs = {}, ...kids) {
|
|
32
|
+
const node = document.createElement(tag);
|
|
33
|
+
if (css) node.style.cssText = css;
|
|
34
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
35
|
+
if (v === undefined) continue;
|
|
36
|
+
if (k === 'text') node.textContent = v;
|
|
37
|
+
else node.setAttribute(k, v);
|
|
38
|
+
}
|
|
39
|
+
kids.flat().forEach((c) => { if (c != null && c !== false) node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
|
|
40
|
+
return node;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
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;";
|
|
44
|
+
|
|
45
|
+
function ghostBtn(css, label, iconName, onClick, withText) {
|
|
46
|
+
const btn = el('button', GHOST + css, { type: 'button', title: label, 'aria-label': label });
|
|
47
|
+
btn.iconSlot = icon(iconName, 14);
|
|
48
|
+
btn.labelSlot = withText ? el('span', '', { text: label }) : null;
|
|
49
|
+
btn.append(btn.iconSlot);
|
|
50
|
+
if (btn.labelSlot) btn.append(btn.labelSlot);
|
|
51
|
+
btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(255,255,255,0.16)'; btn.style.borderColor = 'rgba(255,255,255,0.4)'; });
|
|
52
|
+
btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(255,255,255,0.07)'; btn.style.borderColor = 'rgba(255,255,255,0.22)'; });
|
|
53
|
+
btn.addEventListener('click', onClick);
|
|
54
|
+
return btn;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `hooks.capture()` -> Promise<Blob>, called on open and on retry;
|
|
59
|
+
* `hooks.download(blob)` and `hooks.copy(blob)` run the host's own save and
|
|
60
|
+
* clipboard flows so the toasts stay consistent with the rest of the editor.
|
|
61
|
+
*
|
|
62
|
+
* (`export const` + function expression, not `export function`: build.js's
|
|
63
|
+
* transform only recognizes `export (const|function|class)` -- either form
|
|
64
|
+
* works, and this file follows screenshot.js.)
|
|
65
|
+
*/
|
|
66
|
+
export const createStoryViewer = function (core, hooks) {
|
|
67
|
+
const t = (key, params) => core.t(key, params);
|
|
68
|
+
|
|
69
|
+
const s = {
|
|
70
|
+
open: false, page: 0, pages: 1, playing: true, done: false, held: false,
|
|
71
|
+
remaining: PAGE_MS, startedAt: 0, timer: 0, token: 0,
|
|
72
|
+
url: '', blob: null, natW: 0, natH: 0, cardW: 0, cardH: 0, showH: 0,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ---- chrome -----------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
// Dim only, no backdrop blur -- a full-screen blur is a many-frame stall on
|
|
78
|
+
// weak GPUs each time the overlay fades in; the deeper dim carries the focus.
|
|
79
|
+
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(9,11,16,0.86); opacity: 0; transition: opacity 0.22s ease;', { class: 'mc-story' });
|
|
80
|
+
// Clicking the dark surround closes; clicks on the card are the tap zones'
|
|
81
|
+
// business and must not fall through to here.
|
|
82
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
|
83
|
+
|
|
84
|
+
const rail = el('div', 'display: none; gap: 4px; height: 3px; flex: none;');
|
|
85
|
+
const headRow = el('div', 'display: flex; align-items: center; gap: 10px; flex: none;');
|
|
86
|
+
const headText = el('div', 'flex: 1; min-width: 0;');
|
|
87
|
+
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);');
|
|
88
|
+
headText.append(kickerEl);
|
|
89
|
+
const closeBtn = ghostBtn('width: 30px; padding: 0;', t('action.close'), 'x', () => close());
|
|
90
|
+
headRow.append(headText, closeBtn);
|
|
91
|
+
|
|
92
|
+
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;');
|
|
93
|
+
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;', { alt: '' });
|
|
94
|
+
card.appendChild(shot);
|
|
95
|
+
|
|
96
|
+
// Skeleton: shown while the capture renders and cross-faded out when the
|
|
97
|
+
// image arrives, so the card never flashes empty or resizes under the eye.
|
|
98
|
+
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;');
|
|
99
|
+
const shimmer = el('div', 'position: absolute; inset: 0; background: linear-gradient(100deg, transparent 22%, var(--ed-soft) 40%, transparent 58%); background-size: 260% 100%; animation: mcStoryShimmer 1.6s linear infinite;');
|
|
100
|
+
const skelText = el('div', 'position: relative; font-family: var(--ed-font); font-size: 12.5px; font-weight: 600; color: var(--ed-text);');
|
|
101
|
+
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;');
|
|
102
|
+
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' });
|
|
103
|
+
retryBtn.append(icon('refresh', 14), el('span', '', { text: t('story.retry') }));
|
|
104
|
+
retryBtn.addEventListener('click', () => load());
|
|
105
|
+
skeleton.append(shimmer, skelText, skelHint, retryBtn);
|
|
106
|
+
card.appendChild(skeleton);
|
|
107
|
+
|
|
108
|
+
// Tap zones, story grammar: the left third steps back, the rest forward.
|
|
109
|
+
// A press-and-hold pauses and resumes on release, so `pointerdown` starts
|
|
110
|
+
// the pause and only a short press counts as a navigating tap.
|
|
111
|
+
const zone = (css, onTap) => {
|
|
112
|
+
const z = el('div', 'position: absolute; top: 0; bottom: 0; ' + css + ' cursor: pointer;');
|
|
113
|
+
let downAt = 0;
|
|
114
|
+
z.addEventListener('pointerdown', () => { downAt = performance.now(); if (s.playing && !s.done) pause(true); });
|
|
115
|
+
const up = () => {
|
|
116
|
+
if (!downAt) return;
|
|
117
|
+
const held = performance.now() - downAt;
|
|
118
|
+
downAt = 0;
|
|
119
|
+
if (held < 220) onTap();
|
|
120
|
+
else if (s.held) play();
|
|
121
|
+
};
|
|
122
|
+
z.addEventListener('pointerup', up);
|
|
123
|
+
z.addEventListener('pointerleave', up);
|
|
124
|
+
return z;
|
|
125
|
+
};
|
|
126
|
+
card.append(
|
|
127
|
+
zone('left: 0; width: 32%;', () => step(-1)),
|
|
128
|
+
zone('left: 32%; right: 0;', () => step(1)),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const footer = el('div', 'display: flex; align-items: center; gap: 8px; flex: none;');
|
|
132
|
+
const metaEl = el('div', 'flex: 1; min-width: 0; font-family: ui-monospace, monospace; font-size: 9px; letter-spacing: 0.09em; color: rgba(255,255,255,0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;');
|
|
133
|
+
const playBtn = ghostBtn('width: 30px; padding: 0;', t('story.pause'), 'pause', () => (s.playing ? pause(false) : play()));
|
|
134
|
+
const copyBtn = ghostBtn('padding: 0 11px;', t('story.copy'), 'copy', () => hooks.copy(s.blob), true);
|
|
135
|
+
const dlBtn = ghostBtn('padding: 0 11px; border-color: rgba(255,255,255,0.4); background: rgba(255,255,255,0.16);', t('action.downloadPng'), 'download', () => hooks.download(s.blob), true);
|
|
136
|
+
footer.append(metaEl, playBtn, copyBtn, dlBtn);
|
|
137
|
+
|
|
138
|
+
overlay.append(rail, headRow, card, footer);
|
|
139
|
+
|
|
140
|
+
// ---- timing -----------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
function fills() { return Array.from(rail.children).map((seg) => seg.firstChild); }
|
|
143
|
+
|
|
144
|
+
/** Paints the rail for the current screen at `fraction`, optionally running the remainder of that screen's fill. */
|
|
145
|
+
function paintRail(fraction, animate) {
|
|
146
|
+
fills().forEach((fill, i) => {
|
|
147
|
+
fill.style.transition = 'none';
|
|
148
|
+
if (i < s.page) { fill.style.transform = 'scaleX(1)'; return; }
|
|
149
|
+
if (i > s.page) { fill.style.transform = 'scaleX(0)'; return; }
|
|
150
|
+
fill.style.transform = 'scaleX(' + fraction + ')';
|
|
151
|
+
if (!animate) return;
|
|
152
|
+
void fill.offsetWidth; // commit the start value before arming the run
|
|
153
|
+
fill.style.transition = 'transform ' + Math.max(0, s.remaining) + 'ms linear';
|
|
154
|
+
fill.style.transform = 'scaleX(1)';
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Time is tracked as "milliseconds still owed on this screen" rather than
|
|
160
|
+
* read back off the animation, so a pause and resume can hand the rail an
|
|
161
|
+
* exact starting fraction instead of sampling a computed transform matrix.
|
|
162
|
+
*/
|
|
163
|
+
function arm() {
|
|
164
|
+
clearTimeout(s.timer);
|
|
165
|
+
if (!s.playing || s.done || s.pages < 2) return;
|
|
166
|
+
s.startedAt = performance.now();
|
|
167
|
+
paintRail(1 - s.remaining / PAGE_MS, true);
|
|
168
|
+
s.timer = setTimeout(() => step(1), s.remaining);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function pause(held) {
|
|
172
|
+
if (!s.playing) return;
|
|
173
|
+
clearTimeout(s.timer);
|
|
174
|
+
s.remaining = Math.max(0, s.remaining - (performance.now() - s.startedAt));
|
|
175
|
+
s.playing = false;
|
|
176
|
+
s.held = !!held; // a hold resumes on release; the button toggle stays paused
|
|
177
|
+
paintRail(1 - s.remaining / PAGE_MS, false);
|
|
178
|
+
syncPlayBtn();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function play() {
|
|
182
|
+
s.held = false;
|
|
183
|
+
if (s.done) { goto(0); return; }
|
|
184
|
+
s.playing = true;
|
|
185
|
+
arm();
|
|
186
|
+
syncPlayBtn();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function syncPlayBtn() {
|
|
190
|
+
const label = s.done ? t('story.replay') : s.playing ? t('story.pause') : t('story.play');
|
|
191
|
+
const name = s.done ? 'refresh' : s.playing ? 'pause' : 'play';
|
|
192
|
+
const next = icon(name, 14);
|
|
193
|
+
playBtn.replaceChild(next, playBtn.iconSlot);
|
|
194
|
+
playBtn.iconSlot = next;
|
|
195
|
+
playBtn.title = label;
|
|
196
|
+
playBtn.setAttribute('aria-label', label);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---- paging -----------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
function offsetFor(page) {
|
|
202
|
+
// Shorter than the card (a very short template): centered, never paged.
|
|
203
|
+
if (s.showH <= s.cardH) return Math.round((s.cardH - s.showH) / 2);
|
|
204
|
+
return -Math.min(Math.round(page * s.cardH * STEP), Math.round(s.showH - s.cardH));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function goto(page, animate) {
|
|
208
|
+
const next = Math.max(0, Math.min(s.pages - 1, page));
|
|
209
|
+
s.page = next;
|
|
210
|
+
s.remaining = PAGE_MS;
|
|
211
|
+
if (s.done) { s.done = false; s.playing = true; } // replaying from the end
|
|
212
|
+
shot.style.transition = animate === false
|
|
213
|
+
? 'opacity 0.32s ease'
|
|
214
|
+
: 'transform ' + SLIDE_MS + 'ms cubic-bezier(0.22,0.61,0.36,1), opacity 0.32s ease';
|
|
215
|
+
shot.style.transform = 'translate3d(0, ' + offsetFor(next) + 'px, 0)';
|
|
216
|
+
renderMeta();
|
|
217
|
+
arm();
|
|
218
|
+
syncPlayBtn();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** One tap/arrow move. Running off the end stops on the last screen rather than looping. */
|
|
222
|
+
function step(dir) {
|
|
223
|
+
if (dir > 0 && s.page >= s.pages - 1) {
|
|
224
|
+
clearTimeout(s.timer);
|
|
225
|
+
s.done = true;
|
|
226
|
+
s.playing = false;
|
|
227
|
+
s.held = false;
|
|
228
|
+
paintRail(1, false);
|
|
229
|
+
syncPlayBtn();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (dir < 0 && s.page === 0) { goto(0); return; }
|
|
233
|
+
goto(s.page + dir);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function renderMeta() {
|
|
237
|
+
metaEl.textContent = s.natW ? t('story.meta', { w: s.natW, h: s.natH, i: s.page + 1, n: s.pages }) : '';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---- layout -----------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Sizes the card from the space the shell actually has: as tall as the
|
|
244
|
+
* overlay allows, and no wider than 3/4 of that (capped at 430px), which
|
|
245
|
+
* keeps a 620px-wide email readable instead of shrinking it to a thumbnail.
|
|
246
|
+
* Re-run on resize, and once more when the image's intrinsic size is known.
|
|
247
|
+
*/
|
|
248
|
+
function layout() {
|
|
249
|
+
if (!s.open) return;
|
|
250
|
+
const availH = Math.max(280, overlay.clientHeight - 44 - 96);
|
|
251
|
+
const availW = Math.max(240, overlay.clientWidth - 44);
|
|
252
|
+
s.cardH = availH;
|
|
253
|
+
s.cardW = Math.max(240, Math.min(430, availW, Math.round(availH * 0.75)));
|
|
254
|
+
card.style.width = s.cardW + 'px';
|
|
255
|
+
card.style.height = s.cardH + 'px';
|
|
256
|
+
[rail, headRow, footer].forEach((n) => { n.style.width = s.cardW + 'px'; });
|
|
257
|
+
if (!s.natW) return;
|
|
258
|
+
s.showH = s.cardW * (s.natH / s.natW);
|
|
259
|
+
s.pages = s.showH <= s.cardH ? 1 : Math.ceil((s.showH - s.cardH) / (s.cardH * STEP)) + 1;
|
|
260
|
+
buildRail();
|
|
261
|
+
goto(Math.min(s.page, s.pages - 1), false);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildRail() {
|
|
265
|
+
rail.innerHTML = '';
|
|
266
|
+
rail.style.display = s.pages > 1 ? 'flex' : 'none';
|
|
267
|
+
for (let i = 0; i < s.pages; i++) {
|
|
268
|
+
const fill = el('div', 'width: 100%; height: 100%; border-radius: 2px; background: #fff; transform: scaleX(0); transform-origin: left center;');
|
|
269
|
+
rail.appendChild(el('div', 'flex: 1; height: 100%; border-radius: 2px; background: rgba(255,255,255,0.24); overflow: hidden;', {}, fill));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- load / open / close ----------------------------------------------
|
|
274
|
+
|
|
275
|
+
function setActionsEnabled(on) {
|
|
276
|
+
[copyBtn, dlBtn, playBtn].forEach((b) => {
|
|
277
|
+
b.disabled = !on;
|
|
278
|
+
b.style.opacity = on ? '' : '0.4';
|
|
279
|
+
b.style.pointerEvents = on ? '' : 'none';
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function showSkeleton(failed) {
|
|
284
|
+
skeleton.style.display = 'flex';
|
|
285
|
+
skeleton.style.opacity = '1';
|
|
286
|
+
shimmer.style.display = failed ? 'none' : 'block';
|
|
287
|
+
skelText.textContent = failed ? t('story.failed') : t('story.rendering');
|
|
288
|
+
skelHint.textContent = failed ? t('story.failedHint') : t('story.renderingHint');
|
|
289
|
+
retryBtn.style.display = failed ? 'flex' : 'none';
|
|
290
|
+
setActionsEnabled(false);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `token` guards every async landing: closing (or retrying) bumps it, so a
|
|
295
|
+
* capture that finishes after the user moved on can never paint into the
|
|
296
|
+
* card or leak its object URL.
|
|
297
|
+
*/
|
|
298
|
+
function load() {
|
|
299
|
+
const token = ++s.token;
|
|
300
|
+
releaseUrl();
|
|
301
|
+
Object.assign(s, { blob: null, natW: 0, natH: 0, page: 0, pages: 1, done: false, playing: true, held: false, remaining: PAGE_MS });
|
|
302
|
+
clearTimeout(s.timer);
|
|
303
|
+
shot.style.opacity = '0';
|
|
304
|
+
rail.style.display = 'none';
|
|
305
|
+
renderMeta();
|
|
306
|
+
showSkeleton(false);
|
|
307
|
+
hooks.capture().then((blob) => {
|
|
308
|
+
if (token !== s.token || !s.open) return;
|
|
309
|
+
s.blob = blob;
|
|
310
|
+
s.url = URL.createObjectURL(blob);
|
|
311
|
+
shot.onload = () => {
|
|
312
|
+
if (token !== s.token) return;
|
|
313
|
+
s.natW = shot.naturalWidth;
|
|
314
|
+
s.natH = shot.naturalHeight;
|
|
315
|
+
layout();
|
|
316
|
+
shot.style.opacity = '1';
|
|
317
|
+
skeleton.style.opacity = '0';
|
|
318
|
+
setTimeout(() => { if (token === s.token) skeleton.style.display = 'none'; }, 300);
|
|
319
|
+
setActionsEnabled(true);
|
|
320
|
+
// A template that fits on one screen has nothing to advance through:
|
|
321
|
+
// land on "replay" rather than running an invisible timer.
|
|
322
|
+
if (s.pages < 2) { s.done = true; s.playing = false; }
|
|
323
|
+
syncPlayBtn();
|
|
324
|
+
arm();
|
|
325
|
+
};
|
|
326
|
+
shot.src = s.url;
|
|
327
|
+
}, () => {
|
|
328
|
+
if (token !== s.token || !s.open) return;
|
|
329
|
+
showSkeleton(true);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function releaseUrl() {
|
|
334
|
+
if (!s.url) return;
|
|
335
|
+
URL.revokeObjectURL(s.url);
|
|
336
|
+
s.url = '';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Escape and the arrow keys are claimed in the capture phase: the editor's
|
|
341
|
+
* own window-level handler (editor-core `mountKeyboard`) would otherwise
|
|
342
|
+
* close every modal underneath on the same Escape that closes the story.
|
|
343
|
+
*/
|
|
344
|
+
function onKey(e) {
|
|
345
|
+
if (!s.open) return;
|
|
346
|
+
if (e.key === 'Escape') { e.stopPropagation(); close(); return; }
|
|
347
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.stopPropagation(); pause(false); step(1); return; }
|
|
348
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.stopPropagation(); pause(false); step(-1); return; }
|
|
349
|
+
if (e.key === ' ') { e.stopPropagation(); e.preventDefault(); if (s.playing) pause(false); else play(); }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let wheelAt = 0;
|
|
353
|
+
card.addEventListener('wheel', (e) => {
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
const now = performance.now();
|
|
356
|
+
// One screen per gesture: a trackpad fires dozens of small deltas, and
|
|
357
|
+
// acting on each would race the slide against itself.
|
|
358
|
+
if (now - wheelAt < SLIDE_MS * 0.8 || Math.abs(e.deltaY) < 6) return;
|
|
359
|
+
wheelAt = now;
|
|
360
|
+
pause(false);
|
|
361
|
+
step(e.deltaY > 0 ? 1 : -1);
|
|
362
|
+
}, { passive: false });
|
|
363
|
+
|
|
364
|
+
function open() {
|
|
365
|
+
if (s.open) return;
|
|
366
|
+
s.open = true;
|
|
367
|
+
overlay.style.display = 'flex';
|
|
368
|
+
overlay.removeAttribute('aria-hidden');
|
|
369
|
+
kickerEl.textContent = t('story.kicker');
|
|
370
|
+
void overlay.offsetWidth; // start the fade from 0, not from "already shown"
|
|
371
|
+
overlay.style.opacity = '1';
|
|
372
|
+
card.style.transform = 'scale(1) translateY(0)';
|
|
373
|
+
card.style.opacity = '1';
|
|
374
|
+
layout();
|
|
375
|
+
load();
|
|
376
|
+
window.addEventListener('keydown', onKey, true);
|
|
377
|
+
window.addEventListener('resize', layout);
|
|
378
|
+
closeBtn.focus({ preventScroll: true });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function close() {
|
|
382
|
+
if (!s.open) return;
|
|
383
|
+
s.open = false;
|
|
384
|
+
s.token++;
|
|
385
|
+
clearTimeout(s.timer);
|
|
386
|
+
window.removeEventListener('keydown', onKey, true);
|
|
387
|
+
window.removeEventListener('resize', layout);
|
|
388
|
+
overlay.style.opacity = '0';
|
|
389
|
+
card.style.transform = 'scale(0.965) translateY(10px)';
|
|
390
|
+
card.style.opacity = '0';
|
|
391
|
+
setTimeout(() => {
|
|
392
|
+
if (s.open) return; // reopened during the fade -- leave it as it is
|
|
393
|
+
overlay.style.display = 'none';
|
|
394
|
+
overlay.setAttribute('aria-hidden', 'true');
|
|
395
|
+
shot.removeAttribute('src');
|
|
396
|
+
releaseUrl();
|
|
397
|
+
s.blob = null;
|
|
398
|
+
}, 240);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Re-labels the viewer in place after a `locale`/`.messages` change. */
|
|
402
|
+
function retranslate() {
|
|
403
|
+
closeBtn.title = t('action.close');
|
|
404
|
+
closeBtn.setAttribute('aria-label', t('action.close'));
|
|
405
|
+
copyBtn.labelSlot.textContent = t('story.copy');
|
|
406
|
+
copyBtn.title = t('story.copy');
|
|
407
|
+
dlBtn.labelSlot.textContent = t('action.downloadPng');
|
|
408
|
+
dlBtn.title = t('action.downloadPng');
|
|
409
|
+
retryBtn.lastChild.textContent = t('story.retry');
|
|
410
|
+
if (s.open) { kickerEl.textContent = t('story.kicker'); renderMeta(); }
|
|
411
|
+
syncPlayBtn();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return { node: overlay, open, close, retranslate, isOpen: () => s.open };
|
|
415
|
+
};
|