@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
@@ -22,7 +22,7 @@ BLOCKS.push(
22
22
  { type: 'codeblock', code: 'PRE', label: 'Code', hint: 'Preformatted code sample', make: () => ({ code: 'curl -X POST https://api.example.com/v1/sends \\\n -H "Authorization: Bearer $KEY" \\\n -d campaign=mc-4471', bg: '#172033', color: '#e8e9ea', size: 12.5, pad: 14 }) },
23
23
  );
24
24
  BLOCKS.push(
25
- { type: 'box', code: 'BOX', label: 'Section box', hint: 'Styled container with free content', make: () => ({ html: '<strong style="font-size:19px;display:block;margin-bottom:6px">Section title</strong>Drop copy here, or paste markup. The box takes background, padding, border and radius.', bg: '#f8fafc', bgImage: '', border: 1, borderStyle: 'solid', lineColor: '#e2e8f0', topBorder: true, rightBorder: true, bottomBorder: true, leftBorder: true, radius: 12, pad: 22, align: 'left', minH: 0, maxW: 100, shadow: false }) },
25
+ { type: 'box', code: 'BOX', label: 'Section box', hint: 'Styled container with free content', make: () => ({ html: '<strong style="font-size:19px;display:block;margin-bottom:6px">Section title</strong>Drop copy here, or paste markup. The box takes background, padding, border and radius.', bg: '#f8fafc', bgImage: '', bgSize: 'cover', bgPos: 'center', bgRepeat: 'no-repeat', border: 1, borderStyle: 'solid', lineColor: '#e2e8f0', topBorder: true, rightBorder: true, bottomBorder: true, leftBorder: true, radius: 12, pad: 22, align: 'left', minH: 0, maxW: 100, shadow: false }) },
26
26
  { type: 'svg', code: 'SVG', label: 'Inline SVG', hint: 'Paste SVG markup', make: () => ({ code: '<svg viewBox="0 0 120 40" width="120" height="40" fill="none" stroke="#0065b3" stroke-width="1.5"><rect x="0.75" y="0.75" width="118.5" height="38.5"/><path d="M12 28l14-16 12 10 10-8 18 14"/></svg>', align: 'left', width: 100, py: 10 }) },
27
27
  );
28
28
  // Dynamic-content markers. Same contract as merge tags (variables.js): the
@@ -167,7 +167,7 @@ export const GROUPS = {
167
167
  row.cols[0].blocks = [
168
168
  blk('divider', { py: 0 }),
169
169
  blk('social', { align: 'center', size: 18, shape: 'bare' }),
170
- blk('text', { html: '<strong>MailCraft, Inc.</strong><br />220 Foundry Street, Suite 4, Portland OR 97209<br />You are receiving this because you signed up at mailcraft.co.', size: 11.5, align: 'center', py: 6 }),
170
+ blk('text', { html: '<strong>Selise Group</strong><br />You are receiving this because you signed up at selisegroup.com.', size: 11.5, align: 'center', py: 6 }),
171
171
  blk('text', { html: '<a href="{{ unsubscribe_url }}">Unsubscribe</a> · <a href="#">Update preferences</a>', size: 11.5, align: 'center', py: 0 }),
172
172
  ];
173
173
  return [row];
@@ -289,7 +289,7 @@ export function normalizeDoc(input) {
289
289
  // rebuilding the column without them silently stripped a card
290
290
  // column's paint on every loadDoc/loadTemplate, before export ever
291
291
  // saw it.
292
- ['bg', 'radius', 'padY', 'padX', 'border', 'borderStyle', 'lineColor'].forEach((k) => {
292
+ ['bg', 'bgImage', 'bgSize', 'bgPos', 'bgRepeat', 'overlay', 'radius', 'padY', 'padX', 'border', 'borderStyle', 'lineColor'].forEach((k) => {
293
293
  if (c[k] !== undefined && c[k] !== '' && c[k] !== 0) col[k] = c[k];
294
294
  });
295
295
  return col;
@@ -81,6 +81,24 @@ function syncRichContent(block, key, val) {
81
81
  return true;
82
82
  }
83
83
 
84
+ /*
85
+ * The background-image vocabulary, shared by a row's panel and the content
86
+ * area's. Duplicated inline in both, they drifted the moment either grew an
87
+ * option -- and two panels offering different words for the same CSS is the
88
+ * kind of difference a user reads as a bug.
89
+ */
90
+ const BG_FIT = [{ value: 'cover', label: 'Fill the section' }, { value: 'contain', label: 'Fit inside' }, { value: 'auto', label: 'Actual size' }];
91
+ // All nine CSS keyword positions. The importer keeps `right bottom` and
92
+ // friends faithfully; with only five single keywords on offer, a select showed
93
+ // nothing for such a value and the first touch of the control destroyed it.
94
+ const BG_POS = [
95
+ { value: 'center', label: 'Center' }, { value: 'top', label: 'Top' }, { value: 'bottom', label: 'Bottom' },
96
+ { value: 'left', label: 'Left' }, { value: 'right', label: 'Right' },
97
+ { value: 'left top', label: 'Top left' }, { value: 'right top', label: 'Top right' },
98
+ { value: 'left bottom', label: 'Bottom left' }, { value: 'right bottom', label: 'Bottom right' },
99
+ ];
100
+ const BG_REPEAT = [{ value: 'no-repeat', label: 'No repeat' }, { value: 'repeat', label: 'Tile' }, { value: 'repeat-x', label: 'Tile across' }];
101
+
84
102
  const BORDER_STYLES = [
85
103
  { value: 'solid', label: 'Solid' },
86
104
  { value: 'dashed', label: 'Dashed' },
@@ -200,6 +218,47 @@ export const AI_TONES = [
200
218
  /** Every goal string, flattened -- the order the `<select>` presents them in. */
201
219
  export const AI_GOAL_VALUES = AI_GOALS.flatMap((g) => g.items);
202
220
 
221
+
222
+ /**
223
+ * What the editor knows about an image source that no control can express.
224
+ *
225
+ * All three of these render fine on the canvas and then arrive broken -- or
226
+ * as an empty box -- in a real inbox, which is exactly the failure nobody
227
+ * catches before sending: the built-in placeholder is an SVG data URI, and
228
+ * Gmail and Outlook block data URIs outright; a `cid:` source only resolves
229
+ * inside a sent message that actually carries the attachment.
230
+ */
231
+ export function imageSourceNotes(src) {
232
+ const s = String(src || '');
233
+ if (/^cid:/i.test(s)) return ['Attached image (cid:). It cannot resolve in the editor or a preview -- only in a sent message that carries the attachment.'];
234
+ if (!/^data:/i.test(s)) return [];
235
+ /*
236
+ * The built-in placeholder, told apart from any other SVG data URI by the
237
+ * hatched pattern `core/placeholder.js` draws and nothing else does -- the
238
+ * demo templates ship real illustrations as data URIs too, and calling
239
+ * those "a placeholder" would be simply untrue. Both still warn: the
240
+ * reason differs, the outcome in Gmail and Outlook does not.
241
+ */
242
+ if (/pattern%20id%3D%22s%22/.test(s)) return ['Placeholder, not artwork. Gmail and Outlook block data: images, so this ships as an empty box -- replace it from the library before sending.'];
243
+ return ['A data: image is blocked by Gmail and Outlook and inflates the message. Upload the file and use its URL instead.'];
244
+ }
245
+
246
+ /** Every image source in the document that `imageSourceNotes` objects to -- block artwork and the three background-image slots alike, since a placeholder behind a row ships just as silently as one in an image block. */
247
+ export function unsendableSources(doc) {
248
+ const out = [];
249
+ const check = (v) => { if (imageSourceNotes(v).length) out.push(String(v)); };
250
+ const t = (doc && doc.theme) || {};
251
+ check(t.bgImage); check(t.contentBgImage);
252
+ ((doc && doc.rows) || []).forEach((r) => {
253
+ check(r.props && r.props.bgImage);
254
+ (r.cols || []).forEach((c) => {
255
+ check(c.bgImage);
256
+ (c.blocks || []).forEach((b) => check(b.props && b.props.src));
257
+ });
258
+ });
259
+ return out;
260
+ }
261
+
203
262
  export class EditorCore {
204
263
  constructor({ variables, aiProvider, iconProvider, messages, storageProvider, storageLimits } = {}) {
205
264
  this.variablesRaw = variables ?? null;
@@ -881,6 +940,24 @@ export class EditorCore {
881
940
  if (rewrote) this.editStale = id;
882
941
  }
883
942
 
943
+ /**
944
+ * Several props of one block in ONE undo step.
945
+ *
946
+ * `setProp` twice is two entries in the history, so a control that has to
947
+ * move a pair together -- switching an image's width to pixels seeds the
948
+ * pixel value at the same moment -- would otherwise need two Ctrl-Z to put
949
+ * back. No rich-content sync here: this is for plain value props, not the
950
+ * `html`/`size` pair `setProp` guards.
951
+ */
952
+ setProps(id, patch) {
953
+ this.commit((doc) => {
954
+ const f = this.find(doc, id);
955
+ const target = f.block ? f.block.props : (f.row ? f.row.props : null);
956
+ if (!target) return;
957
+ Object.assign(target, patch);
958
+ });
959
+ }
960
+
884
961
  setTheme(key, val) { this.commit((doc) => { doc.theme[key] = val; }); }
885
962
 
886
963
  /** Column-level styling (`bg`, `radius`, `padY`, `padX` on a col object -- all optional, absent means unstyled). What lets one section hold differently-colored card columns, which row-level props can't express. */
@@ -1417,7 +1494,24 @@ export class EditorCore {
1417
1494
  useAsset(a) {
1418
1495
  const t = this.state.assetTarget;
1419
1496
  if (t) {
1420
- this.setProp(t.id || t, t.key || 'src', a.url);
1497
+ // `{ theme: true, key }` targets the document's own paint (the content
1498
+ // area's background image); `{ id, ci, key }` one column of a row;
1499
+ // everything else is a block or row id.
1500
+ if (t.theme) this.setTheme(t.key, a.url);
1501
+ else if (t.ci !== undefined) this.setColProp(t.id, t.ci, t.key || 'bgImage', a.url);
1502
+ // Artwork picked from the library knows its own dimensions, so the
1503
+ // aspect ratio comes with it -- that is what lets the export write a
1504
+ // `height` beside the `width` and reserve the image's box while the
1505
+ // recipient still has images blocked (render/block-body.js). Both
1506
+ // props in one undo step, and only for an image block's own source.
1507
+ // Guarded on the block actually BEING an image: a video block's
1508
+ // thumbnail picker uses the same `src` key, and a ratio there is a
1509
+ // prop nothing reads -- which the video's `data-mcp` marker would then
1510
+ // carry into every export.
1511
+ else if ((t.key || 'src') === 'src' && a.w > 0 && a.ht > 0 && (this.find(this.state.doc, t.id || t).block || {}).type === 'image') {
1512
+ this.setProps(t.id || t, { src: a.url, ratio: Math.round((a.ht / a.w) * 10000) / 10000 });
1513
+ }
1514
+ else this.setProp(t.id || t, t.key || 'src', a.url);
1421
1515
  this.setState({ libraryOpen: false, assetTarget: null });
1422
1516
  this.flash(this.t('toast.imageReplaced'));
1423
1517
  } else {
@@ -1439,6 +1533,16 @@ export class EditorCore {
1439
1533
 
1440
1534
  openExport = () => {
1441
1535
  this.setState({ exportOpen: true, exportCode: this.buildHtml(), copied: false, libraryOpen: false, aiOpen: false, codeOpen: false });
1536
+ /*
1537
+ * The preflight, at the one moment it can still change the outcome: the
1538
+ * author is about to take the HTML out of the editor. A placeholder or a
1539
+ * data:/cid: source renders perfectly on the canvas and arrives as an
1540
+ * empty box in the inbox, which is precisely why it ships unnoticed --
1541
+ * nothing in the editor ever said so. Named here, not blocked: a `cid:`
1542
+ * source is legitimate for a host that attaches the file at send time.
1543
+ */
1544
+ const bad = unsendableSources(this.state.doc);
1545
+ if (bad.length) this.flash(bad.length === 1 ? this.t('toast.exportImageOne') : this.t('toast.exportImageMany', { count: bad.length }));
1442
1546
  };
1443
1547
 
1444
1548
  copyExport = () => {
@@ -1585,7 +1689,55 @@ export class EditorCore {
1585
1689
  ? [{ kind: 'richLinks', label: 'Links', html: b.props.html || '', onChange: (v) => this.setProp(b.id, 'html', v) }]
1586
1690
  : [],
1587
1691
  [B.area('Text', 'html'), B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)), B.range('Text size', 'size', ...SIZE_SPAN.text, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '500', label: 'Medium' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
1588
- case 'image': return decorate(base.concat([B.btn('Choose from library', () => this.openLibrary({ id: b.id, key: 'src' })), B.text('Alt text', 'alt', 'Describe the image'), B.text('Link URL', 'href', 'https://'), B.range('Width', 'width', 5, 100, 1, '%'), B.seg('Align', 'align', ALIGN), B.range('Rounded corners', 'radius', 0, 200, 1, 'px')], padF));
1692
+ case 'image': {
1693
+ /*
1694
+ * Width in PERCENT or in PIXELS.
1695
+ *
1696
+ * Percent is the default and the right answer for a hero -- it
1697
+ * follows the column on every screen. It cannot say "88px",
1698
+ * though, and forcing a logo through it both rounded the size
1699
+ * (88 of 620 is 14.2%) and let it drift: the next save re-derived
1700
+ * the percentage from the rounded pixels. Pixels pin the number
1701
+ * exactly, and `max-width:100%` in the sent mail keeps a pinned
1702
+ * image from holding a phone column open.
1703
+ *
1704
+ * Switching to pixels seeds the current on-canvas size, so the
1705
+ * image does not jump at the moment of the switch -- and both
1706
+ * props move in ONE undo step (`setProps`).
1707
+ */
1708
+ const colSpan = Number((this.find(this.state.doc, b.id).col || {}).span) || 100;
1709
+ const colPx = Math.max(20, Math.round((Number(this.state.doc.theme.width) || 620) * (colSpan / 100)));
1710
+ const pinned = b.props.wUnit === 'px';
1711
+ const unitSeg = {
1712
+ kind: 'seg',
1713
+ label: 'Width unit',
1714
+ options: [{ value: 'pct', label: 'Percent' }, { value: 'px', label: 'Pixels' }].map((o) => {
1715
+ const on = (o.value === 'px') === pinned;
1716
+ return {
1717
+ label: o.label,
1718
+ bg: on ? 'var(--ed-accent)' : 'transparent',
1719
+ fg: on ? 'var(--ed-accent-ink)' : 'var(--ed-muted)',
1720
+ onClick: () => {
1721
+ if (o.value === 'px') {
1722
+ this.setProps(b.id, { wUnit: 'px', wpx: Number(b.props.wpx) > 0 ? Number(b.props.wpx) : Math.max(1, Math.round(colPx * ((Number(b.props.width) || 100) / 100))) });
1723
+ } else {
1724
+ // The percentage the pinned size currently amounts to,
1725
+ // so the image does not jump back either.
1726
+ this.setProps(b.id, { wUnit: 'pct', width: Math.max(5, Math.min(100, Math.round(((Number(b.props.wpx) || colPx) / colPx) * 100))) });
1727
+ }
1728
+ },
1729
+ };
1730
+ }),
1731
+ };
1732
+ return decorate(base.concat(
1733
+ [B.btn('Choose from library', () => this.openLibrary({ id: b.id, key: 'src' }))],
1734
+ imageSourceNotes(b.props.src).map((n) => B.note(n)),
1735
+ [B.text('Alt text', 'alt', 'Describe the image'), B.text('Link URL', 'href', 'https://'), unitSeg],
1736
+ [pinned ? B.range('Width', 'wpx', 8, Math.max(200, colPx), 1, 'px') : B.range('Width', 'width', 5, 100, 1, '%')],
1737
+ [B.seg('Align', 'align', ALIGN), B.range('Rounded corners', 'radius', 0, 200, 1, 'px')],
1738
+ padF,
1739
+ ));
1740
+ }
1589
1741
  case 'button': return decorate(base.concat([B.text('Label', 'label'), B.text('Link URL', 'href', 'https://'), B.color('Button color', 'bg'), B.color('Text color', 'color'), B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Rounded corners', 'radius', 0, 60, 1, 'px'), B.range('Outline thickness', 'borderW', 0, 6, 1, 'px')].concat(b.props.borderW ? [B.sel('Outline style', 'borderStyle', BORDER_STYLES), B.color('Outline color', 'borderColor')] : []).concat([B.range('Button height', 'py', 0, 60, 1, 'px'), B.range('Button width', 'px', 0, 120, 2, 'px'), B.seg('Align', 'align', ALIGN), B.tog('Full width', 'full')])));
1590
1742
  case 'divider': return decorate(base.concat([B.range('Thickness', 'thickness', 1, 20, 1, 'px'), B.sel('Line style', 'lineStyle', BORDER_STYLES), B.range('Width', 'width', 5, 100, 5, '%'), B.color('Color', 'color'), B.range('Space above & below', 'py', 0, 160, 2, 'px')]));
1591
1743
  case 'spacer': return decorate(base.concat([B.range('Height', 'height', 0, 400, 2, 'px')]));
@@ -1632,7 +1784,7 @@ export class EditorCore {
1632
1784
  B.color('Header color', 'headBg'), B.color('Line color', 'lineColor'), B.seg('Align', 'align', ALIGN),
1633
1785
  ]));
1634
1786
  case 'css': return decorate(base.concat([B.area('CSS', 'code'), B.text('Note to yourself', 'note')]));
1635
- case 'box': return decorate(base.concat([B.area('Content (HTML allowed)', 'html'), B.color('Background color', 'bg'), B.btn('Choose background image', () => this.openLibrary({ id: b.id, key: 'bgImage' })), B.text('Background image URL', 'bgImage', 'https://'), B.range('Space inside', 'pad', 0, 160, 2, 'px'), B.range('Border thickness', 'border', 0, 20, 1, 'px')].concat(b.props.border ? [
1787
+ case 'box': return decorate(base.concat([B.area('Content (HTML allowed)', 'html'), B.color('Background color', 'bg'), B.btn('Choose background image', () => this.openLibrary({ id: b.id, key: 'bgImage' })), B.text('Background image URL', 'bgImage', 'https://'), ...(b.props.bgImage ? [B.sel('Image fit', 'bgSize', BG_FIT), B.sel('Image position', 'bgPos', BG_POS), B.sel('Image repeat', 'bgRepeat', BG_REPEAT)] : []), B.range('Space inside', 'pad', 0, 160, 2, 'px'), B.range('Border thickness', 'border', 0, 20, 1, 'px')].concat(b.props.border ? [
1636
1788
  B.sel('Border style', 'borderStyle', BORDER_STYLES), B.color('Border color', 'lineColor'),
1637
1789
  B.tog('Top border', 'topBorder', true), B.tog('Right border', 'rightBorder', true),
1638
1790
  B.tog('Bottom border', 'bottomBorder', true), B.tog('Left border', 'leftBorder', true),
@@ -1706,6 +1858,19 @@ export class EditorCore {
1706
1858
  return acc.concat([
1707
1859
  B.head('Column ' + (ci + 1)),
1708
1860
  CB.color('Background color', 'bg'),
1861
+ // A photo behind ONE column of a row -- the two-up "image beside
1862
+ // copy" band that until now could only be built by putting the
1863
+ // image in the column as a block, where it could not sit behind
1864
+ // the text. Same three controls the section background offers,
1865
+ // in the same order, so the two read as one feature at two
1866
+ // scales.
1867
+ CB.btn(c.bgImage ? 'Change background image' : 'Add background image', () => this.openLibrary({ id: r.id, ci, key: 'bgImage' })),
1868
+ ...(c.bgImage ? [
1869
+ CB.btn('Remove background image', () => this.setColProp(r.id, ci, 'bgImage', '')),
1870
+ CB.sel('Image fit', 'bgSize', BG_FIT),
1871
+ CB.sel('Image position', 'bgPos', BG_POS),
1872
+ CB.range('Darken image', 'overlay', 0, 100, 1, '%'),
1873
+ ] : []),
1709
1874
  CB.range('Border thickness', 'border', 0, 20, 1, 'px'),
1710
1875
  ...(c.border ? [CB.sel('Border style', 'borderStyle', BORDER_STYLES), CB.color('Border color', 'lineColor')] : []),
1711
1876
  CB.range('Rounded corners', 'radius', 0, 100, 1, 'px'),
@@ -1718,8 +1883,8 @@ export class EditorCore {
1718
1883
  B.btn(p.bgImage ? 'Change background image' : 'Add background image', () => this.openLibrary({ id: r.id, key: 'bgImage' })),
1719
1884
  ...(p.bgImage ? [
1720
1885
  B.btn('Remove background image', () => this.setProp(r.id, 'bgImage', '')),
1721
- B.sel('Image fit', 'bgSize', [{ value: 'cover', label: 'Fill the section' }, { value: 'contain', label: 'Fit inside' }, { value: 'auto', label: 'Actual size' }]),
1722
- B.sel('Image position', 'bgPos', [{ value: 'center', label: 'Center' }, { value: 'top', label: 'Top' }, { value: 'bottom', label: 'Bottom' }, { value: 'left', label: 'Left' }, { value: 'right', label: 'Right' }]),
1886
+ B.sel('Image fit', 'bgSize', BG_FIT),
1887
+ B.sel('Image position', 'bgPos', BG_POS),
1723
1888
  B.range('Darken image', 'overlay', 0, 100, 1, '%'),
1724
1889
  ] : []),
1725
1890
 
@@ -1771,7 +1936,7 @@ export class EditorCore {
1771
1936
  ] : []),
1772
1937
  ...(p.layout === 'grid' ? [B.range('Grid columns', 'gridCols', 1, 12, 1, '')] : []),
1773
1938
  B.text('Background image URL', 'bgImage', 'https://'),
1774
- B.sel('Image repeat', 'bgRepeat', [{ value: 'no-repeat', label: 'No repeat' }, { value: 'repeat', label: 'Tile' }, { value: 'repeat-x', label: 'Tile across' }]),
1939
+ B.sel('Image repeat', 'bgRepeat', BG_REPEAT),
1775
1940
  B.range('Max width', 'maxW', 10, 100, 5, '%'),
1776
1941
  B.seg('Vertical align', 'valign', [{ value: 'top', label: 'Top' }, { value: 'middle', label: 'Middle' }, { value: 'bottom', label: 'Bottom' }]),
1777
1942
  ] : []),
@@ -1817,7 +1982,11 @@ export class EditorCore {
1817
1982
 
1818
1983
  themeFields() {
1819
1984
  const B = binder(() => this.state.doc.theme, (k, v) => this.setTheme(k, v), this);
1985
+ const th = this.state.doc.theme;
1820
1986
  return decorate([
1987
+ B.head('Message'),
1988
+ B.text('Preview text', 'preheader', 'Shown next to the subject line in the inbox'),
1989
+ B.sel('Reading direction', 'dir', [{ value: '', label: 'Left to right' }, { value: 'rtl', label: 'Right to left' }]),
1821
1990
  B.head('Canvas'),
1822
1991
  B.slider('Content area width', 'width', 280, 900, 5, 'px'),
1823
1992
  // The page section: everything outside the content column. Its colour
@@ -1825,12 +1994,32 @@ export class EditorCore {
1825
1994
  // way to size it, the band around a template could only be seen (and
1826
1995
  // never adjusted) in a sent message.
1827
1996
  B.color('Page background color', 'bg', { transparent: true, solid: '#eef2f7' }),
1997
+ B.btn(th.bgImage ? 'Change background image' : 'Add background image', () => this.openLibrary({ theme: true, key: 'bgImage' })),
1998
+ ...(th.bgImage ? [
1999
+ B.btn('Remove background image', () => this.setTheme('bgImage', '')),
2000
+ B.sel('Image fit', 'bgSize', BG_FIT),
2001
+ B.sel('Image position', 'bgPos', BG_POS),
2002
+ B.sel('Image repeat', 'bgRepeat', BG_REPEAT),
2003
+ ] : []),
2004
+ B.text('Background image URL', 'bgImage', 'https://'),
1828
2005
  group('Space around content', [
1829
2006
  B.range('Top & bottom', 'padY', 0, 120, 2, 'px'),
1830
2007
  B.range('Sides', 'padX', 0, 120, 2, 'px'),
1831
2008
  ]),
1832
2009
  B.head('Content area'),
1833
2010
  B.color('Content area background color', 'contentBg', { transparent: true, solid: '#ffffff' }),
2011
+ // ONE place to add a background image: the full-width page, above. A
2012
+ // second "add" here read as a choice nobody could make sense of. The
2013
+ // content column can still CARRY an image -- an imported template that
2014
+ // painted its content table keeps it, faithfully -- so when one is
2015
+ // present it is shown, adjustable and removable rather than becoming
2016
+ // state the panel hides. Nothing offers to add one from here.
2017
+ ...(th.contentBgImage ? [
2018
+ B.btn('Remove content background image', () => this.setTheme('contentBgImage', '')),
2019
+ B.sel('Image fit', 'contentBgSize', BG_FIT),
2020
+ B.sel('Image position', 'contentBgPos', BG_POS),
2021
+ B.sel('Image repeat', 'contentBgRepeat', BG_REPEAT),
2022
+ ] : []),
1834
2023
  B.range('Corner radius', 'radius', 0, 48, 1, 'px'),
1835
2024
  B.range('Border thickness', 'borderW', 0, 12, 1, 'px'),
1836
2025
  B.sel('Drop shadow', 'shadow', [