@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.
- package/CHANGELOG.md +99 -0
- package/DOCS.md +27 -27
- package/README.md +1 -1
- package/README.md.txt +1 -1
- package/dist/mailcraft-editor.bundle.js +76 -48
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -1
- package/src/core/blocks.js +15 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +88 -6
- package/src/core/export.js +195 -6
- package/src/core/i18n/index.js +83 -83
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +482 -31
- 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 +69 -1
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +41 -2
- package/src/render/block-body.js +109 -12
- package/src/render/canvas.js +45 -3
- package/src/render/fields.js +28 -2
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +6 -0
- package/src/render/story.js +415 -415
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
|
@@ -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
|
-
|
|
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
|
};
|
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
|
@@ -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
|
-
|
|
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' });
|