@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
@@ -3,9 +3,10 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>MailCraft — live demo</title>
7
- <meta name="description" content="A host application embedding the MailCraft email editor: host-owned theme, locale, brand colour, typography and template gallery." />
8
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230065b3'/%3E%3Crect x='7' y='9.5' width='18' height='13' rx='2.2' fill='none' stroke='white' stroke-width='2.2'/%3E%3Cpath d='M7 11.5 16 18l9-6.5' fill='none' stroke='white' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
6
+ <title>Blocks MailCraft — live demo</title>
7
+ <meta name="description" content="A host application embedding the Blocks MailCraft email editor: host-owned theme, locale, brand colour, typography and template gallery." />
8
+ <link rel="icon" href="../assets/brand/icon.svg" media="(prefers-color-scheme: light)">
9
+ <link rel="icon" href="../assets/brand/icon-white.svg" media="(prefers-color-scheme: dark)">
9
10
  <style>
10
11
  /* The host app's brand color, as a design token -- exactly where a real
11
12
  app keeps it. The editor is pointed at it with accent="var(--brand)".
@@ -59,8 +60,11 @@
59
60
  flex: none; display: flex; align-items: center; gap: 18px; height: 56px; padding: 0 18px;
60
61
  background: var(--surface); border-bottom: 1px solid var(--line-soft);
61
62
  }
62
- .brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: var(--brand); }
63
- .brand-name { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; color: var(--text); }
63
+ .brand { display: flex; align-items: center; text-decoration: none; }
64
+ .brand-logo { width: auto; display: block; }
65
+ .logo-dark { display: none; }
66
+ [data-theme="dark"] .logo-light, .dark .logo-light { display: none; }
67
+ [data-theme="dark"] .logo-dark, .dark .logo-dark { display: block; }
64
68
  .tag {
65
69
  font-size: 10.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
66
70
  color: var(--brand); background: var(--surface-2);
@@ -104,7 +108,7 @@
104
108
  input[type="color"] { width: 34px; padding: 2px; }
105
109
 
106
110
  @media (max-width: 780px) {
107
- .tag, .brand-name { display: none; }
111
+ .tag { display: none; }
108
112
  .topbar, .controls { gap: 12px; padding: 0 12px; }
109
113
  }
110
114
  </style>
@@ -118,21 +122,18 @@
118
122
  pushed into the editor as attributes -- the editor never asks. -->
119
123
  <header class="topbar">
120
124
  <a class="brand" id="home-link" href="../README.md">
121
- <svg width="26" height="26" viewBox="0 0 32 32" aria-hidden="true">
122
- <rect width="32" height="32" rx="7" fill="currentColor" />
123
- <rect x="7" y="9.5" width="18" height="13" rx="2.2" fill="none" stroke="#fff" stroke-width="2.2" />
124
- <path d="M7 11.5 16 18l9-6.5" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
125
- </svg>
126
- <span class="brand-name">MailCraft</span>
125
+ <img class="brand-logo logo-light" src="../assets/brand/blocks-mailcraft-light.svg" alt="Blocks MailCraft" height="34">
126
+ <img class="brand-logo logo-dark" src="../assets/brand/blocks-mailcraft-dark.svg" alt="Blocks MailCraft" height="34">
127
127
  </a>
128
128
  <span class="tag">Live demo</span>
129
129
 
130
- <!-- Menu. The documentation link is resolved at runtime (see the script at
131
- the foot of the file): DOCS.md when this file is opened straight off
132
- disk, DOCS.html once the same file is served from GitHub Pages. -->
130
+ <!-- Menu. The doc links are resolved at runtime (see the script at the
131
+ foot of the file): the .md sources when this file is opened straight
132
+ off disk, the Jekyll-built .html pages once the same file is served
133
+ from GitHub Pages. The logo is the way back to the homepage. -->
133
134
  <nav class="menu" aria-label="Primary">
134
- <a id="docs-link" href="../DOCS.md">Documentation</a>
135
- <a id="readme-link" href="../README.md">Overview</a>
135
+ <a id="quickstart-link" href="../README.md">Quick start</a>
136
+ <a id="docs-link" href="../DOCS.md">Guide</a>
136
137
  <!-- No GitHub link while the repository is private -- it 404s for anyone
137
138
  outside the org. Restore it when the repo goes public. -->
138
139
  <a href="https://www.npmjs.com/package/@seliseblocks/mailcraft" rel="noopener">npm</a>
@@ -1443,7 +1444,7 @@
1443
1444
  // One check keeps both links live instead of picking a winner.
1444
1445
  if (location.protocol !== 'file:') {
1445
1446
  document.getElementById('docs-link').href = '../DOCS.html';
1446
- document.getElementById('readme-link').href = '../';
1447
+ document.getElementById('quickstart-link').href = '../README.html';
1447
1448
  document.getElementById('home-link').href = '../';
1448
1449
  }
1449
1450
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seliseblocks/mailcraft",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Framework-agnostic drag-and-drop email template editor, packaged as a zero-dependency Web Component.",
5
5
  "license": "MIT",
6
6
  "author": "SELISE Digital Platforms",
@@ -60,7 +60,14 @@ export const mkRow = (spans, blocks) => ({
60
60
  border: 0, borderStyle: 'solid', lineColor: '#e2e2e5', bTop: true, bRight: true, bBottom: true, bLeft: true, radius: 0, shadow: '', maxW: 100,
61
61
  mt: 0, mr: 0, mb: 0, ml: 0,
62
62
  layout: 'columns', flexDir: 'row', justify: 'flex-start', alignItems: 'stretch', wrap: true, gridCols: 2,
63
- py: 20, px: 24, padSplit: false, gap: 20, valign: 'top', stackMobile: true,
63
+ py: 20, px: 24, padSplit: false, gap: 20, valign: 'top',
64
+ // What this row does on a narrow screen. `mobileCols` is the number of
65
+ // columns it keeps there -- 1 stacks (the old `stackMobile: true`), 2 makes
66
+ // a two-up grid, 'keep' leaves the desktop layout alone (the old
67
+ // `stackMobile: false`). `mobileOrder: 'reverse'` flips the visual order,
68
+ // which is how an alternating image/text strip keeps the image on top of
69
+ // every band once stacked. Saved documents are mapped over in migrateDoc.
70
+ mobileCols: 1, mobileOrder: 'normal',
64
71
  },
65
72
  cols: spans.map((s, i) => ({ id: uid(), span: s, blocks: i === 0 && blocks ? blocks : [] })),
66
73
  });
@@ -210,6 +217,13 @@ export function migrateDoc(doc) {
210
217
  // 'square' choice rather than silently becoming an unrecognized value.
211
218
  c.blocks.forEach((b) => { if (b.type === 'social' && b.props.shape === 'solid') b.props.shape = 'square'; });
212
219
  });
220
+ // Mobile behaviour used to be one boolean, `stackMobile`. Mapped before
221
+ // the defaults are applied, or every document saved before this build
222
+ // would take `mobileCols: 1` from the defaults and a row that had
223
+ // deliberately opted out of stacking would start stacking.
224
+ if (r.props.mobileCols === undefined && r.props.stackMobile !== undefined) {
225
+ r.props.mobileCols = r.props.stackMobile === false ? 'keep' : 1;
226
+ }
213
227
  Object.keys(defaults).forEach((k) => { if (r.props[k] === undefined) r.props[k] = defaults[k]; });
214
228
  if (hadBlocks && !r.cols.some((c) => c.blocks.length)) emptied.push(r.id);
215
229
  });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Blocks MailCraft brand artwork, inline.
3
+ *
4
+ * The editor ships as one self-contained bundle, so the logo cannot be an
5
+ * external file the way it is on the site -- these are the official vectors
6
+ * (assets/brand/) transcribed as path data. Two pieces:
7
+ *
8
+ * MARK the standalone Blocks mark, on its own 245.455 x 360 canvas
9
+ * LOCKUP the full "Blocks MailCraft" logo, mark plus wordmark, on the
10
+ * 557 x 200 canvas the official product lockups use
11
+ *
12
+ * Colour comes from --ed-logo-mark / --ed-logo-text (see render/style.js), not
13
+ * from currentColor: a logo must not be recoloured by the host's brand
14
+ * attribute the way the rest of the accent-driven chrome is.
15
+ */
16
+
17
+ const SVG_NS = 'http://www.w3.org/2000/svg';
18
+
19
+ const MARK = [
20
+ 'M245.455 68.1627V129.877L168.982 156.67V93.9638L245.455 68.1627Z',
21
+ 'M240.389 62.3805L165.49 87.6573L5.30933 24.2563L85.3314 0L240.389 62.3805Z',
22
+ 'M161.797 93.8295V156.43L81.1141 122.607V188.07L0 152.738V29.6846L161.797 93.8295Z',
23
+ 'M76.4728 266.036L0 291.837V230.123L76.4728 203.329V266.036Z',
24
+ 'M160.123 360L5.07178 297.619L79.964 272.343L240.145 335.744L160.123 360Z',
25
+ 'M245.454 330.315L83.657 266.17V203.57L164.34 237.393V171.93L245.454 207.262V330.315Z',
26
+ ];
27
+
28
+ const LOCKUP_MARK = [
29
+ 'M137 38.4761V73.3121L94.3168 88.4361V53.0401L137 38.4761Z',
30
+ 'M134.173 35.212L92.368 49.48L2.9635 13.692L47.6276 0L134.173 35.212Z',
31
+ 'M90.3067 52.9639V88.2999L45.2737 69.2079V106.16L0 86.2159V16.7559L90.3067 52.9639Z',
32
+ 'M42.6832 146.96L0 161.524V126.688L42.6832 111.564V146.96Z',
33
+ 'M89.3723 200L2.83105 164.788L44.632 150.52L134.036 186.308L89.3723 200Z',
34
+ 'M137 183.244L46.6932 147.036V111.7L91.7263 130.792V93.8398L137 113.784V183.244Z',
35
+ ];
36
+
37
+ const LOCKUP_TEXT = [
38
+ 'M198.375 79.4375H210.781C214.323 79.4375 216.896 78.7708 218.5 77.4375C220.125 76.1042 220.938 74.2188 220.938 71.7812V71.6875C220.938 70.0208 220.635 68.5938 220.031 67.4062C219.448 66.1979 218.521 65.2708 217.25 64.625C215.979 63.9792 214.344 63.6562 212.344 63.6562H198.375V58.6875H212.344C214.99 58.6875 216.979 58.0833 218.312 56.875C219.667 55.6667 220.344 53.875 220.344 51.5C220.344 49.0208 219.604 47.1667 218.125 45.9375C216.646 44.7083 214.427 44.0938 211.469 44.0938H198.375V39.0625H212.531C215.469 39.0625 217.927 39.5625 219.906 40.5625C221.885 41.5625 223.365 42.9792 224.344 44.8125C225.323 46.6458 225.812 48.8333 225.812 51.375C225.812 52.9167 225.51 54.375 224.906 55.75C224.302 57.1042 223.375 58.2708 222.125 59.25C220.896 60.2292 219.365 60.8854 217.531 61.2188C219.365 61.4896 220.948 62.1458 222.281 63.1875C223.615 64.2083 224.635 65.4896 225.344 67.0312C226.052 68.5729 226.406 70.2396 226.406 72.0312V72.125C226.406 74.5833 225.865 76.75 224.781 78.625C223.698 80.5 222.146 81.9479 220.125 82.9688C218.104 83.9896 215.75 84.5 213.062 84.5H198.375V79.4375ZM194.625 39.0625H200.062V84.5H194.625V39.0625ZM240.11 76.0312C240.11 77.1146 240.36 77.9583 240.86 78.5625C241.381 79.1458 242.1 79.4375 243.016 79.4375H246.016V84.5625H242.266C239.85 84.5625 237.975 83.8125 236.641 82.3125C235.329 80.8125 234.673 78.6979 234.673 75.9688V39.0625H240.11V76.0312ZM265.033 84.9688C262.22 84.9688 259.814 84.4167 257.814 83.3125C255.814 82.2083 254.283 80.6042 253.22 78.5C252.178 76.375 251.658 73.8229 251.658 70.8438V65.5625C251.658 62.625 252.178 60.1146 253.22 58.0312C254.283 55.9271 255.814 54.3333 257.814 53.25C259.814 52.1458 262.22 51.5938 265.033 51.5938C267.845 51.5938 270.251 52.1458 272.251 53.25C274.251 54.3333 275.772 55.9271 276.814 58.0312C277.876 60.1146 278.408 62.625 278.408 65.5625V70.9375C278.408 73.8958 277.876 76.4271 276.814 78.5312C275.772 80.6146 274.251 82.2083 272.251 83.3125C270.251 84.4167 267.845 84.9688 265.033 84.9688ZM265.033 79.8438C267.553 79.8438 269.501 79.0625 270.876 77.5C272.272 75.9375 272.97 73.75 272.97 70.9375V65.5625C272.97 62.75 272.272 60.5729 270.876 59.0312C269.501 57.4896 267.553 56.7188 265.033 56.7188C262.512 56.7188 260.553 57.4896 259.158 59.0312C257.783 60.5729 257.095 62.75 257.095 65.5625V70.9375C257.095 73.75 257.783 75.9375 259.158 77.5C260.553 79.0625 262.512 79.8438 265.033 79.8438ZM299.924 84.9688C296.882 84.9688 294.32 84.4375 292.236 83.375C290.153 82.2917 288.57 80.7083 287.486 78.625C286.424 76.5208 285.893 73.9375 285.893 70.875V65.5312C285.893 62.5104 286.424 59.9688 287.486 57.9062C288.57 55.8229 290.153 54.25 292.236 53.1875C294.32 52.125 296.882 51.5938 299.924 51.5938C301.653 51.5938 303.257 51.7917 304.736 52.1875C306.215 52.5833 307.538 53.1667 308.705 53.9375C309.872 54.6875 310.861 55.6354 311.674 56.7812L307.705 60.3125C306.705 59.125 305.549 58.2292 304.236 57.625C302.924 57.0208 301.518 56.7188 300.018 56.7188C297.184 56.7188 295.028 57.4688 293.549 58.9688C292.07 60.4479 291.33 62.6354 291.33 65.5312V70.875C291.33 73.8125 292.07 76.0417 293.549 77.5625C295.028 79.0833 297.184 79.8438 300.018 79.8438C301.58 79.8438 303.028 79.5417 304.361 78.9375C305.715 78.3125 306.903 77.4062 307.924 76.2188L311.674 79.875C310.84 80.9792 309.84 81.9167 308.674 82.6875C307.507 83.4375 306.184 84.0104 304.705 84.4062C303.247 84.7812 301.653 84.9688 299.924 84.9688ZM321.065 72.8125L338.596 52.0625H345.003L320.409 79.75L321.065 72.8125ZM318.065 39.0625H323.503V84.5H318.065V39.0625ZM329.409 66.3438L332.784 62.0938L346.596 84.5H340.409L329.409 66.3438ZM362.613 84.9688C360.779 84.9688 359.029 84.7917 357.363 84.4375C355.717 84.0625 354.165 83.5208 352.706 82.8125C351.269 82.1042 349.967 81.2396 348.8 80.2188L351.988 76.3125C353.758 77.5625 355.55 78.5208 357.363 79.1875C359.175 79.8542 360.925 80.1875 362.613 80.1875C365.425 80.1875 367.571 79.7292 369.05 78.8125C370.529 77.875 371.269 76.5312 371.269 74.7812C371.269 73.5521 370.894 72.6354 370.144 72.0312C369.394 71.4062 368.446 70.9896 367.3 70.7812C366.175 70.5729 364.592 70.3854 362.55 70.2188C362.363 70.2188 362.165 70.2188 361.956 70.2188C361.748 70.1979 361.55 70.1771 361.363 70.1562C361.279 70.1562 361.196 70.1562 361.113 70.1562C361.029 70.1354 360.946 70.125 360.863 70.125C358.738 69.9375 356.946 69.625 355.488 69.1875C354.05 68.75 352.842 67.9375 351.863 66.75C350.904 65.5625 350.425 63.8542 350.425 61.625C350.425 59.4375 350.915 57.6042 351.894 56.125C352.873 54.625 354.31 53.5 356.206 52.75C358.102 51.9792 360.435 51.5938 363.206 51.5938C364.748 51.5938 366.258 51.7396 367.738 52.0312C369.217 52.3229 370.623 52.7604 371.956 53.3438C373.31 53.9062 374.581 54.6146 375.769 55.4688L372.55 59.3438C370.967 58.3646 369.363 57.625 367.738 57.125C366.133 56.625 364.592 56.375 363.113 56.375C360.654 56.375 358.779 56.8125 357.488 57.6875C356.196 58.5625 355.55 59.8333 355.55 61.5C355.55 62.5833 355.904 63.3958 356.613 63.9375C357.342 64.4792 358.248 64.8438 359.331 65.0312C360.415 65.1979 361.935 65.3333 363.894 65.4375C363.998 65.4583 364.102 65.4688 364.206 65.4688C364.331 65.4688 364.446 65.4792 364.55 65.5C364.613 65.5 364.675 65.5 364.738 65.5C364.8 65.5 364.863 65.5104 364.925 65.5312C367.196 65.6562 369.123 65.9479 370.706 66.4062C372.31 66.8438 373.654 67.7292 374.738 69.0625C375.821 70.375 376.363 72.2812 376.363 74.7812C376.363 77.0104 375.842 78.875 374.8 80.375C373.758 81.875 372.217 83.0208 370.175 83.8125C368.133 84.5833 365.613 84.9688 362.613 84.9688Z',
39
+ 'M194.6250 164.5000V113.3830H209.2415L222.2737 149.9422H220.4842L233.5164 113.3830H248.1109V164.5000H238.1148V125.0732L239.0389 125.2493L226.2267 159.9677H216.5312L203.6970 125.2493L204.6211 125.0732V164.5000ZM278.3938 164.5000 277.6457 159.3443V141.6330Q277.6457 138.5822 275.6693 137.1301Q273.6928 135.6780 270.4293 135.6780Q267.5470 135.6780 264.5182 136.7854Q261.4893 137.8928 259.2231 139.8289L254.5954 133.2431Q258.5264 130.1849 262.5417 128.8941Q266.5570 127.6033 271.2286 127.6033Q278.7532 127.6033 282.9738 131.3473Q287.1945 135.0912 287.1945 141.6330V164.5000ZM267.6717 165.2407Q263.7188 165.2407 260.5725 163.7593Q257.4263 162.2778 255.6222 159.5570Q253.8180 156.8361 253.8180 153.2572Q253.8180 150.1476 255.1308 147.8778Q256.4436 145.6080 258.6071 144.3832Q260.3599 143.3418 262.5857 142.8908Q264.8115 142.4397 267.3197 142.4397H278.4378V149.5463H268.7131Q267.6130 149.5463 266.5973 149.7370Q265.5816 149.9276 264.8262 150.4703Q264.1808 150.9470 263.8105 151.6621Q263.4401 152.3771 263.4401 153.2792Q263.4401 155.2153 264.9766 156.3630Q266.5130 157.5108 269.0798 157.5108Q271.4193 157.5108 273.3628 156.5024Q275.3062 155.4940 276.4760 153.7302Q277.6457 151.9664 277.6457 149.7076L280.0586 154.8634Q278.9511 158.6476 277.0187 160.9358Q275.0862 163.2239 272.6807 164.2323Q270.2752 165.2407 267.6717 165.2407ZM293.5324 164.5000V128.3441H303.1324V164.5000ZM293.3050 122.8950V113.3830H303.4111V122.8950ZM318.5276 165.2407Q314.7654 165.2407 312.2498 162.9562Q309.7343 160.6717 309.7343 155.6847V113.3830H319.2831V153.7925Q319.2831 155.2006 319.9835 155.7947Q320.6838 156.3887 321.7912 156.3887Q322.3999 156.3887 323.0270 156.2677Q323.6540 156.1467 324.3287 155.9560V164.1113Q322.7740 164.7274 321.2522 164.9840Q319.7304 165.2407 318.5276 165.2407ZM351.5828 165.6881Q344.2856 165.6881 338.7376 162.3365Q333.1895 158.9849 330.0909 152.9565Q326.9924 146.9281 326.9924 138.9048Q326.9924 130.8816 330.0909 124.8898Q333.1895 118.8981 338.7376 115.5465Q344.2856 112.1949 351.5828 112.1949Q359.7967 112.1949 365.7518 116.2909Q371.7069 120.3868 374.3031 127.5960L364.9011 130.9990Q363.3023 126.6280 359.8811 124.1858Q356.4598 121.7436 351.5828 121.7436Q347.1458 121.7436 343.8493 123.8265Q340.5527 125.9093 338.7706 129.7265Q336.9885 133.5438 336.9885 138.9048Q336.9885 144.2659 338.7706 148.1198Q340.5527 151.9737 343.8493 154.0565Q347.1458 156.1393 351.5828 156.1393Q356.4598 156.1393 359.8811 153.6862Q363.3023 151.2330 364.9011 146.8840L374.3031 150.2650Q371.7069 157.4008 365.7518 161.5445Q359.7967 165.6881 351.5828 165.6881ZM378.8149 164.5000V128.3441H388.4883V138.1788L387.0802 138.2888Q387.2856 135.1646 388.9210 132.7554Q390.5564 130.3462 393.0060 128.9748Q395.4555 127.6033 398.0003 127.6033Q399.8411 127.6033 401.5096 128.0984Q403.1780 128.5934 404.9968 129.7302L400.3765 138.3842Q399.5991 137.8928 398.3120 137.5225Q397.0249 137.1521 395.7708 137.1521Q394.3700 137.1521 393.0573 137.6581Q391.7445 138.1642 390.7361 139.2239Q389.7277 140.2836 389.1484 141.9631Q388.8037 142.9898 388.6203 144.5886Q388.4370 146.1874 388.4370 149.2016V164.5000ZM430.7987 164.5000 430.0507 159.3443V141.6330Q430.0507 138.5822 428.0742 137.1301Q426.0977 135.6780 422.8342 135.6780Q419.9520 135.6780 416.9231 136.7854Q413.8942 137.8928 411.6280 139.8289L407.0004 133.2431Q410.9313 130.1849 414.9466 128.8941Q418.9619 127.6033 423.6336 127.6033Q431.1581 127.6033 435.3788 131.3473Q439.5994 135.0912 439.5994 141.6330V164.5000ZM420.0767 165.2407Q416.1237 165.2407 412.9775 163.7593Q409.8313 162.2778 408.0271 159.5570Q406.2230 156.8361 406.2230 153.2572Q406.2230 150.1476 407.5358 147.8778Q408.8485 145.6080 411.0120 144.3832Q412.7648 143.3418 414.9906 142.8908Q417.2164 142.4397 419.7246 142.4397H430.8427V149.5463H421.1181Q420.0180 149.5463 419.0023 149.7370Q417.9865 149.9276 417.2312 150.4703Q416.5858 150.9470 416.2154 151.6621Q415.8451 152.3771 415.8451 153.2792Q415.8451 155.2153 417.3815 156.3630Q418.9179 157.5108 421.4848 157.5108Q423.8243 157.5108 425.7677 156.5024Q427.7112 155.4940 428.8809 153.7302Q430.0507 151.9664 430.0507 149.7076L432.4635 154.8634Q431.3561 158.6476 429.4236 160.9358Q427.4911 163.2239 425.0857 164.2323Q422.6802 165.2407 420.0767 165.2407ZM448.7902 164.5000V135.8027H442.3877V128.3441H448.8635V124.6772Q448.8635 119.9028 450.4403 117.2883Q452.0171 114.6738 454.6353 113.6580Q457.2535 112.6423 460.3190 112.6423Q462.6072 112.6423 464.3013 113.0603Q465.9954 113.4783 467.5429 114.2117L464.4627 121.5236Q463.7586 121.1349 462.9849 120.8782Q462.2112 120.6216 461.3898 120.6216Q459.7103 120.6216 459.0613 121.7546Q458.4123 122.8877 458.4123 124.7285V128.3441H465.9735V135.8027H458.4123V164.5000ZM483.8475 165.2407Q480.9213 165.2407 478.3435 164.1223Q475.7656 163.0039 474.2071 160.4700Q472.6487 157.9362 472.6487 153.6679V120.1522L482.3441 114.9378V150.9763Q482.3441 154.1372 483.1948 155.7617Q484.0456 157.3861 486.4877 157.3861Q487.2211 157.3861 488.1452 157.2284Q489.0693 157.0708 490.1400 156.7774V164.1333Q488.5925 164.6980 487.0341 164.9694Q485.4757 165.2407 483.8475 165.2407ZM466.1949 135.8027V128.3441H490.1400V135.8027Z',
40
+ ];
41
+
42
+ function svg(viewBox, w, h) {
43
+ const el = document.createElementNS(SVG_NS, 'svg');
44
+ el.setAttribute('viewBox', viewBox);
45
+ el.setAttribute('width', String(Math.round(w)));
46
+ el.setAttribute('height', String(Math.round(h)));
47
+ el.setAttribute('fill', 'none');
48
+ el.style.display = 'block';
49
+ el.style.flex = 'none';
50
+ return el;
51
+ }
52
+
53
+ function fill(parent, list, paint) {
54
+ list.forEach((d) => {
55
+ const p = document.createElementNS(SVG_NS, 'path');
56
+ p.setAttribute('d', d);
57
+ p.setAttribute('fill', paint);
58
+ parent.appendChild(p);
59
+ });
60
+ }
61
+
62
+ /** The bare Blocks mark at `height` px, width from the artwork's own aspect. */
63
+ export function brandMark(height) {
64
+ const h = height || 22;
65
+ const el = svg('0 0 245.455 360', (h * 245.455) / 360, h);
66
+ el.setAttribute('role', 'img');
67
+ el.setAttribute('aria-label', 'Blocks');
68
+ fill(el, MARK, 'var(--ed-logo-mark)');
69
+ return el;
70
+ }
71
+
72
+ /** The full "Blocks MailCraft" lockup at `height` px. */
73
+ export function brandLockup(height) {
74
+ const h = height || 34;
75
+ const el = svg('0 0 557 200', (h * 557) / 200, h);
76
+ el.setAttribute('role', 'img');
77
+ el.setAttribute('aria-label', 'Blocks MailCraft');
78
+ fill(el, LOCKUP_MARK, 'var(--ed-logo-mark)');
79
+ fill(el, LOCKUP_TEXT, 'var(--ed-logo-text)');
80
+ return el;
81
+ }
@@ -3,7 +3,7 @@ import { mk, blk, mkRow, GROUPS, LAYOUTS, migrateDoc, normalizeDoc, blankDoc } f
3
3
  import { binder, decorate, group } from './binder.js';
4
4
  import { ALL_FOLDER_ID, normalizeAsset, resolveLimits, providerProblems } from './storage.js';
5
5
  import { validateFiles, limitsProblem } from './storage-limits.js';
6
- import { migrateTokens, cleanHtml, escHtml } from './sanitize.js';
6
+ import { migrateTokens, cleanHtml, escHtml, linkHref } from './sanitize.js';
7
7
  import { buildHtml as buildHtmlFn } from './export.js';
8
8
  import { htmlToDoc } from './import-html.js';
9
9
  import { boxCss } from './layout-style.js';
@@ -490,11 +490,16 @@ export class EditorCore {
490
490
 
491
491
  applyLink = (b) => {
492
492
  const d = this.state.linkDraft; if (!d) return;
493
- this.exec('createLink', d.href);
493
+ // Normalized here rather than at render: inline links live inside the
494
+ // block's own HTML, which nothing re-templates, so a bare `selise.ch`
495
+ // typed in this dialog would be stored -- and shipped -- as a relative
496
+ // URL that resolves against the mail client instead of the site.
497
+ const href = linkHref(d.href);
498
+ this.exec('createLink', href);
494
499
  const elNode = this.editEl;
495
500
  if (elNode) {
496
501
  elNode.querySelectorAll('a').forEach((a) => {
497
- if (a.getAttribute('href') !== d.href) return;
502
+ if (a.getAttribute('href') !== href) return;
498
503
  if (d.blank) { a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener'); }
499
504
  else { a.removeAttribute('target'); a.removeAttribute('rel'); }
500
505
  });
@@ -827,17 +832,39 @@ export class EditorCore {
827
832
  };
828
833
  }
829
834
 
830
- canvasDragOver = (e) => {
835
+ /**
836
+ * The element whose children are the row slots, given whatever the drag
837
+ * listener was attached to. The row slots are children of the sheet, but the
838
+ * canvas's own listeners sit on the page wrapper around it, so that a drag
839
+ * held over the page's padding still counts as aimed at the template
840
+ * (canvas.js). Reading the page's children finds no slots at all, so an
841
+ * index derived from it came back 0 and every section landed at the top no
842
+ * matter where the drop line was shown. The canvas now passes its index
843
+ * outright; this keeps the derived path right for any other host wiring.
844
+ */
845
+ rowSlotHost(target) {
846
+ if (!target || !target.getAttribute) return target || null;
847
+ if (target.getAttribute('data-mc-sheet') === '1') return target;
848
+ return (target.querySelector && target.querySelector('[data-mc-sheet="1"]')) || target;
849
+ }
850
+
851
+ canvasDragOver = (e, index) => {
831
852
  const d = this.drag; if (!d) return;
832
853
  e.preventDefault();
833
- const index = this.indexFromPoint(e.currentTarget, e.clientY);
834
- if (this.state.rowDrop !== index) this.setState({ rowDrop: index, drop: null });
854
+ const at = typeof index === 'number' ? index : this.indexFromPoint(this.rowSlotHost(e.currentTarget), e.clientY);
855
+ if (this.state.rowDrop !== at) this.setState({ rowDrop: at, drop: null });
835
856
  };
836
857
 
837
- canvasDrop = (e) => {
858
+ /**
859
+ * `index` is the slot the caller already highlighted. The canvas passes it
860
+ * so the drop lands where the line was drawn -- one measurement, not two of
861
+ * them racing. Omitted (a host wiring these onto its own element), the
862
+ * index is derived here instead.
863
+ */
864
+ canvasDrop = (e, index) => {
838
865
  const d = this.drag; if (!d) return;
839
866
  e.preventDefault();
840
- const index = this.indexFromPoint(e.currentTarget, e.clientY);
867
+ index = typeof index === 'number' ? index : this.indexFromPoint(this.rowSlotHost(e.currentTarget), e.clientY);
841
868
  if (d.kind === 'row') this.insertRow(d.spans, index, d.html);
842
869
  else if (d.kind === 'move-row') this.moveRow(d.id, index);
843
870
  else if (d.kind === 'block') this.insertBlock(d.type, null, 0, index);
@@ -1278,6 +1305,17 @@ export class EditorCore {
1278
1305
  // line and a dead divider strip under the title.
1279
1306
  const base = [];
1280
1307
  const padF = [B.head('Spacing'), group(null, [B.range('Above & below', 'py', 0, 160, 2, 'px'), B.range('Left & right', 'px', 0, 120, 2, 'px')])];
1308
+ // Appended to every block type at once rather than repeated across
1309
+ // twenty switch arms. An absent `vis` means "all devices", so the
1310
+ // property only ever appears in a document that asked for it and no
1311
+ // migration is needed. Re-decorating an already-decorated list is safe:
1312
+ // `decorate` derives its flags from `kind`, which survives the pass.
1313
+ const visF = [B.head('Visibility'), B.seg('Show on', 'vis', [
1314
+ { value: 'all', label: 'All' },
1315
+ { value: 'desktop', label: 'Desktop' },
1316
+ { value: 'mobile', label: 'Mobile' },
1317
+ ])];
1318
+ const built = (() => {
1281
1319
  switch (b.type) {
1282
1320
  case 'text': return decorate(base.concat(
1283
1321
  /<a(?:\s|>)/i.test(String(b.props.html || ''))
@@ -1341,6 +1379,11 @@ export class EditorCore {
1341
1379
  case 'codeblock': return decorate(base.concat([B.area('Code', 'code'), B.color('Background', 'bg'), B.color('Text color', 'color'), B.range('Size', 'size', 8, 32, 0.5, 'px'), B.range('Padding', 'pad', 0, 80, 2, 'px')]));
1342
1380
  default: return [];
1343
1381
  }
1382
+ })();
1383
+ // Logic markers are editor furniture with no rendered body, so there is
1384
+ // nothing for a device to show or hide.
1385
+ if (b.type === 'condition' || b.type === 'loop') return built;
1386
+ return decorate(built.concat(visF));
1344
1387
  }
1345
1388
  if (f.row) {
1346
1389
  const r = f.row;
@@ -1378,7 +1421,21 @@ export class EditorCore {
1378
1421
  },
1379
1422
  ...(r.cols.length > 1 ? [
1380
1423
  B.range('Space between columns', 'gap', 0, 120, 2, 'px'),
1381
- B.tog('Stack columns on mobile', 'stackMobile'),
1424
+ B.head('On mobile'),
1425
+ // Replaces the old "Stack columns on mobile" switch, which could
1426
+ // only say all-or-nothing. Saved documents are mapped onto these
1427
+ // values in migrateDoc, so an existing toggle keeps its meaning.
1428
+ B.seg('Columns', 'mobileCols', [
1429
+ { value: 1, label: 'One' },
1430
+ { value: 2, label: 'Two' },
1431
+ { value: 'keep', label: 'Keep' },
1432
+ ]),
1433
+ // Only offered where it changes something: reversing a row that
1434
+ // keeps its desktop layout would do nothing.
1435
+ ...(p.mobileCols !== 'keep' ? [B.seg('Order', 'mobileOrder', [
1436
+ { value: 'normal', label: 'Normal' },
1437
+ { value: 'reverse', label: 'Reverse' },
1438
+ ])] : []),
1382
1439
  ] : []),
1383
1440
  // Per-column styling, only for multi-column sections (a single
1384
1441
  // column's background is just the section background).
@@ -48,6 +48,57 @@ function logicPlan(doc) {
48
48
  return { emit, tail };
49
49
  }
50
50
 
51
+ /**
52
+ * The two Word-engine declarations that have to be applied to the finished
53
+ * markup rather than to the DOM the renderer builds.
54
+ *
55
+ * `mso-*` are not real CSS properties, and CSSOM silently drops anything it
56
+ * does not recognise -- `style.msoLineHeightRule = 'exactly'` and
57
+ * `setProperty('mso-line-height-rule', ...)` both no-op, and the canvas is
58
+ * built through `Object.assign(node.style, ...)`. Since the exporter reads
59
+ * that DOM back as `outerHTML`, a string pass over the result is the only
60
+ * place these can be added. They are also meaningless anywhere but Outlook,
61
+ * so the canvas is better off without them.
62
+ *
63
+ * - `mso-line-height-rule:exactly` -- Classic Outlook otherwise ignores
64
+ * `line-height` outright and sets text solid. Added only where a real
65
+ * line-height is already declared, so it never invents spacing of its own.
66
+ * - `mso-table-lspace/rspace:0pt` -- Word adds its own horizontal space
67
+ * around a table, which shows up as phantom gaps between columns.
68
+ *
69
+ * Both are idempotent: a second pass finds its own marker and skips.
70
+ */
71
+ export function msoHarden(html) {
72
+ return html
73
+ .replace(/style="([^"]*line-height:[^"]*)"/g, (m0, css) => {
74
+ if (/mso-line-height-rule/.test(css)) return m0;
75
+ // A ratio resolved against the font size in the same declaration.
76
+ // `exactly` tells Word to use the line-height verbatim, and a unitless
77
+ // 1.65 is not a length -- the pair is ambiguous at best and collapses
78
+ // the leading at worst. Every block that sets a line-height sets its
79
+ // font-size beside it, so the multiplication is exact rather than a
80
+ // guess; anything the renderer did not author (imported markup with a
81
+ // bare ratio, or a keyword like `normal`) is left exactly as it is and
82
+ // gets no `exactly` either, since there would be no length to honour.
83
+ const fs = css.match(/font-size:\s*([\d.]+)px/);
84
+ const out = fs
85
+ ? css.replace(/line-height:\s*([\d.]+)\s*(;|$)/g, (s0, ratio, end) => 'line-height:' + Math.round(parseFloat(fs[1]) * parseFloat(ratio)) + 'px' + end)
86
+ : css;
87
+ if (!/line-height:\s*[\d.]+px/.test(out)) return 'style="' + out + '"';
88
+ return 'style="' + out + (out.trim().endsWith(';') ? '' : ';') + 'mso-line-height-rule:exactly;"';
89
+ })
90
+ .replace(/<table\b([^>]*)>/g, (m0, attrs) => {
91
+ if (/mso-table-lspace/.test(attrs)) return m0;
92
+ const spacing = 'mso-table-lspace:0pt;mso-table-rspace:0pt;';
93
+ // Appended, never prepended: the declarations the template actually
94
+ // authored stay at the front of the attribute, where both the importer
95
+ // and a human reading the source expect to find them.
96
+ return /style="/.test(attrs)
97
+ ? '<table' + attrs.replace(/style="([^"]*)"/, (s0, css) => 'style="' + css + (!css.trim() || css.trim().endsWith(';') ? '' : ';') + spacing + '"') + '>'
98
+ : '<table' + attrs + ' style="' + spacing + '">';
99
+ });
100
+ }
101
+
51
102
  /**
52
103
  * Display-only dressing for the Code modal's live-preview iframe: the
53
104
  * exported template carries literal {{#if}}/{{#each}} tags, which an iframe
@@ -102,8 +153,50 @@ export function buildHtml(state, root, boxCss) {
102
153
  .replace(/\sdata-(?:gramm|gramm_editor|enable-grammarly|lt-active)="[^"]*"/g, '')
103
154
  .replace(/\sdraggable="[^"]*"/g, '');
104
155
  };
156
+ /*
157
+ * Which mobile rules this document actually needs. Only the ones a row (or
158
+ * a block) asks for are emitted, and rows that ask for the same thing share
159
+ * one class -- so a template that never leaves the defaults ships exactly
160
+ * the stylesheet it shipped before this feature existed, and one that uses
161
+ * every mode still ships a handful of rules rather than BEE's one selector
162
+ * per block with no deduplication.
163
+ */
164
+ const need = { stack: false, twoUp: false, reverse: false, hideM: false, hideD: false };
165
+ /**
166
+ * One decision per row, made once: what goes on the row box (`<tr>`, or the
167
+ * flex/grid wrapper) and what goes on each cell.
168
+ *
169
+ * The plain one-up stack keeps the exact markup and rule it had before this
170
+ * feature -- `mc-col` on the cells, no flex anywhere -- so the overwhelmingly
171
+ * common case cannot regress, and if a sanitiser ever strips `display:flex`
172
+ * the fancy modes degrade to the desktop layout rather than taking ordinary
173
+ * stacking down with them.
174
+ */
175
+ const mobilePlan = (rp, cols) => {
176
+ const mode = rp.mobileCols === undefined ? 1 : rp.mobileCols;
177
+ if (cols < 2 || mode === 'keep') return { row: '', cell: '' };
178
+ const rowCls = [];
179
+ // Two-up and reverse both need the row to become a flex container, which
180
+ // is safe precisely because it only ever runs inside the media query: a
181
+ // client that cannot do flex is a client that ignored the query and is
182
+ // still being shown the desktop table.
183
+ if (String(mode) === '2') { rowCls.push('mc-2up'); need.twoUp = true; }
184
+ if (rp.mobileOrder === 'reverse') { rowCls.push('mc-rev'); need.reverse = true; }
185
+ // Cells only carry `mc-col` in one-up; in two-up the row's own rule sizes
186
+ // them, so the two never fight over width.
187
+ const cell = String(mode) === '2' ? '' : 'mc-col';
188
+ if (cell) need.stack = true;
189
+ return { row: rowCls.join(' '), cell };
190
+ };
191
+ /** Per-block device visibility. Absent means "all devices", so nothing is emitted. */
192
+ const visClass = (bp) => {
193
+ if (bp.vis === 'desktop') { need.hideM = true; return ' class="mc-only-d"'; }
194
+ if (bp.vis === 'mobile') { need.hideD = true; return ' class="mc-only-m"'; }
195
+ return '';
196
+ };
105
197
  const rows = d.rows.map((r) => {
106
198
  const rp = r.props;
199
+ const plan = mobilePlan(rp, r.cols.length);
107
200
  // A row holding nothing but logic markers exists to wrap the *sections*
108
201
  // around it (drop a Condition onto the canvas above and below a group of
109
202
  // rows). Emitting its <tr> scaffolding would leave an empty padded band
@@ -119,7 +212,7 @@ export function buildHtml(state, root, boxCss) {
119
212
  if (b.type === 'html') return b.props.code || '';
120
213
  if (b.type === 'svg') return '<div style="text-align:' + b.props.align + ';padding:' + b.props.py + 'px 0">' + (b.props.code || '') + '</div>';
121
214
  if (b.type === 'condition' || b.type === 'loop') return logic.emit.get(b.id) || '';
122
- return '<div style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
215
+ return '<div' + visClass(b.props) + ' style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
123
216
  }).filter(Boolean).join('\n ') || '&nbsp;';
124
217
  const cells = r.cols.map((c) => {
125
218
  // Column-level styling (bg/radius/inner padding) renders as a wrapper
@@ -128,14 +221,21 @@ export function buildHtml(state, root, boxCss) {
128
221
  const inner = (c.bg || c.border || c.radius || c.padY || c.padX)
129
222
  ? '<div style="background:' + (c.bg || 'transparent') + ';' + (c.border ? 'border:' + c.border + 'px ' + (c.borderStyle || 'solid') + ' ' + (c.lineColor || '#e2e2e5') + ';' : '') + 'border-radius:' + (c.radius || 0) + 'px;padding:' + (c.padY || 0) + 'px ' + (c.padX || 0) + 'px">\n ' + colInner(c) + '\n </div>'
130
223
  : colInner(c);
131
- return '<td width="' + c.span + '%" valign="' + rp.valign + '" style="padding:0 ' + Math.round(rp.gap / 2) + 'px;">\n ' + inner + '\n </td>';
224
+ return '<td' + (plan.cell ? ' class="' + plan.cell + '"' : '') + ' width="' + c.span + '%" valign="' + rp.valign + '" style="padding:0 ' + Math.round(rp.gap / 2) + 'px;">\n ' + inner + '\n </td>';
132
225
  }).join('\n ');
226
+ // The CSS-layout rows reach the same behaviour through their wrapper: one
227
+ // class on the flex/grid container, so the markup stays exactly as it was
228
+ // for every wide client.
229
+ const wrapCls = [plan.cell ? 'mc-stack' : '', plan.row].filter(Boolean).join(' ');
230
+ const stackWrap = wrapCls ? ' class="' + wrapCls + '"' : '';
133
231
  const cssBody = rp.layout === 'grid'
134
- ? '<div style="display:grid;grid-template-columns:repeat(' + (rp.gridCols || 2) + ',minmax(0,1fr));gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div>' + colInner(c) + '</div>').join('\n ') + '\n </div>'
135
- : '<div style="display:flex;flex-direction:' + (rp.flexDir || 'row') + ';justify-content:' + (rp.justify || 'flex-start') + ';align-items:' + (rp.alignItems || 'stretch') + ';flex-wrap:' + (rp.wrap ? 'wrap' : 'nowrap') + ';gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div style="flex:' + c.span + ' 1 auto;min-width:0">' + colInner(c) + '</div>').join('\n ') + '\n </div>';
232
+ ? '<div' + stackWrap + ' style="display:grid;grid-template-columns:repeat(' + (rp.gridCols || 2) + ',minmax(0,1fr));gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div>' + colInner(c) + '</div>').join('\n ') + '\n </div>'
233
+ : '<div' + stackWrap + ' style="display:flex;flex-direction:' + (rp.flexDir || 'row') + ';justify-content:' + (rp.justify || 'flex-start') + ';align-items:' + (rp.alignItems || 'stretch') + ';flex-wrap:' + (rp.wrap ? 'wrap' : 'nowrap') + ';gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div style="flex:' + c.span + ' 1 auto;min-width:0">' + colInner(c) + '</div>').join('\n ') + '\n </div>';
136
234
  const body = rp.layout && rp.layout !== 'columns'
137
235
  ? cssBody
138
- : '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>\n ' + cells + '\n </tr></table>';
236
+ // The `<tr>` is what becomes the flex container for two-up and reverse;
237
+ // in the default one-up stack it carries no class at all, exactly as before.
238
+ : '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr' + (plan.row ? ' class="' + plan.row + '"' : '') + '>\n ' + cells + '\n </tr></table>';
139
239
  const tdBg = rp.bgImage
140
240
  ? 'background-color:' + (rp.bg || t.contentBg || 'transparent') + ';background-image:' + (rp.overlay ? 'linear-gradient(rgba(20,22,24,' + (rp.overlay / 100) + '),rgba(20,22,24,' + (rp.overlay / 100) + ')),' : '') + 'url(&quot;' + cssUrl(rp.bgImage) + '&quot;);background-size:' + (rp.bgSize || 'cover') + ';background-position:' + (rp.bgPos || 'center') + ';background-repeat:' + (rp.bgRepeat || 'no-repeat') + ';'
141
241
  // Falls all the way through to `transparent`: a row inherits the
@@ -165,5 +265,94 @@ export function buildHtml(state, root, boxCss) {
165
265
  // Patchy in mail clients (Outlook drops it), but honest: what the user
166
266
  // styled ships, and capable clients render it.
167
267
  + (t.shadow ? 'box-shadow:' + t.shadow + ';' : '');
168
- return '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light dark">\n<title>' + 'Email' + '</title>\n</head>\n<body style="margin:0;padding:0;background:' + pageBg + ';font-family:' + t.font.replace(/"/g, "'") + ';color:' + t.text + ';-webkit-font-smoothing:antialiased;">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n <table role="presentation" width="' + t.width + '" cellpadding="0" cellspacing="0" border="0" style="width:' + t.width + 'px;max-width:100%;background:' + (t.contentBg || 'transparent') + ';' + contentShape + '">\n' + rows + '\n </table>\n </td></tr>\n</table>\n</body>\n</html>';
268
+ /*
269
+ * The content column is `width:100%` capped by `max-width`, never a fixed
270
+ * `width:<n>px`.
271
+ *
272
+ * The fixed width was what made the sent email unresponsive, and not only
273
+ * for itself: a px width becomes the table's min-content contribution, so
274
+ * it propagated outward and pinned the full-width wrapper open too. A plain
275
+ * text-only template measured 620px of horizontal scroll on a 390px phone,
276
+ * and `max-width:100%` -- already sitting right there -- never got the
277
+ * chance to engage, because 100% of a container the table had itself forced
278
+ * to 620px is 620px. Swapping the two makes the cap the real constraint:
279
+ * measured 500px (fits) on the phone, unchanged 620px on the desktop.
280
+ *
281
+ * The `<!--[if mso]>` pair is the price of that. Word-based Outlook honours
282
+ * neither `max-width` nor the media query below, so on its own it would now
283
+ * render the column edge to edge across the whole window; the ghost table
284
+ * is a fixed-width cage only Outlook sees, which holds the old geometry for
285
+ * exactly the client that cannot do better. Every other client skips the
286
+ * conditional comment entirely and gets the fluid table.
287
+ */
288
+ const ghostOpen = '<!--[if mso]><table role="presentation" width="' + t.width + '" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->';
289
+ const ghostClose = '<!--[if mso]></td></tr></table><![endif]-->';
290
+ const shell = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:' + t.width + 'px;background:' + (t.contentBg || 'transparent') + ';' + contentShape + '">\n' + rows + '\n </table>';
291
+ /*
292
+ * The one embedded stylesheet in the document, and the only place the
293
+ * exporter is not inline-styled -- a media query cannot be expressed
294
+ * inline, and every rule here is a narrow-screen override of an inline
295
+ * style, hence `!important` throughout.
296
+ *
297
+ * `mc-col` / `mc-stack` are what finally make "Stack columns on mobile" do
298
+ * something: the toggle has shipped in the row inspector since columns
299
+ * existed, but nothing read the prop -- no media query was emitted and the
300
+ * canvas ignored it -- so a 4-column row stayed 4 columns of 60px on a
301
+ * phone. The image rule is the other half of being responsive: a fixed-width
302
+ * image from an import would otherwise hold a stacked column open.
303
+ */
304
+ const stackCss = '\n<style>\n'
305
+ + '/* Apple Mail and iOS auto-detect dates, addresses and phone numbers and\n'
306
+ + ' repaint them as blue underlined links. This hands them back to the\n'
307
+ + ' surrounding text; links the template actually declares are untouched,\n'
308
+ + ' because they carry their own inline colour. */\n'
309
+ + 'a[x-apple-data-detectors] { color:inherit !important; text-decoration:none !important; }\n'
310
+ /*
311
+ * No `p { line-height: inherit }` here, though it is standard in
312
+ * hand-written email and BEE emits it. This document is also an *input*:
313
+ * core/css-cascade.js folds every non-`@media` rule into inline styles on
314
+ * import, so that one rule came back stamped on every paragraph and an
315
+ * export -> import -> export cycle stopped converging. The pixel
316
+ * line-heights it would have protected are already inline on the block
317
+ * that owns them, which is the stronger guarantee anyway.
318
+ */
319
+ // A mobile-only block has to be hidden here, outside the query, because
320
+ // Classic Outlook never reads the query -- `display:none` alone would
321
+ // leave it visible in exactly the client that cannot be told otherwise.
322
+ // `mso-hide` is the half Word understands; the rest is for everyone else.
323
+ + (need.hideD ? '.mc-only-m, .mc-only-m table { mso-hide:all; display:none; max-height:0; overflow:hidden; }\n' : '')
324
+ + '@media only screen and (max-width:' + t.width + 'px) {\n'
325
+ + (need.stack ? ' .mc-col { display:block !important; width:100% !important; padding-left:0 !important; padding-right:0 !important; }\n'
326
+ + ' .mc-stack { display:block !important; }\n'
327
+ + ' .mc-stack > div { width:100% !important; }\n' : '')
328
+ // Two-up: the row becomes a flex container and each cell takes half.
329
+ // `box-sizing` is load-bearing -- with the default content box, a cell's
330
+ // own padding pushes 50% over the line and every cell wraps to its own row.
331
+ + (need.twoUp ? ' .mc-2up { display:flex !important; flex-wrap:wrap !important; }\n'
332
+ + ' .mc-2up > td, .mc-2up > div { box-sizing:border-box !important; flex:0 0 50% !important; max-width:50% !important; }\n' : '')
333
+ // Reverse: `column-reverse` flips a stack of any depth, where the usual
334
+ // `table-header-group` trick has only two usable slots and so cannot
335
+ // reverse a three- or four-column row at all. Two-up reverses along both
336
+ // axes so the last cell ends up first.
337
+ + (need.reverse ? ' .mc-rev { display:flex !important; flex-wrap:wrap !important; flex-direction:column-reverse !important; }\n'
338
+ + ' .mc-2up.mc-rev { flex-direction:row-reverse !important; flex-wrap:wrap-reverse !important; }\n' : '')
339
+ + (need.hideM ? ' .mc-only-d, .mc-only-d table { display:none !important; max-height:0 !important; overflow:hidden !important; }\n' : '')
340
+ + (need.hideD ? ' .mc-only-m, .mc-only-m table { display:block !important; max-height:none !important; overflow:visible !important; }\n' : '')
341
+ + ' img { max-width:100% !important; height:auto !important; }\n'
342
+ + '}\n</style>';
343
+ /*
344
+ * `xmlns:o` earns its place; `xmlns:v` does not. The Office namespace is
345
+ * what makes `<o:OfficeDocumentSettings>` parse, and `PixelsPerInch` 96 is
346
+ * a live bug fix rather than a legacy one: on a high-DPI Windows display
347
+ * Outlook renders at 120dpi and scales the whole template about 25% larger
348
+ * than authored. VML's namespace is deliberately absent -- nothing here
349
+ * emits VML, and declaring a namespace for markup that never appears is
350
+ * noise in every other client.
351
+ */
352
+ const msoHead = '\n<!--[if mso]>\n<xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>\n<![endif]-->';
353
+ // `text-size-adjust` at 100%, never `none`: both stop a mobile client
354
+ // inflating the type, but `none` also blocks legitimate scaling and leaves
355
+ // text unreadably small on some Android clients.
356
+ const bodyStyle = 'margin:0;padding:0;background:' + pageBg + ';font-family:' + t.font.replace(/"/g, "'") + ';color:' + t.text + ';-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;text-size-adjust:100%;';
357
+ return msoHarden('<!doctype html>\n<html lang="en" xmlns:o="urn:schemas-microsoft-com:office:office">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light">\n<meta name="supported-color-schemes" content="light">\n<title>' + 'Email' + '</title>' + msoHead + stackCss + '\n</head>\n<body style="' + bodyStyle + '">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n ' + ghostOpen + '\n ' + shell + '\n ' + ghostClose + '\n </td></tr>\n</table>\n</body>\n</html>');
169
358
  }
@@ -81,7 +81,7 @@ export const AR = {
81
81
  'rows.hintStack': 'تكديس حر: بدون تخطيطات — كل كتلة تسحبها تصبح قسمًا مستقلًا بعرض كامل، مرتبة بالتسلسل.',
82
82
 
83
83
  'files.libraryCount': 'المكتبة — {count} ملفات',
84
- 'files.openManager': 'فتح مدير الملفات',
84
+ 'files.openManager': 'فتح مكتبة الوسائط',
85
85
  'files.dragToCanvas': 'اسحب أي ملف مباشرة إلى اللوحة لوضعه كصورة.',
86
86
 
87
87
  'layers.structure': 'البنية',
@@ -100,7 +100,7 @@ export const AR = {
100
100
  'vars.noneDeclared': 'لم يُعرِّف تطبيقك أي متغيرات بعد.',
101
101
  'vars.rteHint': 'أثناء تحرير النص يمكنك أيضًا اختيار متغير من القائمة المنسدلة في شريط الأدوات المضمّن.',
102
102
 
103
- 'modal.fileManager': 'مدير الملفات',
103
+ 'modal.fileManager': 'مكتبة الوسائط',
104
104
  'modal.assetsTitle': 'الأصول',
105
105
  'modal.replaceImage': 'استبدال الصورة',
106
106
  'library.searchPlaceholder': 'بحث في الملفات…',
@@ -66,7 +66,7 @@ export const BG = {
66
66
  'Базирано на секции: първо пуснете оформление, после блокове в неговите колони — всяка секция има собствен фон, рамка, flex или grid настройки.',
67
67
  'rows.hintStack': 'Свободно подреждане: без оформления — всеки пуснат блок става собствена секция на цялата ширина, подредена по ред.',
68
68
  'files.libraryCount': 'Библиотека — {count} файла',
69
- 'files.openManager': 'Отваряне на файловия мениджър',
69
+ 'files.openManager': 'Отваряне на медийната библиотека',
70
70
  'files.dragToCanvas': 'Плъзнете произволен файл директно върху платното, за да го поставите като блок с изображение.',
71
71
  'layers.structure': 'Структура',
72
72
  'layers.sectionSingle': 'Секция',
@@ -82,7 +82,7 @@ export const BG = {
82
82
  'vars.noMatch': 'Няма променлива, отговаряща на този филтър.',
83
83
  'vars.noneDeclared': 'Вашето приложение все още не е декларирало променливи.',
84
84
  'vars.rteHint': 'При редактиране на текст можете да изберете променлива и директно от падащото меню на вградената лента с инструменти.',
85
- 'modal.fileManager': 'Файлов мениджър',
85
+ 'modal.fileManager': 'Медийна библиотека',
86
86
  'modal.assetsTitle': 'Ресурси',
87
87
  'modal.replaceImage': 'Замяна на изображение',
88
88
  'library.searchPlaceholder': 'Търсене на файлове…',