@seliseblocks/mailcraft 0.2.16 → 0.2.19

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 (78) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/DOCS.md +70 -67
  3. package/README.md +8 -8
  4. package/README.md.txt +8 -8
  5. package/dist/mailcraft-editor.bundle.js +89 -76
  6. package/dist/mailcraft-editor.bundle.js.map +3 -3
  7. package/examples/templates/activate-your-account.html +1 -1
  8. package/examples/templates/back-in-stock.html +4 -4
  9. package/examples/templates/cart-left-behind.html +3 -3
  10. package/examples/templates/community-giveaway.html +3 -3
  11. package/examples/templates/frontend-futures-invite.html +3 -3
  12. package/examples/templates/give-25-get-25.html +2 -2
  13. package/examples/templates/invoice-paid.html +3 -3
  14. package/examples/templates/meet-nova-launch.html +7 -7
  15. package/examples/templates/mega-weekend-sale.html +1 -1
  16. package/examples/templates/order-confirmed.html +6 -6
  17. package/examples/templates/rate-your-headphones.html +3 -3
  18. package/examples/templates/reset-your-password.html +1 -1
  19. package/examples/templates/thankyou-promo-code.html +3 -3
  20. package/examples/templates/the-sunday-brief.html +4 -4
  21. package/examples/templates/welcome-to-your-workspace.html +4 -4
  22. package/examples/templates/your-order-shipped.html +2 -2
  23. package/examples/templates/your-password-was-changed.html +2 -2
  24. package/examples/templates/your-signin-code.html +1 -1
  25. package/examples/vanilla.html +66 -66
  26. package/package.json +1 -1
  27. package/src/core/binder.js +10 -0
  28. package/src/core/blocks.js +3 -3
  29. package/src/core/editor-core.js +195 -6
  30. package/src/core/export.js +311 -22
  31. package/src/core/i18n/ar.js +2 -0
  32. package/src/core/i18n/bg.js +2 -0
  33. package/src/core/i18n/bn.js +2 -0
  34. package/src/core/i18n/ca.js +2 -0
  35. package/src/core/i18n/cs.js +2 -0
  36. package/src/core/i18n/da.js +2 -0
  37. package/src/core/i18n/de-CH.js +2 -0
  38. package/src/core/i18n/de.js +2 -0
  39. package/src/core/i18n/dz.js +2 -0
  40. package/src/core/i18n/el.js +2 -0
  41. package/src/core/i18n/en.js +2 -0
  42. package/src/core/i18n/es.js +2 -0
  43. package/src/core/i18n/et.js +2 -0
  44. package/src/core/i18n/fi.js +2 -0
  45. package/src/core/i18n/fr.js +2 -0
  46. package/src/core/i18n/hr.js +2 -0
  47. package/src/core/i18n/hu.js +2 -0
  48. package/src/core/i18n/index.js +83 -83
  49. package/src/core/i18n/it.js +2 -0
  50. package/src/core/i18n/lt.js +2 -0
  51. package/src/core/i18n/lv.js +2 -0
  52. package/src/core/i18n/nb.js +2 -0
  53. package/src/core/i18n/nl.js +2 -0
  54. package/src/core/i18n/pl.js +2 -0
  55. package/src/core/i18n/pt.js +2 -0
  56. package/src/core/i18n/ro.js +2 -0
  57. package/src/core/i18n/ru.js +2 -0
  58. package/src/core/i18n/sk.js +2 -0
  59. package/src/core/i18n/sl.js +2 -0
  60. package/src/core/i18n/sv.js +2 -0
  61. package/src/core/i18n/tr.js +2 -0
  62. package/src/core/i18n/uk.js +2 -0
  63. package/src/core/icons.js +17 -43
  64. package/src/core/ids.js +1 -1
  65. package/src/core/import-html.js +390 -20
  66. package/src/core/layout-style.js +25 -0
  67. package/src/core/parse.js +10 -10
  68. package/src/core/placeholder.js +15 -15
  69. package/src/core/sanitize.js +4 -1
  70. package/src/core/theme.js +22 -1
  71. package/src/core/variables.js +11 -11
  72. package/src/mailcraft-editor.js +11 -1
  73. package/src/render/block-body.js +129 -17
  74. package/src/render/canvas.js +35 -6
  75. package/src/render/fields.js +19 -0
  76. package/src/render/focus-preserve.js +158 -158
  77. package/src/render/screenshot.js +26 -3
  78. package/src/render/style.js +6 -1
@@ -148,15 +148,153 @@ function bgImageOf(el) {
148
148
  const m = ((st.backgroundImage || '') + ' ' + (st.background || '')).match(/url\(["']?([^"')]+)["']?\)/);
149
149
  if (m) return m[1];
150
150
  }
151
+ // CSSOM is the first read, not the only one. A declaration the parser could
152
+ // not fully digest -- the `center / cover` slash syntax under some engines,
153
+ // a vendor-prefixed layer, anything malformed -- comes back as an empty
154
+ // property, and the url with it. The attribute text still has it.
155
+ const raw = el.getAttribute ? (el.getAttribute('style') || '') : '';
156
+ const rm = raw.match(/background(?:-image)?\s*:[^;]*?url\(\s*["']?([^"')]+?)["']?\s*\)/i);
157
+ if (rm) return rm[1];
151
158
  return (el.getAttribute && el.getAttribute('background')) || '';
152
159
  }
153
160
 
154
- /** The exporter's own overlay idiom, read back: a section image ships as `linear-gradient(rgba(20,22,24,a),rgba(20,22,24,a)),url(...)` (core/export.js), and the alpha is the row's `overlay` percentage. Only that exact neutral-dark signature is folded back -- a foreign gradient says nothing about MailCraft's tint and stays out of the model, exactly as before. Without this the tint silently vanished on every save/reload while the photo survived. */
161
+ /**
162
+ * The tint box a section image now ships behind: the painted element's only
163
+ * child, a `<div>` whose background is the exporter's own neutral-dark rgba
164
+ * (core/export.js). Recognized by that exact signature and nothing else, so a
165
+ * foreign wrapper that happens to be a single div is left alone -- and by CSS
166
+ * rather than by a marker attribute, so it survives `exportHtml({markers:false})`.
167
+ *
168
+ * It is scaffolding, not content: the walk descends through it, and the row
169
+ * padding it carries is the row's (the exporter moved it off the cell so the
170
+ * tint covers the whole band, not just the content area).
171
+ */
172
+ function tintOf(el) {
173
+ if (!el || !el.children || el.children.length !== 1) return null;
174
+ const kid = el.children[0];
175
+ if (!kid || kid.tagName !== 'DIV' || !kid.style) return null;
176
+ const m = String(kid.style.backgroundColor || kid.style.background || '')
177
+ .match(/^rgba\(\s*20,\s*22,\s*24,\s*(0?\.\d+|1|0)\s*\)$/);
178
+ if (!m) return null;
179
+ return { el: kid, pct: Math.round(parseFloat(m[1]) * 100) };
180
+ }
181
+
182
+ /**
183
+ * The exporter's overlay idiom, read back: the alpha of the neutral-dark tint
184
+ * over a section image is the row's `overlay` percentage. Only that exact
185
+ * signature is folded back -- a foreign gradient says nothing about
186
+ * MailCraft's tint and stays out of the model. Without this the tint silently
187
+ * vanished on every save/reload while the photo survived.
188
+ *
189
+ * One shape is read here, the layered `linear-gradient(...),url(...)` that the
190
+ * exporter shipped before the Outlook fix. Current exports carry the tint as
191
+ * its own box instead, and `unwrapTints` has already rewritten those into this
192
+ * shape by the time any of this runs -- so both eras arrive here identical.
193
+ */
155
194
  function overlayOf(el) {
156
- const st = el.style;
195
+ const st = el && el.style;
157
196
  if (!st) return 0;
158
- const m = ((st.backgroundImage || '') + ' ' + (st.background || '')).match(/linear-gradient\(rgba\(20,\s*22,\s*24,\s*(0?\.\d+|1|0)\s*\)/);
159
- return m ? Math.round(parseFloat(m[1]) * 100) : 0;
197
+ const decl = (st.backgroundImage || '') + ' ' + (st.background || '');
198
+ const m = decl.match(/linear-gradient\(rgba\(20,\s*22,\s*24,\s*(0?\.\d+|1|0)\s*\)/);
199
+ if (m) return Math.round(parseFloat(m[1]) * 100);
200
+ // A foreign tint: two identical dark rgba stops over the photo, which is
201
+ // how every other builder writes "darken this image". The alpha maps to the
202
+ // Darken slider exactly, and a dark stop is what the slider means -- a light
203
+ // or coloured wash is a different effect and still stays out of the model
204
+ // rather than being misread as a darkening.
205
+ const f = decl.match(/linear-gradient\(\s*rgba\((\d+),\s*(\d+),\s*(\d+),\s*(0?\.\d+|1|0)\)\s*,\s*rgba\((\d+),\s*(\d+),\s*(\d+),\s*(0?\.\d+|1|0)\)\s*\)/);
206
+ if (f && f[1] === f[5] && f[2] === f[6] && f[3] === f[7] && f[4] === f[8]) {
207
+ const lum = (0.299 * +f[1] + 0.587 * +f[2] + 0.114 * +f[3]) / 255;
208
+ if (lum < 0.35) return Math.round(parseFloat(f[4]) * 100);
209
+ }
210
+ return 0;
211
+ }
212
+
213
+ /**
214
+ * Normalises the current export's tint box back into the legacy layered
215
+ * background, in the parsed DOM, before anything walks it.
216
+ *
217
+ * The exporter can no longer put the tint in the `background-image` (see
218
+ * core/export.js: outlook.com drops a layered value whole), so it ships the
219
+ * photo on the cell and the tint on a `<div>` inside it, which also carries
220
+ * the row padding so the tint covers the padded band rather than just the
221
+ * content. Left alone, that div reads as content: the walker took its rgba
222
+ * for the row's own background colour and the row padding vanished with it.
223
+ *
224
+ * Rewriting it here rather than teaching the walker about it keeps the change
225
+ * to one pre-pass -- every downstream path (bgOf, paddingOf, overlayOf, the
226
+ * column-wrapper and band-merge heuristics) then sees the shape it has always
227
+ * seen. Guarded on the host actually carrying a background image, so an
228
+ * unrelated div that happens to be this exact rgba is left alone.
229
+ */
230
+ /**
231
+ * The image an Outlook-first template carries ONLY inside its VML. Builders
232
+ * that target Word write the photo as `<v:fill src="...">` (or `<v:image>`)
233
+ * inside an `<!--[if gte mso 9]>` conditional, plus a `background=` attribute
234
+ * if you are lucky -- and nothing else. Comments are inert to the row walk
235
+ * (correctly: they are Word's, not ours), so without this the image existed
236
+ * only for Outlook and vanished for everyone the moment the template was
237
+ * imported. Applied only where nothing else already declares an image on the
238
+ * comment's host, so a CSS/attribute background always wins.
239
+ */
240
+ function readVmlBackgrounds(doc) {
241
+ const walker = doc.createTreeWalker(doc.body, 128 /* NodeFilter.SHOW_COMMENT */);
242
+ const hits = [];
243
+ for (let c = walker.nextNode(); c; c = walker.nextNode()) {
244
+ const text = String(c.nodeValue || '');
245
+ if (!/<v:(?:fill|image)\b/i.test(text)) continue;
246
+ const m = text.match(/<v:(?:fill|image)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/i);
247
+ if (m && c.parentElement) hits.push({ host: c.parentElement, url: m[1] });
248
+ }
249
+ hits.forEach(({ host, url }) => {
250
+ if (bgImageOf(host)) return;
251
+ if (!host.style) return;
252
+ host.style.backgroundImage = 'url("' + url + '")';
253
+ if (!host.style.backgroundSize) host.style.backgroundSize = 'cover';
254
+ if (!host.style.backgroundPosition) host.style.backgroundPosition = 'center';
255
+ if (!host.style.backgroundRepeat) host.style.backgroundRepeat = 'no-repeat';
256
+ });
257
+ }
258
+
259
+ /**
260
+ * A column's background image, read off whichever element carries it -- the
261
+ * `<td>` itself in a foreign template, or the styled wrapper `<div>` this
262
+ * exporter writes inside it. Mirrors what `applyBgImage` does for a row, one
263
+ * level in, and writes nothing when the element has no image, so a
264
+ * colour-only column is untouched.
265
+ *
266
+ * The tint and the VML-only variants need no special case here: `unwrapTints`
267
+ * and `readVmlBackgrounds` have already normalised both into a plain layered
268
+ * background on this element by the time the column walk runs.
269
+ */
270
+ function applyColBgImage(col, el) {
271
+ if (!col || !el) return;
272
+ const url = bgImageOf(el);
273
+ if (!url || col.bgImage) return;
274
+ const st = el.style || {};
275
+ col.bgImage = url;
276
+ const ov = overlayOf(el);
277
+ if (ov) col.overlay = ov;
278
+ if (st.backgroundSize) col.bgSize = st.backgroundSize;
279
+ if (st.backgroundPosition) col.bgPos = st.backgroundPosition;
280
+ if (st.backgroundRepeat) col.bgRepeat = st.backgroundRepeat;
281
+ }
282
+
283
+ function unwrapTints(doc) {
284
+ Array.from(doc.body.querySelectorAll('*')).forEach((host) => {
285
+ const url = bgImageOf(host);
286
+ if (!url) return;
287
+ const tint = tintOf(host);
288
+ if (!tint) return;
289
+ const a = tint.pct / 100;
290
+ host.style.backgroundImage = 'linear-gradient(rgba(20,22,24,' + a + '),rgba(20,22,24,' + a + ')),url("' + url + '")';
291
+ const st = tint.el.style;
292
+ const pad = st.padding
293
+ || [st.paddingTop, st.paddingRight, st.paddingBottom, st.paddingLeft].filter(Boolean).join(' ');
294
+ if (pad) host.style.padding = pad;
295
+ while (tint.el.firstChild) host.insertBefore(tint.el.firstChild, tint.el);
296
+ tint.el.remove();
297
+ });
160
298
  }
161
299
 
162
300
  /** Carries a wrapper's background image (hero photo sections) onto the rows it produced, mirroring applyBg -- fit/position/repeat come along when declared. */
@@ -165,6 +303,20 @@ function applyBgImage(rows, el) {
165
303
  if (!url) return rows;
166
304
  const st = el.style || {};
167
305
  const ov = overlayOf(el);
306
+ /*
307
+ * ONE image, ONE band. This is the only place a wrapper's background is
308
+ * carried onto the rows beneath it, so it is also the only place that can
309
+ * stop the same image being stamped onto every one of them -- which is what
310
+ * happened: a hero section that walked into six rows got six copies, each
311
+ * repainting the photo from its own top edge, and the recipient saw the
312
+ * image tiled down the email in bands. `mergeBandRows` was meant to fold
313
+ * such sections first, but it only wrapped two of the four paths into
314
+ * here, and gave up entirely if any single row differed. Runs of
315
+ * foldable rows are merged here instead, on every path; a row that cannot
316
+ * fold (two columns, its own frame) simply ends the run and starts a new
317
+ * one, so a footer no longer costs the five rows above it their band.
318
+ */
319
+ rows = mergeBandRuns(rows);
168
320
  rows.forEach((r) => {
169
321
  if (r.props.bgImage) return;
170
322
  r.props.bgImage = url;
@@ -189,17 +341,19 @@ function applyBgImage(rows, el) {
189
341
  * frame or image on a row is a real band boundary) are merged, so everything
190
342
  * that isn't the hero shape walks exactly as before.
191
343
  */
192
- function mergeBandRows(rows, el) {
193
- if (rows.length < 2 || !bgImageOf(el)) return rows;
194
- const bg0 = rows[0].props.bg || '';
195
- const plain = rows.every((r) => r.cols.length === 1
196
- && !r.props.bgImage && !r.props.border && !r.props.radius && !r.props.shadow
197
- && (r.props.bg || '') === bg0);
198
- if (!plain) return rows;
344
+ /** A row that can dissolve into a band: one column, no paint or frame of its own. Its padding moves onto its blocks when merged. */
345
+ function foldable(r) {
346
+ return r.cols.length === 1 && !r.props.bgImage && !r.props.border && !r.props.radius && !r.props.shadow;
347
+ }
348
+
349
+ /** Folds one run of foldable rows (same `bg`) into a single row, moving each row's padding onto its blocks -- the same convention as the block-table unwrap in `blocksFromNodes`. */
350
+ function foldRun(run) {
351
+ if (run.length < 2) return run;
352
+ const bg0 = run[0].props.bg || '';
199
353
  const merged = mkRow([100]);
200
354
  merged.props.py = 0; merged.props.px = 0; merged.props.gap = 0;
201
355
  if (bg0) merged.props.bg = bg0;
202
- merged.cols[0].blocks = rows.reduce((acc, r) => {
356
+ merged.cols[0].blocks = run.reduce((acc, r) => {
203
357
  const padded = r.props.py || r.props.px || r.props.pt !== undefined;
204
358
  r.cols[0].blocks.forEach((b) => {
205
359
  if (padded && b.type !== 'button' && 'py' in b.props) {
@@ -210,7 +364,27 @@ function mergeBandRows(rows, el) {
210
364
  });
211
365
  return acc;
212
366
  }, []);
213
- return merged.cols[0].blocks.length ? [merged] : rows;
367
+ return merged.cols[0].blocks.length ? [merged] : run;
368
+ }
369
+
370
+ /** Merges every run of consecutive foldable rows sharing a background colour; anything else passes through in place and ends the run. */
371
+ function mergeBandRuns(rows) {
372
+ if (rows.length < 2) return rows;
373
+ const out = [];
374
+ let run = [];
375
+ const flush = () => { if (run.length) out.push(...foldRun(run)); run = []; };
376
+ rows.forEach((r) => {
377
+ if (foldable(r) && (!run.length || (run[0].props.bg || '') === (r.props.bg || ''))) { run.push(r); return; }
378
+ flush();
379
+ if (foldable(r)) run.push(r); else out.push(r);
380
+ });
381
+ flush();
382
+ return out;
383
+ }
384
+
385
+ function mergeBandRows(rows, el) {
386
+ if (rows.length < 2 || !bgImageOf(el)) return rows;
387
+ return mergeBandRuns(rows);
214
388
  }
215
389
 
216
390
  /** Padding read from the longhands, which are populated by the `padding` shorthand too -- but not vice versa: builders that write `padding-top/-left/...` individually (Beefree et al.) read back an empty `style.padding`, which is how every one of their cells imported with zero padding. Carries the exact per-side values plus the averaged py/px pair for consumers that only have a pair to store. */
@@ -281,6 +455,18 @@ function classifyImage(el) {
281
455
  }
282
456
  }
283
457
  if (!img) return null;
458
+ /*
459
+ * A 1x1 is a tracking pixel or a spacer gif, never a picture. Classified as
460
+ * an image block it became a visible 2%-wide slab in the editor (and was
461
+ * unrepresentable there: the Width slider starts at 5%). It passes through
462
+ * as raw HTML instead, so the open-tracking survives the round trip and the
463
+ * canvas shows nothing for it -- exactly what it was in the source.
464
+ */
465
+ const attrW = parseInt(img.getAttribute('width'), 10);
466
+ const attrH = parseInt(img.getAttribute('height'), 10);
467
+ const cssW = PX(img.style.width); const cssH = PX(img.style.height);
468
+ const tiny = (v) => v > 0 && v <= 1;
469
+ if ((tiny(attrW) || tiny(cssW)) && (tiny(attrH) || tiny(cssH))) return blk('html', { code: img.outerHTML });
284
470
  // A pixel size wins over a percent one wherever either appears: builders
285
471
  // routinely write `width:100%` on the img and put the real constraint on a
286
472
  // `width` attribute or a wrapper's `max-width` (`<div style="max-width:88px">
@@ -299,16 +485,66 @@ function classifyImage(el) {
299
485
  ];
300
486
  const pxHint = pxCandidates.find((v) => v && !isPct(v) && PX(v));
301
487
  const pctHint = [a && a.style ? a.style.width : '', img.style.width].find((v) => isPct(v) && PX(v));
488
+ /*
489
+ * A MEANINGFUL percentage outranks the pixel hint; a 100% one does not.
490
+ *
491
+ * The rule above exists for the builder idiom `<div style="max-width:88px">
492
+ * <img style="width:100%" width="88">`, where the percentage says nothing
493
+ * and the real constraint is the px. But `width:100%` is exactly what makes
494
+ * that percentage meaningless -- any other value is a width someone chose,
495
+ * and since the exporter now also writes a px `width` attribute for Word
496
+ * (block-body.js), letting px win unconditionally meant every image came
497
+ * back a few percent off and no template was a save fixed point any more.
498
+ */
499
+ const pctFirst = pctHint && PX(pctHint) !== 100 ? pctHint : '';
302
500
  let width = 100;
303
- if (pxHint) {
501
+ if (pctFirst) {
502
+ width = PX(pctFirst);
503
+ } else if (pxHint) {
304
504
  // Convert to % of the nearest fixed-width ancestor.
305
505
  const colPx = ancestorPxWidth(img) || 600;
306
506
  width = Math.max(2, Math.min(100, Math.round((PX(pxHint) / colPx) * 100)));
307
507
  } else if (pctHint) {
308
508
  width = PX(pctHint);
309
509
  }
510
+ /*
511
+ * A PIXEL width, kept as pixels.
512
+ *
513
+ * The percentage above is still computed -- it stays the responsive
514
+ * fallback and what the Width slider shows -- but a source that said
515
+ * `width="88"` and nothing else meant 88, and rounding that into 15% of a
516
+ * 600px column both lost 5px and made the value drift on every save (15%
517
+ * of the next column width is not 88). `wpx` pins the number the source
518
+ * actually gave; render/block-body.js writes it straight back out, so the
519
+ * document is a fixed point.
520
+ *
521
+ * Only when the source expressed NO percentage at all. `width:100%` on the
522
+ * image with a `width` attribute beside it is this exporter's own
523
+ * full-width shape (the attribute is the floor Word reads, not a pin), and
524
+ * reading that as a pixel width would freeze every responsive hero at the
525
+ * column width it happened to be exported from. The same guard, from the
526
+ * other end: a pixel hint that already fills the column is full-width, so
527
+ * it stays a percentage too.
528
+ */
529
+ const colPxForPin = ancestorPxWidth(img) || 600;
530
+ const pinPx = !pctHint && pxHint && PX(pxHint) > 0 && PX(pxHint) < colPxForPin * 0.98
531
+ ? Math.round(PX(pxHint))
532
+ : 0;
533
+ /*
534
+ * The source's aspect ratio, so the export can reserve the image's box
535
+ * with a `height` attribute (block-body.js explains why that matters with
536
+ * images blocked). Taken only where the source gave both dimensions --
537
+ * never derived from one of them -- and rounded, so it stays a short
538
+ * number in the saved JSON and a stable one across reloads.
539
+ */
540
+ const ratioW = attrW > 1 ? attrW : (cssW > 1 ? cssW : 0);
541
+ const ratioH = attrH > 1 ? attrH : (cssH > 1 ? cssH : 0);
542
+ const ratio = ratioW && ratioH ? Math.round((ratioH / ratioW) * 10000) / 10000 : 0;
310
543
  const over = {
311
- src: img.getAttribute('src') || '',
544
+ // `//cdn/x.png` is a relative URL to a mail client (there is no page
545
+ // origin to resolve it against), so it gets the scheme the same way a
546
+ // link does (sanitize.js linkHref).
547
+ src: String(img.getAttribute('src') || '').replace(/^\/\//, 'https://'),
312
548
  alt: img.getAttribute('alt') || '',
313
549
  href,
314
550
  width,
@@ -317,6 +553,22 @@ function classifyImage(el) {
317
553
  // the corners of every imported image that had none.
318
554
  radius: PX(img.style.borderRadius) || 0,
319
555
  };
556
+ if (pinPx) { over.wUnit = 'px'; over.wpx = pinPx; }
557
+ if (ratio) over.ratio = ratio;
558
+ /*
559
+ * Retina sources and the tooltip, previously dropped on the floor: a
560
+ * template imported for a copy edit came back out having lost its 2x
561
+ * artwork. `sizes` only rides along with a `srcset` -- on its own it
562
+ * describes nothing.
563
+ */
564
+ const srcset = String(img.getAttribute('srcset') || '').trim();
565
+ if (srcset) {
566
+ over.srcset = srcset;
567
+ const sizes = String(img.getAttribute('sizes') || '').trim();
568
+ if (sizes) over.sizes = sizes;
569
+ }
570
+ const title = String(img.getAttribute('title') || '').trim();
571
+ if (title) over.title = title;
320
572
  // The block's own spacing lives on the wrapper the exporter writes
321
573
  // (`padding: py px`); unread, it reset to 0/0 on every save.
322
574
  if (el !== img && el.style) {
@@ -1237,7 +1489,20 @@ function isPassthroughTable(tb) {
1237
1489
  // rowsFromContentTable mints from it.
1238
1490
  if (trs[0].getAttribute('data-mc-logic')) return false;
1239
1491
  const cells = Array.from(trs[0].children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
1240
- return cells.length === 1;
1492
+ if (cells.length !== 1) return false;
1493
+ /*
1494
+ * The same rule as the logic marker above, one level in. A flex/grid row
1495
+ * IS a row, and it is a div -- the exporter stamps its settings on that
1496
+ * div as `data-mcr`. Passing the cell through walks the div as ordinary
1497
+ * content, so each column div became a row of its own: a document whose
1498
+ * ONLY row was a flex or grid row reloaded as N stacked one-column rows
1499
+ * with the layout, the spans and any column paint gone. With two or more
1500
+ * rows the content table was never a passthrough, so the bug needed a
1501
+ * single-row document to show itself.
1502
+ */
1503
+ const lone = onlyChild(cells[0], 'DIV');
1504
+ if (lone && lone.getAttribute && lone.getAttribute('data-mcr')) return false;
1505
+ return true;
1241
1506
  }
1242
1507
 
1243
1508
  /** CSSOM hands colors back as `rgb(r, g, b)` even when the source (and the
@@ -1399,7 +1664,7 @@ function applyFrame(rows, el) {
1399
1664
  function rowsFromContentTable(table) {
1400
1665
  const tableWidthPx = PX(table.getAttribute('width') || table.style.width || '0') || null;
1401
1666
  const trs = Array.from(table.querySelectorAll(':scope > tbody > tr, :scope > tr'));
1402
- return trs.map((tr) => {
1667
+ const built = trs.map((tr) => {
1403
1668
  // A synthetic marker row minted by foldLogicWrappers: one dynamic-content
1404
1669
  // marker block, at the exact place the tag held in the source.
1405
1670
  const logicTag = tr.getAttribute('data-mc-logic');
@@ -1511,6 +1776,29 @@ function rowsFromContentTable(table) {
1511
1776
  if (pd && !pd.t && !pd.b && pd.l > 0 && pd.l === pd.r && pd.l <= 60) gapPx = pd.l * 2;
1512
1777
  }
1513
1778
  }
1779
+ /*
1780
+ * A nested SECTION: one cell whose only child is a multi-row table. Not a
1781
+ * component (a button is one cell, a data table has <th>s, a social strip
1782
+ * classifies as one block) and not layout scaffolding (that is one row,
1783
+ * handled above) -- it is another band of rows, usually a hero or a card
1784
+ * stack with a background image of its own. It used to reach the
1785
+ * never-drop-content floor and import as ONE opaque html block: not a
1786
+ * row, no background controls, nothing inside it editable. It is walked
1787
+ * as rows instead, the section's own image applied once as a band
1788
+ * (applyBgImage folds the runs) and the cell's paint, padding and frame
1789
+ * carried onto the result -- the same composition as the td -> tr ->
1790
+ * table branch in collectRows.
1791
+ */
1792
+ if (cells.length === 1 && !classifyButton(cells[0])) {
1793
+ const section = onlyChild(cells[0], 'TABLE');
1794
+ const secRows = section ? section.querySelectorAll(':scope > tbody > tr, :scope > tr') : [];
1795
+ if (section && secRows.length >= 2 && !section.closest('form,svg')
1796
+ && !section.querySelector(':scope > tbody > tr > th, :scope > tr > th') && !classifySocial(section)) {
1797
+ const td = cells[0];
1798
+ const inner = rowsFromContentTable(section);
1799
+ if (inner.length) return applyBgImage(applyBgImage(applyFrame(applyPad(applyBg(inner, bgOf(td)), padOf(td)), td), td), section);
1800
+ }
1801
+ }
1514
1802
  const spans = cells.length === 1 ? [100] : spansFromCells(cells, tableWidthPx);
1515
1803
  const row = mkRow(spans);
1516
1804
  row.props.py = 0; row.props.px = 0; row.props.gap = gapPx;
@@ -1581,6 +1869,7 @@ function rowsFromContentTable(table) {
1581
1869
  cells.forEach((cell, i) => {
1582
1870
  const col = row.cols[i]; if (!col) return;
1583
1871
  const cbg = bgOf(cell); if (cbg) col.bg = cbg;
1872
+ applyColBgImage(col, cell);
1584
1873
  const crad = radiusOf(cell.style); if (crad) col.radius = crad;
1585
1874
  const cframe = borderSidesOf(cell.style);
1586
1875
  if (cframe.width) {
@@ -1603,7 +1892,13 @@ function rowsFromContentTable(table) {
1603
1892
  if (radius) row.props.radius = radius;
1604
1893
  const shadow = (cellsUniform ? bgSource.style.boxShadow : '') || table.style.boxShadow || '';
1605
1894
  if (shadow && shadow !== 'none') row.props.shadow = shadow;
1606
- const bgiEl = (cellsUniform && bgImageOf(bgSource)) ? bgSource : (bgImageOf(table) ? table : null);
1895
+ // A cell's own image is this row's. The TABLE's image is only this row's
1896
+ // when the table IS one row; a multi-row table's image is the band's, and
1897
+ // is applied once to all its rows after they exist (see the return below)
1898
+ // -- stamping it here, row by row as they were built, is what put a fresh
1899
+ // copy of the section photo on every row, each repainting it from its own
1900
+ // top edge, and left nothing for the band merge to fold.
1901
+ const bgiEl = (cellsUniform && bgImageOf(bgSource)) ? bgSource : (trs.length === 1 && bgImageOf(table) ? table : null);
1607
1902
  if (bgiEl) applyBgImage([row], bgiEl);
1608
1903
  // The table itself can carry section padding (Beefree writes
1609
1904
  // `padding-top: 60px` on `.row-content`) on top of the cell's own -- the
@@ -1631,10 +1926,15 @@ function rowsFromContentTable(table) {
1631
1926
  // its children, or the whole card collapses into one opaque text blob.
1632
1927
  let contentEl = cell;
1633
1928
  const lone = onlyChild(cell, 'DIV');
1634
- if (lone && (bgOf(lone) || radiusOf(lone.style) || borderSidesOf(lone.style).width) && !classifyNode(lone)) {
1929
+ if (lone && (bgOf(lone) || bgImageOf(lone) || radiusOf(lone.style) || borderSidesOf(lone.style).width) && !classifyNode(lone)) {
1635
1930
  const col = row.cols[i];
1636
1931
  if (col) {
1637
1932
  const cbg = bgOf(lone); if (cbg && !col.bg) col.bg = cbg;
1933
+ // The column's own photo ships on this same wrapper (core/export.js
1934
+ // writes the paint, the border, the radius and the padding
1935
+ // together), so it is read back from here too -- otherwise a
1936
+ // two-up row with a photo in one column lost it on every reload.
1937
+ applyColBgImage(col, lone);
1638
1938
  const crad = radiusOf(lone.style); if (crad && !col.radius) col.radius = crad;
1639
1939
  // The column's own border ships on this same wrapper (core/export.js
1640
1940
  // writes bg, border, radius and padding together); reading everything
@@ -1653,7 +1953,11 @@ function rowsFromContentTable(table) {
1653
1953
  row.cols[i].blocks = blocksFromNodes(Array.from(contentEl.childNodes));
1654
1954
  });
1655
1955
  return row;
1656
- }).filter((r) => r && r.cols.some((c) => c.blocks.length));
1956
+ }).flatMap((r) => (Array.isArray(r) ? r : [r])).filter((r) => r && r.cols.some((c) => c.blocks.length));
1957
+ // The multi-row table's own image, once, as a band (applyBgImage folds the
1958
+ // runs of foldable rows first). The content table never reaches this with
1959
+ // an image: themeFromParsedDoc has already claimed and consumed it.
1960
+ return trs.length > 1 && bgImageOf(table) ? applyBgImage(built, table) : built;
1657
1961
  }
1658
1962
 
1659
1963
  /**
@@ -1883,12 +2187,44 @@ function partialFrame(st) {
1883
2187
  function themeFromParsedDoc(doc) {
1884
2188
  const theme = {};
1885
2189
  const body = doc.body;
2190
+ // Reading direction and preview line, both document-level. The preheader
2191
+ // div is removed once read -- left in place it walked as a text row of
2192
+ // invisible copy, and re-exported as a second preheader on every save.
2193
+ if (doc.documentElement && doc.documentElement.getAttribute('dir') === 'rtl') theme.dir = 'rtl';
2194
+ const pre = Array.from(body.children).find((n) => n.tagName === 'DIV' && n.style && n.style.display === 'none'
2195
+ && (/mso-hide/i.test(n.getAttribute('style') || '') || PX(n.style.maxHeight) === 0 || n.style.fontSize === '1px'));
2196
+ if (pre) {
2197
+ const text = String(pre.textContent || '').replace(/[\u034f\u200c\u00a0]+$/g, '').trim();
2198
+ if (text) theme.preheader = text;
2199
+ pre.remove();
2200
+ }
1886
2201
  let bg = hexOf((body.style && (body.style.backgroundColor || body.style.background)) || body.getAttribute('bgcolor') || '');
1887
2202
  if (!bg) {
1888
2203
  const outer = body.querySelector('table');
1889
2204
  if (outer) bg = bgOf(outer) || (outer.querySelector('td') ? bgOf(outer.querySelector('td')) : '');
1890
2205
  }
1891
2206
  if (bg) theme.bg = bg;
2207
+ // The page's background IMAGE: on the body, or on the outermost wrapper
2208
+ // when that wrapper is full-width (a lone fixed-width table is the content
2209
+ // column, and its image belongs to the content read below, not here).
2210
+ // Claimed means consumed, so the row walk never stamps it onto rows.
2211
+ {
2212
+ const outer = body.querySelector('table');
2213
+ const outerW = outer ? String(outer.getAttribute('width') || (outer.style && outer.style.width) || '') : '';
2214
+ const outerFull = outer && (outerW === '100%' || (!outerW && !PX(outerW)));
2215
+ const host = bgImageOf(body) ? body : (outerFull && bgImageOf(outer) ? outer : null);
2216
+ if (host) {
2217
+ theme.bgImage = bgImageOf(host);
2218
+ if (host.style && host.style.backgroundSize) theme.bgSize = host.style.backgroundSize;
2219
+ if (host.style && host.style.backgroundPosition) theme.bgPos = host.style.backgroundPosition;
2220
+ if (host.style && host.style.backgroundRepeat) theme.bgRepeat = host.style.backgroundRepeat;
2221
+ [body, outer].forEach((n) => {
2222
+ if (!n) return;
2223
+ if (n.style) { n.style.backgroundImage = ''; n.style.backgroundSize = ''; n.style.backgroundPosition = ''; n.style.backgroundRepeat = ''; }
2224
+ if (n.removeAttribute) n.removeAttribute('background');
2225
+ });
2226
+ }
2227
+ }
1892
2228
  const widthCounts = {};
1893
2229
  body.querySelectorAll('table').forEach((tb) => {
1894
2230
  const px = fixedWidthOf(tb);
@@ -1917,6 +2253,36 @@ function themeFromParsedDoc(doc) {
1917
2253
  // whose content table declares nothing keeps the white default.
1918
2254
  const cbg = bgOf(content);
1919
2255
  if (cbg) theme.contentBg = cbg;
2256
+ // The content column's background image, read back the same way a row's
2257
+ // is -- CSS url() or the `background` attribute, plus whichever of
2258
+ // fit/position/repeat the source actually declared.
2259
+ const cbgi = bgImageOf(content);
2260
+ if (cbgi) {
2261
+ theme.contentBgImage = cbgi;
2262
+ if (content.style && content.style.backgroundSize) theme.contentBgSize = content.style.backgroundSize;
2263
+ if (content.style && content.style.backgroundPosition) theme.contentBgPos = content.style.backgroundPosition;
2264
+ if (content.style && content.style.backgroundRepeat) theme.contentBgRepeat = content.style.backgroundRepeat;
2265
+ // Claimed means consumed, as below. Left on the node, `applyBgImage`
2266
+ // in the row pass reads the very same image off the content table and
2267
+ // stamps a copy onto every row it produces -- so one reload turned a
2268
+ // document-level background into a per-row one, and (because a row
2269
+ // with an image emits VML) changed the exported bytes on a document
2270
+ // nobody had edited.
2271
+ if (content.style) {
2272
+ content.style.backgroundImage = '';
2273
+ content.style.backgroundSize = '';
2274
+ content.style.backgroundPosition = '';
2275
+ content.style.backgroundRepeat = '';
2276
+ }
2277
+ // `bgcolor` goes with it: the exporter writes the pair together, and
2278
+ // the colour has already been claimed as `contentBg` just above --
2279
+ // left behind, the row walker reads the attribute and repaints it as
2280
+ // a row background on every reload.
2281
+ if (content.removeAttribute) {
2282
+ content.removeAttribute('background');
2283
+ content.removeAttribute('bgcolor');
2284
+ }
2285
+ }
1920
2286
  const r = PX(content.style && content.style.borderRadius);
1921
2287
  if (r > 0) theme.radius = r;
1922
2288
  // The content column's full border, written by the exporter as a
@@ -2113,6 +2479,10 @@ export function htmlToDoc(src) {
2113
2479
  // (never-inlined exports, hand-written emails) classify like inlined ones.
2114
2480
  // Best-effort: a pathological stylesheet must never block the import.
2115
2481
  try { inlineStylesheets(doc); } catch { /* proceed with inline styles only */ }
2482
+ // Then fold the tint box back into a layered background, so the walk below
2483
+ // sees one shape for section images regardless of which exporter wrote them.
2484
+ try { readVmlBackgrounds(doc); } catch { /* VML is a bonus read, never a blocker */ }
2485
+ try { unwrapTints(doc); } catch { /* an odd tree is not worth failing the import over */ }
2116
2486
  // Theme first: themeFromParsedDoc consumes the styles it claims off the
2117
2487
  // scaffold nodes, and the row walker must see the cleaned DOM.
2118
2488
  const theme = themeFromParsedDoc(doc);
@@ -93,6 +93,31 @@ export function rowBg(p) {
93
93
  };
94
94
  }
95
95
 
96
+ /**
97
+ * A column's paint for the canvas, the counterpart of `rowBg` one level in.
98
+ *
99
+ * Unpainted or colour-only columns keep the single `background` shorthand
100
+ * they have always drawn with, so nothing about an existing document's DOM
101
+ * changes. Only a column that actually carries an image switches to the
102
+ * longhands -- with the tint as a gradient layer, which is safe here because
103
+ * the canvas is a browser, not an email client (the exporter writes the same
104
+ * tint as its own rgba box instead; core/export.js says why).
105
+ */
106
+ export function colBg(c) {
107
+ if (!c.bgImage) return { background: c.bg || 'transparent' };
108
+ const ov = (c.overlay || 0) / 100;
109
+ const layers = [];
110
+ if (ov) layers.push('linear-gradient(rgba(20,22,24,' + ov + '),rgba(20,22,24,' + ov + '))');
111
+ layers.push('url("' + cssUrl(c.bgImage) + '")');
112
+ return {
113
+ backgroundColor: c.bg || 'transparent',
114
+ backgroundImage: layers.join(','),
115
+ backgroundSize: c.bgSize || 'cover',
116
+ backgroundPosition: c.bgPos || 'center',
117
+ backgroundRepeat: c.bgRepeat || 'no-repeat',
118
+ };
119
+ }
120
+
96
121
  export function colsWrap(p) {
97
122
  const gap = p.gap || 0;
98
123
  if (p.layout === 'grid') return { display: 'grid', gridTemplateColumns: 'repeat(' + (p.gridCols || 2) + ', minmax(0, 1fr))', gap: gap + 'px' };
package/src/core/parse.js CHANGED
@@ -1,10 +1,10 @@
1
- export function parseItems(s) {
2
- return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
3
- const i = l.indexOf('|');
4
- return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
5
- });
6
- }
7
-
8
- export function cellsOf(p) {
9
- return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
10
- }
1
+ export function parseItems(s) {
2
+ return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
3
+ const i = l.indexOf('|');
4
+ return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
5
+ });
6
+ }
7
+
8
+ export function cellsOf(p) {
9
+ return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
10
+ }
@@ -1,15 +1,15 @@
1
- /** Data-URI placeholder image generator, ported verbatim from the original. */
2
- function enc(s) {
3
- return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
4
- }
5
-
6
- export function PH(label, w, ht) {
7
- return 'data:image/svg+xml;utf8,' + enc(
8
- '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
9
- '<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
10
- '<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
11
- '<rect width="100%" height="100%" fill="url(#s)"/>' +
12
- '<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
13
- '<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
14
- );
15
- }
1
+ /** Data-URI placeholder image generator, ported verbatim from the original. */
2
+ function enc(s) {
3
+ return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
4
+ }
5
+
6
+ export function PH(label, w, ht) {
7
+ return 'data:image/svg+xml;utf8,' + enc(
8
+ '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
9
+ '<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
10
+ '<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
11
+ '<rect width="100%" height="100%" fill="url(#s)"/>' +
12
+ '<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
13
+ '<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
14
+ );
15
+ }
@@ -102,7 +102,10 @@ export const cleanImportHtml = (html, dropProps, keepProps) => {
102
102
  });
103
103
  Array.from(el.attributes).forEach((at) => {
104
104
  const keep = (el.tagName === 'A' && ['href', 'target', 'rel'].indexOf(at.name) > -1)
105
- || (el.tagName === 'IMG' && ['src', 'alt', 'width', 'height'].indexOf(at.name) > -1);
105
+ // `srcset`/`sizes` are the retina sources and `title` the tooltip:
106
+ // dropped, a template imported for a copy edit came back out
107
+ // having quietly lost its 2x artwork.
108
+ || (el.tagName === 'IMG' && ['src', 'alt', 'width', 'height', 'srcset', 'sizes', 'title'].indexOf(at.name) > -1);
106
109
  if (!keep) el.removeAttribute(at.name);
107
110
  });
108
111
  if (kept.length) el.setAttribute('style', kept.join(';'));