@seliseblocks/mailcraft 0.2.7 → 0.2.9

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.
@@ -1,46 +1,46 @@
1
- /**
2
- * Pure style-computation functions shared by the live renderer and the export
3
- * builder, ported verbatim. Objects use camelCase keys so they can be applied
4
- * directly via `Object.assign(el.style, obj)`.
5
- */
6
-
7
- import { cssUrl } from './sanitize.js';
8
- export function pad(p) {
9
- return (p.py || 0) + 'px ' + (p.px || 0) + 'px';
10
- }
11
-
12
- export function boxStyle(p) {
13
- const on = (key) => p[key] !== false;
14
- const side = (key) => (p.bBorder && on(key) ? p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5') : '0');
15
- return {
16
- background: p.bBg || 'transparent',
17
- borderTop: side('bTop'), borderRight: side('bRight'), borderBottom: side('bBottom'), borderLeft: side('bLeft'),
18
- borderRadius: (p.bRadius || 0) + 'px',
19
- padding: (p.bPad || 0) + 'px',
20
- };
21
- }
22
-
23
- export function boxCss(p) {
24
- const bits = [];
25
- if (p.bBg) bits.push('background:' + p.bBg);
26
- if (p.bBorder) {
27
- const value = p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5');
28
- const sides = { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
29
- if (sides.top && sides.right && sides.bottom && sides.left) bits.push('border:' + value);
30
- else Object.keys(sides).filter((key) => sides[key]).forEach((key) => bits.push('border-' + key + ':' + value));
31
- }
32
- if (p.bRadius) bits.push('border-radius:' + p.bRadius + 'px');
33
- if (p.bPad) bits.push('padding:' + p.bPad + 'px');
34
- return bits.length ? bits.join(';') + ';' : 'margin:0';
35
- }
36
-
37
- /** A row's effective padding as a four-value CSS shorthand. `pt/pb/pl/pr` are optional per-side overrides (set by the inspector's "Per-side padding" split, or by the importer for asymmetric source padding); wherever a side is absent it follows the linked `py`/`px` pair, so documents that never split keep behaving exactly as before. */
1
+ /**
2
+ * Pure style-computation functions shared by the live renderer and the export
3
+ * builder, ported verbatim. Objects use camelCase keys so they can be applied
4
+ * directly via `Object.assign(el.style, obj)`.
5
+ */
6
+
7
+ import { cssUrl } from './sanitize.js';
8
+ export function pad(p) {
9
+ return (p.py || 0) + 'px ' + (p.px || 0) + 'px';
10
+ }
11
+
12
+ export function boxStyle(p) {
13
+ const on = (key) => p[key] !== false;
14
+ const side = (key) => (p.bBorder && on(key) ? p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5') : '0');
15
+ return {
16
+ background: p.bBg || 'transparent',
17
+ borderTop: side('bTop'), borderRight: side('bRight'), borderBottom: side('bBottom'), borderLeft: side('bLeft'),
18
+ borderRadius: (p.bRadius || 0) + 'px',
19
+ padding: (p.bPad || 0) + 'px',
20
+ };
21
+ }
22
+
23
+ export function boxCss(p) {
24
+ const bits = [];
25
+ if (p.bBg) bits.push('background:' + p.bBg);
26
+ if (p.bBorder) {
27
+ const value = p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5');
28
+ const sides = { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
29
+ if (sides.top && sides.right && sides.bottom && sides.left) bits.push('border:' + value);
30
+ else Object.keys(sides).filter((key) => sides[key]).forEach((key) => bits.push('border-' + key + ':' + value));
31
+ }
32
+ if (p.bRadius) bits.push('border-radius:' + p.bRadius + 'px');
33
+ if (p.bPad) bits.push('padding:' + p.bPad + 'px');
34
+ return bits.length ? bits.join(';') + ';' : 'margin:0';
35
+ }
36
+
37
+ /** A row's effective padding as a four-value CSS shorthand. `pt/pb/pl/pr` are optional per-side overrides (set by the inspector's "Per-side padding" split, or by the importer for asymmetric source padding); wherever a side is absent it follows the linked `py`/`px` pair, so documents that never split keep behaving exactly as before. */
38
38
  export function rowPad(p) {
39
- const t = p.pt ?? p.py ?? 0;
40
- const b = p.pb ?? p.py ?? 0;
41
- const l = p.pl ?? p.px ?? 0;
42
- const r = p.pr ?? p.px ?? 0;
43
- return t + 'px ' + r + 'px ' + b + 'px ' + l + 'px';
39
+ const t = p.pt ?? p.py ?? 0;
40
+ const b = p.pb ?? p.py ?? 0;
41
+ const l = p.pl ?? p.px ?? 0;
42
+ const r = p.pr ?? p.px ?? 0;
43
+ return t + 'px ' + r + 'px ' + b + 'px ' + l + 'px';
44
44
  }
45
45
 
46
46
  /** A row's outside spacing in CSS clockwise order, with the old vertical `my` value as a saved-document fallback. Empty horizontal margins can remain `auto` in the live canvas so the advanced max-width control stays centered. */
@@ -52,64 +52,64 @@ export function rowMargin(p, centerEmpty) {
52
52
  const emptyHorizontal = centerEmpty && !r && !l;
53
53
  return t + 'px ' + (emptyHorizontal ? 'auto' : r + 'px') + ' ' + b + 'px ' + (emptyHorizontal ? 'auto' : l + 'px');
54
54
  }
55
-
56
- /** Which sides a row's border draws on. Sides default ON (`!== false`) so documents saved before per-side toggles existed keep their full border. */
57
- export function rowBorderSides(p) {
58
- return { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
59
- }
60
-
61
- /** The row border as an inline-CSS string (export path). Empty when the width is 0 or every side is toggled off. */
62
- export function rowBorderCss(p) {
63
- if (!p.border) return '';
64
- const s = rowBorderSides(p);
65
- const value = p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5');
66
- if (s.top && s.right && s.bottom && s.left) return 'border:' + value + ';';
67
- return ['top', 'right', 'bottom', 'left'].filter((k) => s[k]).map((k) => 'border-' + k + ':' + value + ';').join('');
68
- }
69
-
70
- export function rowBg(p) {
71
- const ov = (p.overlay || 0) / 100;
72
- const layers = [];
73
- if (p.bgImage && ov) layers.push('linear-gradient(rgba(20,22,24,' + ov + '),rgba(20,22,24,' + ov + '))');
74
- if (p.bgImage) layers.push('url("' + cssUrl(p.bgImage) + '")');
75
- const s = rowBorderSides(p);
76
- const side = (on) => (p.border && on ? p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5') : '0');
77
- return {
78
- backgroundColor: p.bg || 'transparent',
79
- backgroundImage: layers.length ? layers.join(',') : 'none',
80
- backgroundSize: p.bgSize || 'cover',
81
- backgroundPosition: p.bgPos || 'center',
82
- backgroundRepeat: p.bgRepeat || 'no-repeat',
83
- borderTop: side(s.top),
84
- borderRight: side(s.right),
85
- borderBottom: side(s.bottom),
86
- borderLeft: side(s.left),
87
- borderRadius: (p.radius || 0) + 'px',
88
- // A raw CSS string, not a boolean: imports keep the source's exact
89
- // shadow; the inspector toggle writes/clears a standard one.
90
- boxShadow: p.shadow || 'none',
91
- maxWidth: (p.maxW || 100) + '%',
55
+
56
+ /** Which sides a row's border draws on. Sides default ON (`!== false`) so documents saved before per-side toggles existed keep their full border. */
57
+ export function rowBorderSides(p) {
58
+ return { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
59
+ }
60
+
61
+ /** The row border as an inline-CSS string (export path). Empty when the width is 0 or every side is toggled off. */
62
+ export function rowBorderCss(p) {
63
+ if (!p.border) return '';
64
+ const s = rowBorderSides(p);
65
+ const value = p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5');
66
+ if (s.top && s.right && s.bottom && s.left) return 'border:' + value + ';';
67
+ return ['top', 'right', 'bottom', 'left'].filter((k) => s[k]).map((k) => 'border-' + k + ':' + value + ';').join('');
68
+ }
69
+
70
+ export function rowBg(p) {
71
+ const ov = (p.overlay || 0) / 100;
72
+ const layers = [];
73
+ if (p.bgImage && ov) layers.push('linear-gradient(rgba(20,22,24,' + ov + '),rgba(20,22,24,' + ov + '))');
74
+ if (p.bgImage) layers.push('url("' + cssUrl(p.bgImage) + '")');
75
+ const s = rowBorderSides(p);
76
+ const side = (on) => (p.border && on ? p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5') : '0');
77
+ return {
78
+ backgroundColor: p.bg || 'transparent',
79
+ backgroundImage: layers.length ? layers.join(',') : 'none',
80
+ backgroundSize: p.bgSize || 'cover',
81
+ backgroundPosition: p.bgPos || 'center',
82
+ backgroundRepeat: p.bgRepeat || 'no-repeat',
83
+ borderTop: side(s.top),
84
+ borderRight: side(s.right),
85
+ borderBottom: side(s.bottom),
86
+ borderLeft: side(s.left),
87
+ borderRadius: (p.radius || 0) + 'px',
88
+ // A raw CSS string, not a boolean: imports keep the source's exact
89
+ // shadow; the inspector toggle writes/clears a standard one.
90
+ boxShadow: p.shadow || 'none',
91
+ maxWidth: (p.maxW || 100) + '%',
92
92
  margin: rowMargin(p, true),
93
93
  };
94
94
  }
95
-
96
- export function colsWrap(p) {
97
- const gap = p.gap || 0;
98
- if (p.layout === 'grid') return { display: 'grid', gridTemplateColumns: 'repeat(' + (p.gridCols || 2) + ', minmax(0, 1fr))', gap: gap + 'px' };
99
- if (p.layout === 'flex') {
100
- return {
101
- display: 'flex', flexDirection: p.flexDir || 'row', justifyContent: p.justify || 'flex-start',
102
- alignItems: p.alignItems || 'stretch', flexWrap: p.wrap ? 'wrap' : 'nowrap', gap: gap + 'px',
103
- };
104
- }
105
- return { display: 'flex', alignItems: 'stretch', margin: '0 ' + (-gap / 2) + 'px' };
106
- }
107
-
108
- export function colStyle(p, c) {
109
- if (p.layout === 'grid') return { minWidth: 0 };
110
- if (p.layout === 'flex') return { flex: (p.flexDir || 'row').indexOf('column') === 0 ? '0 0 auto' : c.span + ' 1 auto', minWidth: 0 };
111
- return {
112
- flex: c.span + ' 1 0%', minWidth: 0, padding: '0 ' + (p.gap || 0) / 2 + 'px',
113
- alignSelf: p.valign === 'middle' ? 'center' : (p.valign === 'bottom' ? 'flex-end' : 'flex-start'),
114
- };
115
- }
95
+
96
+ export function colsWrap(p) {
97
+ const gap = p.gap || 0;
98
+ if (p.layout === 'grid') return { display: 'grid', gridTemplateColumns: 'repeat(' + (p.gridCols || 2) + ', minmax(0, 1fr))', gap: gap + 'px' };
99
+ if (p.layout === 'flex') {
100
+ return {
101
+ display: 'flex', flexDirection: p.flexDir || 'row', justifyContent: p.justify || 'flex-start',
102
+ alignItems: p.alignItems || 'stretch', flexWrap: p.wrap ? 'wrap' : 'nowrap', gap: gap + 'px',
103
+ };
104
+ }
105
+ return { display: 'flex', alignItems: 'stretch', margin: '0 ' + (-gap / 2) + 'px' };
106
+ }
107
+
108
+ export function colStyle(p, c) {
109
+ if (p.layout === 'grid') return { minWidth: 0 };
110
+ if (p.layout === 'flex') return { flex: (p.flexDir || 'row').indexOf('column') === 0 ? '0 0 auto' : c.span + ' 1 auto', minWidth: 0 };
111
+ return {
112
+ flex: c.span + ' 1 0%', minWidth: 0, padding: '0 ' + (p.gap || 0) / 2 + 'px',
113
+ alignSelf: p.valign === 'middle' ? 'center' : (p.valign === 'bottom' ? 'flex-end' : 'flex-start'),
114
+ };
115
+ }
package/src/core/parse.js CHANGED
@@ -1,10 +1,10 @@
1
- export function parseItems(s) {
2
- return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
3
- const i = l.indexOf('|');
4
- return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
5
- });
6
- }
7
-
8
- export function cellsOf(p) {
9
- return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
10
- }
1
+ export function parseItems(s) {
2
+ return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
3
+ const i = l.indexOf('|');
4
+ return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
5
+ });
6
+ }
7
+
8
+ export function cellsOf(p) {
9
+ return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
10
+ }
@@ -1,15 +1,15 @@
1
- /** Data-URI placeholder image generator, ported verbatim from the original. */
2
- function enc(s) {
3
- return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
4
- }
5
-
6
- export function PH(label, w, ht) {
7
- return 'data:image/svg+xml;utf8,' + enc(
8
- '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
9
- '<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
10
- '<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
11
- '<rect width="100%" height="100%" fill="url(#s)"/>' +
12
- '<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
13
- '<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
14
- );
15
- }
1
+ /** Data-URI placeholder image generator, ported verbatim from the original. */
2
+ function enc(s) {
3
+ return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
4
+ }
5
+
6
+ export function PH(label, w, ht) {
7
+ return 'data:image/svg+xml;utf8,' + enc(
8
+ '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
9
+ '<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
10
+ '<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
11
+ '<rect width="100%" height="100%" fill="url(#s)"/>' +
12
+ '<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
13
+ '<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
14
+ );
15
+ }
@@ -102,6 +102,67 @@ export const cleanImportHtml = (html) => {
102
102
  return doc.body.innerHTML.replace(/<!--[\s\S]*?-->/g, '').replace(/\s{2,}/g, ' ').trim();
103
103
  };
104
104
 
105
+ /**
106
+ * Mutation-time repairs for rich block HTML. Imported content keeps its
107
+ * per-element inline typography on purpose (`cleanImportHtml` above), but a
108
+ * descendant's own `font-size`/`color`/`line-height` always outranks the
109
+ * inherited value from the block wrapper -- so the inspector's Text size,
110
+ * Text color, Line spacing and Text weight controls were dead on any block
111
+ * whose paragraphs carried their own styles. The render-time strip that fixed
112
+ * the Font control (`overrideRichFont`, render/block-body.js) can't be reused
113
+ * here: `fontFamily` defaults to empty so "unset" means "leave the import
114
+ * alone", while `size`/`color`/`lh`/`weight` always hold a value -- stripping
115
+ * at render time would flatten every imported design on first paint. These
116
+ * run once, at the moment the user moves the control (core `setProp`), so an
117
+ * untouched document renders byte-identical and the rewrite lands in the same
118
+ * undo step as the prop change.
119
+ *
120
+ * Both return the input string untouched (same reference, no reparse churn)
121
+ * when there is nothing to rewrite.
122
+ */
123
+
124
+ /** Drops trailing float noise without inventing integers: 60.75 stays, 78.00 becomes 78. */
125
+ const trimNum = (n) => String(Number(n.toFixed(2)));
126
+
127
+ /**
128
+ * Multiplies every absolute inline font-size by `ratio`, so the block's Text
129
+ * size acts as a master scale and a 15/26/15px imported hierarchy survives a
130
+ * base change instead of being flattened. Only px/pt scale -- em/% are
131
+ * relative and already follow the wrapper. A px line-height sitting beside a
132
+ * scaled font-size scales with it, or the imported `line-height:22px` would
133
+ * strangle 45px glyphs.
134
+ */
135
+ export const scaleInlineSizes = (html, ratio) => {
136
+ const src = String(html == null ? '' : html);
137
+ if (!src || !Number.isFinite(ratio) || ratio <= 0 || ratio === 1 || !/font-size/i.test(src)) return src;
138
+ const doc = new DOMParser().parseFromString(src, 'text/html');
139
+ let changed = false;
140
+ doc.body.querySelectorAll('[style]').forEach((node) => {
141
+ const size = /^([\d.]+)(px|pt)$/.exec(node.style.fontSize || '');
142
+ if (!size) return;
143
+ node.style.fontSize = trimNum(parseFloat(size[1]) * ratio) + size[2];
144
+ const lh = /^([\d.]+)px$/.exec(node.style.lineHeight || '');
145
+ if (lh) node.style.lineHeight = trimNum(parseFloat(lh[1]) * ratio) + 'px';
146
+ changed = true;
147
+ });
148
+ return changed ? doc.body.innerHTML : src;
149
+ };
150
+
151
+ /** Removes one CSS property from every descendant's inline style -- the block-level control now owns it. The property name is exact (`color` never touches `background-color`). */
152
+ export const stripInlineStyle = (html, prop) => {
153
+ const src = String(html == null ? '' : html);
154
+ if (!src || src.indexOf(prop) === -1) return src;
155
+ const doc = new DOMParser().parseFromString(src, 'text/html');
156
+ let changed = false;
157
+ doc.body.querySelectorAll('[style]').forEach((node) => {
158
+ if (!node.style.getPropertyValue(prop)) return;
159
+ node.style.removeProperty(prop);
160
+ if (!node.getAttribute('style')) node.removeAttribute('style');
161
+ changed = true;
162
+ });
163
+ return changed ? doc.body.innerHTML : src;
164
+ };
165
+
105
166
  /**
106
167
  * Makes an image URL safe to drop inside `url("...")`.
107
168
  *
@@ -160,7 +221,14 @@ export const linkHref = (u) => {
160
221
  if (!/^[a-z][a-z0-9+.-]*:\d/i.test(raw)) return '';
161
222
  }
162
223
  if (raw.startsWith('//')) return 'https:' + raw;
163
- if (/^[#/]/.test(raw) || raw.startsWith('{{')) return raw;
224
+ // A href that *is* a merge placeholder is handed back untouched. Only
225
+ // `{{ }}` used to be, so `[Survey URL]` -- and Mailchimp's `*|URL|*`, and
226
+ // `%%url%%`, and `${url}` -- came back as `https://[Survey URL]`, which the
227
+ // sending engine then expands into `https://https://...` and the link is
228
+ // dead. Anchored to the start on purpose: `example.com/{{id}}` is a real
229
+ // relative URL and still needs its scheme.
230
+ if (/^(?:\{\{|\[|%%|\*\||\$\{)/.test(raw)) return raw;
231
+ if (/^[#/]/.test(raw)) return raw;
164
232
  if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) return 'mailto:' + raw;
165
233
  return 'https://' + raw;
166
234
  };
@@ -1,11 +1,11 @@
1
- export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
2
- export const TOKEN = (t) => '{' + '{ ' + t + ' }' + '}';
3
-
4
- /** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
5
- export function vars(raw) {
6
- const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
7
- return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
8
- }
9
-
10
- /** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
11
- export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };
1
+ export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
2
+ export const TOKEN = (t) => '{' + '{ ' + t + ' }' + '}';
3
+
4
+ /** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
5
+ export function vars(raw) {
6
+ const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
7
+ return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
8
+ }
9
+
10
+ /** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
11
+ export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };
@@ -1039,6 +1039,22 @@ export class MailCraftEditor extends ElementBase {
1039
1039
  }
1040
1040
 
1041
1041
  renderTabBody() {
1042
+ // The browser's native colour dialog is bound to the identity of its
1043
+ // <input type="color"> node: Chromium closes the dialog the moment that
1044
+ // node leaves the document -- even if the same node is spliced back into
1045
+ // the rebuilt tree within the same task (unlike the range slider's mouse
1046
+ // capture, which focus-preserve.js does save that way). The picker lives
1047
+ // in this panel, and its `input` events commit (debounced) to the doc, so
1048
+ // every colour the user tried used to rebuild the panel ~120ms later and
1049
+ // slam the dialog shut. While the picker holds focus -- the only time its
1050
+ // dialog can be open -- the panel therefore skips its rebuild: the canvas
1051
+ // has already re-rendered above (that is the live preview), and the pill
1052
+ // previews itself (fields.js mutates its swatch and hex text directly on
1053
+ // every `input`). Nothing else in the panel changes on a colour commit,
1054
+ // so nothing is stale; the moment focus moves anywhere else, the next
1055
+ // render rebuilds the panel as usual.
1056
+ const active = this.shadowRoot.activeElement;
1057
+ if (active && active.tagName === 'INPUT' && active.type === 'color' && this.tabBody.contains(active)) return;
1042
1058
  this.tabBody.innerHTML = '';
1043
1059
  const tab = this.core.state.tab;
1044
1060
  if (tab === 'design') this.tabBody.appendChild(this.renderDesignTab());
@@ -1579,7 +1595,30 @@ export class MailCraftEditor extends ElementBase {
1579
1595
  this.codeStatusEl = elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { class: 'mc-code-kicker' });
1580
1596
  headText.append(this.codeStatusEl, elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 15px; line-height: 1.2;', { text: t('modal.rawHtmlTitle'), 'data-i18n': 'modal.rawHtmlTitle', class: 'mc-code-heading' }));
1581
1597
  this.codeWidthSeg = elS('div', '', { class: 'mc-segment' });
1582
- const reloadBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 11px; display: flex; align-items: center; gap: 6px; font-family: var(--ed-font); font-size: 11px; font-weight: 600; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.reloadHint'), 'data-i18n-title': 'action.reloadHint' });
1598
+ // Ghost-button chrome shared by the header's Copy and Reload.
1599
+ const GHOST_BTN = 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 11px; display: flex; align-items: center; gap: 6px; font-family: var(--ed-font); font-size: 11px; font-weight: 600; transition: border-color 0.16s, background 0.16s;';
1600
+ const copyBtn = elS('button', GHOST_BTN, { type: 'button', title: t('toast.htmlCopied'), 'data-i18n-title': 'toast.htmlCopied', class: 'mc-code-copy' });
1601
+ copyBtn.appendChild(icon('copy', 14));
1602
+ // Reuses keys every locale already carries (story.copy / export.copied)
1603
+ // rather than minting new ones across 32 translations.
1604
+ const copyLabel = elS('span', '', { text: t('story.copy'), 'data-i18n': 'story.copy' });
1605
+ copyBtn.appendChild(copyLabel);
1606
+ copyBtn.addEventListener('mouseenter', () => { copyBtn.style.borderColor = 'var(--ed-accent)'; copyBtn.style.background = 'var(--ed-soft)'; });
1607
+ copyBtn.addEventListener('mouseleave', () => { copyBtn.style.borderColor = 'var(--ed-line)'; copyBtn.style.background = 'transparent'; });
1608
+ copyBtn.addEventListener('click', () => {
1609
+ this.core.copyCode();
1610
+ // Momentary "Copied": swap the i18n key itself, not just the text --
1611
+ // refreshStrings relabels every [data-i18n] node on each render and
1612
+ // would otherwise revert the feedback mid-flash.
1613
+ copyLabel.setAttribute('data-i18n', 'export.copied');
1614
+ copyLabel.textContent = this.core.t('export.copied');
1615
+ clearTimeout(this._codeCopyTimer);
1616
+ this._codeCopyTimer = setTimeout(() => {
1617
+ copyLabel.setAttribute('data-i18n', 'story.copy');
1618
+ copyLabel.textContent = this.core.t('story.copy');
1619
+ }, 1600);
1620
+ });
1621
+ const reloadBtn = elS('button', GHOST_BTN, { type: 'button', title: t('action.reloadHint'), 'data-i18n-title': 'action.reloadHint' });
1583
1622
  reloadBtn.appendChild(icon('refresh', 14));
1584
1623
  reloadBtn.appendChild(elS('span', '', { text: t('action.reload'), 'data-i18n': 'action.reload' }));
1585
1624
  reloadBtn.addEventListener('mouseenter', () => { reloadBtn.style.borderColor = 'var(--ed-accent)'; reloadBtn.style.background = 'var(--ed-soft)'; });
@@ -1601,7 +1640,7 @@ export class MailCraftEditor extends ElementBase {
1601
1640
  closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; });
1602
1641
  closeBtn.addEventListener('click', () => this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }));
1603
1642
  tip(closeBtn, t('action.closeWithoutApplyingHint'), 'down', 'end');
1604
- head.append(headIcon, headText, this.codeWidthSeg, reloadBtn, applyBtn, closeBtn);
1643
+ head.append(headIcon, headText, this.codeWidthSeg, copyBtn, reloadBtn, applyBtn, closeBtn);
1605
1644
  overlay.appendChild(head);
1606
1645
 
1607
1646
  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' });