@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seliseblocks/mailcraft",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "Framework-agnostic drag-and-drop email template editor, packaged as a zero-dependency Web Component.",
5
5
  "license": "MIT",
6
6
  "author": "SELISE Digital Platforms",
@@ -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() { this.story.open(); }
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) {
@@ -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
- return fetch(url, { mode: 'cors' }).then((res) => {
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) {
@@ -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
- 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' });
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
- 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: '' });
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
- 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;');
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
- s.natW = shot.naturalWidth;
413
- s.natH = shot.naturalHeight;
414
- layout();
415
- shot.style.opacity = '1';
416
- skeleton.style.opacity = '0';
417
- setTimeout(() => { if (token === s.token) skeleton.style.display = 'none'; }, 300);
418
- setActionsEnabled(true);
419
- // A template that fits on one screen has nothing to advance through:
420
- // land on "replay" rather than running an invisible timer.
421
- if (s.pages < 2) { s.done = true; s.playing = false; }
422
- syncPlayBtn();
423
- arm();
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
- load();
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';
@@ -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 { background-position: 140% 0; } to { background-position: -40% 0; } }
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