@seliseblocks/mailcraft 0.2.12 → 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.
- package/CHANGELOG.md +12 -0
- package/DOCS.md +8 -0
- package/dist/mailcraft-editor.bundle.js +68 -56
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -2
- package/src/core/code-tools.js +302 -0
- package/src/core/export.js +5 -2
- package/src/core/i18n/ar.js +8 -0
- package/src/core/i18n/bg.js +8 -0
- package/src/core/i18n/bn.js +8 -0
- package/src/core/i18n/ca.js +8 -0
- package/src/core/i18n/cs.js +8 -0
- package/src/core/i18n/da.js +8 -0
- package/src/core/i18n/de-CH.js +8 -0
- package/src/core/i18n/de.js +8 -0
- package/src/core/i18n/dz.js +8 -0
- package/src/core/i18n/el.js +8 -0
- package/src/core/i18n/en.js +8 -0
- package/src/core/i18n/es.js +8 -0
- package/src/core/i18n/et.js +8 -0
- package/src/core/i18n/fi.js +8 -0
- package/src/core/i18n/fr.js +8 -0
- package/src/core/i18n/hr.js +8 -0
- package/src/core/i18n/hu.js +8 -0
- package/src/core/i18n/it.js +8 -0
- package/src/core/i18n/lt.js +8 -0
- package/src/core/i18n/lv.js +8 -0
- package/src/core/i18n/nb.js +8 -0
- package/src/core/i18n/nl.js +8 -0
- package/src/core/i18n/pl.js +8 -0
- package/src/core/i18n/pt.js +8 -0
- package/src/core/i18n/ro.js +8 -0
- package/src/core/i18n/ru.js +8 -0
- package/src/core/i18n/sk.js +8 -0
- package/src/core/i18n/sl.js +8 -0
- package/src/core/i18n/sv.js +8 -0
- package/src/core/i18n/tr.js +8 -0
- package/src/core/i18n/uk.js +8 -0
- package/src/core/icons.js +2 -0
- package/src/mailcraft-editor.js +468 -16
package/src/mailcraft-editor.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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
|
|
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
|
-
|
|
1786
|
-
const
|
|
1787
|
-
|
|
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)) || '​';
|
|
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 --------------------------------------------------
|