@seliseblocks/mailcraft 0.2.6 → 0.2.8

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 (51) hide show
  1. package/DOCS.md +4 -3
  2. package/README.md +4 -1
  3. package/README.md.txt +4 -1
  4. package/dist/mailcraft-editor.bundle.js +84 -59
  5. package/dist/mailcraft-editor.bundle.js.map +3 -3
  6. package/examples/vanilla.html +19 -18
  7. package/package.json +1 -1
  8. package/src/core/blocks.js +15 -1
  9. package/src/core/brand.js +81 -0
  10. package/src/core/editor-core.js +66 -9
  11. package/src/core/export.js +195 -6
  12. package/src/core/i18n/ar.js +2 -2
  13. package/src/core/i18n/bg.js +2 -2
  14. package/src/core/i18n/bn.js +2 -2
  15. package/src/core/i18n/ca.js +2 -2
  16. package/src/core/i18n/cs.js +2 -2
  17. package/src/core/i18n/da.js +2 -2
  18. package/src/core/i18n/de-CH.js +2 -2
  19. package/src/core/i18n/de.js +2 -2
  20. package/src/core/i18n/dz.js +2 -2
  21. package/src/core/i18n/el.js +2 -2
  22. package/src/core/i18n/en.js +2 -2
  23. package/src/core/i18n/es.js +2 -2
  24. package/src/core/i18n/et.js +2 -2
  25. package/src/core/i18n/fi.js +2 -2
  26. package/src/core/i18n/fr.js +2 -2
  27. package/src/core/i18n/hr.js +2 -2
  28. package/src/core/i18n/hu.js +2 -2
  29. package/src/core/i18n/it.js +2 -2
  30. package/src/core/i18n/lt.js +2 -2
  31. package/src/core/i18n/lv.js +2 -2
  32. package/src/core/i18n/nb.js +2 -2
  33. package/src/core/i18n/nl.js +2 -2
  34. package/src/core/i18n/pl.js +2 -2
  35. package/src/core/i18n/pt.js +2 -2
  36. package/src/core/i18n/ro.js +2 -2
  37. package/src/core/i18n/ru.js +2 -2
  38. package/src/core/i18n/sk.js +2 -2
  39. package/src/core/i18n/sl.js +2 -2
  40. package/src/core/i18n/sv.js +2 -2
  41. package/src/core/i18n/tr.js +2 -2
  42. package/src/core/i18n/uk.js +2 -2
  43. package/src/core/icons.js +1 -0
  44. package/src/core/import-html.js +330 -20
  45. package/src/core/sanitize.js +41 -0
  46. package/src/mailcraft-editor.js +20 -10
  47. package/src/render/block-body.js +120 -17
  48. package/src/render/canvas.js +50 -4
  49. package/src/render/fields.js +9 -1
  50. package/src/render/screenshot.js +13 -2
  51. package/src/render/style.js +10 -11
@@ -88,7 +88,33 @@ function cellPadOf(tb) {
88
88
  * zero font. A zero font-size ALONE is not hidden: emails set it on
89
89
  * whitespace-collapsing wrappers around buttons and menus.
90
90
  */
91
+ /**
92
+ * A device-visibility wrapper, as a `vis` value -- or '' for ordinary content.
93
+ *
94
+ * Both this exporter's `mc-only-d`/`mc-only-m` and the `desktop_hide`/
95
+ * `mobile_hide` convention every other builder uses (BEE, Stripo, Mailchimp)
96
+ * are recognised, so a foreign template's device variants import as an
97
+ * editable property instead of being silently thrown away.
98
+ *
99
+ * This has to be consulted *before* `isHidden`, and that is the whole point:
100
+ * a mobile-only block is deliberately `display:none` in the base stylesheet
101
+ * so Classic Outlook -- which never reads a media query -- does not show it.
102
+ * The importer folds those base rules inline (core/css-cascade.js drops the
103
+ * `@media` block that would have un-hidden it), so without this the block
104
+ * looked exactly like a hidden preheader and was dropped on re-import,
105
+ * losing content the user had authored.
106
+ */
107
+ function visibilityOf(el) {
108
+ const cls = (el && el.getAttribute && el.getAttribute('class')) || '';
109
+ if (!cls) return '';
110
+ if (/\b(?:mc-only-m|mobile_hide|mobile-hide)\b/.test(cls)) return 'mobile';
111
+ if (/\b(?:mc-only-d|desktop_hide|desktop-hide)\b/.test(cls)) return 'desktop';
112
+ return '';
113
+ }
114
+
91
115
  function isHidden(el) {
116
+ // Hidden *on this device* is not hidden: see visibilityOf.
117
+ if (visibilityOf(el)) return false;
92
118
  const st = el.style;
93
119
  if (!st) return false;
94
120
  if (st.display === 'none' || st.visibility === 'hidden') return true;
@@ -209,10 +235,25 @@ function classifyImage(el) {
209
235
  }
210
236
 
211
237
  function classifyButton(el) {
212
- let a = null; let outerAlign = '';
238
+ let a = null; let outerAlign = ''; let pillTable = null;
213
239
  if (el.tagName === 'A') a = el;
214
240
  else if (el.tagName === 'DIV' || el.tagName === 'TD') {
215
241
  a = onlyChild(el, 'A');
242
+ // The bulletproof shape: the anchor is wrapped in a one-cell table so
243
+ // Word has a `<td>` to paint and pad (this exporter emits that, and so
244
+ // does every hand-written bulletproof button). Reached through the
245
+ // wrapper rather than the cell because that is the node the row walker
246
+ // offers, and the wrapper is also what carries the alignment.
247
+ if (!a) {
248
+ pillTable = onlyChild(el, 'TABLE');
249
+ // Direct row cells only. A descendant search would count the `<td>`s
250
+ // inside whatever the cell contains, so a layout row holding one block
251
+ // could look like a one-cell button table -- or stop looking like one
252
+ // the moment the block itself contained a table.
253
+ const cells = pillTable ? pillTable.querySelectorAll(':scope > tbody > tr > td, :scope > tr > td') : [];
254
+ if (cells.length === 1) a = onlyChild(cells[0], 'A');
255
+ if (!a) pillTable = null;
256
+ }
216
257
  if (a) outerAlign = el.style.textAlign || el.getAttribute('align') || '';
217
258
  }
218
259
  if (!a) return null;
@@ -221,11 +262,16 @@ function classifyButton(el) {
221
262
  // generators (Beefree et al.) leave the `<a>` bare and hang the background,
222
263
  // radius and padding on nested `<span>`s inside it. Whichever element
223
264
  // carries the background is the pill; padding may sit a level deeper still.
224
- const hasBg = (e) => !!(e.style && (e.style.backgroundColor || e.style.background));
225
- const pill = hasBg(a) ? a : Array.from(a.querySelectorAll('span')).find(hasBg);
265
+ const hasBg = (e) => !!(e.style && (e.style.backgroundColor || e.style.background)) || !!(e.getAttribute && e.getAttribute('bgcolor'));
266
+ // The cell is checked last and only as a fallback: an anchor that paints
267
+ // its own pill still describes the button best (that is where the radius
268
+ // and the label colour sit), and the cell is what carries them when the
269
+ // source put the paint and the padding on the `<td>` instead.
270
+ const cell = a.closest ? a.closest('td') : null;
271
+ const pill = hasBg(a) ? a : (Array.from(a.querySelectorAll('span')).find(hasBg) || (cell && hasBg(cell) ? cell : null));
226
272
  if (!pill) return null;
227
273
  const st = pill.style;
228
- let pad = paddingOf(a.style) || paddingOf(st);
274
+ let pad = paddingOf(a.style) || paddingOf(st) || (cell ? paddingOf(cell.style) : null);
229
275
  if (!pad) {
230
276
  const padded = Array.from(pill.querySelectorAll('span')).find((s) => paddingOf(s.style));
231
277
  if (padded) pad = paddingOf(padded.style);
@@ -235,8 +281,8 @@ function classifyButton(el) {
235
281
  const over = {
236
282
  label: (a.textContent || '').trim() || 'Button',
237
283
  href: a.getAttribute('href') || '#',
238
- bg: st.backgroundColor || st.background,
239
- color: st.color || a.style.color || '#ffffff',
284
+ bg: st.backgroundColor || st.background || (pill.getAttribute && pill.getAttribute('bgcolor')) || '',
285
+ color: a.style.color || st.color || '#ffffff',
240
286
  radius: radiusOf(st),
241
287
  py: (pad && pad.py) || 13,
242
288
  px: (pad && pad.px) || 26,
@@ -245,13 +291,23 @@ function classifyButton(el) {
245
291
  // about where the pill sits in the row -- that's the container's call,
246
292
  // so alignment is read starting at the parent, never at the anchor.
247
293
  align: outerAlign || textAlignOf(a.parentElement || a),
248
- full: a.style.display === 'block',
294
+ // In the one-cell shape the anchor is always `display:block` (it fills
295
+ // the padded cell), so full-width has to be read off the table instead --
296
+ // otherwise every bulletproof button imports as a full-width one.
297
+ full: pillTable
298
+ ? /100%/.test((pillTable.style && pillTable.style.width) || pillTable.getAttribute('width') || '')
299
+ : a.style.display === 'block',
249
300
  };
250
301
  const size = fontPx(st.fontSize) || fontPx(a.style.fontSize);
251
302
  if (size) over.size = size;
252
- // Outline buttons: transparent fill, the pill drawn by its border.
253
- const bw = borderSidesOf(st).width;
254
- if (bw) { over.borderW = bw; over.borderStyle = borderStyleOf(st); over.borderColor = borderColorOf(st) || over.color; }
303
+ // Outline buttons: transparent fill, the pill drawn by its border. The
304
+ // border is looked for on the cell as well as the pill, because in the
305
+ // one-cell shape the paint and the frame are on different elements -- the
306
+ // anchor is the pill (it carries the background) while the `<td>` draws the
307
+ // outline. Reading only the pill dropped `borderW` on every round trip.
308
+ const frame = borderSidesOf(st).width ? st : (cell && cell.style && borderSidesOf(cell.style).width ? cell.style : st);
309
+ const bw = borderSidesOf(frame).width;
310
+ if (bw) { over.borderW = bw; over.borderStyle = borderStyleOf(frame); over.borderColor = borderColorOf(frame) || over.color; }
255
311
  return blk('button', over);
256
312
  }
257
313
 
@@ -397,7 +453,18 @@ const CLASSIFIERS = [classifyImage, classifyButton, classifySocial, classifyMenu
397
453
  function classifyNode(el) {
398
454
  for (const fn of CLASSIFIERS) {
399
455
  const b = fn(el);
400
- if (b) return b;
456
+ if (!b) continue;
457
+ // The class sits on the wrapper the exporter puts around each block, and
458
+ // on a foreign template it can be a level or two further out, so a short
459
+ // walk up finds it. Only ever set when a wrapper actually says so --
460
+ // absent means "all devices", which is what every ordinary block wants.
461
+ let n = el;
462
+ for (let i = 0; n && i < 4; i += 1) {
463
+ const v = visibilityOf(n);
464
+ if (v) { b.props.vis = v; break; }
465
+ n = n.parentElement;
466
+ }
467
+ return b;
401
468
  }
402
469
  return null;
403
470
  }
@@ -409,7 +476,18 @@ function isStructural(el) {
409
476
  /** `core/export.js` wraps every block in a column with `<div style="{boxCss(b.props)}">`, which for every block type shipped today (none define the `bBg/bBorder/bLine/bRadius/bPad` props `boxCss` reads) always resolves to a bare `<div style="margin:0">` -- a see-through spacing wrapper, not real content. Unwraps that one level so the classifiers see the block's own signature div directly; leaves any div that carries other styling (an intentionally-styled container someone pasted in) alone, since that's real content, not framework wrapper. */
410
477
  function unwrapBoxDiv(el) {
411
478
  if (el.tagName !== 'DIV' || el.children.length !== 1) return el;
412
- const extra = Array.from(el.style).filter((prop) => prop.indexOf('margin') !== 0);
479
+ // A device-visibility wrapper is framework too, and the declarations that
480
+ // hide it are the framework's, not the author's: a mobile-only block is
481
+ // `display:none` in the base stylesheet so Classic Outlook never shows it,
482
+ // and css-cascade folds that inline on import. Without this exemption the
483
+ // wrapper looked like a deliberately styled container, never unwrapped, and
484
+ // the heading inside it came back as an untyped run of text. `classifyNode`
485
+ // reads the visibility off this same wrapper by walking back up, so the
486
+ // property survives the unwrap.
487
+ const framework = visibilityOf(el)
488
+ ? /^(?:margin|display|max-height|overflow|mso-)/
489
+ : /^margin/;
490
+ const extra = Array.from(el.style).filter((prop) => !framework.test(prop));
413
491
  if (extra.length) return el;
414
492
  const child = el.firstElementChild;
415
493
  if ((el.textContent || '') !== (child.textContent || '')) return el;
@@ -557,10 +635,16 @@ function normalizeSpans(spans) {
557
635
  function spansFromCells(cells, tableWidthPx) {
558
636
  const parsed = cells.map((td) => {
559
637
  const sw = td.style.width || '';
638
+ // `max-width` before `width`, but only where `width` says nothing useful.
639
+ // A CSS-layout column is written `width:100%;max-width:50%` -- the 100%
640
+ // is the fluid instruction and the cap is the actual share of the row, so
641
+ // reading `width` first would score every column at 100.
642
+ const mw = td.style.maxWidth || '';
643
+ if (mw.endsWith('%') && (!sw || PX(sw) >= 100)) return { pct: PX(mw) };
560
644
  if (sw.endsWith('%')) return { pct: PX(sw) };
561
645
  const aw = td.getAttribute('width') || '';
562
646
  if (aw.endsWith('%')) return { pct: PX(aw) };
563
- const px = PX(sw) || PX(aw);
647
+ const px = PX(sw) || PX(aw) || (mw.endsWith('px') ? PX(mw) : 0);
564
648
  return px ? { px } : null;
565
649
  });
566
650
  if (parsed.every((p) => p && p.pct != null)) {
@@ -574,10 +658,140 @@ function spansFromCells(cells, tableWidthPx) {
574
658
  return spans;
575
659
  }
576
660
 
661
+ /*
662
+ * Columns that are not table cells.
663
+ *
664
+ * A row's columns do not have to be `<td>`s. MJML, and to a degree Unlayer and
665
+ * Stripo, emit them as sibling `<div>`s that sit side by side through
666
+ * `display:inline-block` and a percentage cap, with the table version hidden
667
+ * inside `<!--[if mso]>` conditionals for Outlook. Those comments are comments
668
+ * to a DOM parser, so all the walker ever saw was one `<td>` holding two
669
+ * divs -- and a real 50/50 row imported as two stacked full-width rows. The
670
+ * blocks all survived; the layout relationship did not.
671
+ *
672
+ * Returning the divs here hands them to exactly the same machinery the table
673
+ * path already uses -- `spansFromCells` for the widths, gap and gutter
674
+ * detection, per-column background and padding -- so there is no second
675
+ * column model, only a second way of recognising one.
676
+ *
677
+ * Deliberately conservative, because a wrong grouping is worse than none: a
678
+ * false positive welds unrelated sections into one row, which the user then
679
+ * has to take apart by hand. Every one of these has to hold.
680
+ */
681
+ /*
682
+ * A column's share of its row is not always in its inline style.
683
+ *
684
+ * MJML writes the column as `width:100%` inline and puts the real share in a
685
+ * class rule inside `@media only screen and (min-width:480px)` -- a *desktop*
686
+ * query, so it carries the layout rather than overriding it. `css-cascade.js`
687
+ * strips every at-rule before folding, by design (a mobile override cannot be
688
+ * represented in the model), which means that share never reaches the element
689
+ * and three columns looked like three widthless divs.
690
+ *
691
+ * So the stylesheets are read once per document for `.class { width: N% }`,
692
+ * from top-level rules and from `min-width` blocks -- never from `max-width`
693
+ * blocks, which are the mobile overrides the cascade is right to drop.
694
+ */
695
+ const CLASS_WIDTHS = new WeakMap();
696
+
697
+ function scanWidthRules(css, map) {
698
+ let i = 0;
699
+ while (i < css.length) {
700
+ const at = css.indexOf('@', i);
701
+ const plain = css.slice(i, at < 0 ? css.length : at);
702
+ plain.replace(/([^{}]+)\{([^{}]*)\}/g, (m0, sel, decl) => {
703
+ const w = decl.match(/(?:^|;)\s*width\s*:\s*([\d.]+)%/i);
704
+ if (w) {
705
+ sel.split(',').forEach((one) => {
706
+ const cm = one.trim().match(/^\.([-\w]+)$/);
707
+ if (cm && !map[cm[1]]) map[cm[1]] = parseFloat(w[1]);
708
+ });
709
+ }
710
+ return m0;
711
+ });
712
+ if (at < 0) break;
713
+ const brace = css.indexOf('{', at);
714
+ if (brace < 0) break;
715
+ const prelude = css.slice(at, brace);
716
+ let depth = 0;
717
+ let j = brace;
718
+ for (; j < css.length; j += 1) {
719
+ if (css[j] === '{') depth += 1;
720
+ else if (css[j] === '}') { depth -= 1; if (!depth) break; }
721
+ }
722
+ // `max-width` is a narrow-screen override, which says nothing about the
723
+ // desktop layout this is trying to recover.
724
+ if (!/max-width/i.test(prelude)) scanWidthRules(css.slice(brace + 1, j), map);
725
+ i = j + 1;
726
+ }
727
+ }
728
+
729
+ function classWidths(doc) {
730
+ if (!doc || !doc.querySelectorAll) return {};
731
+ const hit = CLASS_WIDTHS.get(doc);
732
+ if (hit) return hit;
733
+ const map = {};
734
+ Array.from(doc.querySelectorAll('style')).forEach((st) => {
735
+ scanWidthRules(String(st.textContent || '').replace(/\/\*[\s\S]*?\*\//g, ''), map);
736
+ });
737
+ CLASS_WIDTHS.set(doc, map);
738
+ return map;
739
+ }
740
+
741
+ function inlineColumnGroup(nodes) {
742
+ const kids = Array.from(nodes).filter((n) => {
743
+ if (n.nodeType === 8) return false; // the MSO conditionals themselves
744
+ if (n.nodeType === 3) return !!String(n.textContent || '').trim(); // layout whitespace between the divs
745
+ return n.nodeType === 1 && !/^(STYLE|SCRIPT)$/.test(n.tagName);
746
+ });
747
+ // Two to six siblings, all plain containers. A run of one is not a row, and
748
+ // a run of many is far more likely to be stacked content than columns.
749
+ if (kids.length < 2 || kids.length > 6) return null;
750
+ if (!kids.every((k) => k.nodeType === 1 && k.tagName === 'DIV')) return null;
751
+ // Side-by-side has to be stated, not guessed. `inline-block` is the email
752
+ // idiom; floats are the older one. A plain block div is stacked content and
753
+ // must not be swept up.
754
+ const sideBySide = (k) => {
755
+ const s = k.style || {};
756
+ return /inline-block|inline-flex/.test(s.display || '') || /^(left|right)$/.test(s.cssFloat || s.float || '');
757
+ };
758
+ if (!kids.every(sideBySide)) return null;
759
+ // Every sibling must declare a share of the row, and the shares must add up
760
+ // to one row. Anything else is a layout this cannot claim to understand.
761
+ const shareOf = (k) => {
762
+ const s = k.style || {};
763
+ const mw = s.maxWidth || '';
764
+ if (mw.endsWith('%')) return PX(mw);
765
+ const w = s.width || '';
766
+ if (w.endsWith('%') && PX(w) < 100) return PX(w);
767
+ // Nothing inline: the share may be carried by one of the element's
768
+ // classes (see classWidths).
769
+ const byClass = classWidths(k.ownerDocument);
770
+ const named = ((k.getAttribute && k.getAttribute('class')) || '').split(/\s+/);
771
+ for (let i = 0; i < named.length; i += 1) {
772
+ const v = byClass[named[i]];
773
+ if (v && v < 100) return v;
774
+ }
775
+ return 0;
776
+ };
777
+ const shares = kids.map(shareOf);
778
+ if (!shares.every((w) => w > 0)) return null;
779
+ const total = shares.reduce((a, b) => a + b, 0);
780
+ if (total < 90 || total > 110) return null;
781
+ kids.shares = shares;
782
+ return kids;
783
+ }
784
+
577
785
  /** Detects MailCraft's own two-table row shape -- an outer `<td>` (row padding/bg/border) wrapping a single-row `role="presentation"` table that holds the actual column(s), per `core/export.js` -- and widens to its cells (one or many) so rows round-trip back into real columns instead of one opaque `html` block. */
578
786
  function unwrapNestedLayout(td) {
579
787
  const only = onlyChild(td, 'TABLE');
580
788
  if (!only) return null;
789
+ // Not every single-row table is layout. A bulletproof button is a one-cell
790
+ // table too, and reading it as a row made its cell the row's `bgSource` --
791
+ // so a pink button in a white section repainted the whole band pink and
792
+ // the section's own background was lost. A component table is one block,
793
+ // never a row; the same guard covers a social strip built the same way.
794
+ if (classifyButton(td) || classifySocial(only)) return null;
581
795
  const trs = only.querySelectorAll(':scope > tbody > tr, :scope > tr');
582
796
  if (trs.length !== 1) return null;
583
797
  if (only.querySelector('th')) return null;
@@ -623,6 +837,39 @@ function padOf(el) {
623
837
  return el.style ? paddingOf(el.style) : null;
624
838
  }
625
839
 
840
+ /*
841
+ * One column of a row, on its own.
842
+ *
843
+ * The same container the multi-column detector groups, but appearing singly --
844
+ * a full-width MJML section is one of these. It matters because of what is
845
+ * *inside* it: a table whose every `<tr>` is one block. The generic walk turns
846
+ * every `<tr>` into a MailCraft row, so a section holding a heading and a
847
+ * subheading came back as two rows, each repainted with the section's
848
+ * background and each carrying the inner cell's padding while the section's
849
+ * own padding was dropped. A source `<tr>` inside a column is a block slot,
850
+ * not a row.
851
+ *
852
+ * Recognising the boundary keeps the column's blocks together in one row, and
853
+ * lets the section's `<td>` supply that row's padding, which is where it
854
+ * belonged all along. `tr -> row` is untouched everywhere else, which is what
855
+ * keeps MailCraft's own export round-tripping.
856
+ */
857
+ function isColumnContainer(el) {
858
+ if (!el || el.nodeType !== 1 || el.tagName !== 'DIV') return false;
859
+ const s = el.style || {};
860
+ const sideBySide = /inline-block|inline-flex/.test(s.display || '') || /^(left|right)$/.test(s.cssFloat || s.float || '');
861
+ if (!sideBySide) return false;
862
+ if (!(s.width || s.maxWidth)) return false;
863
+ // A column holds its blocks in a table; a lone inline-block div with no
864
+ // table is a badge or a pill, and is content in its own right.
865
+ return Array.from(el.children).some((c) => c.tagName === 'TABLE');
866
+ }
867
+
868
+ /** Every block a set of rows holds, in order -- a MailCraft column is a flat list. */
869
+ function blocksOfRows(rows) {
870
+ return rows.reduce((acc, r) => acc.concat(r.cols.reduce((a, c) => a.concat(c.blocks), [])), []);
871
+ }
872
+
626
873
  /** A div/center that itself nests a div/table/center is acting as a structural container (a section wrapper, an outer page wrapper holding several sections) rather than as one piece of content -- its children need to be walked in their own right, not folded into one block. A div holding only inline content (text, spans, an image not otherwise recognized) is real content and is left to `blocksFromNodes`. */
627
874
  function looksLikeContainer(el) {
628
875
  return Array.from(el.children).some((c) => c.tagName === 'TABLE' || c.tagName === 'DIV' || c.tagName === 'CENTER');
@@ -735,8 +982,14 @@ function rowsFromContentTable(table) {
735
982
  const bgSource = outerCells[0];
736
983
  let cells = outerCells;
737
984
  if (outerCells.length === 1) {
985
+ // The table shape first, since it is unambiguous; the CSS-layout shape
986
+ // only when there is no table row to read.
738
987
  const nested = unwrapNestedLayout(outerCells[0]);
739
988
  if (nested) cells = nested;
989
+ else {
990
+ const inline = inlineColumnGroup(outerCells[0].childNodes);
991
+ if (inline) cells = inline;
992
+ }
740
993
  }
741
994
  // Spacer columns: a content-free `<td>` (often `class="column gap"`,
742
995
  // holding only an empty fixed-width table) between real columns exists
@@ -889,6 +1142,36 @@ function rowsFromContentTable(table) {
889
1142
  * its ancestor's.
890
1143
  */
891
1144
  function collectRows(nodes) {
1145
+ /*
1146
+ * Before anything else: are these siblings one row's columns rather than a
1147
+ * sequence of sections? This has to be asked here, of the whole list, and
1148
+ * not inside the loop below -- that loop's job is to walk each structural
1149
+ * container in its own right, which is exactly what flattened a real 50/50
1150
+ * row into two stacked full-width ones. The relationship between the
1151
+ * siblings is only visible while they are still siblings.
1152
+ */
1153
+ const columns = inlineColumnGroup(nodes);
1154
+ if (columns) {
1155
+ // The resolved shares win over the inline widths: a CSS-layout column is
1156
+ // `width:100%` inline, so reading the elements again would score every
1157
+ // column at 100 and fall back to an even split -- right for 33/33/34,
1158
+ // wrong for 40/60.
1159
+ const row = mkRow(columns.shares ? normalizeSpans(columns.shares) : spansFromCells(columns, 0));
1160
+ row.props.py = 0; row.props.px = 0; row.props.gap = 0;
1161
+ columns.forEach((k, i) => {
1162
+ // A MailCraft column holds a flat list of blocks, so whatever the
1163
+ // column div decomposes into is flattened into it. Recursing through
1164
+ // collectRows rather than blocksFromNodes is what lets a column hold a
1165
+ // content table (the usual MJML shape) instead of one opaque blob.
1166
+ row.cols[i].blocks = blocksOfRows(collectRows(Array.from(k.childNodes)));
1167
+ const cbg = bgOf(k); if (cbg) row.cols[i].bg = cbg;
1168
+ const crad = radiusOf(k.style); if (crad) row.cols[i].radius = crad;
1169
+ const cpd = padOf(k); if (cpd) { row.cols[i].padY = cpd.py; row.cols[i].padX = cpd.px; }
1170
+ });
1171
+ // If nothing recognisable came out of it, it was not a row after all --
1172
+ // fall through and let the ordinary walk have it.
1173
+ if (row.cols.some((c) => c.blocks.length)) return [row];
1174
+ }
892
1175
  const rows = [];
893
1176
  let buf = [];
894
1177
  const flushBuf = () => {
@@ -905,6 +1188,18 @@ function collectRows(nodes) {
905
1188
  if (n.nodeType === 8) return; // HTML comments (Outlook/MSO conditionals) -- inert
906
1189
  if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName)) return;
907
1190
  if (n.nodeType === 1 && isHidden(n)) return;
1191
+ // A lone column: its blocks belong to one row, not one row each.
1192
+ if (n.nodeType === 1 && isColumnContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
1193
+ flushBuf();
1194
+ const blocks = blocksOfRows(collectRows(Array.from(n.childNodes)));
1195
+ if (blocks.length) {
1196
+ const row = mkRow([100]);
1197
+ row.props.py = 0; row.props.px = 0; row.props.gap = 0;
1198
+ row.cols[0].blocks = blocks;
1199
+ rows.push(...applyBgImage(applyFrame(applyPad(applyBg([row], bgOf(n)), padOf(n)), n), n));
1200
+ return;
1201
+ }
1202
+ }
908
1203
  if (n.nodeType === 1 && (n.tagName === 'DIV' || n.tagName === 'CENTER') && looksLikeContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
909
1204
  flushBuf();
910
1205
  const inner = collectRows(Array.from(n.childNodes));
@@ -974,6 +1269,26 @@ function collectRows(nodes) {
974
1269
  * document's theme -- a DM Sans email on #F1F5F9 came back in the default
975
1270
  * Georgia on the default parchment, which read as "the import broke".
976
1271
  */
1272
+ /**
1273
+ * A layout table's committed pixel width, however it declares one: the
1274
+ * `width` attribute, a `width` style, or -- for a responsive template -- the
1275
+ * `max-width` that caps a fluid `width:100%`.
1276
+ *
1277
+ * `max-width` is not a nicety. It is how this exporter now writes the content
1278
+ * column (`width:100%;max-width:620px`, so the email can narrow to a phone),
1279
+ * and it is the shape every other modern email builder emits too. Reading
1280
+ * only the fixed forms meant an export -> import round trip came back with no
1281
+ * `theme.width` at all and silently fell to the default. Returns 0 for a
1282
+ * purely proportional table, which the callers already skip.
1283
+ */
1284
+ function fixedWidthOf(tb) {
1285
+ const w = tb.getAttribute('width') || (tb.style && tb.style.width) || '';
1286
+ if (w && !String(w).endsWith('%')) return PX(w);
1287
+ const cap = tb.style && tb.style.maxWidth;
1288
+ if (cap && !String(cap).endsWith('%')) return PX(cap);
1289
+ return 0;
1290
+ }
1291
+
977
1292
  function themeFromParsedDoc(doc) {
978
1293
  const theme = {};
979
1294
  const body = doc.body;
@@ -985,9 +1300,7 @@ function themeFromParsedDoc(doc) {
985
1300
  if (bg) theme.bg = bg;
986
1301
  const widthCounts = {};
987
1302
  body.querySelectorAll('table').forEach((tb) => {
988
- const w = tb.getAttribute('width') || (tb.style && tb.style.width) || '';
989
- if (!w || String(w).endsWith('%')) return;
990
- const px = PX(w);
1303
+ const px = fixedWidthOf(tb);
991
1304
  if (px >= 320 && px <= 900) widthCounts[px] = (widthCounts[px] || 0) + 1;
992
1305
  });
993
1306
  const bestWidth = Object.keys(widthCounts).sort((a, b) => widthCounts[b] - widthCounts[a])[0];
@@ -998,10 +1311,7 @@ function themeFromParsedDoc(doc) {
998
1311
  // column's corner. Without this an import flattened both, and the next
999
1312
  // export silently squared the template off and closed the gap around it.
1000
1313
  if (bestWidth) {
1001
- const content = Array.from(body.querySelectorAll('table')).find((tb) => {
1002
- const w = tb.getAttribute('width') || (tb.style && tb.style.width) || '';
1003
- return w && !String(w).endsWith('%') && PX(w) === Number(bestWidth);
1004
- });
1314
+ const content = Array.from(body.querySelectorAll('table')).find((tb) => fixedWidthOf(tb) === Number(bestWidth));
1005
1315
  if (content) {
1006
1316
  // The content column's own background -- including the literal
1007
1317
  // `transparent` the exporter always writes for a see-through column.
@@ -131,6 +131,47 @@ export const cssUrl = (u) => {
131
131
  return raw.replace(/["'()\\]|\s/g, (c) => CSS_URL_ESCAPE[c] || '%20');
132
132
  };
133
133
 
134
+ /**
135
+ * A link target on its way into an anchor. Two jobs, and both of them showed
136
+ * up as "the link does not work" rather than as anything that errors:
137
+ *
138
+ * - A bare host (`selise.ch`, `www.selise.ch/pricing`) gets `https://`.
139
+ * Typed with no scheme it is a *relative* URL, so the mail client
140
+ * resolves it against its own origin and the click lands nowhere -- the
141
+ * single most common way an authored link ships broken, and invisible in
142
+ * the editor because the canvas never follows it.
143
+ * - Schemes are allowlisted the way `cssUrl` allowlists image sources, so
144
+ * `javascript:` resolves to nothing instead of becoming a click target.
145
+ *
146
+ * Left exactly as typed: anything already carrying an allowed scheme, an
147
+ * in-page `#anchor`, a rooted `/path`, and anything opening with a merge tag
148
+ * -- `{{ResetUrl}}` is a whole URL the host substitutes later, so prefixing
149
+ * it would corrupt what reaches the template engine. A bare `name@host.tld`
150
+ * becomes `mailto:`, which is the only thing it could have meant.
151
+ */
152
+ export const linkHref = (u) => {
153
+ const raw = String(u == null ? '' : u).trim();
154
+ if (!raw) return '';
155
+ const scheme = raw.match(/^([a-z][a-z0-9+.-]*):/i);
156
+ if (scheme) {
157
+ if (/^(https?|mailto|tel)$/i.test(scheme[1])) return raw;
158
+ // `selise.ch:8080/x` matches the scheme shape but is a host and a port;
159
+ // it falls through to be prefixed rather than dropped.
160
+ if (!/^[a-z][a-z0-9+.-]*:\d/i.test(raw)) return '';
161
+ }
162
+ if (raw.startsWith('//')) return 'https:' + raw;
163
+ // A href that *is* a merge placeholder is handed back untouched. Only
164
+ // `{{ }}` used to be, so `[Survey URL]` -- and Mailchimp's `*|URL|*`, and
165
+ // `%%url%%`, and `${url}` -- came back as `https://[Survey URL]`, which the
166
+ // sending engine then expands into `https://https://...` and the link is
167
+ // dead. Anchored to the start on purpose: `example.com/{{id}}` is a real
168
+ // relative URL and still needs its scheme.
169
+ if (/^(?:\{\{|\[|%%|\*\||\$\{)/.test(raw)) return raw;
170
+ if (/^[#/]/.test(raw)) return raw;
171
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) return 'mailto:' + raw;
172
+ return 'https://' + raw;
173
+ };
174
+
134
175
  export const escHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
135
176
 
136
177
  /** Minimal HTML syntax highlighter for the code view. */
@@ -6,6 +6,7 @@ import { renderDoc } from './render/canvas.js';
6
6
  import { renderRte } from './render/rte.js';
7
7
  import { renderField, renderFieldCards, typeCommit } from './render/fields.js';
8
8
  import { icon } from './core/icons.js';
9
+ import { brandLockup } from './core/brand.js';
9
10
  import { TOKEN } from './core/variables.js';
10
11
  import { hl, cssUrl } from './core/sanitize.js';
11
12
  import { decorateLogicTags } from './core/export.js';
@@ -494,7 +495,15 @@ export class MailCraftEditor extends ElementBase {
494
495
  async copyScreenshot(blob) {
495
496
  const t = this.core.t;
496
497
  try {
497
- const png = blob || await this.screenshotPng();
498
+ // Safari only honours `clipboard.write()` while the user gesture is
499
+ // still live, and awaiting the capture first (per-image fetch +
500
+ // inline, decode, rasterize, PNG encode) always outlives it --
501
+ // NotAllowedError on every attempt, where Chromium is lenient and
502
+ // lets it through. Handing ClipboardItem the *promise* is the
503
+ // supported shape for exactly this: the write is issued
504
+ // synchronously with the gesture and the capture resolves inside
505
+ // it. Chromium accepts the same form, so there is no branch here.
506
+ const png = blob ? Promise.resolve(blob) : this.screenshotPng();
498
507
  await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
499
508
  this.core.flash(t('toast.pngCopied'));
500
509
  } catch {
@@ -569,15 +578,11 @@ export class MailCraftEditor extends ElementBase {
569
578
  const t = this.core.t;
570
579
  const header = elS('header', "display: flex; align-items: center; gap: 14px; padding: 0 16px; border-bottom: 1px solid var(--ed-line); background: linear-gradient(to bottom, var(--ed-panel), var(--ed-panel-2)); position: relative; z-index: 30;", { class: 'mc-header' });
571
580
 
572
- const brandMark = elS('span', '', { class: 'mc-brand-mark' });
573
- const markSvg = icon('mailSpark', 20);
574
- markSvg.setAttribute('stroke-width', '1.8');
575
- brandMark.appendChild(markSvg);
576
- const brand = elS('div', 'display: flex; align-items: center; gap: 9px;');
577
- brand.append(
578
- brandMark,
579
- elS('span', 'font-family: var(--ed-font); font-weight: 600; font-size: 15px; letter-spacing: -0.01em; line-height: 1;', { text: 'MailCraft', class: 'mc-brand-name' }),
580
- );
581
+ // The logo itself, not a mark beside a typeset word: the wordmark is part
582
+ // of the artwork, so it keeps the brand's own typeface whatever font the
583
+ // host pushes into --ed-font.
584
+ const brand = elS('div', 'display: flex; align-items: center;', { class: 'mc-brand' });
585
+ brand.appendChild(brandLockup(30));
581
586
  if (on.logo) header.append(brand, elS('div', 'width: 1px; height: 22px; background: var(--ed-line);'));
582
587
 
583
588
  this.savedLabel = elS('span', 'display: flex; align-items: center; gap: 6px; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ed-faint); white-space: nowrap;');
@@ -1224,6 +1229,11 @@ export class MailCraftEditor extends ElementBase {
1224
1229
  tile.addEventListener('mouseleave', () => { tile.style.borderColor = 'var(--ed-line)'; });
1225
1230
  tile.addEventListener('dragstart', this.core.startDrag({ kind: 'asset', assetId: a.id }));
1226
1231
  tile.addEventListener('dragend', () => { this.core.drag = null; this.core.setState({ drop: null, rowDrop: null }); });
1232
+ // Touch Safari never fires HTML5 drag events, so drag alone left
1233
+ // these tiles unusable on iOS. Click is the same fallback the
1234
+ // library modal's tiles already carry; a completed drag ends in
1235
+ // `dragend`, not `click`, so the two do not both fire.
1236
+ tile.addEventListener('click', () => this.core.useAsset(a));
1227
1237
  tile.appendChild(elS('div', `width: 100%; height: 64px; background-image: url("${a.url}"); background-size: cover; background-position: center; background-color: var(--ed-work);`, { role: 'img', 'aria-label': a.name }));
1228
1238
  tile.appendChild(elS('div', 'font-family: ui-monospace, monospace; font-size: 9px; color: var(--ed-muted); padding: 4px 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', { text: a.name }));
1229
1239
  grid.appendChild(tile);