@seliseblocks/mailcraft 0.2.11 → 0.2.13

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/DOCS.md +8 -0
  3. package/dist/mailcraft-editor.bundle.js +69 -57
  4. package/dist/mailcraft-editor.bundle.js.map +3 -3
  5. package/package.json +2 -2
  6. package/src/core/code-tools.js +302 -0
  7. package/src/core/editor-core.js +61 -0
  8. package/src/core/export.js +5 -2
  9. package/src/core/i18n/ar.js +8 -0
  10. package/src/core/i18n/bg.js +8 -0
  11. package/src/core/i18n/bn.js +8 -0
  12. package/src/core/i18n/ca.js +8 -0
  13. package/src/core/i18n/cs.js +8 -0
  14. package/src/core/i18n/da.js +8 -0
  15. package/src/core/i18n/de-CH.js +8 -0
  16. package/src/core/i18n/de.js +8 -0
  17. package/src/core/i18n/dz.js +8 -0
  18. package/src/core/i18n/el.js +8 -0
  19. package/src/core/i18n/en.js +8 -0
  20. package/src/core/i18n/es.js +8 -0
  21. package/src/core/i18n/et.js +8 -0
  22. package/src/core/i18n/fi.js +8 -0
  23. package/src/core/i18n/fr.js +8 -0
  24. package/src/core/i18n/hr.js +8 -0
  25. package/src/core/i18n/hu.js +8 -0
  26. package/src/core/i18n/index.js +83 -83
  27. package/src/core/i18n/it.js +8 -0
  28. package/src/core/i18n/lt.js +8 -0
  29. package/src/core/i18n/lv.js +8 -0
  30. package/src/core/i18n/nb.js +8 -0
  31. package/src/core/i18n/nl.js +8 -0
  32. package/src/core/i18n/pl.js +8 -0
  33. package/src/core/i18n/pt.js +8 -0
  34. package/src/core/i18n/ro.js +8 -0
  35. package/src/core/i18n/ru.js +8 -0
  36. package/src/core/i18n/sk.js +8 -0
  37. package/src/core/i18n/sl.js +8 -0
  38. package/src/core/i18n/sv.js +8 -0
  39. package/src/core/i18n/tr.js +8 -0
  40. package/src/core/i18n/uk.js +8 -0
  41. package/src/core/icons.js +2 -0
  42. package/src/core/ids.js +1 -1
  43. package/src/core/layout-style.js +100 -100
  44. package/src/core/parse.js +10 -10
  45. package/src/core/placeholder.js +15 -15
  46. package/src/core/variables.js +11 -11
  47. package/src/mailcraft-editor.js +468 -16
  48. package/src/render/focus-preserve.js +158 -158
  49. package/src/render/rte.js +27 -2
  50. package/src/render/story.js +415 -415
@@ -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
+ };