@seliseblocks/mailcraft 0.1.1 → 0.2.1

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 (86) hide show
  1. package/DOCS.md +243 -16
  2. package/README.md +15 -6
  3. package/README.md.txt +135 -0
  4. package/dist/mailcraft-editor.bundle.js +87 -47
  5. package/dist/mailcraft-editor.bundle.js.map +3 -3
  6. package/examples/templates/activate-your-account.html +1 -1
  7. package/examples/templates/back-in-stock.html +1 -1
  8. package/examples/templates/cart-left-behind.html +1 -1
  9. package/examples/templates/community-giveaway.html +1 -1
  10. package/examples/templates/frontend-futures-invite.html +1 -1
  11. package/examples/templates/give-25-get-25.html +1 -1
  12. package/examples/templates/invoice-paid.html +1 -1
  13. package/examples/templates/meet-nova-launch.html +1 -1
  14. package/examples/templates/mega-weekend-sale.html +1 -1
  15. package/examples/templates/order-confirmed.html +80 -80
  16. package/examples/templates/rate-your-headphones.html +1 -1
  17. package/examples/templates/reset-your-password.html +1 -1
  18. package/examples/templates/thankyou-promo-code.html +1 -1
  19. package/examples/templates/the-sunday-brief.html +1 -1
  20. package/examples/templates/welcome-to-your-workspace.html +1 -1
  21. package/examples/templates/your-order-shipped.html +1 -1
  22. package/examples/templates/your-password-was-changed.html +1 -1
  23. package/examples/templates/your-signin-code.html +1 -1
  24. package/examples/vanilla.html +261 -47
  25. package/package.json +7 -2
  26. package/src/core/assets.js +10 -15
  27. package/src/core/binder.js +140 -118
  28. package/src/core/blocks.js +14 -1
  29. package/src/core/css-cascade.js +117 -117
  30. package/src/core/editor-core.js +1540 -1492
  31. package/src/core/export.js +158 -55
  32. package/src/core/i18n/ar.js +218 -177
  33. package/src/core/i18n/bg.js +195 -152
  34. package/src/core/i18n/bn.js +217 -176
  35. package/src/core/i18n/ca.js +195 -152
  36. package/src/core/i18n/cs.js +195 -152
  37. package/src/core/i18n/da.js +195 -152
  38. package/src/core/i18n/de-CH.js +195 -152
  39. package/src/core/i18n/de.js +195 -152
  40. package/src/core/i18n/dz.js +220 -179
  41. package/src/core/i18n/el.js +195 -152
  42. package/src/core/i18n/en.js +3 -11
  43. package/src/core/i18n/es.js +195 -152
  44. package/src/core/i18n/et.js +195 -152
  45. package/src/core/i18n/fi.js +195 -152
  46. package/src/core/i18n/fr.js +195 -152
  47. package/src/core/i18n/hr.js +195 -152
  48. package/src/core/i18n/hu.js +195 -152
  49. package/src/core/i18n/index.js +83 -83
  50. package/src/core/i18n/it.js +195 -152
  51. package/src/core/i18n/lt.js +195 -152
  52. package/src/core/i18n/lv.js +195 -152
  53. package/src/core/i18n/nb.js +195 -152
  54. package/src/core/i18n/nl.js +195 -152
  55. package/src/core/i18n/pl.js +195 -152
  56. package/src/core/i18n/pt.js +195 -152
  57. package/src/core/i18n/ro.js +195 -152
  58. package/src/core/i18n/ru.js +195 -152
  59. package/src/core/i18n/sk.js +195 -152
  60. package/src/core/i18n/sl.js +195 -152
  61. package/src/core/i18n/sv.js +195 -152
  62. package/src/core/i18n/tables.js +50 -50
  63. package/src/core/i18n/tr.js +195 -152
  64. package/src/core/i18n/uk.js +195 -152
  65. package/src/core/icons.js +237 -235
  66. package/src/core/ids.js +1 -1
  67. package/src/core/import-html.js +1047 -959
  68. package/src/core/layout-style.js +100 -100
  69. package/src/core/parse.js +10 -10
  70. package/src/core/placeholder.js +15 -15
  71. package/src/core/sanitize.js +141 -141
  72. package/src/core/storage-limits.js +189 -184
  73. package/src/core/storage.js +85 -85
  74. package/src/core/theme.js +15 -1
  75. package/src/core/variables.js +11 -11
  76. package/src/index.js +9 -9
  77. package/src/mailcraft-editor.js +64 -25
  78. package/src/render/block-body.js +49 -6
  79. package/src/render/canvas.js +74 -8
  80. package/src/render/fields.js +619 -588
  81. package/src/render/focus-preserve.js +158 -158
  82. package/src/render/rte.js +241 -212
  83. package/src/render/screenshot.js +138 -132
  84. package/src/render/story.js +415 -415
  85. package/src/render/style.js +44 -8
  86. package/types/index.d.ts +422 -0
@@ -8,6 +8,7 @@ import { renderField, renderFieldCards, typeCommit } from './render/fields.js';
8
8
  import { icon } from './core/icons.js';
9
9
  import { TOKEN } from './core/variables.js';
10
10
  import { hl, cssUrl } from './core/sanitize.js';
11
+ import { decorateLogicTags } from './core/export.js';
11
12
  import { withFocusPreserved } from './render/focus-preserve.js';
12
13
  import { captureTemplatePng } from './render/screenshot.js';
13
14
  import { resolveToolbar, toolbarKey } from './core/toolbar.js';
@@ -310,7 +311,7 @@ export class MailCraftEditor extends ElementBase {
310
311
  this.core.emit();
311
312
  }
312
313
 
313
- /** `dir` wins when the host sets it explicitly; otherwise it defaults from `isRtl(locale)` so `locale="ar"` gets RTL for free. Applied directly to `#mc` (not full state, since it never affects rendered content, only the CSS `dir`). */
314
+ /** `dir` wins when the host sets it explicitly; otherwise it defaults from `isRtl(locale)` so `locale="ar"` gets RTL for free. Applied directly to `#mc` (not full state, since it only mirrors the editor chrome). The email sheet, the code editor, and the value-shaped inspector fields pin their own `dir` so the document being built never flips with the chrome (see render/canvas.js renderDoc). */
314
315
  applyDir() {
315
316
  if (!this.mc) return;
316
317
  const explicit = this.getAttribute('dir');
@@ -417,20 +418,21 @@ export class MailCraftEditor extends ElementBase {
417
418
 
418
419
  /**
419
420
  * Host-supplied file storage -- see `core/storage.js` for the contract, and
420
- * README.md for a worked example. Setting one replaces the seeded demo
421
- * library with the backend's own: folders, paging,
422
- * upload and delete all go through it, and nothing is written to
423
- * localStorage. Set `null` to go back to the built-in behaviour.
421
+ * README.md for a worked example. The package ships no files of its own: with
422
+ * nothing set here the library opens empty and holds only what is dropped
423
+ * into it, in this browser, in this draft. Setting a provider hands the whole
424
+ * library to the backend -- folders, paging, upload and delete all go through
425
+ * it, and nothing is written to localStorage. Set `null` to go back.
424
426
  */
425
427
  get storageProvider() { return this.core.storageProvider; }
426
428
  set storageProvider(provider) { this.core.setStorageProvider(provider); }
427
429
 
428
430
  /**
429
431
  * What may be uploaded: `{ accept, maxBytes, maxWidth, maxHeight, maxFilesPerDrop, allowSvg }`.
430
- * Required alongside a provider and deliberately undefaulted -- the ceilings
431
- * that suit an email template depend on the sending platform, the audience's
432
- * mail clients and the host's own rules, none of which this package can know.
433
- * See `core/storage-limits.js`.
432
+ * Only `maxBytes` is required alongside a provider -- sizes depend on the
433
+ * sending platform and the host's own rules, none of which this package can
434
+ * know. `accept` is optional and defaults to every image type the validator
435
+ * can recognize; list MIME types to narrow it. See `core/storage-limits.js`.
434
436
  */
435
437
  get storageLimits() { return this.core.storageLimits; }
436
438
  set storageLimits(limits) { this.core.setStorageLimits(limits); }
@@ -521,7 +523,7 @@ export class MailCraftEditor extends ElementBase {
521
523
 
522
524
  this.footerBar = this.footerText = this.footerLink = null;
523
525
 
524
- this.body = elS('div', 'display: grid; grid-template-columns: minmax(0, 1fr) 340px; grid-template-rows: minmax(0, 1fr) auto; min-height: 0;', { class: 'mc-layout' });
526
+ this.body = elS('div', 'display: grid; grid-template-columns: minmax(0, 1fr) 340px; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; background: var(--ed-work);', { class: 'mc-layout' });
525
527
  this.frame.appendChild(this.body);
526
528
  this.body.appendChild(this.buildMain());
527
529
  this.body.appendChild(this.buildAside());
@@ -742,7 +744,7 @@ export class MailCraftEditor extends ElementBase {
742
744
  * would strand whichever node the previous render had left in place.
743
745
  */
744
746
  buildFooter() {
745
- this.footerBar = elS('div', 'display: flex; grid-column: 1; grid-row: 2; justify-self: center; align-items: center; justify-content: center; gap: 4px; max-width: calc(100% - 32px); padding: 6px 14px; border-top: 1px solid var(--ed-line); background: var(--ed-panel-2); color: var(--ed-panel-meta); font-family: var(--ed-font); font-size: var(--ed-panel-meta-size); letter-spacing: 0.06em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;', { class: 'mc-footer' });
747
+ this.footerBar = elS('div', 'display: flex; grid-column: 1; grid-row: 2; justify-self: center; align-items: center; justify-content: center; gap: 4px; max-width: calc(100% - 32px); padding: 6px 14px; background: transparent; color: var(--ed-panel-meta); font-family: var(--ed-font); font-size: var(--ed-panel-meta-size); letter-spacing: 0.06em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;', { class: 'mc-footer' });
746
748
  this.footerText = elS('span', '', { class: 'mc-footer-text' });
747
749
  this.footerLink = elS('a', 'color: inherit; text-decoration: none;', { class: 'mc-footer-link' });
748
750
  this.footerBar.appendChild(this.footerText);
@@ -919,8 +921,11 @@ export class MailCraftEditor extends ElementBase {
919
921
  if (this.redoBtn) this.redoBtn.disabled = !s.future.length;
920
922
  // A host-supplied `theme` attribute owns light/dark (see applyTheme) --
921
923
  // hide the built-in toggle rather than let it fight the host's control.
924
+ // setProperty(..., 'important'): a plain assignment loses to the
925
+ // `!important` on `.mc-icon-label` (render/style.js) that keeps every
926
+ // other label-icon button visible, leaving the toggle rendered anyway.
922
927
  if (this.chromeBtn) {
923
- this.chromeBtn.style.display = this.hasAttribute('theme') ? 'none' : 'flex';
928
+ this.chromeBtn.style.setProperty('display', this.hasAttribute('theme') ? 'none' : 'flex', 'important');
924
929
  this.chromeBtn.labelNode.textContent = s.chrome === 'light' ? t('action.chromeToDark') : t('action.chromeToLight');
925
930
  this.chromeBtn.i18nTipKey = s.chrome === 'light' ? 'action.chromeHintToDark' : 'action.chromeHintToLight';
926
931
  this.chromeBtn.tipNode.textContent = t(this.chromeBtn.i18nTipKey);
@@ -1266,7 +1271,8 @@ export class MailCraftEditor extends ElementBase {
1266
1271
  line.addEventListener('click', () => this.core.select('block', b.id));
1267
1272
  const chip = elS('span', `width: 22px; height: 22px; flex: none; border-radius: 6px; display: flex; align-items: center; justify-content: center; background: ${bSel ? 'var(--ed-accent)' : 'var(--ed-soft)'}; color: ${bSel ? 'var(--ed-accent-ink)' : 'var(--ed-accent)'};`);
1268
1273
  chip.appendChild(icon(b.type, 12));
1269
- line.append(chip, elS('span', 'font-family: var(--ed-font); font-size: 12px; font-weight: 500; color: var(--ed-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', { text: text || typeName, class: 'mc-tree-title' }));
1274
+ // dir=auto: this excerpt is the email's own copy, not chrome text.
1275
+ line.append(chip, elS('span', 'font-family: var(--ed-font); font-size: 12px; font-weight: 500; color: var(--ed-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', { text: text || typeName, class: 'mc-tree-title', dir: 'auto' }));
1270
1276
  if (text) {
1271
1277
  line.append(
1272
1278
  elS('span', 'flex: 1; min-width: 8px;'),
@@ -1305,7 +1311,7 @@ export class MailCraftEditor extends ElementBase {
1305
1311
  wrap.appendChild(this.sectionBar(t('vars.title'), t('vars.fromCode', { count: this.core.vars().length })));
1306
1312
  const card = this.sectionBody();
1307
1313
  card.appendChild(elS('p', 'margin: 0 0 12px; font-size: 11.5px; color: var(--ed-muted); line-height: 1.55;', { text: t('vars.declaredNote') }));
1308
- const search = elS('input', 'width: 100%; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); border-radius: 8px; color: var(--ed-text); font: inherit; font-size: 12.5px; padding: 7px 9px; margin-bottom: 8px;', { placeholder: t('vars.filterPlaceholder'), 'data-focus-key': 'var-query' });
1314
+ const search = elS('input', 'width: 100%; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); border-radius: 8px; color: var(--ed-text); font: inherit; font-size: 12.5px; padding: 7px 9px; margin-bottom: 8px;', { placeholder: t('vars.filterPlaceholder'), 'data-focus-key': 'var-query', dir: 'auto' });
1309
1315
  search.value = s.varQuery || '';
1310
1316
  search.addEventListener('focus', () => { search.style.borderColor = 'var(--ed-accent)'; search.style.outline = 'none'; });
1311
1317
  search.addEventListener('blur', () => { search.style.borderColor = 'var(--ed-line)'; });
@@ -1326,7 +1332,8 @@ export class MailCraftEditor extends ElementBase {
1326
1332
  const textSpan = elS('span', 'flex: 1; min-width: 0;');
1327
1333
  textSpan.append(
1328
1334
  elS('span', 'display: block; font-family: var(--ed-font); font-weight: 600; font-size: 12.5px; line-height: 1.2;', { text: name }),
1329
- elS('span', 'display: block; font-family: ui-monospace, monospace; font-size: 10px; color: var(--ed-accent); margin-top: 2px;', { text: TOKEN(v) }),
1335
+ // dir=ltr keeps the braces on the right ends of {{ token }} under RTL.
1336
+ elS('span', 'display: block; font-family: ui-monospace, monospace; font-size: 10px; color: var(--ed-accent); margin-top: 2px;', { text: TOKEN(v), dir: 'ltr' }),
1330
1337
  );
1331
1338
  btn.append(textSpan, elS('span', 'font-family: var(--ed-font); font-size: 9.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ed-faint);', { text: t('vars.insert') }));
1332
1339
  grid.appendChild(btn);
@@ -1356,7 +1363,7 @@ export class MailCraftEditor extends ElementBase {
1356
1363
  elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('modal.fileManager'), 'data-i18n': 'modal.fileManager' }),
1357
1364
  (this.libraryTitleText = elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 16px; line-height: 1.25;')),
1358
1365
  );
1359
- this.assetSearch = elS('input', 'width: 210px; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 7px 9px;', { placeholder: t('library.searchPlaceholder'), 'data-i18n-placeholder': 'library.searchPlaceholder', 'data-focus-key': 'asset-query' });
1366
+ this.assetSearch = elS('input', 'width: 210px; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 7px 9px;', { placeholder: t('library.searchPlaceholder'), 'data-i18n-placeholder': 'library.searchPlaceholder', 'data-focus-key': 'asset-query', dir: 'auto' });
1360
1367
  this.assetSearch.addEventListener('focus', () => { this.assetSearch.style.borderColor = 'var(--ed-accent)'; this.assetSearch.style.outline = 'none'; });
1361
1368
  this.assetSearch.addEventListener('blur', () => { this.assetSearch.style.borderColor = 'var(--ed-line)'; });
1362
1369
  // Debounced re-render; setAssetQuery separately debounces the backend call.
@@ -1516,7 +1523,9 @@ export class MailCraftEditor extends ElementBase {
1516
1523
  head.append(headText, this.copyBtn, this.shotBtn, downloadBtn, closeBtn);
1517
1524
  modal.appendChild(head);
1518
1525
 
1519
- this.exportTextarea = elS('textarea', 'width: 100%; height: 100%; box-sizing: border-box; border: 0; background: var(--ed-work); color: var(--ed-text); font-family: ui-monospace, monospace; font-size: 11.5px; line-height: 1.55; padding: 18px 20px; resize: none;', { readonly: 'true' });
1526
+ // dir=ltr: this holds HTML source, which bidi-scrambles under the RTL
1527
+ // chrome (same reason as buildCodeEditor).
1528
+ this.exportTextarea = elS('textarea', 'width: 100%; height: 100%; box-sizing: border-box; border: 0; background: var(--ed-work); color: var(--ed-text); font-family: ui-monospace, monospace; font-size: 11.5px; line-height: 1.55; padding: 18px 20px; resize: none;', { readonly: 'true', dir: 'ltr' });
1520
1529
  modal.appendChild(this.exportTextarea);
1521
1530
  this.exportMetaEl = elS('div', 'padding: 10px 20px; border-top: 1px solid var(--ed-line); font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.06em; color: var(--ed-faint);');
1522
1531
  modal.appendChild(this.exportMetaEl);
@@ -1583,8 +1592,16 @@ export class MailCraftEditor extends ElementBase {
1583
1592
 
1584
1593
  const right = elS('div', 'display: grid; grid-template-rows: auto 1fr; min-height: 0; border: 1px solid var(--ed-line); border-radius: 12px; overflow: hidden; background: var(--ed-panel);', { class: 'mc-code-preview' });
1585
1594
  right.appendChild(elS('div', 'padding: 7px 16px; border-bottom: 1px solid var(--ed-line); background: var(--ed-panel); font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('code.livePreview'), 'data-i18n': 'code.livePreview', class: 'mc-pane-label' }));
1586
- const previewWrap = elS('div', 'overflow: auto; padding: 20px; display: flex; justify-content: center; background: var(--ed-work);', { class: 'mc-code-preview-body' });
1587
- this.codeFrame = elS('iframe', "width: 100%; height: 100%; min-height: 420px; border: 1px solid var(--ed-line-2); border-radius: 6px; background: #ffffff; box-shadow: 0 6px 18px rgba(15,23,42,0.10); transition: width 0.24s cubic-bezier(0.22,0.61,0.36,1);", { title: t('code.liveHtmlPreviewTitle'), 'data-i18n-title': 'code.liveHtmlPreviewTitle', class: 'mc-code-frame' });
1595
+ // Flush, not a card on a mat: the document inside the frame paints its
1596
+ // own page background edge to edge, so padding the wrap and framing the
1597
+ // iframe stacked a second and third rectangle around a template that
1598
+ // already has one -- the pane, a mat, a bordered card, then the email's
1599
+ // own page, then its content column. `is-device` (renderCodeModal) puts
1600
+ // the mat and the frame back for the 390px phone width, where the card
1601
+ // *is* the point.
1602
+ const previewWrap = elS('div', 'overflow: auto; padding: 0; display: flex; justify-content: center; background: var(--ed-work);', { class: 'mc-code-preview-body' });
1603
+ this.codePreviewBody = previewWrap;
1604
+ this.codeFrame = elS('iframe', "width: 100%; height: 100%; min-height: 420px; border: 0; background: #ffffff; transition: width 0.24s cubic-bezier(0.22,0.61,0.36,1);", { title: t('code.liveHtmlPreviewTitle'), 'data-i18n-title': 'code.liveHtmlPreviewTitle', class: 'mc-code-frame' });
1588
1605
  this.codeFrame.addEventListener('load', () => this.syncCodePreviewScrollbar());
1589
1606
  previewWrap.appendChild(this.codeFrame);
1590
1607
  right.appendChild(previewWrap);
@@ -1601,7 +1618,10 @@ export class MailCraftEditor extends ElementBase {
1601
1618
  * highlight or caret drifting away from the editable text. */
1602
1619
  buildCodeEditor() {
1603
1620
  const MONO = 'font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 20px; tab-size: 2;';
1604
- const root = elS('div', 'position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; background: var(--ed-work);');
1621
+ // dir=ltr: HTML source is directional text; under an RTL chrome the
1622
+ // inherited direction would bidi-scramble it and, worse, let the caret
1623
+ // in the transparent textarea drift off the highlighted layer beneath.
1624
+ const root = elS('div', 'position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; background: var(--ed-work);', { dir: 'ltr' });
1605
1625
  const inner = elS('div', 'position: relative; min-height: 100%; width: 100%; min-width: 0;');
1606
1626
  this.codePre = elS('pre', `${MONO} margin: 0; padding: 14px 0 80px; width: 100%; min-width: 0; min-height: 100%; white-space: normal; color: var(--ed-text); pointer-events: none; background: linear-gradient(to right, var(--ed-panel-2) 0 52px, var(--ed-work) 52px);`, { 'aria-hidden': 'true' });
1607
1627
  this.codeTextarea = elS('textarea', `${MONO} position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 14px 18px 80px 70px; border: 0; background: transparent; color: transparent; caret-color: var(--ed-accent); white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; overflow: hidden; resize: none; outline: none;`, { spellcheck: 'false', wrap: 'soft', 'data-focus-key': 'code-src' });
@@ -1645,7 +1665,10 @@ export class MailCraftEditor extends ElementBase {
1645
1665
  if (!s.codeOpen) return;
1646
1666
  this.refreshCodeSource();
1647
1667
  if (this._codeFrameSrc !== s.codeLive) {
1648
- this.codeFrame.srcdoc = s.codeLive;
1668
+ // Display-only: the preview pane draws logic tags as the canvas's
1669
+ // dashed bands; the source pane and everything exported keep the
1670
+ // literal {{#if}}/{{#each}} text.
1671
+ this.codeFrame.srcdoc = decorateLogicTags(s.codeLive);
1649
1672
  this._codeFrameSrc = s.codeLive;
1650
1673
  }
1651
1674
  this.syncCodePreviewScrollbar();
@@ -1658,7 +1681,9 @@ export class MailCraftEditor extends ElementBase {
1658
1681
  tip(btn, w.label, 'down');
1659
1682
  this.codeWidthSeg.appendChild(btn);
1660
1683
  });
1661
- this.codeFrame.style.width = s.codeDevice === 'mobile' ? '390px' : '100%';
1684
+ const phone = s.codeDevice === 'mobile';
1685
+ this.codeFrame.style.width = phone ? '390px' : '100%';
1686
+ this.codePreviewBody.classList.toggle('is-device', phone);
1662
1687
  }
1663
1688
 
1664
1689
  /** Repaint the syntax layer, gutter and metadata without touching the
@@ -1730,7 +1755,7 @@ export class MailCraftEditor extends ElementBase {
1730
1755
 
1731
1756
  const briefWrap = elS('div');
1732
1757
  briefWrap.appendChild(elS('label', 'display: block; font-size: 11.5px; color: var(--ed-muted); margin-bottom: 4px;', { text: t('ai.briefLabel'), 'data-i18n': 'ai.briefLabel' }));
1733
- this.aiBriefInput = elS('textarea', 'width: 100%; min-height: 78px; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 9px; resize: vertical;', { placeholder: t('ai.briefPlaceholder'), 'data-i18n-placeholder': 'ai.briefPlaceholder', 'data-focus-key': 'ai-brief' });
1758
+ this.aiBriefInput = elS('textarea', 'width: 100%; min-height: 78px; box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font: inherit; font-size: 13px; padding: 9px; resize: vertical;', { placeholder: t('ai.briefPlaceholder'), 'data-i18n-placeholder': 'ai.briefPlaceholder', 'data-focus-key': 'ai-brief', dir: 'auto' });
1734
1759
  this.aiBriefInput.addEventListener('focus', () => { this.aiBriefInput.style.borderColor = 'var(--ed-accent)'; this.aiBriefInput.style.outline = 'none'; });
1735
1760
  // Debounced with a blur flush: runAi reads state.aiBrief, and clicking
1736
1761
  // Generate blurs this field first -- the flush lands the last keystrokes
@@ -1772,7 +1797,7 @@ export class MailCraftEditor extends ElementBase {
1772
1797
  s.aiResults.forEach((r) => {
1773
1798
  const card = elS('div', 'position: relative; border: 1px solid var(--ed-line); border-radius: var(--ed-radius-sm); padding: 15px;');
1774
1799
  card.appendChild(elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-accent); margin-bottom: 7px;', { text: r.kind }));
1775
- card.appendChild(elS('div', 'font-size: 14px; line-height: 1.55; white-space: pre-wrap;', { text: r.text }));
1800
+ card.appendChild(elS('div', 'font-size: 14px; line-height: 1.55; white-space: pre-wrap;', { text: r.text, dir: 'auto' }));
1776
1801
  const useBtn = elS('button', 'margin-top: 12px; border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; padding: 6px 11px; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.12em; text-transform: uppercase; transition: background 0.16s, color 0.16s, border-color 0.16s;', { type: 'button', text: r.action });
1777
1802
  useBtn.addEventListener('mouseenter', () => { useBtn.style.background = 'var(--ed-accent)'; useBtn.style.color = 'var(--ed-accent-ink)'; useBtn.style.borderColor = 'var(--ed-accent)'; });
1778
1803
  useBtn.addEventListener('mouseleave', () => { useBtn.style.background = 'transparent'; useBtn.style.color = 'var(--ed-text)'; useBtn.style.borderColor = 'var(--ed-line)'; });
@@ -1801,7 +1826,14 @@ export class MailCraftEditor extends ElementBase {
1801
1826
  closeBtn.addEventListener('click', () => this.core.setState({ previewOpen: false }));
1802
1827
  head.append(headText, this.previewDeviceSeg, closeBtn);
1803
1828
  overlay.appendChild(head);
1804
- const body = elS('div', 'overflow-y: auto; padding: 32px 24px;', { class: 'mc-preview-body' });
1829
+ // Flush and unpainted: this body *is* the sent email's page, so the
1830
+ // sheet sits edge to edge exactly as exportHtml() renders it -- the
1831
+ // theme's page background is applied on each render (renderPreviewModal).
1832
+ const body = elS('div', 'overflow-y: auto; padding: 0;', { class: 'mc-preview-body' });
1833
+ // Kept on the instance: renderPreviewModal repaints it from the document's
1834
+ // page background on every render, and it runs long after this builder's
1835
+ // local has gone out of scope.
1836
+ this.previewBody = body;
1805
1837
  this.previewSlot = elS('div', '', { class: 'mc-sheet-wrap' });
1806
1838
  this.previewSlot.style.cssText = 'display: flex; justify-content: center;';
1807
1839
  body.appendChild(this.previewSlot);
@@ -1826,6 +1858,13 @@ export class MailCraftEditor extends ElementBase {
1826
1858
  tip(btn, d.label, 'down');
1827
1859
  this.previewDeviceSeg.appendChild(btn);
1828
1860
  });
1861
+ // The preview body doubles as the sent page: the theme's page background
1862
+ // paints it, exactly as the exported <body style="background:..."> does.
1863
+ // Falls back to the empty string -- and so to the stylesheet's dotted
1864
+ // grid -- for a transparent page, which is exactly what a client with no
1865
+ // page colour of its own shows behind the email.
1866
+ const pageBg = s.doc.theme.bg || '';
1867
+ this.previewBody.style.background = /^(transparent|none)$/i.test(pageBg.trim()) ? '' : pageBg;
1829
1868
  this.previewSlot.innerHTML = '';
1830
1869
  this.previewSlot.appendChild(renderDoc(this.core, false));
1831
1870
  }
@@ -15,6 +15,30 @@ function el(tag, style, attrs) {
15
15
 
16
16
  function m(v) { return String(v == null ? '' : v); }
17
17
 
18
+ /**
19
+ * Writing assistants (Grammarly, LanguageTool and friends) treat any
20
+ * `contenteditable` as a field of their own: they float a button over its
21
+ * bottom-right corner -- squarely on top of this editor's own block toolbar --
22
+ * and inject their highlight/overlay DOM into it, which `syncLiveEdit`
23
+ * (mailcraft-editor.js) then reads back as innerHTML and stores in the
24
+ * template, so their markup ends up in the exported email. Every one of them
25
+ * honours an opt-out attribute on the field itself, so each editable surface
26
+ * declares all of them here rather than repeating the list six times below.
27
+ * The `spellcheck="false"` that already rode along stays on every copy,
28
+ * editable or not, and `core/export.js` strips all of it back out on the way
29
+ * to the recipient.
30
+ */
31
+ const NO_ASSIST = {
32
+ 'data-gramm': 'false',
33
+ 'data-gramm_editor': 'false',
34
+ 'data-enable-grammarly': 'false',
35
+ 'data-lt-active': 'false',
36
+ };
37
+
38
+ function editableAttrs(on) {
39
+ return on ? { contenteditable: 'true', spellcheck: 'false', ...NO_ASSIST } : { spellcheck: 'false' };
40
+ }
41
+
18
42
  /** An explicit block font owns every rich descendant; an empty value deliberately leaves imported inline typography untouched. */
19
43
  function overrideRichFont(root, fontFamily) {
20
44
  if (!fontFamily) return;
@@ -38,7 +62,7 @@ export function blockBody(b, theme, live, ctx) {
38
62
  const edit = live;
39
63
  const content = el('div', {
40
64
  padding: pad(p), fontSize: p.size + 'px', lineHeight: p.lh, textAlign: p.align, fontWeight: p.weight, color: p.color || t.text, outline: 'none', fontFamily: p.fontFamily || t.font,
41
- }, { ...attr, contenteditable: edit ? 'true' : undefined, spellcheck: 'false', html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
65
+ }, { ...attr, ...editableAttrs(edit), html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
42
66
  // Imported email HTML keeps inline font-family declarations so an
43
67
  // untouched block retains its source typography. Once the user chooses
44
68
  // a block font, however, those nested declarations outrank inheritance
@@ -76,7 +100,7 @@ export function blockBody(b, theme, live, ctx) {
76
100
  // email pattern; the color falls back to the label color so a bare
77
101
  // "outline thickness" bump looks right without a second step.
78
102
  border: p.borderW ? p.borderW + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.borderColor || p.color) : '0',
79
- }, { href: p.href, contenteditable: live ? 'true' : undefined, spellcheck: 'false', text: p.label });
103
+ }, { href: p.href, ...editableAttrs(live), text: p.label });
80
104
  a.addEventListener('click', (e) => e.preventDefault());
81
105
  // Button editing is deliberately plain: no focus-tracked `editing` state, no
82
106
  // paste handling, no floating RTE toolbar -- only its label commits on blur.
@@ -147,7 +171,7 @@ export function blockBody(b, theme, live, ctx) {
147
171
  const safe = live
148
172
  ? raw.replace(/<style([^>]*)>([\s\S]*?)<\/style>/gi, (mm, at, css) => '<style' + at + '>' + ctx.scopeCss(css, '[data-mc-sheet]') + '</style>')
149
173
  : raw;
150
- const content = el('div', { padding: '6px 0', fontFamily: t.font, color: t.text, outline: 'none' }, { ...attr, contenteditable: edit ? 'true' : undefined, spellcheck: 'false', html: m(safe), 'data-focus-key': edit ? 'block:' + b.id : undefined });
174
+ const content = el('div', { padding: '6px 0', fontFamily: t.font, color: t.text, outline: 'none' }, { ...attr, ...editableAttrs(edit), html: m(safe), 'data-focus-key': edit ? 'block:' + b.id : undefined });
151
175
  if (edit) wireEditable(content, b, 'code', ctx, false);
152
176
  if (!live) return content;
153
177
  return wrapWithRte(b, ctx, edit && ctx.editingId === b.id, content);
@@ -187,7 +211,7 @@ export function blockBody(b, theme, live, ctx) {
187
211
  color: p.color || t.text, outline: 'none',
188
212
  // An explicit per-block font beats the Condensed/Body style toggle.
189
213
  fontFamily: p.fontFamily || (p.font === 'condensed' ? "'Arial Narrow', 'Helvetica Neue Condensed', Helvetica, Arial, sans-serif" : t.font),
190
- }, { ...attr, contenteditable: edit ? 'true' : undefined, spellcheck: 'false', text: m(p.text), 'data-focus-key': edit ? 'block:' + b.id : undefined });
214
+ }, { ...attr, ...editableAttrs(edit), text: m(p.text), 'data-focus-key': edit ? 'block:' + b.id : undefined });
191
215
  if (edit) wireEditable(head, b, 'text', ctx, true);
192
216
  if (!live) return head;
193
217
  return wrapWithRte(b, ctx, ctx.editingId === b.id, head);
@@ -217,7 +241,7 @@ export function blockBody(b, theme, live, ctx) {
217
241
  fontWeight: isHead ? '600' : '400', outline: 'none',
218
242
  fontFamily: p.fontFamily || t.font,
219
243
  letterSpacing: isHead ? '0.06em' : 'normal', textTransform: isHead ? 'uppercase' : 'none', fontSize: isHead ? p.size + 1 + 'px' : p.size + 'px',
220
- }, { contenteditable: edit ? 'true' : undefined, spellcheck: 'false', text: m(cell), 'data-focus-key': edit ? 'block:' + b.id + ':c' + ri + '-' + ci : undefined });
244
+ }, { ...editableAttrs(edit), text: m(cell), 'data-focus-key': edit ? 'block:' + b.id + ':c' + ri + '-' + ci : undefined });
221
245
  if (edit) {
222
246
  // stopPropagation is load-bearing (a bubbled click would select
223
247
  // via the block wrapper AND deselect via the workspace), but it
@@ -262,13 +286,32 @@ export function blockBody(b, theme, live, ctx) {
262
286
  minHeight: p.minH ? p.minH + 'px' : 'auto', maxWidth: p.maxW + '%', margin: p.align === 'center' ? '0 auto' : '0',
263
287
  boxShadow: p.shadow ? '0 10px 30px rgba(29,31,32,0.12)' : 'none',
264
288
  fontFamily: t.font, color: t.text, fontSize: '15px', lineHeight: '1.6',
265
- }, { ...attr, contenteditable: edit ? 'true' : undefined, spellcheck: 'false', html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
289
+ }, { ...attr, ...editableAttrs(edit), html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
266
290
  if (edit) wireEditable(box, b, 'html', ctx, false);
267
291
  if (!live) return box;
268
292
  return wrapWithRte(b, ctx, ctx.editingId === b.id, box);
269
293
  }
270
294
  case 'svg':
271
295
  return el('div', { padding: p.py + 'px 0', textAlign: p.align }, { ...attr, html: '<span style="display:inline-block;width:' + p.width + '%">' + p.code + '</span>' });
296
+ // Dynamic-content markers. Editor furniture, never sent: the exporter
297
+ // emits the template tag itself at this position (core/export.js) and
298
+ // never reads this DOM back. The band renders in the preview too -- the
299
+ // preview shows the template the way it shows merge tokens, so the logic
300
+ // structure stays visible there rather than silently vanishing.
301
+ case 'condition':
302
+ case 'loop': {
303
+ const isLoop = b.type === 'loop';
304
+ const color = isLoop ? '#7c3aed' : '#0e7490';
305
+ const word = p.end ? (isLoop ? 'end loop' : 'end if') : (isLoop ? 'repeat each' : 'show if');
306
+ const band = el('div', {
307
+ display: 'flex', alignItems: 'center', gap: '8px', boxSizing: 'border-box',
308
+ border: '1.5px dashed ' + color, borderRadius: '7px', background: color + '14',
309
+ padding: '6px 12px', margin: '2px 0', color,
310
+ }, attr);
311
+ band.appendChild(el('span', { fontFamily: 'ui-monospace,monospace', fontSize: '9.5px', fontWeight: '700', letterSpacing: '0.12em', textTransform: 'uppercase', flex: 'none' }, { text: (p.end ? '⏶ ' : '⏷ ') + word }));
312
+ if (!p.end) band.appendChild(el('span', { fontFamily: 'ui-monospace,monospace', fontSize: '11.5px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, { text: '{{ ' + (p.expr || '…') + ' }}' }));
313
+ return band;
314
+ }
272
315
  case 'codeblock':
273
316
  return el('pre', { margin: '0', padding: p.pad + 'px', background: p.bg, color: p.color, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: p.size + 'px', lineHeight: '1.6', overflowX: 'auto', whiteSpace: 'pre' }, { ...attr, text: m(p.code) });
274
317
  default:
@@ -169,21 +169,63 @@ export function renderDoc(core, live) {
169
169
  // Lives only for this render's drag gesture(s) -- no core state, no re-render.
170
170
  const rowDropTracker = { active: null };
171
171
 
172
+ // `dir="ltr"` pins the sheet to the document's own direction. The editor
173
+ // chrome mirrors under an RTL locale (`dir=rtl` on `#mc`), but the email
174
+ // being built is a separate document: exportHtml emits no `dir`, so mail
175
+ // clients render it LTR -- letting the chrome's direction cascade in here
176
+ // flipped bidi punctuation and default alignment on the canvas and made it
177
+ // disagree with the exported result.
178
+ const radius = Number(theme.radius) || 0;
172
179
  const root = el('div', {
173
- width: width + 'px', maxWidth: '100%', background: theme.contentBg, color: theme.text, fontFamily: theme.font,
174
- transition: 'width 0.28s cubic-bezier(0.22,0.61,0.36,1), background 0.2s',
175
- }, { 'data-mc-sheet': '1' });
180
+ width: width + 'px', maxWidth: '100%', background: theme.contentBg || 'transparent', color: theme.text, fontFamily: theme.font,
181
+ // Only when the document actually asks for a shape: an unconditional
182
+ // `0px` would override the editor chrome's own soft corner on the sheet
183
+ // (style.js) and square off every template that never touched the field.
184
+ borderRadius: radius ? radius + 'px' : '',
185
+ // Clipping the rows to that corner is right for the sent email (export
186
+ // emits `overflow:hidden` alongside the radius) and for the static
187
+ // preview, but not for the editable canvas: the sheet is also what the
188
+ // row grip straddles and what the floating RTE toolbar overhangs
189
+ // (top:-82px on the first block), and hiding the overflow cuts both off.
190
+ overflow: !live && radius ? 'hidden' : '',
191
+ transition: 'width 0.28s cubic-bezier(0.22,0.61,0.36,1), background 0.2s, border-radius 0.2s',
192
+ }, { 'data-mc-sheet': '1', dir: 'ltr' });
193
+
194
+ /*
195
+ * The page: the full-width section the email sits on, painted from the
196
+ * document's own `bg`/`padY`/`padX` rather than left to the workspace
197
+ * chrome. It is the band a mail client shows around the content column --
198
+ * the one part of the template that used to exist only in the exported
199
+ * HTML, so changing its colour did nothing visible in the editor and its
200
+ * size could not be changed at all. With the padding at its 0 default the
201
+ * page hugs the sheet exactly and nothing about the canvas changes.
202
+ */
203
+ const padY = Number(theme.padY) || 0;
204
+ const padX = Number(theme.padX) || 0;
205
+ const page = el('div', {
206
+ background: theme.bg || 'transparent',
207
+ padding: padY + 'px ' + padX + 'px',
208
+ boxSizing: 'border-box', maxWidth: '100%', display: 'flex', justifyContent: 'center',
209
+ transition: 'background 0.2s, padding 0.22s cubic-bezier(0.22,0.61,0.36,1)',
210
+ // `is-padded` is what moves the editor's frame outward (style.js): with
211
+ // no padding the page hugs the sheet exactly, so the sheet keeps the
212
+ // frame and an untouched canvas looks exactly as it always did.
213
+ }, { 'data-mc-page': '1', class: 'mc-page' + (padY || padX ? ' is-padded' : ''), dir: 'ltr' });
176
214
 
177
215
  const rowLines = [];
178
216
  if (live) {
179
- root.addEventListener('dragover', (e) => {
217
+ // On the page, not the sheet: a drag held over the page padding is still
218
+ // aimed at the template, and these fire for the sheet's own events too
219
+ // (they bubble). Column drops stop propagation before they get here, as
220
+ // they did when these listeners sat on the sheet.
221
+ page.addEventListener('dragover', (e) => {
180
222
  const dr = core.drag; if (!dr) return;
181
223
  e.preventDefault();
182
224
  const index = core.indexFromPoint(root, e.clientY);
183
225
  showLine(rowDropTracker, rowLines[index]);
184
226
  });
185
- root.addEventListener('dragleave', (e) => { if (e.target === root) hideActive(rowDropTracker); });
186
- root.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e); });
227
+ page.addEventListener('dragleave', (e) => { if (e.target === root || e.target === page) hideActive(rowDropTracker); });
228
+ page.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e); });
187
229
  }
188
230
 
189
231
  d.rows.forEach((r, ri) => {
@@ -229,8 +271,31 @@ export function renderDoc(core, live) {
229
271
  bWrap.addEventListener('dragstart', core.startDrag({ kind: 'move-block', id: b.id }));
230
272
  bWrap.addEventListener('dragend', () => { core.drag = null; hideActive(rowDropTracker); hideActive(colTracker); });
231
273
  bWrap.addEventListener('click', (e) => { e.stopPropagation(); core.select('block', b.id); });
274
+ // WebKit gives a `draggable="true"` ancestor priority over the
275
+ // selection machinery: a mousedown anywhere inside this wrapper is
276
+ // taken as the start of an element drag, so in Safari a double-click
277
+ // on block text never selects the word and pressing and sweeping
278
+ // across it never extends a selection. (Chromium and Gecko both
279
+ // special-case contenteditable descendants; WebKit does not.)
280
+ // Dropping the attribute for the length of a gesture that begins
281
+ // inside editable text hands those mousedowns back to the caret;
282
+ // restoring it on mouseup keeps drag-to-reorder working from every
283
+ // other part of the block -- including its padding, which is what a
284
+ // block is normally picked up by.
285
+ bWrap.addEventListener('mousedown', (e) => {
286
+ const target = e.target;
287
+ if (!target || target.nodeType !== 1 || !target.closest('[contenteditable="true"]')) return;
288
+ bWrap.draggable = false;
289
+ const view = bWrap.ownerDocument.defaultView;
290
+ if (view) view.addEventListener('mouseup', () => { bWrap.draggable = true; }, { once: true });
291
+ });
232
292
  }
233
- if (bSel) bWrap.appendChild(toolbar(core, b.id, 'block', DEF(b.type).code));
293
+ // Not while this block is being inline-edited: the pill sits at the
294
+ // block's top-right, flush against the floating RTE toolbar's bottom
295
+ // edge -- visually colliding with it, and putting its Delete button a
296
+ // few pixels from the RTE's own controls. The RTE stands in for it for
297
+ // the duration of the edit; it comes back on blur (still selected).
298
+ if (bSel && ctx.editingId !== b.id) bWrap.appendChild(toolbar(core, b.id, 'block', DEF(b.type).code));
234
299
  bWrap.appendChild(blockBody(b, theme, live, ctx));
235
300
  items.push(bWrap);
236
301
  });
@@ -280,5 +345,6 @@ export function renderDoc(core, live) {
280
345
  root.appendChild(el('div', { border: '1px dashed var(--ed-accent-sheet-line)', padding: '90px 20px', textAlign: 'center', fontFamily: 'ui-monospace,monospace', fontSize: '10.5px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--ed-faint)' }, { text: 'drag a row or block here' }));
281
346
  }
282
347
 
283
- return root;
348
+ page.appendChild(root);
349
+ return page;
284
350
  }