@seliseblocks/mailcraft 0.2.15 → 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 +7 -0
- package/dist/mailcraft-editor.bundle.js +51 -51
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +1 -1
- package/src/mailcraft-editor.js +6 -2
- package/src/render/screenshot.js +14 -2
- package/src/render/story.js +46 -18
- package/src/render/style.js +1 -1
package/package.json
CHANGED
package/src/mailcraft-editor.js
CHANGED
|
@@ -487,8 +487,12 @@ export class MailCraftEditor extends ElementBase {
|
|
|
487
487
|
*/
|
|
488
488
|
screenshotPng(options) { return captureTemplatePng(this.core, this.mc, options); }
|
|
489
489
|
|
|
490
|
-
/** Opens the story-style viewer over the editor and captures into it -- what the Screenshot button does. Nothing touches the filesystem until the user picks Download inside it. */
|
|
491
|
-
previewScreenshot() {
|
|
490
|
+
/** Opens the story-style viewer over the editor and captures into it -- what the Screenshot button does. The export dialog, which hosts that button, is parked while the viewer is up and restored when it closes, so the two never read as stacked modals. Nothing touches the filesystem until the user picks Download inside it. */
|
|
491
|
+
previewScreenshot() {
|
|
492
|
+
const fromExport = this.core.state.exportOpen;
|
|
493
|
+
if (fromExport) this.core.setState({ exportOpen: false });
|
|
494
|
+
this.story.open(fromExport ? { onClose: () => this.core.setState({ exportOpen: true }) } : undefined);
|
|
495
|
+
}
|
|
492
496
|
|
|
493
497
|
/** `screenshotPng(options)` plus the same save-a-file flow as the HTML export download, with success/failure toasts. Pass an already-captured blob (the story viewer does) to save that one instead of rendering a second time; the filename extension follows the blob's actual type. */
|
|
494
498
|
async downloadScreenshot(blob, options) {
|
package/src/render/screenshot.js
CHANGED
|
@@ -62,8 +62,15 @@ function encodeCanvas(canvas, mime, quality) {
|
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Inlined bytes are cached per URL for the session: every capture (open,
|
|
66
|
+
// retry, reopen) used to re-fetch each remote image, which is most of a slow
|
|
67
|
+
// open on a photo-heavy template. A failed fetch is evicted so a transient
|
|
68
|
+
// network error does not pin the blank-pixel fallback forever.
|
|
69
|
+
const inlineCache = new Map();
|
|
70
|
+
|
|
65
71
|
function toDataUri(url) {
|
|
66
|
-
|
|
72
|
+
if (inlineCache.has(url)) return inlineCache.get(url);
|
|
73
|
+
const job = fetch(url, { mode: 'cors' }).then((res) => {
|
|
67
74
|
if (!res.ok) throw new Error('fetch failed: ' + res.status);
|
|
68
75
|
return res.blob();
|
|
69
76
|
}).then((blob) => new Promise((resolve, reject) => {
|
|
@@ -71,7 +78,12 @@ function toDataUri(url) {
|
|
|
71
78
|
fr.onload = () => resolve(fr.result);
|
|
72
79
|
fr.onerror = reject;
|
|
73
80
|
fr.readAsDataURL(blob);
|
|
74
|
-
}))
|
|
81
|
+
})).catch((err) => {
|
|
82
|
+
inlineCache.delete(url);
|
|
83
|
+
throw err;
|
|
84
|
+
});
|
|
85
|
+
inlineCache.set(url, job);
|
|
86
|
+
return job;
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
async function inlineExternalImages(root) {
|
package/src/render/story.js
CHANGED
|
@@ -83,14 +83,17 @@ export const createStoryViewer = function (core, hooks) {
|
|
|
83
83
|
open: false, page: 0, pages: 1, playing: true, done: false, held: false,
|
|
84
84
|
remaining: PAGE_MS, startedAt: 0, timer: 0, token: 0,
|
|
85
85
|
url: '', blob: null, natW: 0, natH: 0, cardW: 0, cardH: 0, showH: 0,
|
|
86
|
-
format: 0, saving: false,
|
|
86
|
+
format: 0, saving: false, onClose: null,
|
|
87
87
|
};
|
|
88
88
|
|
|
89
89
|
// ---- chrome -----------------------------------------------------------
|
|
90
90
|
|
|
91
91
|
// Dim only, no backdrop blur -- a full-screen blur is a many-frame stall on
|
|
92
92
|
// weak GPUs each time the overlay fades in; the deeper dim carries the focus.
|
|
93
|
-
|
|
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' });
|
|
94
97
|
// Clicking the dark surround closes; clicks on the card are the tap zones'
|
|
95
98
|
// business and must not fall through to here.
|
|
96
99
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
|
@@ -108,13 +111,19 @@ export const createStoryViewer = function (core, hooks) {
|
|
|
108
111
|
headRow.append(headText, closeBtn);
|
|
109
112
|
|
|
110
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;');
|
|
111
|
-
|
|
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' });
|
|
112
118
|
card.appendChild(shot);
|
|
113
119
|
|
|
114
120
|
// Skeleton: shown while the capture renders and cross-faded out when the
|
|
115
121
|
// image arrives, so the card never flashes empty or resizes under the eye.
|
|
116
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;');
|
|
117
|
-
|
|
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;');
|
|
118
127
|
const skelText = el('div', 'position: relative; font-family: var(--ed-font); font-size: 12.5px; font-weight: 600; color: var(--ed-text);');
|
|
119
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;');
|
|
120
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' });
|
|
@@ -409,18 +418,24 @@ export const createStoryViewer = function (core, hooks) {
|
|
|
409
418
|
s.url = URL.createObjectURL(blob);
|
|
410
419
|
shot.onload = () => {
|
|
411
420
|
if (token !== s.token) return;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
+
});
|
|
424
439
|
};
|
|
425
440
|
shot.src = s.url;
|
|
426
441
|
}, () => {
|
|
@@ -460,9 +475,10 @@ export const createStoryViewer = function (core, hooks) {
|
|
|
460
475
|
step(e.deltaY > 0 ? 1 : -1);
|
|
461
476
|
}, { passive: false });
|
|
462
477
|
|
|
463
|
-
function open() {
|
|
478
|
+
function open(opts) {
|
|
464
479
|
if (s.open) return;
|
|
465
480
|
s.open = true;
|
|
481
|
+
s.onClose = (opts && opts.onClose) || null;
|
|
466
482
|
overlay.style.display = 'flex';
|
|
467
483
|
overlay.removeAttribute('aria-hidden');
|
|
468
484
|
kickerEl.textContent = t('story.kicker');
|
|
@@ -471,7 +487,14 @@ export const createStoryViewer = function (core, hooks) {
|
|
|
471
487
|
card.style.transform = 'scale(1) translateY(0)';
|
|
472
488
|
card.style.opacity = '1';
|
|
473
489
|
layout();
|
|
474
|
-
|
|
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);
|
|
475
498
|
window.addEventListener('keydown', onKey, true);
|
|
476
499
|
window.addEventListener('resize', layout);
|
|
477
500
|
closeBtn.focus({ preventScroll: true });
|
|
@@ -482,6 +505,11 @@ export const createStoryViewer = function (core, hooks) {
|
|
|
482
505
|
s.open = false;
|
|
483
506
|
s.token++;
|
|
484
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();
|
|
485
513
|
window.removeEventListener('keydown', onKey, true);
|
|
486
514
|
window.removeEventListener('resize', layout);
|
|
487
515
|
overlay.style.opacity = '0';
|
package/src/render/style.js
CHANGED
|
@@ -108,7 +108,7 @@ export const STYLE = `
|
|
|
108
108
|
@keyframes mcIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
|
109
109
|
@keyframes mcFade { from { opacity: 0; } to { opacity: 1; } }
|
|
110
110
|
@keyframes mcPulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
|
|
111
|
-
@keyframes mcStoryShimmer { from {
|
|
111
|
+
@keyframes mcStoryShimmer { from { transform: translateX(-110%); } to { transform: translateX(280%); } }
|
|
112
112
|
|
|
113
113
|
/*
|
|
114
114
|
* Hover/selection outlines on canvas rows and blocks. Re-rendering on every
|