@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
package/src/core/theme.js CHANGED
@@ -11,5 +11,26 @@
11
11
  * fault. Now it is off unless a document asks for it. Both background keys
12
12
  * accept the literal `transparent` (and any rgba()/#rrggbbaa value) as well
13
13
  * as a hex colour.
14
+ *
15
+ * `contentBgImage` paints a photo or pattern behind the content column. It
16
+ * lives at content level rather than page level on purpose: the content
17
+ * column is already a <table>, which is the one element every client paints a
18
+ * background on, whereas a page background has to ride <body> -- and Gmail
19
+ * discards the body element wholesale, so a page-level image is simply absent
20
+ * there. Same fit/position/repeat vocabulary as a row's background image.
21
+ */
22
+ /*
23
+ * `preheader` is the inbox preview line -- the text a client shows next to the
24
+ * subject. Absent, every client scrapes the first visible copy instead, which
25
+ * is usually "View in browser" or a logo's alt text. `dir` is the document's
26
+ * own reading direction, set here and nowhere else: the editor chrome follows
27
+ * the UI locale, but a German-speaking author building an Arabic mailing (or
28
+ * the reverse) must not have the email flip with the menus.
29
+ */
30
+ /*
31
+ * `bgImage` paints the full-width page behind everything. It rides the
32
+ * full-width wrapper <table> and <body> both: Gmail discards the body element,
33
+ * so the table is what makes it render there. Same fit/position/repeat
34
+ * vocabulary as a row and the content area.
14
35
  */
15
- export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, padY: 0, padX: 0, radius: 0, borderW: 0, borderStyle: 'solid', borderColor: '#e2e2e5', shadow: '', font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
36
+ export const THEME = () => ({ preheader: '', dir: '', bg: '#eef2f7', bgImage: '', bgSize: 'cover', bgPos: 'center', bgRepeat: 'no-repeat', contentBg: '#ffffff', contentBgImage: '', contentBgSize: 'cover', contentBgPos: 'center', contentBgRepeat: 'no-repeat', width: 620, padY: 0, padX: 0, radius: 0, borderW: 0, borderStyle: 'solid', borderColor: '#e2e2e5', shadow: '', font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
@@ -1,11 +1,11 @@
1
- export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
2
- export const TOKEN = (t) => '{' + '{ ' + t + ' }' + '}';
3
-
4
- /** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
5
- export function vars(raw) {
6
- const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
7
- return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
8
- }
9
-
10
- /** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
11
- export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };
1
+ export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
2
+ export const TOKEN = (t) => '{' + '{ ' + t + ' }' + '}';
3
+
4
+ /** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
5
+ export function vars(raw) {
6
+ const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
7
+ return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
8
+ }
9
+
10
+ /** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
11
+ export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };
@@ -89,7 +89,9 @@ function tip(node, label, dir, align) {
89
89
  * chrome; while present the built-in toggle is hidden), `footer` ("none" to
90
90
  * remove the attribution strip, or any string to replace its text).
91
91
  * Properties: `.variables`, `.toolbar` (which parts of the top bar are shown),
92
- * `.aiProvider` (optional async fn, replaces the original's `window.claude.complete`),
92
+ * `.aiProvider` (optional async fn -- the host's own model call; the AI
93
+ * features stay hidden until one is set, since the package ships no client
94
+ * and talks to no model service of its own),
93
95
  * `.iconProvider` (optional social-icon override), `.storageProvider` (host-supplied
94
96
  * file storage -- see `core/storage.js`), `.storageLimits` (host-set upload ceilings,
95
97
  * required whenever a provider is set), `.messages` (UI string overrides --
@@ -2421,7 +2423,15 @@ export class MailCraftEditor extends ElementBase {
2421
2423
  // grid -- for a transparent page, which is exactly what a client with no
2422
2424
  // page colour of its own shows behind the email.
2423
2425
  const pageBg = s.doc.theme.bg || '';
2426
+ const th = s.doc.theme;
2424
2427
  this.previewBody.style.background = /^(transparent|none)$/i.test(pageBg.trim()) ? '' : pageBg;
2428
+ // The page background image, the same way the export paints it on the
2429
+ // body and the wrapper table (render/canvas.js does the same for the
2430
+ // editor's own page).
2431
+ this.previewBody.style.backgroundImage = th.bgImage ? 'url("' + cssUrl(th.bgImage) + '")' : '';
2432
+ this.previewBody.style.backgroundSize = th.bgImage ? (th.bgSize || 'cover') : '';
2433
+ this.previewBody.style.backgroundPosition = th.bgImage ? (th.bgPos || 'center') : '';
2434
+ this.previewBody.style.backgroundRepeat = th.bgImage ? (th.bgRepeat || 'no-repeat') : '';
2425
2435
  // Rebuild the sheet only when what it renders from actually changed.
2426
2436
  // renderPreviewModal runs on *every* render pass, and rebuilding the
2427
2437
  // whole email document each time made the open preview visibly rough:
@@ -1,7 +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
+ import { linkHref, cssUrl } from '../core/sanitize.js';
5
5
 
6
6
  function el(tag, style, attrs) {
7
7
  const node = document.createElement(tag);
@@ -93,7 +93,7 @@ function overrideLinkColor(root, link) {
93
93
  * toolbar node or null), `ctx.onTableCellBlur(block, ri, ci, value)`, `ctx.now`
94
94
  * (for the countdown), `ctx.vars` and `ctx.onInsertVariable` for the code-view escape hatch.
95
95
  */
96
- export function blockBody(b, theme, live, ctx) {
96
+ export function blockBody(b, theme, live, ctx, colPx) {
97
97
  const p = b.props; const t = theme;
98
98
  const attr = live ? { 'data-mc-content': b.id } : {};
99
99
 
@@ -119,20 +119,87 @@ export function blockBody(b, theme, live, ctx) {
119
119
  case 'image': {
120
120
  const wrap = el('div', { padding: pad(p), textAlign: p.align, fontSize: '0' }, attr);
121
121
  const imgHref = linkHref(p.href);
122
+ /*
123
+ * Two things every exported image needs and had neither of.
124
+ *
125
+ * `width` as an ATTRIBUTE, in px: the Word engine behind Classic
126
+ * Outlook does not resolve a percentage width on an image and falls
127
+ * back to the file's intrinsic size, so a 1200px hero authored at 60%
128
+ * shipped 1200px wide and tore the layout open. The CSS percentage
129
+ * stays for every client that can do better -- the attribute is only
130
+ * the floor Word reads -- and `height:auto` keeps the aspect ratio once
131
+ * a width is pinned.
132
+ *
133
+ * A real `font-size` on the IMG: the wrapper sets `font-size:0` to kill
134
+ * the inline-block whitespace gap, and alt text inherits it, so with
135
+ * images blocked (Outlook's default) the alt text rendered at 0px and
136
+ * the recipient saw nothing at all. The wrapper keeps its 0; the image
137
+ * carries type of its own for the blocked state.
138
+ */
139
+ /*
140
+ * Two ways to size an image, and the second one is why a logo used to
141
+ * drift.
142
+ *
143
+ * The default is a PERCENTAGE of the column -- responsive, and the
144
+ * right answer for a hero. But a percentage cannot express "88px", and
145
+ * an import that met one had to round it into the nearest whole
146
+ * percent (88 of 600 -> 15% -> 93px). Every save re-derived the
147
+ * percent from the new pixel width, so the logo crept a little wider
148
+ * each time and never landed back on 88.
149
+ *
150
+ * `wUnit: 'px'` with `wpx` pins the real number instead: the CSS width,
151
+ * the Word `width` attribute and the read-back all carry the same
152
+ * integer, so the value is a fixed point. `max-width:100%` (plus the
153
+ * stylesheet's own `img` rule in the sent mail) keeps a pinned image
154
+ * from holding a narrow phone column open.
155
+ */
156
+ const fixed = p.wUnit === 'px' && Number(p.wpx) > 0;
157
+ const pxW = fixed
158
+ ? Math.max(1, Math.round(Number(p.wpx)))
159
+ : Math.max(1, Math.round(((Number(colPx) || Number(t.width) || 620)) * ((Number(p.width) || 100) / 100)));
160
+ const cssW = fixed ? pxW + 'px' : (Number(p.width) || 100) + '%';
161
+ /*
162
+ * `height` as an attribute, derived from the source's own aspect ratio
163
+ * rather than invented: with images blocked (Outlook's default, and
164
+ * Gmail's for an unknown sender) a width-only image collapses to a
165
+ * one-line strip and the layout below it jumps when the image loads.
166
+ * A width AND a height reserve the real box. The CSS `height:auto`
167
+ * still wins in every client that reads CSS, so a client that scales
168
+ * the image down keeps its proportions -- the attribute is only the
169
+ * floor Word and a blocked-image placeholder read.
170
+ *
171
+ * Absent unless the ratio is known (an import that carried both
172
+ * dimensions, or a library asset), so no image gains a guessed height.
173
+ */
174
+ const ratio = Number(p.ratio) > 0 ? Number(p.ratio) : 0;
175
+ const hAttr = ratio ? String(Math.max(1, Math.round(pxW * ratio))) : undefined;
176
+ // Retina and responsive sources ride through untouched. Apple Mail and
177
+ // iOS pick from `srcset`; every other client ignores both attributes
178
+ // and uses `src`, which is why `src` stays the 1x fallback.
179
+ const srcAttrs = {
180
+ srcset: p.srcset || undefined,
181
+ sizes: p.srcset && p.sizes ? p.sizes : undefined,
182
+ title: p.title || undefined,
183
+ };
184
+ const altType = { fontSize: '13px', lineHeight: '1.4', fontFamily: t.font, color: t.text };
122
185
  if (imgHref) {
123
186
  // The % width must live on the anchor, not the img: a percentage on a
124
187
  // child of a shrink-to-fit inline-block resolves against the image's
125
188
  // own intrinsic size (i.e. not at all), which rendered every linked
126
189
  // logo/icon at full intrinsic width no matter what `width` said.
127
- const a = el('a', { display: 'inline-block', width: p.width + '%' }, { href: imgHref });
190
+ // `max-width` only in pixel mode: a percentage anchor is already
191
+ // capped by its container, and adding the declaration unconditionally
192
+ // would rewrite the markup of every linked image that never asked for
193
+ // this feature.
194
+ const a = el('a', { display: 'inline-block', width: cssW, ...(fixed ? { maxWidth: '100%' } : {}) }, { href: imgHref });
128
195
  // Same guard as every other anchor the canvas draws: without it a
129
196
  // click on a linked logo navigates the host application away from the
130
197
  // editor, taking the uncommitted document with it.
131
198
  a.addEventListener('click', (e) => e.preventDefault());
132
- a.appendChild(el('img', { width: '100%', borderRadius: p.radius + 'px', display: 'block', border: '0' }, { src: p.src, alt: p.alt }));
199
+ a.appendChild(el('img', { width: '100%', height: 'auto', borderRadius: p.radius + 'px', display: 'block', border: '0', ...altType }, { src: p.src, alt: p.alt, width: String(pxW), height: hAttr, border: '0', ...srcAttrs }));
133
200
  wrap.appendChild(a);
134
201
  } else {
135
- wrap.appendChild(el('img', { width: p.width + '%', maxWidth: '100%', borderRadius: p.radius + 'px', display: 'inline-block', border: '0' }, { src: p.src, alt: p.alt }));
202
+ wrap.appendChild(el('img', { width: cssW, height: 'auto', maxWidth: '100%', borderRadius: p.radius + 'px', display: 'inline-block', border: '0', ...altType }, { src: p.src, alt: p.alt, width: String(pxW), height: hAttr, border: '0', ...srcAttrs }));
136
203
  }
137
204
  return wrap;
138
205
  }
@@ -197,11 +264,29 @@ export function blockBody(b, theme, live, ctx) {
197
264
  }
198
265
  case 'divider': {
199
266
  const wrap = el('div', { padding: p.py + 'px 0' }, attr);
200
- wrap.appendChild(el('div', { height: '0', width: p.width + '%', margin: '0 auto', borderTop: p.thickness + 'px ' + (p.lineStyle || 'solid') + ' ' + p.color }));
267
+ const rule = p.thickness + 'px ' + (p.lineStyle || 'solid') + ' ' + p.color;
268
+ // Word gives a zero-height div nothing to draw the border on, so the
269
+ // same rule ships twice: once on this div, which every other client
270
+ // renders and the importer reads (classifyDivider: a DIV bar with a
271
+ // border-top), and once on a <td> inside an MSO-only table, the shape
272
+ // Word does paint. `<!--[if !mso]><!-->` keeps the div out of Word so
273
+ // it never draws both. Comments are inert to the importer.
274
+ const doc = wrap.ownerDocument;
275
+ wrap.appendChild(doc.createComment('[if !mso]><!'));
276
+ wrap.appendChild(el('div', { height: '0', width: p.width + '%', margin: '0 auto', borderTop: rule, fontSize: '1px', lineHeight: '1px' }, { html: '&nbsp;' }));
277
+ wrap.appendChild(doc.createComment('<![endif]'));
278
+ wrap.appendChild(doc.createComment('[if mso]><table role="presentation" width="' + p.width + '%" align="center" cellpadding="0" cellspacing="0" border="0"><tr><td style="border-top:' + rule + ';font-size:1px;line-height:1px;">&nbsp;</td></tr></table><![endif]'));
201
279
  return wrap;
202
280
  }
203
- case 'spacer':
204
- return el('div', { height: p.height + 'px' }, attr);
281
+ case 'spacer': {
282
+ // An empty div has no height in Word: it needs a character to give a
283
+ // line to, and an exact line-height to size that line (msoHarden adds
284
+ // mso-line-height-rule:exactly wherever a px line-height stands). This
285
+ // is mj-spacer's shape. The NBSP trims to nothing for the importer, so
286
+ // classifySpacer still sees an empty div with a height.
287
+ const h = Math.max(0, Number(p.height) || 0);
288
+ return el('div', { height: h + 'px', lineHeight: h + 'px', fontSize: '1px' }, { ...attr, html: '&nbsp;' });
289
+ }
205
290
  case 'social': {
206
291
  // Two independent axes cover every style the block offers without a
207
292
  // second hand-drawn icon set per platform: `palette` picks the source
@@ -241,14 +326,32 @@ export function blockBody(b, theme, live, ctx) {
241
326
  }
242
327
  case 'video': {
243
328
  const wrap = el('div', { padding: '4px 0', textAlign: 'center' }, attr);
244
- const a = el('a', { display: 'block', position: 'relative' }, { href: linkHref(p.href) });
329
+ /*
330
+ * The play badge used to float over the thumbnail with
331
+ * position:absolute. Gmail strips `position` outright and Word ignores
332
+ * it, so in both the badge fell to a line below the picture. Now the
333
+ * thumbnail is the cell's background (the one overlay mechanism email
334
+ * has) and the badge is plain centred content inside it. The cell needs
335
+ * a height, since a background gives none: 16:9 of the column width,
336
+ * which is what a video thumbnail is. The whole cell is the link.
337
+ */
338
+ const vw = Math.max(120, Number(colPx) || Number(t.width) || 620);
339
+ const vh = Math.round(vw * 9 / 16);
340
+ const vurl = cssUrl(p.src);
341
+ const table = el('table', { width: '100%', borderCollapse: 'collapse' }, { role: 'presentation', cellpadding: '0', cellspacing: '0', border: '0', width: '100%' });
342
+ const tr = el('tr'); table.appendChild(tr);
343
+ const cell = el('td', {
344
+ height: vh + 'px', textAlign: 'center', verticalAlign: 'middle',
345
+ backgroundColor: '#111111', backgroundImage: vurl ? 'url("' + vurl + '")' : 'none',
346
+ backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat',
347
+ }, { height: String(vh), align: 'center', valign: 'middle', background: p.src || undefined, bgcolor: '#111111' });
348
+ const a = el('a', { display: 'inline-block', textDecoration: 'none' }, { href: linkHref(p.href), title: p.caption || undefined });
245
349
  a.addEventListener('click', (e) => e.preventDefault());
246
- a.appendChild(el('img', { width: '100%', display: 'block', border: '0' }, { src: p.src, alt: p.caption }));
247
- const badge = el('span', { position: 'absolute', inset: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' });
248
- const circle = el('span', { width: '54px', height: '54px', borderRadius: '50%', background: p.badge, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', paddingLeft: '4px' }, { text: '▶' });
249
- badge.appendChild(circle);
250
- a.appendChild(badge);
251
- wrap.appendChild(a);
350
+ const circle = el('span', { display: 'inline-block', width: '54px', height: '54px', lineHeight: '54px', borderRadius: '50%', background: p.badge, color: '#fff', textAlign: 'center', fontSize: '18px', fontFamily: 'Arial, sans-serif' }, { text: '▶' });
351
+ a.appendChild(circle);
352
+ cell.appendChild(a);
353
+ tr.appendChild(cell);
354
+ wrap.appendChild(table);
252
355
  wrap.appendChild(el('div', { fontFamily: t.font, fontSize: '12.5px', color: p.badge, opacity: '0.7', marginTop: '8px' }, { text: p.caption }));
253
356
  return wrap;
254
357
  }
@@ -385,8 +488,17 @@ export function blockBody(b, theme, live, ctx) {
385
488
  const edit = live;
386
489
  const borderSide = (on) => (p.border && on !== false ? p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + p.lineColor : '0');
387
490
  const box = el('div', {
388
- background: p.bgImage ? 'linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0)), url("' + p.bgImage + '")' : p.bg,
389
- backgroundSize: 'cover', backgroundPosition: 'center',
491
+ // Colour and image as separate longhands, never one shorthand. The
492
+ // shorthand made `p.bg` unreachable the moment an image was set --
493
+ // there was no colour left underneath when a client refused the image
494
+ // -- and it opened with a no-op `linear-gradient()` that outlook.com's
495
+ // sanitiser drops the whole declaration over, taking the photo with
496
+ // it. Same rule as a row's background (core/export.js).
497
+ backgroundColor: p.bg || 'transparent',
498
+ backgroundImage: p.bgImage ? 'url("' + cssUrl(p.bgImage) + '")' : 'none',
499
+ // The same three controls a row has; hard-coded cover/center meant a
500
+ // box background could never tile or pin to an edge.
501
+ backgroundSize: p.bgSize || 'cover', backgroundPosition: p.bgPos || 'center', backgroundRepeat: p.bgRepeat || 'no-repeat',
390
502
  borderTop: borderSide(p.topBorder), borderRight: borderSide(p.rightBorder),
391
503
  borderBottom: borderSide(p.bottomBorder), borderLeft: borderSide(p.leftBorder),
392
504
  borderRadius: p.radius + 'px', padding: p.pad + 'px', textAlign: p.align,
@@ -1,6 +1,6 @@
1
1
  import { DEF } from '../core/blocks.js';
2
- import { boxStyle, rowBg, rowPad, colsWrap, colStyle } from '../core/layout-style.js';
3
- import { scopeCss } from '../core/sanitize.js';
2
+ import { boxStyle, rowBg, rowPad, colsWrap, colStyle, colBg } from '../core/layout-style.js';
3
+ import { scopeCss, cssUrl } from '../core/sanitize.js';
4
4
  import { cellsOf } from '../core/parse.js';
5
5
  import { icon } from '../core/icons.js';
6
6
  import { blockBody } from './block-body.js';
@@ -189,7 +189,14 @@ export function renderDoc(core, live) {
189
189
  const radius = Number(theme.radius) || 0;
190
190
  const borderW = Number(theme.borderW) || 0;
191
191
  const root = el('div', {
192
- width: width + 'px', maxWidth: '100%', background: theme.contentBg || 'transparent', color: theme.text, fontFamily: theme.font,
192
+ width: width + 'px', maxWidth: '100%', color: theme.text, fontFamily: theme.font,
193
+ // Colour and image as separate longhands, mirroring what the exporter
194
+ // writes -- the canvas has to show the same paint the recipient gets.
195
+ backgroundColor: theme.contentBg || 'transparent',
196
+ backgroundImage: theme.contentBgImage ? 'url("' + cssUrl(theme.contentBgImage) + '")' : 'none',
197
+ backgroundSize: theme.contentBgSize || 'cover',
198
+ backgroundPosition: theme.contentBgPos || 'center',
199
+ backgroundRepeat: theme.contentBgRepeat || 'no-repeat',
193
200
  // Only when the document actually asks for a shape: an unconditional
194
201
  // `0px` would override the editor chrome's own soft corner on the sheet
195
202
  // (style.js) and square off every template that never touched the field.
@@ -224,6 +231,15 @@ export function renderDoc(core, live) {
224
231
  const padX = Number(theme.padX) || 0;
225
232
  const page = el('div', {
226
233
  background: theme.bg || 'transparent',
234
+ // The page image, only when there is one: the longhands are added AFTER
235
+ // the shorthand so they override it, and are absent otherwise so a
236
+ // document with no image paints exactly as it always did.
237
+ ...(theme.bgImage ? {
238
+ backgroundImage: 'url("' + cssUrl(theme.bgImage) + '")',
239
+ backgroundSize: theme.bgSize || 'cover',
240
+ backgroundPosition: theme.bgPos || 'center',
241
+ backgroundRepeat: theme.bgRepeat || 'no-repeat',
242
+ } : {}),
227
243
  padding: padY + 'px ' + padX + 'px',
228
244
  boxSizing: 'border-box', maxWidth: '100%', display: 'flex', justifyContent: 'center',
229
245
  transition: 'background 0.2s, padding 0.22s cubic-bezier(0.22,0.61,0.36,1)',
@@ -310,7 +326,20 @@ export function renderDoc(core, live) {
310
326
  }
311
327
  : { display: 'block' })
312
328
  : colsWrap(r.props));
329
+ /*
330
+ * What this column measures in a sent email, in px. Word cannot resolve a
331
+ * percentage width on an image (it falls back to the file's intrinsic
332
+ * size, which is how a 1200px hero tore the layout open in Outlook), so
333
+ * the image renderer needs a real number to put in a `width` attribute.
334
+ * Approximate by construction -- cell gutters and column padding are not
335
+ * modelled here -- but an approximate cap is the whole difference between
336
+ * a scaled image and a 1200px one.
337
+ */
338
+ const rowPadX = (r.props.pl ?? r.props.px ?? 0) + (r.props.pr ?? r.props.px ?? 0);
339
+ const gapPx = (r.props.gap || 0) * Math.max(0, r.cols.length - 1);
340
+ const innerPx = Math.max(40, (Number(theme.width) || 620) - rowPadX - gapPx);
313
341
  r.cols.forEach((c, ci) => {
342
+ const colPx = Math.max(20, Math.round(innerPx * ((c.span || 100) / 100)));
314
343
  const colLines = [];
315
344
  const items = [];
316
345
  c.blocks.forEach((b, bi) => {
@@ -356,7 +385,7 @@ export function renderDoc(core, live) {
356
385
  // few pixels from the RTE's own controls. The RTE stands in for it for
357
386
  // the duration of the edit; it comes back on blur (still selected).
358
387
  if (bSel && ctx.editingId !== b.id) bWrap.appendChild(toolbar(core, b.id, 'block', DEF(b.type).code));
359
- bWrap.appendChild(blockBody(b, theme, live, ctx));
388
+ bWrap.appendChild(blockBody(b, theme, live, ctx, colPx));
360
389
  items.push(bWrap);
361
390
  });
362
391
  let colTracker;
@@ -379,9 +408,9 @@ export function renderDoc(core, live) {
379
408
  // background must stop at the column's visual edge, not bleed across
380
409
  // the gutter. `host` is also what the drag listeners and
381
410
  // `indexFromPoint` must use -- the block slots are its children.
382
- const styled = c.bg || c.border || c.radius || c.padY || c.padX;
411
+ const styled = c.bg || c.bgImage || c.border || c.radius || c.padY || c.padX;
383
412
  const host = styled
384
- ? el('div', { background: c.bg || 'transparent', border: c.border ? c.border + 'px ' + (c.borderStyle || 'solid') + ' ' + (c.lineColor || '#e2e2e5') : '0', borderRadius: (c.radius || 0) + 'px', padding: (c.padY || 0) + 'px ' + (c.padX || 0) + 'px', height: '100%', boxSizing: 'border-box' })
413
+ ? el('div', { ...colBg(c), border: c.border ? c.border + 'px ' + (c.borderStyle || 'solid') + ' ' + (c.lineColor || '#e2e2e5') : '0', borderRadius: (c.radius || 0) + 'px', padding: (c.padY || 0) + 'px ' + (c.padX || 0) + 'px', height: '100%', boxSizing: 'border-box' })
385
414
  : colEl;
386
415
  if (live) {
387
416
  host.addEventListener('dragover', (e) => {
@@ -447,6 +447,25 @@ export function renderField(f) {
447
447
  return wrap;
448
448
  }
449
449
 
450
+ /*
451
+ * A note is prose, not a control: no label row, no focus key, nothing to
452
+ * commit. It sits in the flow where the panel put it, tinted by tone --
453
+ * amber for a warning the author should act on before sending, muted for
454
+ * an aside. The left rule rather than a filled panel keeps it from reading
455
+ * as a field the user failed to fill in.
456
+ */
457
+ if (f.isNote) {
458
+ const warn = f.tone !== 'info';
459
+ wrap.appendChild(el('div', {
460
+ fontFamily: 'var(--ed-font)', fontSize: '11px', lineHeight: '1.5',
461
+ color: warn ? 'var(--ed-text)' : 'var(--ed-muted)',
462
+ background: warn ? 'rgba(214,141,32,0.10)' : 'var(--ed-panel-2)',
463
+ borderLeft: '2px solid ' + (warn ? 'rgba(214,141,32,0.85)' : 'var(--ed-line)'),
464
+ padding: '7px 9px', boxSizing: 'border-box',
465
+ }, { text: f.label, class: 'mc-field-note', role: warn ? 'status' : undefined }));
466
+ return wrap;
467
+ }
468
+
450
469
  // No isHead branch: heads never reach renderField -- renderFieldCards
451
470
  // consumes them as card kickers. A field list rendered without the card
452
471
  // grouper would drop its headings, which is the loud failure we want.