@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.
- package/CHANGELOG.md +45 -0
- package/DOCS.md +5 -5
- package/README.md +3 -0
- package/README.md.txt +3 -0
- package/dist/mailcraft-editor.bundle.js +75 -60
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -2
- package/src/core/blocks.js +30 -11
- package/src/core/css-cascade.js +120 -117
- package/src/core/editor-core.js +74 -27
- package/src/core/export.js +83 -10
- package/src/core/i18n/index.js +83 -83
- package/src/core/icons.js +0 -1
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +604 -48
- package/src/core/layout-style.js +100 -100
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/sanitize.js +11 -1
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +25 -2
- package/src/render/block-body.js +17 -5
- package/src/render/canvas.js +14 -0
- package/src/render/focus-preserve.js +158 -158
- package/src/render/story.js +415 -415
- package/src/render/style.js +7 -0
- package/types/index.d.ts +10 -3
package/src/core/layout-style.js
CHANGED
|
@@ -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
|
+
}
|
package/src/core/placeholder.js
CHANGED
|
@@ -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
|
+
}
|
package/src/core/sanitize.js
CHANGED
|
@@ -73,7 +73,8 @@ const IMPORT_STYLES = [
|
|
|
73
73
|
'width', 'max-width', 'height',
|
|
74
74
|
];
|
|
75
75
|
|
|
76
|
-
export
|
|
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
|
});
|
package/src/core/variables.js
CHANGED
|
@@ -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' };
|
package/src/mailcraft-editor.js
CHANGED
|
@@ -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
|
-
|
|
467
|
-
|
|
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
|
|
package/src/render/block-body.js
CHANGED
|
@@ -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);
|
package/src/render/canvas.js
CHANGED
|
@@ -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
|