@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
@@ -1,6 +1,7 @@
1
1
  import { icon, brandIcon, socialKey, SOCIAL_BRAND, contrastInk } from '../core/icons.js';
2
2
  import { pad } from '../core/layout-style.js';
3
3
  import { parseItems, cellsOf } from '../core/parse.js';
4
+ import { linkHref } from '../core/sanitize.js';
4
5
 
5
6
  function el(tag, style, attrs) {
6
7
  const node = document.createElement(tag);
@@ -39,6 +40,31 @@ function editableAttrs(on) {
39
40
  return on ? { contenteditable: 'true', spellcheck: 'false', ...NO_ASSIST } : { spellcheck: 'false' };
40
41
  }
41
42
 
43
+ /**
44
+ * What keeps a text-bearing block inside the column it was dropped in.
45
+ *
46
+ * A long unbroken token -- a tracking URL, a concatenated word -- has a
47
+ * min-content width of the whole token, and nothing above clips it: the block
48
+ * wrapper (render/canvas.js) and the exported `<td>` both let it through, so
49
+ * the run either paints across the next column on the canvas or, in the sent
50
+ * email, widens its own cell and steals the width from its siblings (a 25%
51
+ * column measured 511px against its neighbours' 24px).
52
+ *
53
+ * `anywhere` rather than the more familiar `break-word` on purpose: only
54
+ * `anywhere` lowers the min-content contribution, which is the number table
55
+ * and flex layout actually size against -- `break-word` wraps the glyphs but
56
+ * leaves the box's intrinsic width at the full token, so the column blows out
57
+ * exactly as before. `word-break` carries the same meaning for engines that
58
+ * predate `anywhere`. Neither breaks a token that already fits.
59
+ *
60
+ * It has to live here, in the inline style, rather than in the editor's
61
+ * stylesheet: this is the DOM `core/export.js` reads back and sends. The
62
+ * canvas *looked* fine without it only because Chrome's UA sheet gives
63
+ * `[contenteditable]` an `overflow-wrap: break-word` -- and export strips
64
+ * `contenteditable`, so the editor was hiding the defect that shipped.
65
+ */
66
+ const FIT = { overflowWrap: 'anywhere', wordBreak: 'break-word' };
67
+
42
68
  /** An explicit block font owns every rich descendant; an empty value deliberately leaves imported inline typography untouched. */
43
69
  function overrideRichFont(root, fontFamily) {
44
70
  if (!fontFamily) return;
@@ -61,7 +87,7 @@ export function blockBody(b, theme, live, ctx) {
61
87
  case 'text': {
62
88
  const edit = live;
63
89
  const content = el('div', {
64
- padding: pad(p), fontSize: p.size + 'px', lineHeight: p.lh, textAlign: p.align, fontWeight: p.weight, color: p.color || t.text, outline: 'none', fontFamily: p.fontFamily || t.font,
90
+ padding: pad(p), fontSize: p.size + 'px', lineHeight: p.lh, textAlign: p.align, fontWeight: p.weight, color: p.color || t.text, outline: 'none', fontFamily: p.fontFamily || t.font, ...FIT,
65
91
  }, { ...attr, ...editableAttrs(edit), html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
66
92
  // Imported email HTML keeps inline font-family declarations so an
67
93
  // untouched block retains its source typography. Once the user chooses
@@ -77,12 +103,17 @@ export function blockBody(b, theme, live, ctx) {
77
103
  }
78
104
  case 'image': {
79
105
  const wrap = el('div', { padding: pad(p), textAlign: p.align, fontSize: '0' }, attr);
80
- if (p.href) {
106
+ const imgHref = linkHref(p.href);
107
+ if (imgHref) {
81
108
  // The % width must live on the anchor, not the img: a percentage on a
82
109
  // child of a shrink-to-fit inline-block resolves against the image's
83
110
  // own intrinsic size (i.e. not at all), which rendered every linked
84
111
  // logo/icon at full intrinsic width no matter what `width` said.
85
- const a = el('a', { display: 'inline-block', width: p.width + '%' }, { href: p.href });
112
+ const a = el('a', { display: 'inline-block', width: p.width + '%' }, { href: imgHref });
113
+ // Same guard as every other anchor the canvas draws: without it a
114
+ // click on a linked logo navigates the host application away from the
115
+ // editor, taking the uncommitted document with it.
116
+ a.addEventListener('click', (e) => e.preventDefault());
86
117
  a.appendChild(el('img', { width: '100%', borderRadius: p.radius + 'px', display: 'block', border: '0' }, { src: p.src, alt: p.alt }));
87
118
  wrap.appendChild(a);
88
119
  } else {
@@ -90,22 +121,57 @@ export function blockBody(b, theme, live, ctx) {
90
121
  }
91
122
  return wrap;
92
123
  }
124
+ /*
125
+ * A one-cell table, not a bare padded anchor.
126
+ *
127
+ * Classic Outlook's Word engine does not lay out `display:inline-block`
128
+ * and treats padding on an inline `<a>` inconsistently, so the pill drawn
129
+ * by an anchor alone collapses to bare coloured text there -- the single
130
+ * most-reported defect in hand-written email. A `<td>` is the one box
131
+ * Word sizes and paints reliably, so the background, the padding and the
132
+ * corner live on the cell and the anchor becomes the label inside it.
133
+ * Every other client renders the two identically.
134
+ *
135
+ * VML `<v:roundrect>` is the usual alternative and is deliberately not
136
+ * used: it needs an explicit pixel width and height, which an auto-width
137
+ * button sized by its own label does not have. The only thing this gives
138
+ * up against VML is the rounded corner in Classic Outlook, which squares
139
+ * off there and is correct everywhere else.
140
+ */
93
141
  case 'button': {
94
142
  const wrap = el('div', { textAlign: p.align, padding: '4px 0' }, attr);
95
- const a = el('a', {
96
- display: p.full ? 'block' : 'inline-block', background: p.bg, color: p.color, textDecoration: 'none',
97
- padding: p.py + 'px ' + p.px + 'px', borderRadius: p.radius + 'px', fontFamily: p.fontFamily || t.font, fontSize: p.size + 'px',
98
- fontWeight: '600', letterSpacing: '0.02em', outline: 'none',
143
+ // `inline-table` so the wrapper's text-align still positions it; the
144
+ // `align` attribute is the same instruction for Word, which ignores the
145
+ // display value. A full-width button is a plain 100% table instead.
146
+ const table = el('table', {
147
+ display: p.full ? 'table' : 'inline-table', width: p.full ? '100%' : 'auto', borderCollapse: 'separate',
148
+ }, { role: 'presentation', cellpadding: '0', cellspacing: '0', border: '0', align: p.full ? undefined : p.align });
149
+ const td = el('td', {
150
+ background: p.bg, borderRadius: p.radius + 'px', padding: p.py + 'px ' + p.px + 'px', textAlign: 'center',
99
151
  // Outline-style buttons (transparent fill + border) are a standard
100
152
  // email pattern; the color falls back to the label color so a bare
101
153
  // "outline thickness" bump looks right without a second step.
102
154
  border: p.borderW ? p.borderW + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.borderColor || p.color) : '0',
103
- }, { href: p.href, ...editableAttrs(live), text: p.label });
155
+ }, { align: 'center', bgcolor: p.bg || undefined });
156
+ const a = el('a', {
157
+ display: 'block', color: p.color, textDecoration: 'none',
158
+ // Kept on the anchor as well as the cell: `classifyButton` reads the
159
+ // element carrying the background to decide something is a button at
160
+ // all, and a foreign client that drops the cell's paint still shows
161
+ // the pill. It costs one declaration.
162
+ background: p.bg, borderRadius: p.radius + 'px',
163
+ fontFamily: p.fontFamily || t.font, fontSize: p.size + 'px',
164
+ fontWeight: '600', letterSpacing: '0.02em', outline: 'none', ...FIT,
165
+ }, { href: linkHref(p.href), ...editableAttrs(live), text: p.label });
104
166
  a.addEventListener('click', (e) => e.preventDefault());
105
167
  // Button editing is deliberately plain: no focus-tracked `editing` state, no
106
168
  // paste handling, no floating RTE toolbar -- only its label commits on blur.
107
169
  if (live) a.addEventListener('blur', (e) => { if (e.target.textContent !== p.label) ctx.onBlur(b, 'label', e.target.textContent); });
108
- wrap.appendChild(a);
170
+ td.appendChild(a);
171
+ const tr = el('tr'); tr.appendChild(td);
172
+ const tbody = el('tbody'); tbody.appendChild(tr);
173
+ table.appendChild(tbody);
174
+ wrap.appendChild(table);
109
175
  return wrap;
110
176
  }
111
177
  case 'divider': {
@@ -139,7 +205,7 @@ export function blockBody(b, theme, live, ctx) {
139
205
  borderRadius: shape === 'circle' ? '50%' : shape === 'square' ? Math.round(box * 0.28) + 'px' : '0',
140
206
  color: iconColor, background: badge ? source : 'transparent',
141
207
  border: shape === 'outline' ? '1px solid ' + source : '0',
142
- }, { href: it.href, title: it.label });
208
+ }, { href: linkHref(it.href), title: it.label });
143
209
  a.addEventListener('click', (e) => e.preventDefault());
144
210
  let provided = null;
145
211
  if (ctx && ctx.iconProvider) { try { provided = ctx.iconProvider(key, { label: it.label, size: p.size, color: iconColor }); } catch { provided = null; } }
@@ -154,7 +220,7 @@ export function blockBody(b, theme, live, ctx) {
154
220
  }
155
221
  case 'video': {
156
222
  const wrap = el('div', { padding: '4px 0', textAlign: 'center' }, attr);
157
- const a = el('a', { display: 'block', position: 'relative' }, { href: p.href });
223
+ const a = el('a', { display: 'block', position: 'relative' }, { href: linkHref(p.href) });
158
224
  a.addEventListener('click', (e) => e.preventDefault());
159
225
  a.appendChild(el('img', { width: '100%', display: 'block', border: '0' }, { src: p.src, alt: p.caption }));
160
226
  const badge = el('span', { position: 'absolute', inset: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' });
@@ -184,7 +250,13 @@ export function blockBody(b, theme, live, ctx) {
184
250
  // instead of re-rendering the editor (see the tick in editor-core.js).
185
251
  const wrap = el('div', { padding: '10px 0', textAlign: 'center', fontFamily: p.fontFamily || t.font, color: p.color }, live ? Object.assign({ 'data-mc-countdown': p.target }, attr) : attr);
186
252
  wrap.appendChild(el('div', { fontSize: '12px', letterSpacing: '0.14em', textTransform: 'uppercase', opacity: '0.6', marginBottom: '10px' }, { text: p.label }));
187
- const row = el('div', { display: 'flex', justifyContent: 'center', gap: '10px' });
253
+ // Wraps rather than overhangs. Four 84px boxes plus their gaps need
254
+ // 366px, so in any column narrower than that -- a 50/50 split of a
255
+ // 620px sheet is 296px -- the unwrapped row used to run out over the
256
+ // next column, and in the sent email it dragged its own cell open to
257
+ // 386px against its neighbours' 94px. Wrapping keeps the digits legible
258
+ // at any width; a row with the space for one line still gets one line.
259
+ const row = el('div', { display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '10px' });
188
260
  parts.forEach(([lab, v]) => {
189
261
  const box = el('div', { border: '1px solid ' + p.color + '33', padding: '9px 12px', minWidth: '58px' });
190
262
  box.appendChild(el('div', { fontSize: '25px', fontFamily: p.fontFamily || t.font, fontWeight: '700', lineHeight: '1' }, live ? { text: String(v).padStart(2, '0'), 'data-mc-count': lab } : { text: String(v).padStart(2, '0') }));
@@ -197,7 +269,14 @@ export function blockBody(b, theme, live, ctx) {
197
269
  case 'menu': {
198
270
  const wrap = el('div', { textAlign: p.align, padding: '10px 0' }, attr);
199
271
  parseItems(p.items).forEach((it) => {
200
- const a = el('a', { color: p.color, fontSize: p.size + 'px', fontFamily: p.fontFamily || t.font, textDecoration: 'none', margin: '0 ' + p.gap / 2 + 'px', letterSpacing: '0.12em', textTransform: 'uppercase' }, { href: it.href, text: it.label });
272
+ // `inline-block`, not the default inline: adjacent inline *text* runs
273
+ // offer no soft-wrap opportunity between them, and these anchors are
274
+ // appended with no whitespace in between -- so a menu too wide for its
275
+ // column ran straight off the edge instead of wrapping onto a second
276
+ // line (item 3 of a 3-item menu started 18px past a 148px column). An
277
+ // inline-block is an atomic inline, which does get a break opportunity
278
+ // either side of it, exactly as the social row already relied on.
279
+ const a = el('a', { display: 'inline-block', color: p.color, fontSize: p.size + 'px', fontFamily: p.fontFamily || t.font, textDecoration: 'none', margin: '0 ' + p.gap / 2 + 'px', letterSpacing: '0.12em', textTransform: 'uppercase' }, { href: linkHref(it.href), text: it.label });
201
280
  a.addEventListener('click', (e) => e.preventDefault());
202
281
  wrap.appendChild(a);
203
282
  });
@@ -208,7 +287,7 @@ export function blockBody(b, theme, live, ctx) {
208
287
  const head = el(p.level || 'h2', {
209
288
  margin: '0', padding: pad(p), fontSize: p.size + 'px', lineHeight: p.lh, textAlign: p.align,
210
289
  fontWeight: p.weight, letterSpacing: p.font === 'condensed' ? '0.005em' : '-0.01em',
211
- color: p.color || t.text, outline: 'none',
290
+ color: p.color || t.text, outline: 'none', ...FIT,
212
291
  // An explicit per-block font beats the Condensed/Body style toggle.
213
292
  fontFamily: p.fontFamily || (p.font === 'condensed' ? "'Arial Narrow', 'Helvetica Neue Condensed', Helvetica, Arial, sans-serif" : t.font),
214
293
  }, { ...attr, ...editableAttrs(edit), text: m(p.text), 'data-focus-key': edit ? 'block:' + b.id : undefined });
@@ -218,7 +297,7 @@ export function blockBody(b, theme, live, ctx) {
218
297
  }
219
298
  case 'list': {
220
299
  const items = String(p.items || '').split('\n').filter((l) => l.trim());
221
- const list = el(p.ordered ? 'ol' : 'ul', { margin: '0', padding: (p.py || 0) + 'px 0 ' + (p.py || 0) + 'px 22px', fontFamily: p.fontFamily || t.font, fontSize: p.size + 'px', lineHeight: p.lh, color: p.color || t.text }, attr);
300
+ const list = el(p.ordered ? 'ol' : 'ul', { margin: '0', padding: (p.py || 0) + 'px 0 ' + (p.py || 0) + 'px 22px', fontFamily: p.fontFamily || t.font, fontSize: p.size + 'px', lineHeight: p.lh, color: p.color || t.text, ...FIT }, attr);
222
301
  items.forEach((it) => {
223
302
  const li = el('li', { marginBottom: p.gap + 'px' }, { html: m(it) });
224
303
  list.appendChild(li);
@@ -241,6 +320,17 @@ export function blockBody(b, theme, live, ctx) {
241
320
  fontWeight: isHead ? '600' : '400', outline: 'none',
242
321
  fontFamily: p.fontFamily || t.font,
243
322
  letterSpacing: isHead ? '0.06em' : 'normal', textTransform: isHead ? 'uppercase' : 'none', fontSize: isHead ? p.size + 1 + 'px' : p.size + 'px',
323
+ // See FIT: a `<table>` cannot be laid out narrower than its
324
+ // min-content width, so `width:100%` alone never made it fit a
325
+ // column smaller than the sum of its longest cells (224px for the
326
+ // stock three-column table -- wider than a 3- or 4-way split).
327
+ // Letting the cells break brings that floor down to the column.
328
+ // Deliberately *not* `table-layout:fixed`, the usual reflex here:
329
+ // fixed would also fit, but it divides the width evenly and would
330
+ // silently re-proportion every table already in a saved document.
331
+ // Breaking keeps the content-proportional columns (measured
332
+ // 53/42/39 against fixed's 45/45/45).
333
+ ...FIT,
244
334
  }, { ...editableAttrs(edit), text: m(cell), 'data-focus-key': edit ? 'block:' + b.id + ':c' + ri + '-' + ci : undefined });
245
335
  if (edit) {
246
336
  // stopPropagation is load-bearing (a bubbled click would select
@@ -283,9 +373,15 @@ export function blockBody(b, theme, live, ctx) {
283
373
  borderTop: borderSide(p.topBorder), borderRight: borderSide(p.rightBorder),
284
374
  borderBottom: borderSide(p.bottomBorder), borderLeft: borderSide(p.leftBorder),
285
375
  borderRadius: p.radius + 'px', padding: p.pad + 'px', textAlign: p.align,
376
+ // `maxWidth` has to cap the box the reader sees, not its text area:
377
+ // under the default content-box sizing the padding and border were
378
+ // added *outside* the cap, so a box set to 60% of a 296px column
379
+ // painted 212px wide instead of 178. The default 100% is unaffected
380
+ // either way -- an auto-width block already stops at the column edge.
381
+ boxSizing: 'border-box',
286
382
  minHeight: p.minH ? p.minH + 'px' : 'auto', maxWidth: p.maxW + '%', margin: p.align === 'center' ? '0 auto' : '0',
287
383
  boxShadow: p.shadow ? '0 10px 30px rgba(29,31,32,0.12)' : 'none',
288
- fontFamily: t.font, color: t.text, fontSize: '15px', lineHeight: '1.6',
384
+ fontFamily: t.font, color: t.text, fontSize: '15px', lineHeight: '1.6', ...FIT,
289
385
  }, { ...attr, ...editableAttrs(edit), html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
290
386
  if (edit) wireEditable(box, b, 'html', ctx, false);
291
387
  if (!live) return box;
@@ -312,8 +408,15 @@ export function blockBody(b, theme, live, ctx) {
312
408
  if (!p.end) band.appendChild(el('span', { fontFamily: 'ui-monospace,monospace', fontSize: '11.5px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, { text: '{{ ' + (p.expr || '…') + ' }}' }));
313
409
  return band;
314
410
  }
411
+ // `pre-wrap`, not `pre`: `overflow-x:auto` gives the canvas a scrollbar
412
+ // and so looked contained here, but a mail client has no scrollbar to
413
+ // offer and sizes the cell to the longest line anyway -- a 33% column
414
+ // measured 371px against its neighbours' ~100px. Wrapping the long lines
415
+ // is the one option that keeps the sample readable and the row intact;
416
+ // `anywhere` covers a single unbroken line with no spaces to break at.
417
+ // The horizontal scroll stays for the canvas, where it still helps.
315
418
  case 'codeblock':
316
- return el('pre', { margin: '0', padding: p.pad + 'px', background: p.bg, color: p.color, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: p.size + 'px', lineHeight: '1.6', overflowX: 'auto', whiteSpace: 'pre' }, { ...attr, text: m(p.code) });
419
+ return el('pre', { margin: '0', padding: p.pad + 'px', background: p.bg, color: p.color, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: p.size + 'px', lineHeight: '1.6', overflowX: 'auto', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, { ...attr, text: m(p.code) });
317
420
  default:
318
421
  return el('div');
319
422
  }
@@ -231,7 +231,11 @@ export function renderDoc(core, live) {
231
231
  showLine(rowDropTracker, rowLines[index]);
232
232
  });
233
233
  page.addEventListener('dragleave', (e) => { if (e.target === root || e.target === page) hideActive(rowDropTracker); });
234
- page.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e); });
234
+ // Measured against `root`, exactly as the line above it was: the slots are
235
+ // the sheet's children, not the page's, and deriving the index a second
236
+ // time from the event's own target is what used to send every dropped
237
+ // section to index 0 while the line sat where the pointer actually was.
238
+ page.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e, core.indexFromPoint(root, e.clientY)); });
235
239
  }
236
240
 
237
241
  d.rows.forEach((r, ri) => {
@@ -265,14 +269,50 @@ export function renderDoc(core, live) {
265
269
  rowEl.appendChild(grip);
266
270
  }
267
271
 
268
- const colsEl = el('div', colsWrap(r.props));
272
+ /*
273
+ * The mobile preview lays out exactly the way the sent email does. The
274
+ * device switch used to do nothing but narrow the sheet to 375px, so a
275
+ * 4-column row previewed as four 60px slivers -- a layout no recipient
276
+ * would ever see, since the exported stylesheet collapses that row below
277
+ * the content width. Previewing the wide layout at a narrow width was
278
+ * showing a page that does not exist.
279
+ *
280
+ * The three modes mirror `mobilePlan` in core/export.js: one-up stack,
281
+ * two-up grid, or the desktop layout kept as-is. Reverse flips the order,
282
+ * which is why this uses flex here too -- the same mechanism the media
283
+ * query uses, so the preview cannot drift from the output.
284
+ */
285
+ const mobile = core.state.device === 'mobile' && r.cols.length > 1;
286
+ const mMode = r.props.mobileCols === undefined ? 1 : r.props.mobileCols;
287
+ const stacked = mobile && mMode !== 'keep';
288
+ const twoUp = stacked && String(mMode) === '2';
289
+ const reversed = stacked && r.props.mobileOrder === 'reverse';
290
+ const colsEl = el('div', stacked
291
+ ? (twoUp || reversed
292
+ ? {
293
+ display: 'flex',
294
+ flexWrap: twoUp && reversed ? 'wrap-reverse' : 'wrap',
295
+ flexDirection: twoUp ? (reversed ? 'row-reverse' : 'row') : (reversed ? 'column-reverse' : 'column'),
296
+ }
297
+ : { display: 'block' })
298
+ : colsWrap(r.props));
269
299
  r.cols.forEach((c, ci) => {
270
300
  const colLines = [];
271
301
  const items = [];
272
302
  c.blocks.forEach((b, bi) => {
303
+ /*
304
+ * Device visibility. In the static preview -- the honest picture of
305
+ * what is sent -- a block the current device would not receive is not
306
+ * drawn at all, matching the exported `.mc-only-d` / `.mc-only-m`
307
+ * rules. On the editable canvas it is drawn faded instead: hiding it
308
+ * outright would leave the user with a block they cannot select,
309
+ * move or set back to "all devices".
310
+ */
311
+ const hiddenHere = b.props.vis === (core.state.device === 'mobile' ? 'desktop' : 'mobile');
312
+ if (!live && hiddenHere) return;
273
313
  if (live) { const line = dropLine(); colLines.push(line); items.push(line); }
274
314
  const bSel = live && sel && sel.id === b.id;
275
- const bWrap = el('div', Object.assign({ position: 'relative' }, boxStyle(b.props)), { 'data-mc-slot': '1', draggable: live ? 'true' : undefined, class: live ? `mc-block-el${bSel ? ' is-selected' : ''}` : undefined });
315
+ const bWrap = el('div', Object.assign({ position: 'relative', opacity: hiddenHere ? '0.4' : '' }, boxStyle(b.props)), { 'data-mc-slot': '1', draggable: live ? 'true' : undefined, class: live ? `mc-block-el${bSel ? ' is-selected' : ''}` : undefined });
276
316
  if (live) {
277
317
  bWrap.addEventListener('dragstart', core.startDrag({ kind: 'move-block', id: b.id }));
278
318
  bWrap.addEventListener('dragend', () => { core.drag = null; hideActive(rowDropTracker); hideActive(colTracker); });
@@ -313,7 +353,13 @@ export function renderDoc(core, live) {
313
353
  if (live && !c.blocks.length) {
314
354
  items.push(el('div', { border: '1px dashed var(--ed-accent-sheet-line)', borderRadius: 'var(--ed-radius-sm)', color: 'var(--ed-faint)', fontFamily: 'ui-monospace,monospace', fontSize: '9.5px', letterSpacing: '0.14em', textTransform: 'uppercase', padding: '24px 8px', textAlign: 'center' }, { text: 'drop block' }));
315
355
  }
316
- const colEl = el('div', colStyle(r.props, c));
356
+ // Stacked, a column is simply a full-width block -- the flex sizing and
357
+ // the horizontal gutter both belong to the side-by-side layout only.
358
+ // Two-up takes half, box-sized so its own padding cannot push it over
359
+ // the line and wrap every cell onto a row of its own.
360
+ const colEl = el('div', stacked
361
+ ? (twoUp ? { flex: '0 0 50%', maxWidth: '50%', boxSizing: 'border-box' } : { width: '100%' })
362
+ : colStyle(r.props, c));
317
363
  // Column-level styling lives on an inner wrapper, not on colEl itself:
318
364
  // colEl's padding is the inter-column gutter (colStyle), and a painted
319
365
  // background must stop at the column's visual edge, not bleed across
@@ -1,5 +1,6 @@
1
1
  import { icon, brandIcon, socialKey, SOCIAL_BRAND, contrastInk } from '../core/icons.js';
2
2
  import { parseItems } from '../core/parse.js';
3
+ import { linkHref } from '../core/sanitize.js';
3
4
 
4
5
  function el(tag, style, attrs) {
5
6
  const node = document.createElement(tag);
@@ -226,7 +227,14 @@ function renderRichLinks(f) {
226
227
  const urlCommit = typeCommit((v) => change(i, (a) => a.setAttribute('href', v)));
227
228
  url.addEventListener('input', (e) => urlCommit.call(e.target.value));
228
229
  url.addEventListener('focus', () => { url.style.borderColor = 'var(--ed-accent)'; url.style.outline = 'none'; });
229
- url.addEventListener('blur', () => { url.style.borderColor = 'var(--ed-line)'; urlCommit.flush(); });
230
+ url.addEventListener('blur', () => {
231
+ url.style.borderColor = 'var(--ed-line)';
232
+ // Scheme normalization happens on the way out, never per keystroke: a
233
+ // half-typed "h" rewritten to "https://h" under the caret is unusable.
234
+ url.value = linkHref(url.value);
235
+ urlCommit.call(url.value);
236
+ urlCommit.flush();
237
+ });
230
238
 
231
239
  const targetLabel = el('label', { display: 'flex', alignItems: 'center', gap: '6px', marginTop: '7px', width: 'fit-content', color: 'var(--ed-muted)', cursor: 'pointer', fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '500' });
232
240
  const target = el('input', { accentColor: 'var(--ed-accent)', cursor: 'pointer' }, { type: 'checkbox', 'data-focus-key': `f${f.key}-link-target-${i}` });
@@ -124,9 +124,20 @@ export const captureTemplatePng = async function (core, mountInto, scale = 2) {
124
124
  img.onerror = () => reject(new Error('SVG rasterization failed'));
125
125
  img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
126
126
  });
127
+ // WebKit fires `load` on an SVG-as-image before it is reliably
128
+ // drawable, and drawing on that tick yields a blank or half-laid-out
129
+ // frame. Same `decode()` guard the inner images get above.
130
+ if (img.decode) await img.decode().catch(() => {});
131
+ // Safari caps total canvas area, and past the cap it hands back a
132
+ // *blank* canvas that still encodes successfully -- a silently empty
133
+ // PNG with nothing thrown to catch. Long newsletters reach it easily
134
+ // at 2x (a 680x4000 template is 10.9M device pixels). Scale the
135
+ // capture down to fit instead of returning an empty image.
136
+ const MAX_CANVAS_AREA = 16e6;
137
+ const fit = Math.min(scale, Math.sqrt(MAX_CANVAS_AREA / Math.max(1, w * h)));
127
138
  const canvas = document.createElement('canvas');
128
- canvas.width = Math.max(1, Math.round(w * scale));
129
- canvas.height = Math.max(1, Math.round(h * scale));
139
+ canvas.width = Math.max(1, Math.round(w * fit));
140
+ canvas.height = Math.max(1, Math.round(h * fit));
130
141
  const ctx = canvas.getContext('2d');
131
142
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
132
143
  return await new Promise((resolve, reject) => {
@@ -23,6 +23,10 @@ export const STYLE = `
23
23
  /* The accent as it must read on the email sheet, which is a white page in
24
24
  both themes -- so this family does NOT flip with the chrome (see the dark
25
25
  palette below, where it is deliberately not overridden). */
26
+ /* Logo ink. Deliberately literal rather than accent-derived: the host can
27
+ repaint the chrome through the brand attribute, and a logo that follows
28
+ it stops being the logo. Values are the official light artwork. */
29
+ --ed-logo-mark: #2D86C7; --ed-logo-text: #3C3939;
26
30
  --ed-accent-sheet: #0065b3; --ed-accent-sheet-strong: #004f8c; --ed-accent-sheet-ink: #ffffff;
27
31
  --ed-accent-sheet-line: rgba(0,101,179,0.5);
28
32
  --ed-grid: rgba(71,85,105,0.10);
@@ -43,6 +47,8 @@ export const STYLE = `
43
47
  --ed-panel-label: #e2e8f0; --ed-panel-value: #c0cad8; --ed-panel-meta: #8d9aae;
44
48
  --ed-accent: #58a8e3; --ed-accent-strong: #8fc4ec; --ed-accent-ink: #08111f; --ed-soft: rgba(88,168,227,0.13);
45
49
  --ed-accent-tint: #8fcdf2; --ed-glow: rgba(88,168,227,0.28); --ed-select: rgba(88,168,227,0.3);
50
+ /* The official dark-mode logo artwork: mark and wordmark both go light. */
51
+ --ed-logo-mark: #F2F2F2; --ed-logo-text: #EEEEEE;
46
52
  --ed-grid: rgba(148,163,184,0.08);
47
53
  --ed-danger: #f2777c; --ed-danger-soft: rgba(242,119,124,0.14);
48
54
  --ed-success: #4ade95; --ed-success-soft: rgba(74,222,149,0.14);
@@ -151,14 +157,9 @@ export const STYLE = `
151
157
  .mc-footer-link { cursor: pointer; transition: color 0.14s ease; }
152
158
  .mc-footer-link:hover { color: var(--ed-accent); text-decoration: underline; }
153
159
 
154
- /* Brand mark -- the small gradient envelope glyph in the header. */
155
- .mc-brand-mark {
156
- display: flex; align-items: center; justify-content: center; flex: none;
157
- width: 26px; height: 26px; border-radius: 8px;
158
- background: linear-gradient(145deg, var(--ed-accent-tint), var(--ed-accent) 55%, var(--ed-accent-strong));
159
- box-shadow: 0 5px 14px var(--ed-glow);
160
- color: #ffffff;
161
- }
160
+ /* The header logo. It is the official lockup artwork, so it gets no chip, no
161
+ gradient and no recolouring -- only a height. */
162
+ .mc-brand { display: flex; align-items: center; flex: none; }
162
163
 
163
164
  /*
164
165
  * Shared segmented-control shell -- one bordered/backed track that owns the
@@ -269,9 +270,7 @@ export const STYLE = `
269
270
  /* Opaque, no backdrop-filter: the header sits over the scrolling canvas, so a
270
271
  backdrop blur re-composites every scroll frame -- visible scroll jank. */
271
272
  #mc .mc-header { padding: 0 18px !important; background: var(--ed-panel) !important; }
272
- #mc .mc-brand-mark { width: 30px !important; height: 30px !important; border-radius: 9px; background: linear-gradient(145deg, var(--ed-accent-tint), var(--ed-accent) 56%, var(--ed-accent-strong)) !important; box-shadow: 0 7px 18px var(--ed-glow); }
273
- #mc .mc-brand-mark svg { width: 20px; height: 20px; display: block; color: #fff; }
274
- #mc .mc-brand-name { font-family: var(--ed-font) !important; font-size: 17px !important; letter-spacing: -.03em !important; }
273
+ #mc .mc-brand svg { height: 34px; width: auto; display: block; }
275
274
  #mc .mc-header button { font-family: var(--ed-font) !important; font-size: 10.5px !important; font-weight: 600; letter-spacing: .015em !important; text-transform: none !important; }
276
275
  #mc .mc-header > div { border-radius: 10px; }
277
276
  #mc .mc-segment { gap: 3px !important; padding: 3px !important; border: 0 !important; border-radius: 10px; background: var(--ed-panel-2) !important; }