@seliseblocks/mailcraft 0.1.0 → 0.2.0

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 (69) hide show
  1. package/DOCS.md +240 -12
  2. package/README.md +11 -3
  3. package/README.md.txt +134 -0
  4. package/dist/mailcraft-editor.bundle.js +51 -39
  5. package/dist/mailcraft-editor.bundle.js.map +3 -3
  6. package/examples/templates/order-confirmed.html +80 -80
  7. package/examples/vanilla.html +242 -23
  8. package/package.json +7 -2
  9. package/src/core/assets.js +10 -15
  10. package/src/core/binder.js +120 -118
  11. package/src/core/blocks.js +14 -1
  12. package/src/core/css-cascade.js +117 -117
  13. package/src/core/editor-core.js +1530 -1492
  14. package/src/core/export.js +142 -55
  15. package/src/core/i18n/ar.js +219 -177
  16. package/src/core/i18n/bg.js +196 -152
  17. package/src/core/i18n/bn.js +218 -176
  18. package/src/core/i18n/ca.js +196 -152
  19. package/src/core/i18n/cs.js +196 -152
  20. package/src/core/i18n/da.js +196 -152
  21. package/src/core/i18n/de-CH.js +196 -152
  22. package/src/core/i18n/de.js +196 -152
  23. package/src/core/i18n/dz.js +221 -179
  24. package/src/core/i18n/el.js +196 -152
  25. package/src/core/i18n/en.js +3 -10
  26. package/src/core/i18n/es.js +196 -152
  27. package/src/core/i18n/et.js +196 -152
  28. package/src/core/i18n/fi.js +196 -152
  29. package/src/core/i18n/fr.js +196 -152
  30. package/src/core/i18n/hr.js +196 -152
  31. package/src/core/i18n/hu.js +196 -152
  32. package/src/core/i18n/index.js +83 -83
  33. package/src/core/i18n/it.js +196 -152
  34. package/src/core/i18n/lt.js +196 -152
  35. package/src/core/i18n/lv.js +196 -152
  36. package/src/core/i18n/nb.js +196 -152
  37. package/src/core/i18n/nl.js +196 -152
  38. package/src/core/i18n/pl.js +196 -152
  39. package/src/core/i18n/pt.js +196 -152
  40. package/src/core/i18n/ro.js +196 -152
  41. package/src/core/i18n/ru.js +196 -152
  42. package/src/core/i18n/sk.js +196 -152
  43. package/src/core/i18n/sl.js +196 -152
  44. package/src/core/i18n/sv.js +196 -152
  45. package/src/core/i18n/tables.js +50 -50
  46. package/src/core/i18n/tr.js +196 -152
  47. package/src/core/i18n/uk.js +196 -152
  48. package/src/core/icons.js +237 -235
  49. package/src/core/ids.js +1 -1
  50. package/src/core/import-html.js +1025 -959
  51. package/src/core/layout-style.js +100 -100
  52. package/src/core/parse.js +10 -10
  53. package/src/core/placeholder.js +15 -15
  54. package/src/core/sanitize.js +141 -141
  55. package/src/core/storage-limits.js +184 -184
  56. package/src/core/storage.js +85 -85
  57. package/src/core/theme.js +1 -1
  58. package/src/core/variables.js +11 -11
  59. package/src/index.js +9 -9
  60. package/src/mailcraft-editor.js +26 -14
  61. package/src/render/block-body.js +49 -6
  62. package/src/render/canvas.js +31 -2
  63. package/src/render/fields.js +602 -588
  64. package/src/render/focus-preserve.js +158 -158
  65. package/src/render/rte.js +241 -212
  66. package/src/render/screenshot.js +132 -132
  67. package/src/render/story.js +415 -415
  68. package/src/render/style.js +8 -0
  69. package/types/index.d.ts +419 -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,10 +418,11 @@ 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); }
@@ -1266,7 +1268,8 @@ export class MailCraftEditor extends ElementBase {
1266
1268
  line.addEventListener('click', () => this.core.select('block', b.id));
1267
1269
  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
1270
  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' }));
1271
+ // dir=auto: this excerpt is the email's own copy, not chrome text.
1272
+ 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
1273
  if (text) {
1271
1274
  line.append(
1272
1275
  elS('span', 'flex: 1; min-width: 8px;'),
@@ -1305,7 +1308,7 @@ export class MailCraftEditor extends ElementBase {
1305
1308
  wrap.appendChild(this.sectionBar(t('vars.title'), t('vars.fromCode', { count: this.core.vars().length })));
1306
1309
  const card = this.sectionBody();
1307
1310
  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' });
1311
+ 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
1312
  search.value = s.varQuery || '';
1310
1313
  search.addEventListener('focus', () => { search.style.borderColor = 'var(--ed-accent)'; search.style.outline = 'none'; });
1311
1314
  search.addEventListener('blur', () => { search.style.borderColor = 'var(--ed-line)'; });
@@ -1326,7 +1329,8 @@ export class MailCraftEditor extends ElementBase {
1326
1329
  const textSpan = elS('span', 'flex: 1; min-width: 0;');
1327
1330
  textSpan.append(
1328
1331
  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) }),
1332
+ // dir=ltr keeps the braces on the right ends of {{ token }} under RTL.
1333
+ 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
1334
  );
1331
1335
  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
1336
  grid.appendChild(btn);
@@ -1356,7 +1360,7 @@ export class MailCraftEditor extends ElementBase {
1356
1360
  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
1361
  (this.libraryTitleText = elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 16px; line-height: 1.25;')),
1358
1362
  );
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' });
1363
+ 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
1364
  this.assetSearch.addEventListener('focus', () => { this.assetSearch.style.borderColor = 'var(--ed-accent)'; this.assetSearch.style.outline = 'none'; });
1361
1365
  this.assetSearch.addEventListener('blur', () => { this.assetSearch.style.borderColor = 'var(--ed-line)'; });
1362
1366
  // Debounced re-render; setAssetQuery separately debounces the backend call.
@@ -1516,7 +1520,9 @@ export class MailCraftEditor extends ElementBase {
1516
1520
  head.append(headText, this.copyBtn, this.shotBtn, downloadBtn, closeBtn);
1517
1521
  modal.appendChild(head);
1518
1522
 
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' });
1523
+ // dir=ltr: this holds HTML source, which bidi-scrambles under the RTL
1524
+ // chrome (same reason as buildCodeEditor).
1525
+ 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
1526
  modal.appendChild(this.exportTextarea);
1521
1527
  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
1528
  modal.appendChild(this.exportMetaEl);
@@ -1601,7 +1607,10 @@ export class MailCraftEditor extends ElementBase {
1601
1607
  * highlight or caret drifting away from the editable text. */
1602
1608
  buildCodeEditor() {
1603
1609
  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);');
1610
+ // dir=ltr: HTML source is directional text; under an RTL chrome the
1611
+ // inherited direction would bidi-scramble it and, worse, let the caret
1612
+ // in the transparent textarea drift off the highlighted layer beneath.
1613
+ const root = elS('div', 'position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; background: var(--ed-work);', { dir: 'ltr' });
1605
1614
  const inner = elS('div', 'position: relative; min-height: 100%; width: 100%; min-width: 0;');
1606
1615
  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
1616
  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 +1654,10 @@ export class MailCraftEditor extends ElementBase {
1645
1654
  if (!s.codeOpen) return;
1646
1655
  this.refreshCodeSource();
1647
1656
  if (this._codeFrameSrc !== s.codeLive) {
1648
- this.codeFrame.srcdoc = s.codeLive;
1657
+ // Display-only: the preview pane draws logic tags as the canvas's
1658
+ // dashed bands; the source pane and everything exported keep the
1659
+ // literal {{#if}}/{{#each}} text.
1660
+ this.codeFrame.srcdoc = decorateLogicTags(s.codeLive);
1649
1661
  this._codeFrameSrc = s.codeLive;
1650
1662
  }
1651
1663
  this.syncCodePreviewScrollbar();
@@ -1730,7 +1742,7 @@ export class MailCraftEditor extends ElementBase {
1730
1742
 
1731
1743
  const briefWrap = elS('div');
1732
1744
  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' });
1745
+ 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
1746
  this.aiBriefInput.addEventListener('focus', () => { this.aiBriefInput.style.borderColor = 'var(--ed-accent)'; this.aiBriefInput.style.outline = 'none'; });
1735
1747
  // Debounced with a blur flush: runAi reads state.aiBrief, and clicking
1736
1748
  // Generate blurs this field first -- the flush lands the last keystrokes
@@ -1772,7 +1784,7 @@ export class MailCraftEditor extends ElementBase {
1772
1784
  s.aiResults.forEach((r) => {
1773
1785
  const card = elS('div', 'position: relative; border: 1px solid var(--ed-line); border-radius: var(--ed-radius-sm); padding: 15px;');
1774
1786
  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 }));
1787
+ card.appendChild(elS('div', 'font-size: 14px; line-height: 1.55; white-space: pre-wrap;', { text: r.text, dir: 'auto' }));
1776
1788
  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
1789
  useBtn.addEventListener('mouseenter', () => { useBtn.style.background = 'var(--ed-accent)'; useBtn.style.color = 'var(--ed-accent-ink)'; useBtn.style.borderColor = 'var(--ed-accent)'; });
1778
1790
  useBtn.addEventListener('mouseleave', () => { useBtn.style.background = 'transparent'; useBtn.style.color = 'var(--ed-text)'; useBtn.style.borderColor = 'var(--ed-line)'; });
@@ -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,10 +169,16 @@ 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.
172
178
  const root = el('div', {
173
179
  width: width + 'px', maxWidth: '100%', background: theme.contentBg, color: theme.text, fontFamily: theme.font,
174
180
  transition: 'width 0.28s cubic-bezier(0.22,0.61,0.36,1), background 0.2s',
175
- }, { 'data-mc-sheet': '1' });
181
+ }, { 'data-mc-sheet': '1', dir: 'ltr' });
176
182
 
177
183
  const rowLines = [];
178
184
  if (live) {
@@ -229,8 +235,31 @@ export function renderDoc(core, live) {
229
235
  bWrap.addEventListener('dragstart', core.startDrag({ kind: 'move-block', id: b.id }));
230
236
  bWrap.addEventListener('dragend', () => { core.drag = null; hideActive(rowDropTracker); hideActive(colTracker); });
231
237
  bWrap.addEventListener('click', (e) => { e.stopPropagation(); core.select('block', b.id); });
238
+ // WebKit gives a `draggable="true"` ancestor priority over the
239
+ // selection machinery: a mousedown anywhere inside this wrapper is
240
+ // taken as the start of an element drag, so in Safari a double-click
241
+ // on block text never selects the word and pressing and sweeping
242
+ // across it never extends a selection. (Chromium and Gecko both
243
+ // special-case contenteditable descendants; WebKit does not.)
244
+ // Dropping the attribute for the length of a gesture that begins
245
+ // inside editable text hands those mousedowns back to the caret;
246
+ // restoring it on mouseup keeps drag-to-reorder working from every
247
+ // other part of the block -- including its padding, which is what a
248
+ // block is normally picked up by.
249
+ bWrap.addEventListener('mousedown', (e) => {
250
+ const target = e.target;
251
+ if (!target || target.nodeType !== 1 || !target.closest('[contenteditable="true"]')) return;
252
+ bWrap.draggable = false;
253
+ const view = bWrap.ownerDocument.defaultView;
254
+ if (view) view.addEventListener('mouseup', () => { bWrap.draggable = true; }, { once: true });
255
+ });
232
256
  }
233
- if (bSel) bWrap.appendChild(toolbar(core, b.id, 'block', DEF(b.type).code));
257
+ // Not while this block is being inline-edited: the pill sits at the
258
+ // block's top-right, flush against the floating RTE toolbar's bottom
259
+ // edge -- visually colliding with it, and putting its Delete button a
260
+ // few pixels from the RTE's own controls. The RTE stands in for it for
261
+ // the duration of the edit; it comes back on blur (still selected).
262
+ if (bSel && ctx.editingId !== b.id) bWrap.appendChild(toolbar(core, b.id, 'block', DEF(b.type).code));
234
263
  bWrap.appendChild(blockBody(b, theme, live, ctx));
235
264
  items.push(bWrap);
236
265
  });