@seliseblocks/mailcraft 0.2.11 → 0.2.13

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 (50) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/DOCS.md +8 -0
  3. package/dist/mailcraft-editor.bundle.js +69 -57
  4. package/dist/mailcraft-editor.bundle.js.map +3 -3
  5. package/package.json +2 -2
  6. package/src/core/code-tools.js +302 -0
  7. package/src/core/editor-core.js +61 -0
  8. package/src/core/export.js +5 -2
  9. package/src/core/i18n/ar.js +8 -0
  10. package/src/core/i18n/bg.js +8 -0
  11. package/src/core/i18n/bn.js +8 -0
  12. package/src/core/i18n/ca.js +8 -0
  13. package/src/core/i18n/cs.js +8 -0
  14. package/src/core/i18n/da.js +8 -0
  15. package/src/core/i18n/de-CH.js +8 -0
  16. package/src/core/i18n/de.js +8 -0
  17. package/src/core/i18n/dz.js +8 -0
  18. package/src/core/i18n/el.js +8 -0
  19. package/src/core/i18n/en.js +8 -0
  20. package/src/core/i18n/es.js +8 -0
  21. package/src/core/i18n/et.js +8 -0
  22. package/src/core/i18n/fi.js +8 -0
  23. package/src/core/i18n/fr.js +8 -0
  24. package/src/core/i18n/hr.js +8 -0
  25. package/src/core/i18n/hu.js +8 -0
  26. package/src/core/i18n/index.js +83 -83
  27. package/src/core/i18n/it.js +8 -0
  28. package/src/core/i18n/lt.js +8 -0
  29. package/src/core/i18n/lv.js +8 -0
  30. package/src/core/i18n/nb.js +8 -0
  31. package/src/core/i18n/nl.js +8 -0
  32. package/src/core/i18n/pl.js +8 -0
  33. package/src/core/i18n/pt.js +8 -0
  34. package/src/core/i18n/ro.js +8 -0
  35. package/src/core/i18n/ru.js +8 -0
  36. package/src/core/i18n/sk.js +8 -0
  37. package/src/core/i18n/sl.js +8 -0
  38. package/src/core/i18n/sv.js +8 -0
  39. package/src/core/i18n/tr.js +8 -0
  40. package/src/core/i18n/uk.js +8 -0
  41. package/src/core/icons.js +2 -0
  42. package/src/core/ids.js +1 -1
  43. package/src/core/layout-style.js +100 -100
  44. package/src/core/parse.js +10 -10
  45. package/src/core/placeholder.js +15 -15
  46. package/src/core/variables.js +11 -11
  47. package/src/mailcraft-editor.js +468 -16
  48. package/src/render/focus-preserve.js +158 -158
  49. package/src/render/rte.js +27 -2
  50. package/src/render/story.js +415 -415
@@ -10,6 +10,7 @@ import { brandLockup } from './core/brand.js';
10
10
  import { TOKEN } from './core/variables.js';
11
11
  import { hl, cssUrl } from './core/sanitize.js';
12
12
  import { decorateLogicTags } from './core/export.js';
13
+ import { formatHtml, scanElements, elementAtOffset, findMatches, markRanges } from './core/code-tools.js';
13
14
  import { withFocusPreserved } from './render/focus-preserve.js';
14
15
  import { captureTemplatePng } from './render/screenshot.js';
15
16
  import { resolveToolbar, toolbarKey } from './core/toolbar.js';
@@ -1668,14 +1669,39 @@ export class MailCraftEditor extends ElementBase {
1668
1669
 
1669
1670
  const cols = elS('div', 'display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 12px; min-height: 0; background: var(--ed-work);', { class: 'mc-code-split' });
1670
1671
  const left = 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-source' });
1671
- const leftHead = elS('div', 'padding: 7px 16px; border-bottom: 1px solid var(--ed-line); font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted); display: flex; justify-content: space-between;', { class: 'mc-pane-label' });
1672
+ const leftHead = elS('div', 'padding: 5px 16px; border-bottom: 1px solid var(--ed-line); font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted); display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 30px; box-sizing: border-box;', { class: 'mc-pane-label' });
1673
+ this._codeFind = { open: false, q: '', index: 0, matches: [] };
1672
1674
  this.codeMetaEl = elS('span');
1673
- leftHead.append(elS('span', '', { text: t('code.source'), 'data-i18n': 'code.source' }), this.codeMetaEl);
1675
+ // Native `title` tooltips here on purpose: the pane clips overflow, so the
1676
+ // shared tip() bubble would be cut off at this edge.
1677
+ const miniBtn = (iconName, key, onClick) => {
1678
+ const b = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-muted); cursor: pointer; width: 26px; height: 22px; display: flex; align-items: center; justify-content: center; border-radius: 6px; flex: none; transition: border-color 0.16s, color 0.16s;', { type: 'button', title: t(key), 'data-i18n-title': key, 'aria-label': t(key) });
1679
+ b.appendChild(icon(iconName, 13));
1680
+ b.addEventListener('mouseenter', () => { b.style.borderColor = 'var(--ed-accent)'; });
1681
+ b.addEventListener('mouseleave', () => { b.style.borderColor = b.getAttribute('aria-pressed') === 'true' ? 'var(--ed-accent)' : 'var(--ed-line)'; });
1682
+ b.addEventListener('click', onClick);
1683
+ return b;
1684
+ };
1685
+ this.codeFindBtn = miniBtn('search', 'code.find', () => (this._codeFind.open ? this.closeCodeFind() : this.openCodeFind()));
1686
+ this.codeWrapBtn = miniBtn('wrap', 'code.wrap', () => this.toggleCodeWrap());
1687
+ this.codeFormatBtn = miniBtn('indent', 'code.format', () => this.formatCodeSource());
1688
+ const tools = elS('div', 'display: flex; align-items: center; gap: 5px;', { class: 'mc-code-tools' });
1689
+ tools.append(this.codeFindBtn, this.codeWrapBtn, this.codeFormatBtn, this.codeMetaEl);
1690
+ leftHead.append(elS('span', '', { text: t('code.source'), 'data-i18n': 'code.source' }), tools);
1674
1691
  left.appendChild(leftHead);
1675
1692
  const editorWrap = elS('div', 'position: relative; min-height: 0;');
1676
1693
  editorWrap.appendChild(this.buildCodeEditor());
1694
+ editorWrap.appendChild(this.buildCodeFindBar());
1677
1695
  left.appendChild(editorWrap);
1678
1696
  cols.appendChild(left);
1697
+ // VS Code muscle memory, scoped to the panel so the host page's own
1698
+ // Ctrl+F is untouched anywhere else.
1699
+ overlay.addEventListener('keydown', (e) => {
1700
+ if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
1701
+ const k = e.key.toLowerCase();
1702
+ if (k === 'f') { e.preventDefault(); e.stopPropagation(); this.openCodeFind(); }
1703
+ if (k === 'g') { e.preventDefault(); e.stopPropagation(); this.openCodeFind(true); }
1704
+ });
1679
1705
 
1680
1706
  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' });
1681
1707
  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' }));
@@ -1689,7 +1715,7 @@ export class MailCraftEditor extends ElementBase {
1689
1715
  const previewWrap = elS('div', 'overflow: auto; display: flex; justify-content: center; background: var(--ed-work);', { class: 'mc-code-preview-body' });
1690
1716
  this.codePreviewBody = previewWrap;
1691
1717
  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' });
1692
- this.codeFrame.addEventListener('load', () => this.syncCodePreviewScrollbar());
1718
+ this.codeFrame.addEventListener('load', () => { this.syncCodePreviewScrollbar(); this.wireCodePreviewInspect(); });
1693
1719
  previewWrap.appendChild(this.codeFrame);
1694
1720
  right.appendChild(previewWrap);
1695
1721
  cols.appendChild(right);
@@ -1710,23 +1736,101 @@ export class MailCraftEditor extends ElementBase {
1710
1736
  // in the transparent textarea drift off the highlighted layer beneath.
1711
1737
  const root = elS('div', 'position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; background: var(--ed-work);', { dir: 'ltr' });
1712
1738
  const inner = elS('div', 'position: relative; min-height: 100%; width: 100%; min-width: 0;');
1739
+ this.codeScroller = root;
1740
+ this.codeInnerEl = inner;
1741
+ // Word wrap is a per-user editor preference, not document state -- kept
1742
+ // out of the core (and out of the draft) on purpose.
1743
+ try { this._codeWrap = localStorage.getItem('mailcraft.codewrap') !== 'off'; } catch { this._codeWrap = true; }
1713
1744
  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' });
1714
1745
  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' });
1715
1746
  this.codeTextarea.addEventListener('input', (e) => this.core.setCodeSrc(e.target.value));
1716
- this.codeTextarea.addEventListener('keydown', (e) => {
1717
- if (e.key !== 'Tab') return;
1718
- e.preventDefault();
1719
- const ta = e.target; const a = ta.selectionStart; const b = ta.selectionEnd;
1720
- const next = ta.value.slice(0, a) + ' ' + ta.value.slice(b);
1721
- this.core.setCodeSrc(next);
1722
- ta.value = next;
1723
- ta.selectionStart = ta.selectionEnd = a + 2;
1747
+ this.codeTextarea.addEventListener('keydown', (e) => this.onCodeEditorKeydown(e));
1748
+ // Caret tracking: repaints the active-line wash and (debounced) points the
1749
+ // live preview at the element under the caret, DevTools-style. Typing is
1750
+ // deliberately excluded from the inspect ping -- the preview iframe is
1751
+ // 350ms behind while typing, and scrolling it mid-thought is noise.
1752
+ const caretPing = () => {
1753
+ if (!this.core.state.codeOpen) return;
1754
+ this.refreshCodeCaret();
1755
+ clearTimeout(this._inspectTimer);
1756
+ this._inspectTimer = setTimeout(() => this.inspectFromCaret(), 160);
1757
+ };
1758
+ this.codeTextarea.addEventListener('click', caretPing);
1759
+ this.codeTextarea.addEventListener('keyup', (e) => {
1760
+ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(e.key)) caretPing();
1724
1761
  });
1725
1762
  inner.append(this.codePre, this.codeTextarea);
1726
1763
  root.appendChild(inner);
1727
1764
  return root;
1728
1765
  }
1729
1766
 
1767
+ /** Editing keys inside the source textarea. Inserts run through `codeInsertText` so the browser's native undo stack (Ctrl+Z) survives -- the old Tab handler reassigned `.value` and silently wiped it. */
1768
+ onCodeEditorKeydown(e) {
1769
+ const ta = e.target;
1770
+ if (e.key === 'Escape' && this._codeFind && this._codeFind.open) {
1771
+ // Escape means "close the find bar", not the whole modal -- stop it
1772
+ // before the window-level shortcut handler sees it.
1773
+ e.preventDefault();
1774
+ e.stopPropagation();
1775
+ this.closeCodeFind();
1776
+ return;
1777
+ }
1778
+ if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && !e.isComposing) {
1779
+ // Auto-indent: a new line starts at the previous line's indentation.
1780
+ e.preventDefault();
1781
+ const at = ta.selectionStart;
1782
+ const lineStart = ta.value.lastIndexOf('\n', at - 1) + 1;
1783
+ const ind = (/^[ \t]*/.exec(ta.value.slice(lineStart, at)) || [''])[0];
1784
+ this.codeInsertText('\n' + ind);
1785
+ this.refreshCodeCaret();
1786
+ return;
1787
+ }
1788
+ if (e.key !== 'Tab') return;
1789
+ e.preventDefault();
1790
+ const a = ta.selectionStart;
1791
+ const b = ta.selectionEnd;
1792
+ const val = ta.value;
1793
+ if (!e.shiftKey && !val.slice(a, b).includes('\n')) { this.codeInsertText(' '); return; }
1794
+ // Multi-line selection (or Shift+Tab): indent/outdent whole lines.
1795
+ const start = val.lastIndexOf('\n', a - 1) + 1;
1796
+ let end = val.indexOf('\n', b);
1797
+ if (end === -1) end = val.length;
1798
+ const block = val.slice(start, end);
1799
+ const next = e.shiftKey ? block.replace(/^ {1,2}/gm, '') : block.replace(/^/gm, ' ');
1800
+ if (next === block) return;
1801
+ try { ta.setSelectionRange(start, end); } catch { return; }
1802
+ this.codeInsertText(next);
1803
+ try { ta.setSelectionRange(start, start + next.length); } catch { /* ignore */ }
1804
+ }
1805
+
1806
+ /**
1807
+ * Replaces the textarea selection with `text`, preferring `execCommand
1808
+ * ('insertText')` so the edit lands on the browser's native undo stack --
1809
+ * deprecated but universally supported for exactly this, and the only way
1810
+ * Ctrl+Z keeps working across Format / Replace / Tab. The manual splice
1811
+ * covers environments without it (jsdom); both paths end in `setCodeSrc`
1812
+ * (execCommand fires a real `input` event, the splice calls it directly).
1813
+ */
1814
+ codeInsertText(text) {
1815
+ const ta = this.codeTextarea;
1816
+ try { ta.focus(); } catch { /* ignore */ }
1817
+ const a = ta.selectionStart;
1818
+ const b = ta.selectionEnd;
1819
+ const expected = ta.value.slice(0, a) + text + ta.value.slice(b);
1820
+ try {
1821
+ const doc = this.ownerDocument;
1822
+ if (doc && doc.execCommand) (text === '' ? doc.execCommand('delete', false) : doc.execCommand('insertText', false, text));
1823
+ } catch { /* fall through to the splice */ }
1824
+ // Trust the result, not the return value: environments shim execCommand as
1825
+ // a truthy no-op (jsdom), and some browsers report success on edits they
1826
+ // dropped. When it really ran, its input event has already hit setCodeSrc.
1827
+ if (ta.value === expected) return;
1828
+ ta.value = expected;
1829
+ const at = a + text.length;
1830
+ try { ta.setSelectionRange(at, at); } catch { /* ignore */ }
1831
+ this.core.setCodeSrc(ta.value);
1832
+ }
1833
+
1730
1834
  /** The iframe owns a separate document, so the Shadow DOM scrollbar rules
1731
1835
  * cannot reach its viewport. Mirror the editor scrollbar as preview chrome
1732
1836
  * only; the source string and the HTML applied/exported by the host remain
@@ -1750,6 +1854,7 @@ export class MailCraftEditor extends ElementBase {
1750
1854
  const t = this.core.t;
1751
1855
  this.codeModal.style.display = s.codeOpen ? 'grid' : 'none';
1752
1856
  if (!s.codeOpen) return;
1857
+ this.applyCodeWrap();
1753
1858
  this.refreshCodeSource();
1754
1859
  if (this._codeFrameSrc !== s.codeLive) {
1755
1860
  // Display-only: the preview pane draws logic tags as the canvas's
@@ -1773,8 +1878,8 @@ export class MailCraftEditor extends ElementBase {
1773
1878
  this.codePreviewBody.classList.toggle('is-device', phone);
1774
1879
  }
1775
1880
 
1776
- /** Repaint the syntax layer, gutter and metadata without touching the
1777
- * canvas, inspector, iframe, or focused textarea node. */
1881
+ /** Repaint the syntax layer, gutter, find marks and metadata without
1882
+ * touching the canvas, inspector, iframe, or focused textarea node. */
1778
1883
  refreshCodeSource() {
1779
1884
  if (!this.core.state.codeOpen || !this.codeTextarea) return;
1780
1885
  const s = this.core.state;
@@ -1782,9 +1887,356 @@ export class MailCraftEditor extends ElementBase {
1782
1887
  this.codeStatusEl.textContent = s.codeDirty ? t('code.statusEdited') : t('code.statusSynced');
1783
1888
  if (this.codeTextarea.value !== s.codeSrc) this.codeTextarea.value = s.codeSrc;
1784
1889
  const lines = s.codeSrc.split('\n');
1785
- this.codePre.innerHTML = lines.map((line, index) => `<span style="display:grid;grid-template-columns:52px minmax(0,1fr);min-height:20px"><span style="padding-right:10px;text-align:right;color:var(--ed-faint);border-right:1px solid var(--ed-line);user-select:none">${index + 1}</span><span style="min-width:0;padding:0 18px;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word">${hl(line) || '&#8203;'}</span></span>`).join('');
1786
- const lineCount = lines.length;
1787
- this.codeMetaEl.textContent = t('code.meta', { kb: (s.codeSrc.length / 1024).toFixed(1), lines: lineCount });
1890
+ // Find state: recomputed on every repaint so marks track live edits.
1891
+ const find = this._codeFind;
1892
+ let matches = [];
1893
+ if (find && find.open && find.q) {
1894
+ matches = findMatches(s.codeSrc, find.q);
1895
+ find.matches = matches;
1896
+ if (find.index >= matches.length) find.index = Math.max(0, matches.length - 1);
1897
+ } else if (find) {
1898
+ find.matches = [];
1899
+ find.index = 0;
1900
+ }
1901
+ if (this.codeFindCount) this.codeFindCount.textContent = matches.length ? `${find.index + 1}/${matches.length}` : '0/0';
1902
+ const wrap = this._codeWrap !== false;
1903
+ const rowCols = wrap ? '52px minmax(0,1fr)' : '52px max-content';
1904
+ const cellWrapCss = wrap ? 'white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word' : 'white-space:pre';
1905
+ const caret = Math.min(this.codeTextarea.selectionStart || 0, s.codeSrc.length);
1906
+ let pos = 0;
1907
+ let mi = 0;
1908
+ let caretLine = 0;
1909
+ this.codePre.innerHTML = lines.map((line, index) => {
1910
+ const lineEnd = pos + line.length;
1911
+ const isCaret = caret >= pos && caret <= lineEnd;
1912
+ if (isCaret) caretLine = index;
1913
+ const ranges = [];
1914
+ while (mi < matches.length && matches[mi].start <= lineEnd) {
1915
+ const m = matches[mi];
1916
+ if (m.end > pos) ranges.push({ start: Math.max(0, m.start - pos), end: Math.min(line.length, m.end - pos), cur: mi === find.index });
1917
+ mi++;
1918
+ }
1919
+ const html = (ranges.length ? markRanges(hl(line), ranges) : hl(line)) || '&#8203;';
1920
+ const numCss = 'padding-right:10px;text-align:right;user-select:none;border-right:1px solid var(--ed-line);' + (isCaret ? 'color:var(--ed-accent);font-weight:700' : 'color:var(--ed-faint)');
1921
+ const rowCss = `display:grid;grid-template-columns:${rowCols};min-height:20px;${isCaret ? 'background:var(--ed-soft);' : ''}`;
1922
+ pos = lineEnd + 1;
1923
+ return `<span style="${rowCss}"><span style="${numCss}">${index + 1}</span><span style="min-width:0;padding:0 18px;${cellWrapCss}">${html}</span></span>`;
1924
+ }).join('');
1925
+ this._codeCaretLine = caretLine;
1926
+ this.codeMetaEl.textContent = t('code.meta', { kb: (s.codeSrc.length / 1024).toFixed(1), lines: lines.length });
1927
+ }
1928
+
1929
+ /** Cheap caret-only follow-up: repaints the rows only when the caret changed line, so plain clicks and arrow keys don't pay the full rebuild twice. */
1930
+ refreshCodeCaret() {
1931
+ if (!this.core.state.codeOpen || !this.codeTextarea) return;
1932
+ const caret = this.codeTextarea.selectionStart || 0;
1933
+ const line = this.core.state.codeSrc.slice(0, caret).split('\n').length - 1;
1934
+ if (line !== this._codeCaretLine) this.refreshCodeSource();
1935
+ }
1936
+
1937
+ /** 0-based line index of a character offset into the source pane. */
1938
+ codeLineOf(offset) {
1939
+ return this.core.state.codeSrc.slice(0, Math.max(0, offset)).split('\n').length - 1;
1940
+ }
1941
+
1942
+ /** Scrolls the numbered row for `line` into view; `flash` outlines it for a beat (how "here it is" reads after a preview click or Go-to-line). */
1943
+ scrollCodeLineIntoView(line, flash) {
1944
+ const row = this.codePre && this.codePre.children[line];
1945
+ if (!row) return;
1946
+ if (typeof row.scrollIntoView === 'function') { try { row.scrollIntoView({ block: 'center' }); } catch { /* ignore */ } }
1947
+ if (!flash) return;
1948
+ row.style.outline = '1.5px solid var(--ed-accent)';
1949
+ row.style.outlineOffset = '-1px';
1950
+ setTimeout(() => { row.style.outline = ''; row.style.outlineOffset = ''; }, 900);
1951
+ }
1952
+
1953
+ // ---- code view: wrap + format ------------------------------------------
1954
+
1955
+ /** Applies the current wrap mode to both layers. Wrap off swaps the pane to one-source-line-per-row with a shared horizontal scroll: the inner sizer grows to the widest highlighted row and the transparent textarea (absolutely positioned over it) inherits that width, so caret and colors stay aligned. */
1956
+ applyCodeWrap() {
1957
+ const ta = this.codeTextarea;
1958
+ if (!ta) return;
1959
+ const on = this._codeWrap !== false;
1960
+ ta.setAttribute('wrap', on ? 'soft' : 'off');
1961
+ ta.style.whiteSpace = on ? 'pre-wrap' : 'pre';
1962
+ ta.style.overflowWrap = on ? 'anywhere' : 'normal';
1963
+ ta.style.wordBreak = on ? 'break-word' : 'normal';
1964
+ if (this.codeScroller) this.codeScroller.style.overflowX = on ? 'hidden' : 'auto';
1965
+ if (this.codeInnerEl) {
1966
+ this.codeInnerEl.style.width = on ? '100%' : 'max-content';
1967
+ this.codeInnerEl.style.minWidth = on ? '0' : '100%';
1968
+ }
1969
+ if (this.codeWrapBtn) {
1970
+ this.codeWrapBtn.setAttribute('aria-pressed', String(on));
1971
+ this.codeWrapBtn.style.color = on ? 'var(--ed-accent)' : 'var(--ed-muted)';
1972
+ this.codeWrapBtn.style.borderColor = on ? 'var(--ed-accent)' : 'var(--ed-line)';
1973
+ }
1974
+ }
1975
+
1976
+ toggleCodeWrap() {
1977
+ this._codeWrap = this._codeWrap === false;
1978
+ try { localStorage.setItem('mailcraft.codewrap', this._codeWrap ? 'on' : 'off'); } catch { /* ignore */ }
1979
+ this.applyCodeWrap();
1980
+ this.refreshCodeSource();
1981
+ }
1982
+
1983
+ /** Pretty-prints the source pane in place (core/code-tools.js `formatHtml` -- whitespace between tags only, the rendered email is unchanged). One undo step; nothing reaches the canvas until Apply. */
1984
+ formatCodeSource() {
1985
+ const src = this.core.state.codeSrc;
1986
+ let next = src;
1987
+ try { next = formatHtml(src); } catch { next = src; }
1988
+ if (next === src) return;
1989
+ const ta = this.codeTextarea;
1990
+ try { ta.focus(); ta.setSelectionRange(0, ta.value.length); } catch { /* ignore */ }
1991
+ this.codeInsertText(next);
1992
+ try { ta.setSelectionRange(0, 0); } catch { /* ignore */ }
1993
+ if (this.codeScroller) this.codeScroller.scrollTop = 0;
1994
+ this.refreshCodeSource();
1995
+ }
1996
+
1997
+ // ---- code view: find / replace / go-to-line ------------------------------
1998
+
1999
+ buildCodeFindBar() {
2000
+ const t = this.core.t;
2001
+ const bar = elS('div', 'position: absolute; top: 8px; right: 16px; z-index: 6; display: none; flex-direction: column; gap: 6px; padding: 8px; background: var(--ed-panel); border: 1px solid var(--ed-line-2); border-radius: 10px; box-shadow: var(--ed-shadow-lg);', { class: 'mc-code-findbar', dir: 'ltr' });
2002
+ const INPUT = 'box-sizing: border-box; background: var(--ed-panel-2); border: 1px solid var(--ed-line); color: var(--ed-text); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; padding: 5px 8px; border-radius: 6px; outline: none;';
2003
+ const iconBtn = (name, key, onClick) => {
2004
+ const b = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-muted); cursor: pointer; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 6px; flex: none; transition: border-color 0.16s;', { type: 'button', title: t(key), 'data-i18n-title': key, 'aria-label': t(key) });
2005
+ b.appendChild(icon(name, 12));
2006
+ b.addEventListener('mouseenter', () => { b.style.borderColor = 'var(--ed-accent)'; });
2007
+ b.addEventListener('mouseleave', () => { b.style.borderColor = 'var(--ed-line)'; });
2008
+ b.addEventListener('click', onClick);
2009
+ return b;
2010
+ };
2011
+ const textBtn = (key, onClick) => {
2012
+ const b = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 24px; padding: 0 8px; display: flex; align-items: center; border-radius: 6px; font-family: var(--ed-font); font-size: 10.5px; font-weight: 600; flex: none; transition: border-color 0.16s;', { type: 'button' });
2013
+ b.appendChild(elS('span', '', { text: t(key), 'data-i18n': key }));
2014
+ b.addEventListener('mouseenter', () => { b.style.borderColor = 'var(--ed-accent)'; });
2015
+ b.addEventListener('mouseleave', () => { b.style.borderColor = 'var(--ed-line)'; });
2016
+ b.addEventListener('click', onClick);
2017
+ return b;
2018
+ };
2019
+ const row1 = elS('div', 'display: flex; align-items: center; gap: 6px;');
2020
+ this.codeFindInput = elS('input', INPUT + 'width: 168px;', { placeholder: t('code.find'), 'data-i18n-placeholder': 'code.find', 'data-focus-key': 'code-find' });
2021
+ this.codeFindInput.addEventListener('input', (e) => {
2022
+ this._codeFind.q = e.target.value;
2023
+ this._codeFind.index = 0;
2024
+ this.refreshCodeSource();
2025
+ });
2026
+ this.codeFindInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.codeFindStep(e.shiftKey ? -1 : 1); } });
2027
+ this.codeFindCount = elS('span', 'font-family: ui-monospace, monospace; font-size: 10.5px; color: var(--ed-muted); min-width: 42px; text-align: center; flex: none;', { text: '0/0' });
2028
+ row1.append(this.codeFindInput, this.codeFindCount, iconBtn('up', 'code.prevMatch', () => this.codeFindStep(-1)), iconBtn('down', 'code.nextMatch', () => this.codeFindStep(1)), iconBtn('x', 'action.close', () => this.closeCodeFind()));
2029
+ const row2 = elS('div', 'display: flex; align-items: center; gap: 6px;');
2030
+ this.codeReplaceInput = elS('input', INPUT + 'width: 130px;', { placeholder: t('code.replace'), 'data-i18n-placeholder': 'code.replace', 'data-focus-key': 'code-replace' });
2031
+ this.codeReplaceInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.codeReplaceCurrent(); } });
2032
+ this.codeLineInput = elS('input', INPUT + 'width: 52px;', { placeholder: ':42', title: t('code.goToLine'), 'data-i18n-title': 'code.goToLine', 'aria-label': t('code.goToLine'), 'data-focus-key': 'code-goline' });
2033
+ this.codeLineInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.codeGoToLine(e.target.value); } });
2034
+ row2.append(this.codeReplaceInput, textBtn('code.replace', () => this.codeReplaceCurrent()), textBtn('code.replaceAll', () => this.codeReplaceAll()), this.codeLineInput);
2035
+ bar.addEventListener('keydown', (e) => {
2036
+ if (e.key !== 'Escape') return;
2037
+ e.preventDefault();
2038
+ e.stopPropagation();
2039
+ this.closeCodeFind();
2040
+ });
2041
+ bar.append(row1, row2);
2042
+ this.codeFindBar = bar;
2043
+ return bar;
2044
+ }
2045
+
2046
+ openCodeFind(focusLine) {
2047
+ const f = this._codeFind;
2048
+ f.open = true;
2049
+ this.codeFindBar.style.display = 'flex';
2050
+ const ta = this.codeTextarea;
2051
+ const sel = ta.value.slice(ta.selectionStart || 0, ta.selectionEnd || 0);
2052
+ if (sel && !sel.includes('\n')) {
2053
+ this.codeFindInput.value = sel;
2054
+ f.q = sel;
2055
+ f.index = 0;
2056
+ }
2057
+ this.refreshCodeSource();
2058
+ const target = focusLine ? this.codeLineInput : this.codeFindInput;
2059
+ try { target.focus(); target.select(); } catch { /* ignore */ }
2060
+ }
2061
+
2062
+ closeCodeFind() {
2063
+ this._codeFind.open = false;
2064
+ this.codeFindBar.style.display = 'none';
2065
+ this.refreshCodeSource();
2066
+ try { this.codeTextarea.focus(); } catch { /* ignore */ }
2067
+ }
2068
+
2069
+ codeFindStep(dir) {
2070
+ const f = this._codeFind;
2071
+ if (!f.matches.length) return;
2072
+ f.index = (f.index + dir + f.matches.length) % f.matches.length;
2073
+ const m = f.matches[f.index];
2074
+ // Selection set silently (focus stays in the find bar); the pane shows the
2075
+ // current match through its mark, and Escape lands the caret on it.
2076
+ try { this.codeTextarea.setSelectionRange(m.start, m.end); } catch { /* ignore */ }
2077
+ this.refreshCodeSource();
2078
+ this.scrollCodeLineIntoView(this.codeLineOf(m.start));
2079
+ }
2080
+
2081
+ codeReplaceCurrent() {
2082
+ const f = this._codeFind;
2083
+ if (!f.matches.length) return;
2084
+ const m = f.matches[f.index];
2085
+ const ta = this.codeTextarea;
2086
+ try { ta.focus(); ta.setSelectionRange(m.start, m.end); } catch { return; }
2087
+ this.codeInsertText(this.codeReplaceInput.value);
2088
+ // Matches recompute on the repaint; the unchanged index now points at what
2089
+ // was the following occurrence.
2090
+ this.refreshCodeSource();
2091
+ this.scrollCodeLineIntoView(this.codeLineOf(m.start));
2092
+ try { this.codeReplaceInput.focus(); } catch { /* ignore */ }
2093
+ }
2094
+
2095
+ codeReplaceAll() {
2096
+ const f = this._codeFind;
2097
+ if (!f.q || !f.matches.length) return;
2098
+ const src = this.core.state.codeSrc;
2099
+ const rep = this.codeReplaceInput.value;
2100
+ let out = '';
2101
+ let last = 0;
2102
+ for (const m of f.matches) { out += src.slice(last, m.start) + rep; last = m.end; }
2103
+ out += src.slice(last);
2104
+ const ta = this.codeTextarea;
2105
+ try { ta.focus(); ta.setSelectionRange(0, ta.value.length); } catch { return; }
2106
+ this.codeInsertText(out);
2107
+ try { ta.setSelectionRange(0, 0); } catch { /* ignore */ }
2108
+ this.refreshCodeSource();
2109
+ try { this.codeFindInput.focus(); } catch { /* ignore */ }
2110
+ }
2111
+
2112
+ codeGoToLine(value) {
2113
+ const num = parseInt(String(value).replace(/[^0-9]/g, ''), 10);
2114
+ if (!num) return;
2115
+ const lines = this.core.state.codeSrc.split('\n');
2116
+ const line = Math.min(Math.max(1, num), lines.length) - 1;
2117
+ let off = 0;
2118
+ for (let i = 0; i < line; i++) off += lines[i].length + 1;
2119
+ try { this.codeTextarea.focus(); this.codeTextarea.setSelectionRange(off, off); } catch { /* ignore */ }
2120
+ this.refreshCodeSource();
2121
+ this.scrollCodeLineIntoView(line, true);
2122
+ }
2123
+
2124
+ // ---- code view: inspect (code <-> preview) -------------------------------
2125
+
2126
+ /** Tag scan of the source pane, cached per source string -- caret pings and preview clicks both read it. */
2127
+ codeScan() {
2128
+ const src = this.core.state.codeSrc;
2129
+ if (this._codeScanSrc !== src) {
2130
+ this._codeScanSrc = src;
2131
+ this._codeScanned = scanElements(src);
2132
+ }
2133
+ return this._codeScanned;
2134
+ }
2135
+
2136
+ /** Same-tag elements of the preview document in document order, minus the decoration nodes `decorateLogicTags` adds (chips/band rows exist only in the preview, never in the source being mapped). */
2137
+ codePreviewEls(doc, tag) {
2138
+ const deco = (el) => (el.closest && el.closest('[data-mc-deco]'))
2139
+ || (el.firstElementChild && el.firstElementChild.hasAttribute && el.firstElementChild.hasAttribute('data-mc-deco'));
2140
+ return Array.from(doc.getElementsByTagName(tag)).filter((el) => !deco(el));
2141
+ }
2142
+
2143
+ /**
2144
+ * Caret -> preview half of the inspect link. Both documents render the same
2145
+ * string, so "the nth <td>" identifies the same element on both sides --
2146
+ * per-tag counting on purpose: the parser's inserted <tbody>s (and the
2147
+ * preview-only decoration, filtered above) would shift any global index.
2148
+ * When the counts for a tag disagree (mid-typing, malformed markup), the
2149
+ * mapping walks up to the nearest ancestor whose counts still line up
2150
+ * rather than guessing.
2151
+ */
2152
+ inspectFromCaret() {
2153
+ const s = this.core.state;
2154
+ if (!s.codeOpen) return;
2155
+ const doc = this.codeFrame && this.codeFrame.contentDocument;
2156
+ if (!doc || !doc.body) return;
2157
+ const scan = this.codeScan();
2158
+ const caret = Math.min(this.codeTextarea.selectionStart || 0, Math.max(0, s.codeSrc.length - 1));
2159
+ let el = elementAtOffset(scan, caret);
2160
+ let node = null;
2161
+ while (el) {
2162
+ const list = this.codePreviewEls(doc, el.tag);
2163
+ if (list.length === (scan.byTag[el.tag] || []).length) { node = list[el.nth] || null; break; }
2164
+ el = el.parent >= 0 ? scan.els[el.parent] : null;
2165
+ }
2166
+ this.highlightPreviewNode(node);
2167
+ }
2168
+
2169
+ highlightPreviewNode(node) {
2170
+ const doc = this.codeFrame && this.codeFrame.contentDocument;
2171
+ if (!doc) return;
2172
+ if (this._previewHit && this._previewHit !== node) {
2173
+ try { this._previewHit.removeAttribute('data-mc-hit'); } catch { /* ignore */ }
2174
+ }
2175
+ this._previewHit = null;
2176
+ if (!node || node === doc.body || node === doc.documentElement) return;
2177
+ this._previewHit = node;
2178
+ node.setAttribute('data-mc-hit', '');
2179
+ this.ensureInspectStyle(doc);
2180
+ if (typeof node.scrollIntoView === 'function') {
2181
+ try { node.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch { try { node.scrollIntoView(); } catch { /* ignore */ } }
2182
+ }
2183
+ }
2184
+
2185
+ /** Preview-only outline style, injected the same way as the preview scrollbar chrome -- the source string and everything exported stay untouched. */
2186
+ ensureInspectStyle(doc) {
2187
+ if (!doc.head) return;
2188
+ const view = this.ownerDocument && this.ownerDocument.defaultView;
2189
+ const accent = (view && view.getComputedStyle(this.mc).getPropertyValue('--ed-accent').trim()) || '#2563eb';
2190
+ let style = doc.head.querySelector('[data-mc-inspect-style]');
2191
+ if (!style) {
2192
+ style = doc.createElement('style');
2193
+ style.setAttribute('data-mc-inspect-style', '');
2194
+ doc.head.appendChild(style);
2195
+ }
2196
+ style.textContent = `[data-mc-hit] { outline: 2px solid ${accent} !important; outline-offset: -1px !important; }`;
2197
+ }
2198
+
2199
+ /** Attach the preview -> code click handler to the iframe's current document (each srcdoc load mints a new one). Capture phase so an <a> highlights its source instead of navigating the preview away. */
2200
+ wireCodePreviewInspect() {
2201
+ const doc = this.codeFrame && this.codeFrame.contentDocument;
2202
+ if (!doc || doc === this._inspectWiredDoc) return;
2203
+ this._inspectWiredDoc = doc;
2204
+ this._previewHit = null;
2205
+ doc.addEventListener('click', (e) => {
2206
+ if (!this.core.state.codeOpen) return;
2207
+ e.preventDefault();
2208
+ this.revealSourceFromPreview(e.target);
2209
+ }, true);
2210
+ }
2211
+
2212
+ /** Preview -> caret half of the inspect link: selects the clicked element's open tag in the source pane and scrolls its line into view. */
2213
+ revealSourceFromPreview(target) {
2214
+ const doc = this.codeFrame && this.codeFrame.contentDocument;
2215
+ if (!doc) return;
2216
+ const scan = this.codeScan();
2217
+ let node = target && target.nodeType === 1 ? target : target && target.parentElement;
2218
+ let found = null;
2219
+ let hit = null;
2220
+ while (node && node !== doc.documentElement) {
2221
+ if (node.closest && node.closest('[data-mc-deco]')) { node = node.parentElement; continue; }
2222
+ const tag = node.tagName.toLowerCase();
2223
+ const list = this.codePreviewEls(doc, tag);
2224
+ const nth = list.indexOf(node);
2225
+ if (nth !== -1 && (scan.byTag[tag] || []).length === list.length) {
2226
+ found = scan.byTag[tag][nth];
2227
+ hit = node;
2228
+ break;
2229
+ }
2230
+ node = node.parentElement;
2231
+ }
2232
+ if (!found) return;
2233
+ try {
2234
+ this.codeTextarea.focus();
2235
+ this.codeTextarea.setSelectionRange(found.openStart, found.openEnd);
2236
+ } catch { /* ignore */ }
2237
+ this.refreshCodeSource();
2238
+ this.scrollCodeLineIntoView(this.codeLineOf(found.openStart), true);
2239
+ this.highlightPreviewNode(hit);
1788
2240
  }
1789
2241
 
1790
2242
  // ---- modals: AI copy --------------------------------------------------