@seliseblocks/mailcraft 0.2.9 → 0.2.11

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
+ }
@@ -73,7 +73,8 @@ const IMPORT_STYLES = [
73
73
  'width', 'max-width', 'height',
74
74
  ];
75
75
 
76
- export const cleanImportHtml = (html) => {
76
+ /** `dropProps` (optional): style properties to leave out of the kept whitelist -- the importer passes `['font-family']` when a run's family has been claimed as the block-level font, so the same declaration doesn't ship twice (and the renderer's overrideRichFont then has nothing to strip at render time, which keeps export -> import -> export byte-stable). */
77
+ export const cleanImportHtml = (html, dropProps, keepProps) => {
77
78
  const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
78
79
  doc.querySelectorAll('style,script,meta,link,title,head').forEach((n) => n.remove());
79
80
  const walk = (node) => {
@@ -87,6 +88,15 @@ export const cleanImportHtml = (html) => {
87
88
  }
88
89
  const kept = [];
89
90
  IMPORT_STYLES.forEach((p) => {
91
+ if (dropProps && dropProps.indexOf(p) > -1) return;
92
+ const v = el.style.getPropertyValue(p);
93
+ if (v) kept.push(p + ':' + v);
94
+ });
95
+ // `keepProps`: extra properties a specific caller vouches for beyond the
96
+ // shared whitelist -- the section-box reader passes the display/margin
97
+ // pair its own template writes (`<strong style="display:block;...">`),
98
+ // which the general list rightly refuses from arbitrary paste.
99
+ (keepProps || []).forEach((p) => {
90
100
  const v = el.style.getPropertyValue(p);
91
101
  if (v) kept.push(p + ':' + v);
92
102
  });
@@ -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' };
@@ -137,6 +137,7 @@ export class MailCraftEditor extends ElementBase {
137
137
  return tag === 'input' || tag === 'textarea' || (target && target.isContentEditable);
138
138
  });
139
139
  this.core.onFormatChange = () => this.scheduleRteRefresh();
140
+ this.core.onFoldLiveEdit = () => this.syncLiveEdit();
140
141
  this.core.onCodeSourceChange = () => this.refreshCodeSource();
141
142
  this.core.onSavedChange = () => this.refreshSavedLabel();
142
143
  this.core.onToast = () => this.refreshToast();
@@ -151,6 +152,7 @@ export class MailCraftEditor extends ElementBase {
151
152
  disconnectedCallback() {
152
153
  this.unsubscribe?.();
153
154
  this.core.onFormatChange = null;
155
+ this.core.onFoldLiveEdit = null;
154
156
  this.core.onCodeSourceChange = null;
155
157
  this.core.onSavedChange = null;
156
158
  this.core.onToast = null;
@@ -463,8 +465,9 @@ export class MailCraftEditor extends ElementBase {
463
465
  */
464
466
  loadTemplate(tpl) { this.core.loadTemplate(tpl); }
465
467
 
466
- exportHtml() {
467
- const html = this.core.buildHtml();
468
+ /** `options.markers: false` omits the fidelity-marker attributes (`data-mc*`, the inert `mc-keep` class) for hosts that want pristine HTML -- at the price of a lossy reload for the few blocks whose rendered shape cannot be read back (countdown, video, section box, code, raw CSS, flex/grid rows). */
469
+ exportHtml(options) {
470
+ const html = this.core.buildHtml(options);
468
471
  this.dispatchEvent(new CustomEvent('export', { detail: html }));
469
472
  return html;
470
473
  }
@@ -888,6 +891,10 @@ export class MailCraftEditor extends ElementBase {
888
891
  this.renderInner();
889
892
  } finally {
890
893
  this.core.rendering = false;
894
+ // Every render rebuilds the canvas from props, so by here the live
895
+ // contenteditable is a copy of them again -- whatever `editStale`
896
+ // claimed is now true of the DOM as well (see `syncLiveEdit`).
897
+ this.core.editStale = null;
891
898
  }
892
899
  if (this.mainEl) {
893
900
  if (this.mainEl.scrollTop !== mainTop) this.mainEl.scrollTop = mainTop;
@@ -901,10 +908,26 @@ export class MailCraftEditor extends ElementBase {
901
908
 
902
909
  syncLiveEdit() {
903
910
  const c = this.core;
911
+ // One-shot right of way for the two cases where props are deliberately
912
+ // *ahead* of the live contenteditable: a rich-content rewrite
913
+ // (`syncRichContent`) and an undo/redo, both in editor-core.js. Syncing
914
+ // the DOM back then is the "props are the truth" rule pointing the wrong
915
+ // way -- it silently undid every Text size / color / spacing change made
916
+ // while the block was focused, and every undo of one. Props win this one
917
+ // render; the rebuild right after puts the new html into the DOM. The flag
918
+ // The flag is cleared by the rebuild in `render()`, not here: a fold can
919
+ // run several times (once per `setProp`) before the next frame, and
920
+ // clearing it on the first of those handed the render back to the DOM
921
+ // copy -- two quick clicks of the RTE's `+` moved nothing at all.
922
+ if (c.editStale && c.editStale === c.state.editing) return;
904
923
  if (!c.state.editing || !c.editEl || !c.editEl.isConnected || !c.editKey) return;
905
924
  const found = c.find(c.state.doc, c.state.editing);
906
925
  if (!found.block) return;
907
926
  const val = c.editPlain ? c.editEl.textContent : c.editEl.innerHTML;
927
+ // Unchanged since the render that built it (`editRendered`, canvas.js
928
+ // `onFocus`) means there is no user edit to fold -- only the render's own
929
+ // DOM decorations, which must not become part of the document.
930
+ if (val === c.editRendered) return;
908
931
  if (val !== found.block.props[c.editKey]) found.block.props[c.editKey] = val;
909
932
  }
910
933
 
@@ -71,6 +71,20 @@ function overrideRichFont(root, fontFamily) {
71
71
  root.querySelectorAll('[style]').forEach((node) => node.style.removeProperty('font-family'));
72
72
  }
73
73
 
74
+ /** An anchor merely restating the document link color drops it, so the sheet rule -- and a later Link color edit -- owns it again. The exporter stamps `theme.link` on every colorless anchor (mail clients need the value inline), and an import keeps that stamp in the block's html; left in place it froze the link color at whatever the theme said on the day of the save. A genuinely different inline color is the user's and stays. Compared through the same rgb-vs-hex fold the importer uses, since CSSOM serializes one and pickers speak the other. */
75
+ function overrideLinkColor(root, link) {
76
+ if (!link) return;
77
+ const key = (v) => {
78
+ const m = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(String(v || '').trim());
79
+ const hex = m ? '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('') : String(v || '');
80
+ return hex.toLowerCase();
81
+ };
82
+ const want = key(link);
83
+ root.querySelectorAll('a[style]').forEach((a) => {
84
+ if (a.style.color && key(a.style.color) === want) a.style.removeProperty('color');
85
+ });
86
+ }
87
+
74
88
  /**
75
89
  * Ported from the original `blockBody(b, theme, live)`. `ctx` supplies the
76
90
  * editing wiring that in the original lived on `this` (Component instance):
@@ -97,6 +111,7 @@ export function blockBody(b, theme, live, ctx) {
97
111
  // and other inline formatting remain intact, while the selected family
98
112
  // now applies consistently in the canvas and exported read-back DOM.
99
113
  overrideRichFont(content, p.fontFamily);
114
+ overrideLinkColor(content, t.link);
100
115
  if (edit) wireEditable(content, b, 'html', ctx, false);
101
116
  if (!live) return content;
102
117
  return wrapWithRte(b, ctx, edit && ctx.editingId === b.id, content);
@@ -303,6 +318,7 @@ export function blockBody(b, theme, live, ctx) {
303
318
  list.appendChild(li);
304
319
  });
305
320
  overrideRichFont(list, p.fontFamily);
321
+ overrideLinkColor(list, t.link);
306
322
  return list;
307
323
  }
308
324
  case 'table': {
@@ -351,11 +367,6 @@ export function blockBody(b, theme, live, ctx) {
351
367
  table.appendChild(tbody);
352
368
  return table;
353
369
  }
354
- case 'embed': {
355
- const wrap = el('div', { padding: p.py + 'px 0' }, attr);
356
- wrap.appendChild(el('iframe', { width: '100%', height: p.height + 'px', border: '1px solid rgba(29,31,32,0.14)', background: '#fff', display: 'block' }, { src: p.src, title: p.label }));
357
- return wrap;
358
- }
359
370
  case 'css': {
360
371
  const wrap = el('div', { fontSize: '0', lineHeight: '0' }, attr);
361
372
  wrap.appendChild(el('style', {}, { html: live ? ctx.scopeCss(p.code, '[data-mc-sheet]') : p.code }));
@@ -383,6 +394,7 @@ export function blockBody(b, theme, live, ctx) {
383
394
  boxShadow: p.shadow ? '0 10px 30px rgba(29,31,32,0.12)' : 'none',
384
395
  fontFamily: t.font, color: t.text, fontSize: '15px', lineHeight: '1.6', ...FIT,
385
396
  }, { ...attr, ...editableAttrs(edit), html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
397
+ overrideLinkColor(box, t.link);
386
398
  if (edit) wireEditable(box, b, 'html', ctx, false);
387
399
  if (!live) return box;
388
400
  return wrapWithRte(b, ctx, ctx.editingId === b.id, box);
@@ -118,6 +118,17 @@ function blockCtx(core, editingBlockId) {
118
118
  core.editEl = node;
119
119
  core.editKey = key;
120
120
  core.editPlain = !!isPlainText;
121
+ // What the renderer just produced for this node, before the user has
122
+ // touched it -- including the decorations the render applies to the DOM
123
+ // and not to props (`overrideRichFont`, `overrideLinkColor` in
124
+ // block-body.js). `syncLiveEdit` folds the node back into props only
125
+ // when it differs from this, so a *user* edit is still captured but a
126
+ // rendering decision is never written into the document. Without it,
127
+ // picking a block font while editing baked the strip into props.html,
128
+ // and switching the control back to Inherit could no longer bring the
129
+ // imported per-paragraph fonts back. Refreshed on every refocus, which
130
+ // is every rebuild (focus-preserve.js).
131
+ core.editRendered = isPlainText ? node.textContent : node.innerHTML;
121
132
  if (core.state.editing === block.id && core.state.sel && core.state.sel.type === 'block' && core.state.sel.id === block.id) return;
122
133
  // Snapshotted only on a genuine new focus (not the guarded no-op above,
123
134
  // which also covers every re-render-triggered refocus while typing --
@@ -196,6 +207,9 @@ export function renderDoc(core, live) {
196
207
  overflow: !live && radius ? 'hidden' : '',
197
208
  transition: 'width 0.28s cubic-bezier(0.22,0.61,0.36,1), background 0.2s, border-radius 0.2s',
198
209
  }, { 'data-mc-sheet': '1', dir: 'ltr' });
210
+ // A custom property cannot ride Object.assign(style, ...) -- '--mc-link'
211
+ // is not a CSSOM member -- so it is set the one way that works.
212
+ root.style.setProperty('--mc-link', theme.link || '');
199
213
 
200
214
  /*
201
215
  * The page: the full-width section the email sits on, painted from the