@seliseblocks/mailcraft 0.2.9 → 0.2.10
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 +36 -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 +25 -13
- 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 +545 -47
- 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 +3 -2
- package/src/render/block-body.js +17 -5
- package/src/render/canvas.js +3 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seliseblocks/mailcraft",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "Framework-agnostic drag-and-drop email template editor, packaged as a zero-dependency Web Component.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "SELISE Digital Platforms",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"build": "node build.js",
|
|
63
|
-
"test": "node test/storage.test.mjs && node test/export.test.mjs && node test/templates.test.mjs && node test/system.test.mjs && node test/toolbar.test.mjs && node test/editor-dom.test.mjs && node test/css-cascade.test.mjs && node test/render-dom.test.mjs && node test/import-html.test.mjs && node test/interaction.test.mjs && node test/focus-preserve.test.mjs && node test/shortcuts.test.mjs && node test/import-structure.test.mjs",
|
|
63
|
+
"test": "node test/storage.test.mjs && node test/export.test.mjs && node test/templates.test.mjs && node test/system.test.mjs && node test/toolbar.test.mjs && node test/editor-dom.test.mjs && node test/css-cascade.test.mjs && node test/render-dom.test.mjs && node test/import-html.test.mjs && node test/interaction.test.mjs && node test/focus-preserve.test.mjs && node test/shortcuts.test.mjs && node test/import-structure.test.mjs && node test/roundtrip.test.mjs",
|
|
64
64
|
"prepublishOnly": "npm run build && npm test",
|
|
65
65
|
"coverage": "c8 --reporter=text --reporter=text-summary --src=src --all --exclude=\"test/**\" --exclude=\"examples/**\" --exclude=\"build.js\" npm test"
|
|
66
66
|
},
|
package/src/core/blocks.js
CHANGED
|
@@ -18,7 +18,6 @@ BLOCKS.push(
|
|
|
18
18
|
{ type: 'heading', code: 'HED', label: 'Heading', hint: 'Display heading', make: () => ({ text: 'What we kept', level: 'h2', size: 34, lh: 1.12, align: 'left', color: '', weight: '600', font: 'body', py: 8, px: 0 }) },
|
|
19
19
|
{ type: 'list', code: 'LST', label: 'List', hint: 'Bulleted or numbered list', make: () => ({ items: 'Import your list\nPick a template\nSend the first one', ordered: false, size: 15, lh: 1.7, color: '', gap: 4, py: 8, px: 0 }) },
|
|
20
20
|
{ type: 'table', code: 'TBL', label: 'Table', hint: 'Data table — edit cells inline', make: () => ({ data: 'Plan|Price|Seats\nStarter|$19|3\nTeam|$49|10\nStudio|$99|25', header: true, borders: true, borderWidth: 1, borderStyle: 'solid', striped: true, pad: 10, size: 14, headBg: '#f8fafc', lineColor: '#e2e8f0', align: 'left', width: 100 }) },
|
|
21
|
-
{ type: 'embed', code: 'EMB', label: 'Embed', hint: 'Iframe embed — map, form, player', make: () => ({ src: 'https://example.com/embed', height: 320, label: 'Embedded content', py: 8 }) },
|
|
22
21
|
{ type: 'css', code: 'CSS', label: 'Raw CSS', hint: 'Inject a style block', make: () => ({ code: '.mc-note { font: 13px/1.6 "Helvetica Neue", Helvetica, Arial, sans-serif; color: #0065b3; letter-spacing: 0.04em; }', note: 'Styles apply to raw HTML blocks below.' }) },
|
|
23
22
|
{ type: 'codeblock', code: 'PRE', label: 'Code', hint: 'Preformatted code sample', make: () => ({ code: 'curl -X POST https://api.example.com/v1/sends \\\n -H "Authorization: Bearer $KEY" \\\n -d campaign=mc-4471', bg: '#172033', color: '#e8e9ea', size: 12.5, pad: 14 }) },
|
|
24
23
|
);
|
|
@@ -180,7 +179,7 @@ export const PALETTE = [
|
|
|
180
179
|
{ t: 'heading' }, { t: 'text' }, { t: 'list' }, { g: 'quote' }, { t: 'image' }, { g: 'gallery' },
|
|
181
180
|
{ t: 'button' }, { t: 'table' }, { g: 'card' }, { g: 'product' }, { g: 'hero' }, { g: 'stats' },
|
|
182
181
|
{ t: 'box' }, { t: 'divider' }, { t: 'spacer' }, { t: 'social' }, { t: 'video' },
|
|
183
|
-
{ t: '
|
|
182
|
+
{ t: 'menu' }, { g: 'footer' }, { t: 'html' }, { t: 'css' }, { t: 'svg' },
|
|
184
183
|
{ t: 'codeblock' }, { t: 'countdown' }, { t: 'condition' }, { t: 'loop' },
|
|
185
184
|
];
|
|
186
185
|
|
|
@@ -208,6 +207,15 @@ export function migrateDoc(doc) {
|
|
|
208
207
|
if (r.props.mb === undefined) r.props.mb = legacyMarginY;
|
|
209
208
|
if (r.props.ml === undefined) r.props.ml = 0;
|
|
210
209
|
r.cols.forEach((c) => {
|
|
210
|
+
// The embed block (an <iframe>) was removed on 2026-09-02: mail clients
|
|
211
|
+
// strip iframes, so the block only ever rendered in browser contexts.
|
|
212
|
+
// A saved document keeps its content as the raw html it always exported.
|
|
213
|
+
c.blocks.forEach((b) => {
|
|
214
|
+
if (b.type !== 'embed') return;
|
|
215
|
+
const p = b.props || {};
|
|
216
|
+
b.type = 'html';
|
|
217
|
+
b.props = { code: '<div style="padding:' + (p.py || 0) + 'px 0"><iframe src="' + (p.src || '') + '" title="' + (p.label || '') + '" style="width:100%;height:' + (p.height || 320) + 'px;border:1px solid rgba(29,31,32,0.14);background:#fff;display:block"></iframe></div>' };
|
|
218
|
+
});
|
|
211
219
|
// A block whose type this build no longer knows is dropped rather than
|
|
212
220
|
// rendered as an unrecognized shape; a row emptied that way goes with it.
|
|
213
221
|
c.blocks = c.blocks.filter((b) => DEF(b.type));
|
|
@@ -266,15 +274,26 @@ export function normalizeDoc(input) {
|
|
|
266
274
|
return {
|
|
267
275
|
id: r.id || uid(),
|
|
268
276
|
props: Object.assign({}, rowDefaults, r.props || {}),
|
|
269
|
-
cols: cols.map((c) =>
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
277
|
+
cols: cols.map((c) => {
|
|
278
|
+
const col = {
|
|
279
|
+
id: c.id || uid(),
|
|
280
|
+
span: Number(c.span) > 0 ? Number(c.span) : evenSpan,
|
|
281
|
+
blocks: (Array.isArray(c.blocks) ? c.blocks : []).filter((b) => b && typeof b === 'object' && b.type).map((b) => (
|
|
282
|
+
DEF(b.type)
|
|
283
|
+
? { id: b.id || uid(), type: b.type, props: Object.assign(DEF(b.type).make(), b.props || {}) }
|
|
284
|
+
: { id: b.id || uid(), type: b.type, props: b.props || {} }
|
|
285
|
+
)),
|
|
286
|
+
};
|
|
287
|
+
// A column's optional styling (the canvas's `styled` wrapper and the
|
|
288
|
+
// importer's hoist both speak these keys) rides through untouched --
|
|
289
|
+
// rebuilding the column without them silently stripped a card
|
|
290
|
+
// column's paint on every loadDoc/loadTemplate, before export ever
|
|
291
|
+
// saw it.
|
|
292
|
+
['bg', 'radius', 'padY', 'padX', 'border', 'borderStyle', 'lineColor'].forEach((k) => {
|
|
293
|
+
if (c[k] !== undefined && c[k] !== '' && c[k] !== 0) col[k] = c[k];
|
|
294
|
+
});
|
|
295
|
+
return col;
|
|
296
|
+
}),
|
|
278
297
|
};
|
|
279
298
|
}),
|
|
280
299
|
};
|
package/src/core/css-cascade.js
CHANGED
|
@@ -1,117 +1,120 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Import-time CSS cascade: folds a parsed document's `<style>` rules into the
|
|
3
|
-
* elements' inline styles, so the importer's classifiers (which read inline
|
|
4
|
-
* styles only) see class-styled templates -- Mailchimp exports, hand-written
|
|
5
|
-
* emails, framework output that was never inlined -- the same way a mail
|
|
6
|
-
* client would.
|
|
7
|
-
*
|
|
8
|
-
* Deliberately a subset of a real cascade, matched to what email CSS uses:
|
|
9
|
-
* - `@media` blocks (and every other at-rule) are dropped whole: responsive
|
|
10
|
-
* overrides can't be represented in the imported model, desktop values win.
|
|
11
|
-
* A side benefit: base-rule `.desktop_hide { display:none }` still applies,
|
|
12
|
-
* so mobile-only duplicate content is correctly dropped by the importer's
|
|
13
|
-
* hidden-element skip.
|
|
14
|
-
* - Selectors containing `:` are skipped -- pseudo-classes/-elements are
|
|
15
|
-
* interactive or generated state with no place in a static import.
|
|
16
|
-
* - Specificity is the classic ids/classes/tags count; equal specificity
|
|
17
|
-
* resolves by source order; `!important` wins over everything including
|
|
18
|
-
* inline styles, which otherwise always win (matching browser behavior for
|
|
19
|
-
* the combinations that matter here).
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
function stripComments(css) {
|
|
23
|
-
return String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Removes every at-rule: block-less ones (`@import ...;`) to the semicolon, block ones (`@media { ... }`) across balanced braces. */
|
|
27
|
-
function stripAtRules(css) {
|
|
28
|
-
let out = '';
|
|
29
|
-
let i = 0;
|
|
30
|
-
while (i < css.length) {
|
|
31
|
-
if (css[i] === '@') {
|
|
32
|
-
const semi = css.indexOf(';', i);
|
|
33
|
-
const brace = css.indexOf('{', i);
|
|
34
|
-
if (brace === -1 || (semi !== -1 && semi < brace)) {
|
|
35
|
-
i = semi === -1 ? css.length : semi + 1;
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
let depth = 0;
|
|
39
|
-
let j = brace;
|
|
40
|
-
for (; j < css.length; j++) {
|
|
41
|
-
if (css[j] === '{') depth++;
|
|
42
|
-
else if (css[j] === '}') { depth--; if (!depth) break; }
|
|
43
|
-
}
|
|
44
|
-
i = j + 1;
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
out += css[i];
|
|
48
|
-
i++;
|
|
49
|
-
}
|
|
50
|
-
return out;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function specificity(sel) {
|
|
54
|
-
const ids = (sel.match(/#[\w-]+/g) || []).length;
|
|
55
|
-
const classes = (sel.match(/\.[\w-]+|\[[^\]]*\]/g) || []).length;
|
|
56
|
-
const tags = (sel.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
|
|
57
|
-
return ids * 100 + classes * 10 + tags;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function inlineStylesheets(doc) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
if (value
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
rules.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Import-time CSS cascade: folds a parsed document's `<style>` rules into the
|
|
3
|
+
* elements' inline styles, so the importer's classifiers (which read inline
|
|
4
|
+
* styles only) see class-styled templates -- Mailchimp exports, hand-written
|
|
5
|
+
* emails, framework output that was never inlined -- the same way a mail
|
|
6
|
+
* client would.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately a subset of a real cascade, matched to what email CSS uses:
|
|
9
|
+
* - `@media` blocks (and every other at-rule) are dropped whole: responsive
|
|
10
|
+
* overrides can't be represented in the imported model, desktop values win.
|
|
11
|
+
* A side benefit: base-rule `.desktop_hide { display:none }` still applies,
|
|
12
|
+
* so mobile-only duplicate content is correctly dropped by the importer's
|
|
13
|
+
* hidden-element skip.
|
|
14
|
+
* - Selectors containing `:` are skipped -- pseudo-classes/-elements are
|
|
15
|
+
* interactive or generated state with no place in a static import.
|
|
16
|
+
* - Specificity is the classic ids/classes/tags count; equal specificity
|
|
17
|
+
* resolves by source order; `!important` wins over everything including
|
|
18
|
+
* inline styles, which otherwise always win (matching browser behavior for
|
|
19
|
+
* the combinations that matter here).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function stripComments(css) {
|
|
23
|
+
return String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Removes every at-rule: block-less ones (`@import ...;`) to the semicolon, block ones (`@media { ... }`) across balanced braces. */
|
|
27
|
+
function stripAtRules(css) {
|
|
28
|
+
let out = '';
|
|
29
|
+
let i = 0;
|
|
30
|
+
while (i < css.length) {
|
|
31
|
+
if (css[i] === '@') {
|
|
32
|
+
const semi = css.indexOf(';', i);
|
|
33
|
+
const brace = css.indexOf('{', i);
|
|
34
|
+
if (brace === -1 || (semi !== -1 && semi < brace)) {
|
|
35
|
+
i = semi === -1 ? css.length : semi + 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
let depth = 0;
|
|
39
|
+
let j = brace;
|
|
40
|
+
for (; j < css.length; j++) {
|
|
41
|
+
if (css[j] === '{') depth++;
|
|
42
|
+
else if (css[j] === '}') { depth--; if (!depth) break; }
|
|
43
|
+
}
|
|
44
|
+
i = j + 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
out += css[i];
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function specificity(sel) {
|
|
54
|
+
const ids = (sel.match(/#[\w-]+/g) || []).length;
|
|
55
|
+
const classes = (sel.match(/\.[\w-]+|\[[^\]]*\]/g) || []).length;
|
|
56
|
+
const tags = (sel.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
|
|
57
|
+
return ids * 100 + classes * 10 + tags;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function inlineStylesheets(doc) {
|
|
61
|
+
// A style tag carrying the css-block marker IS a block (import-html reads
|
|
62
|
+
// it back as one); folding its rules inline here stamped them onto every
|
|
63
|
+
// matched element AND kept the block, doubling the styles on each save.
|
|
64
|
+
const sheets = Array.from(doc.querySelectorAll('style')).filter((s) => !(s.getAttribute && s.getAttribute('data-mc')));
|
|
65
|
+
if (!sheets.length) return;
|
|
66
|
+
const css = stripAtRules(stripComments(sheets.map((s) => s.textContent || '').join('\n')));
|
|
67
|
+
|
|
68
|
+
const rules = [];
|
|
69
|
+
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
|
|
70
|
+
let m;
|
|
71
|
+
let order = 0;
|
|
72
|
+
while ((m = ruleRe.exec(css))) {
|
|
73
|
+
const decls = [];
|
|
74
|
+
m[2].split(';').forEach((d) => {
|
|
75
|
+
const at = d.indexOf(':');
|
|
76
|
+
if (at < 0) return;
|
|
77
|
+
const prop = d.slice(0, at).trim().toLowerCase();
|
|
78
|
+
let value = d.slice(at + 1).trim();
|
|
79
|
+
if (!prop || !value || prop.indexOf('--') === 0 || prop.indexOf('mso-') === 0) return;
|
|
80
|
+
const important = /!important\s*$/i.test(value);
|
|
81
|
+
if (important) value = value.replace(/!important\s*$/i, '').trim();
|
|
82
|
+
if (value) decls.push({ prop, value, important });
|
|
83
|
+
});
|
|
84
|
+
if (!decls.length) continue;
|
|
85
|
+
m[1].split(',').forEach((raw) => {
|
|
86
|
+
const sel = raw.trim();
|
|
87
|
+
if (!sel || sel === '*' || sel.indexOf(':') > -1) return;
|
|
88
|
+
rules.push({ sel, decls, spec: specificity(sel), order: order++ });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (!rules.length) return;
|
|
92
|
+
|
|
93
|
+
// Ascending: later (more specific / later-in-source) rules overwrite
|
|
94
|
+
// earlier winners per property below.
|
|
95
|
+
rules.sort((a, b) => a.spec - b.spec || a.order - b.order);
|
|
96
|
+
|
|
97
|
+
const winners = new Map(); // element -> Map(prop -> {value, important})
|
|
98
|
+
rules.forEach((rule) => {
|
|
99
|
+
let matched;
|
|
100
|
+
try { matched = doc.querySelectorAll(rule.sel); } catch { return; }
|
|
101
|
+
matched.forEach((el) => {
|
|
102
|
+
if (el !== doc.body && !doc.body.contains(el)) return;
|
|
103
|
+
let bucket = winners.get(el);
|
|
104
|
+
if (!bucket) { bucket = new Map(); winners.set(el, bucket); }
|
|
105
|
+
rule.decls.forEach((d) => {
|
|
106
|
+
const cur = bucket.get(d.prop);
|
|
107
|
+
if (cur && cur.important && !d.important) return;
|
|
108
|
+
bucket.set(d.prop, d);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
winners.forEach((bucket, el) => {
|
|
114
|
+
if (!el.style) return;
|
|
115
|
+
bucket.forEach((d, prop) => {
|
|
116
|
+
if (!d.important && el.style.getPropertyValue(prop)) return; // inline wins
|
|
117
|
+
try { el.style.setProperty(prop, d.value); } catch { /* unparseable value -- skip */ }
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
package/src/core/editor-core.js
CHANGED
|
@@ -1224,7 +1224,7 @@ export class EditorCore {
|
|
|
1224
1224
|
|
|
1225
1225
|
// ---- export / code view --------------------------------------------------
|
|
1226
1226
|
|
|
1227
|
-
buildHtml() { return buildHtmlFn(this.state, this.exportRoot, boxCss); }
|
|
1227
|
+
buildHtml(opts) { return buildHtmlFn(this.state, this.exportRoot, boxCss, opts); }
|
|
1228
1228
|
|
|
1229
1229
|
openExport = () => {
|
|
1230
1230
|
this.setState({ exportOpen: true, exportCode: this.buildHtml(), copied: false, libraryOpen: false, aiOpen: false, codeOpen: false });
|
|
@@ -1373,9 +1373,9 @@ export class EditorCore {
|
|
|
1373
1373
|
/<a(?:\s|>)/i.test(String(b.props.html || ''))
|
|
1374
1374
|
? [{ kind: 'richLinks', label: 'Links', html: b.props.html || '', onChange: (v) => this.setProp(b.id, 'html', v) }]
|
|
1375
1375
|
: [],
|
|
1376
|
-
[B.area('Text', 'html'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', ...SIZE_SPAN.text, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '500', label: 'Medium' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
|
|
1376
|
+
[B.area('Text', 'html'), B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)), B.range('Text size', 'size', ...SIZE_SPAN.text, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '500', label: 'Medium' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
|
|
1377
1377
|
case 'image': return decorate(base.concat([B.btn('Choose from library', () => this.openLibrary({ id: b.id, key: 'src' })), B.text('Alt text', 'alt', 'Describe the image'), B.text('Link URL', 'href', 'https://'), B.range('Width', 'width', 5, 100, 1, '%'), B.seg('Align', 'align', ALIGN), B.range('Rounded corners', 'radius', 0, 200, 1, 'px')], padF));
|
|
1378
|
-
case 'button': return decorate(base.concat([B.text('Label', 'label'), B.text('Link URL', 'href', 'https://'), B.color('Button color', 'bg'), B.color('Text color', 'color'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Rounded corners', 'radius', 0, 60, 1, 'px'), B.range('Outline thickness', 'borderW', 0, 6, 1, 'px')].concat(b.props.borderW ? [B.sel('Outline style', 'borderStyle', BORDER_STYLES), B.color('Outline color', 'borderColor')] : []).concat([B.range('Button height', 'py', 0, 60, 1, 'px'), B.range('Button width', 'px', 0, 120, 2, 'px'), B.seg('Align', 'align', ALIGN), B.tog('Full width', 'full')])));
|
|
1378
|
+
case 'button': return decorate(base.concat([B.text('Label', 'label'), B.text('Link URL', 'href', 'https://'), B.color('Button color', 'bg'), B.color('Text color', 'color'), B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Rounded corners', 'radius', 0, 60, 1, 'px'), B.range('Outline thickness', 'borderW', 0, 6, 1, 'px')].concat(b.props.borderW ? [B.sel('Outline style', 'borderStyle', BORDER_STYLES), B.color('Outline color', 'borderColor')] : []).concat([B.range('Button height', 'py', 0, 60, 1, 'px'), B.range('Button width', 'px', 0, 120, 2, 'px'), B.seg('Align', 'align', ALIGN), B.tog('Full width', 'full')])));
|
|
1379
1379
|
case 'divider': return decorate(base.concat([B.range('Thickness', 'thickness', 1, 20, 1, 'px'), B.sel('Line style', 'lineStyle', BORDER_STYLES), B.range('Width', 'width', 5, 100, 5, '%'), B.color('Color', 'color'), B.range('Space above & below', 'py', 0, 160, 2, 'px')]));
|
|
1380
1380
|
case 'spacer': return decorate(base.concat([B.range('Height', 'height', 0, 400, 2, 'px')]));
|
|
1381
1381
|
// Dynamic-content markers: the expression lives on the start marker;
|
|
@@ -1400,15 +1400,15 @@ export class EditorCore {
|
|
|
1400
1400
|
B.seg('Icon palette', 'palette', [{ value: 'custom', label: 'Custom' }, { value: 'brand', label: 'Brand' }]),
|
|
1401
1401
|
B.seg('Icon shape', 'shape', [{ value: 'outline', label: 'Outline' }, { value: 'bare', label: 'Bare' }, { value: 'circle', label: 'Circle' }, { value: 'square', label: 'Square' }]),
|
|
1402
1402
|
B.tog('Show network names', 'showLabel'),
|
|
1403
|
-
B.sel('Font', 'fontFamily', this.fontOptions(true)),
|
|
1403
|
+
B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)),
|
|
1404
1404
|
B.seg('Align', 'align', ALIGN), B.range('Icon size', 'size', 10, 64, 1, 'px'), B.range('Space between icons', 'gap', 0, 80, 2, 'px'), B.color('Icon color', 'color'),
|
|
1405
1405
|
]));
|
|
1406
1406
|
case 'video': return decorate(base.concat([B.btn('Choose thumbnail', () => this.openLibrary({ id: b.id, key: 'src' })), B.text('Video URL', 'href', 'https://'), B.text('Caption', 'caption'), B.color('Badge color', 'badge')]));
|
|
1407
1407
|
case 'html': return decorate(base.concat([B.area('Raw HTML', 'code')]));
|
|
1408
|
-
case 'countdown': return decorate(base.concat([B.text('Ends at (YYYY-MM-DDTHH:MM)', 'target'), B.text('Label', 'label'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.color('Color', 'color')]));
|
|
1409
|
-
case 'menu': return decorate(base.concat([B.area('Items — one per line as Label|URL', 'items'), B.seg('Align', 'align', ALIGN), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 32, 1, 'px'), B.range('Space between items', 'gap', 0, 80, 2, 'px'), B.color('Text color', 'color')]));
|
|
1410
|
-
case 'heading': return decorate(base.concat([B.area('Text', 'text'), B.sel('Heading level', 'level', [{ value: 'h1', label: 'H1 — largest' }, { value: 'h2', label: 'H2' }, { value: 'h3', label: 'H3' }, { value: 'h4', label: 'H4 — smallest' }]), B.seg('Font style', 'font', [{ value: 'condensed', label: 'Condensed' }, { value: 'body', label: 'Body' }]), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', ...SIZE_SPAN.heading, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 2.2, 0.02, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '600', label: 'Semibold' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
|
|
1411
|
-
case 'list': return decorate(base.concat([B.area('Items — one per line', 'items'), B.tog('Numbered', 'ordered'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.range('Space between items', 'gap', 0, 60, 1, 'px'), B.color('Text color', 'color')], padF));
|
|
1408
|
+
case 'countdown': return decorate(base.concat([B.text('Ends at (YYYY-MM-DDTHH:MM)', 'target'), B.text('Label', 'label'), B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)), B.color('Color', 'color')]));
|
|
1409
|
+
case 'menu': return decorate(base.concat([B.area('Items — one per line as Label|URL', 'items'), B.seg('Align', 'align', ALIGN), B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)), B.range('Text size', 'size', 8, 32, 1, 'px'), B.range('Space between items', 'gap', 0, 80, 2, 'px'), B.color('Text color', 'color')]));
|
|
1410
|
+
case 'heading': return decorate(base.concat([B.area('Text', 'text'), B.sel('Heading level', 'level', [{ value: 'h1', label: 'H1 — largest' }, { value: 'h2', label: 'H2' }, { value: 'h3', label: 'H3' }, { value: 'h4', label: 'H4 — smallest' }]), B.seg('Font style', 'font', [{ value: 'condensed', label: 'Condensed' }, { value: 'body', label: 'Body' }]), B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)), B.range('Text size', 'size', ...SIZE_SPAN.heading, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 2.2, 0.02, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '600', label: 'Semibold' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
|
|
1411
|
+
case 'list': return decorate(base.concat([B.area('Items — one per line', 'items'), B.tog('Numbered', 'ordered'), B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.range('Space between items', 'gap', 0, 60, 1, 'px'), B.color('Text color', 'color')], padF));
|
|
1412
1412
|
case 'table': return decorate(base.concat([
|
|
1413
1413
|
// Custom kind (render/fields.js `renderTableGrid`): a real grid of
|
|
1414
1414
|
// cell inputs with add/remove row+column controls, replacing the
|
|
@@ -1416,11 +1416,10 @@ export class EditorCore {
|
|
|
1416
1416
|
// `data` string underneath.
|
|
1417
1417
|
{ kind: 'tablegrid', label: 'Cells', value: b.props.data || '', header: !!b.props.header, onChange: (v) => this.setProp(b.id, 'data', v) },
|
|
1418
1418
|
B.tog('Header row', 'header'), B.tog('Cell borders', 'borders'), ...(b.props.borders ? [B.range('Border thickness', 'borderWidth', 1, 10, 1, 'px'), B.sel('Border style', 'borderStyle', BORDER_STYLES)] : []), B.tog('Zebra stripes', 'striped'),
|
|
1419
|
-
B.sel('Font', 'fontFamily', this.fontOptions(true)),
|
|
1419
|
+
B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)),
|
|
1420
1420
|
B.range('Space in cells', 'pad', 0, 50, 1, 'px'), B.range('Text size', 'size', 8, 32, 1, 'px'), B.range('Width', 'width', 10, 100, 5, '%'),
|
|
1421
1421
|
B.color('Header color', 'headBg'), B.color('Line color', 'lineColor'), B.seg('Align', 'align', ALIGN),
|
|
1422
1422
|
]));
|
|
1423
|
-
case 'embed': return decorate(base.concat([B.text('Embed URL', 'src', 'https://'), B.range('Height', 'height', 40, 1200, 10, 'px'), B.text('Accessible label', 'label'), B.range('Padding', 'py', 0, 120, 2, 'px')]));
|
|
1424
1423
|
case 'css': return decorate(base.concat([B.area('CSS', 'code'), B.text('Note to yourself', 'note')]));
|
|
1425
1424
|
case 'box': return decorate(base.concat([B.area('Content (HTML allowed)', 'html'), B.color('Background color', 'bg'), B.btn('Choose background image', () => this.openLibrary({ id: b.id, key: 'bgImage' })), B.text('Background image URL', 'bgImage', 'https://'), B.range('Space inside', 'pad', 0, 160, 2, 'px'), B.range('Border thickness', 'border', 0, 20, 1, 'px')].concat(b.props.border ? [
|
|
1426
1425
|
B.sel('Border style', 'borderStyle', BORDER_STYLES), B.color('Border color', 'lineColor'),
|
|
@@ -1576,7 +1575,7 @@ export class EditorCore {
|
|
|
1576
1575
|
* an empty value meaning "use the theme font" (renderers read
|
|
1577
1576
|
* `p.fontFamily || theme.font`, so old documents need no migration).
|
|
1578
1577
|
*/
|
|
1579
|
-
fontOptions(inherit) {
|
|
1578
|
+
fontOptions(inherit, current) {
|
|
1580
1579
|
const stacks = [
|
|
1581
1580
|
{ value: '"Helvetica Neue", Helvetica, Arial, sans-serif', label: 'Helvetica Neue' },
|
|
1582
1581
|
{ value: 'Helvetica, Arial, sans-serif', label: 'Helvetica / Arial' },
|
|
@@ -1589,7 +1588,20 @@ export class EditorCore {
|
|
|
1589
1588
|
{ value: '"Palatino Linotype", Palatino, Georgia, serif', label: 'Palatino' },
|
|
1590
1589
|
{ value: 'ui-monospace, "Courier New", monospace', label: 'Monospace' },
|
|
1591
1590
|
];
|
|
1592
|
-
|
|
1591
|
+
const opts = inherit ? [{ value: '', label: 'Inherit — theme font' }].concat(stacks) : stacks;
|
|
1592
|
+
// An imported email brings its own stack, and it is almost never one of
|
|
1593
|
+
// the ten above. A <select> whose value matches no option shows nothing
|
|
1594
|
+
// selected -- the control read 'Inherit — theme font' (or the first
|
|
1595
|
+
// stack) about a block that was really set in Georgia, and the only way
|
|
1596
|
+
// to answer "what font is this?" was to overwrite it. Kept as an option
|
|
1597
|
+
// of its own, matched loosely because quoting and spacing differ
|
|
1598
|
+
// between what CSSOM hands back and what the list declares ('DM Sans'
|
|
1599
|
+
// vs "DM Sans").
|
|
1600
|
+
const key = (v) => String(v || '').replace(/["']/g, '').replace(/\s*,\s*/g, ',').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
1601
|
+
if (current && !opts.some((o) => key(o.value) === key(current))) {
|
|
1602
|
+
opts.push({ value: current, label: String(current).split(',')[0].replace(/["']/g, '').trim() + ' — from the email' });
|
|
1603
|
+
}
|
|
1604
|
+
return opts;
|
|
1593
1605
|
}
|
|
1594
1606
|
|
|
1595
1607
|
themeFields() {
|
|
@@ -1621,7 +1633,7 @@ export class EditorCore {
|
|
|
1621
1633
|
B.color('Border color', 'borderColor'),
|
|
1622
1634
|
] : []).concat([
|
|
1623
1635
|
B.head('Type & color'),
|
|
1624
|
-
B.sel('Default font', 'font', this.fontOptions(false)),
|
|
1636
|
+
B.sel('Default font', 'font', this.fontOptions(false, this.state.doc.theme.font)),
|
|
1625
1637
|
B.color('Text color', 'text'),
|
|
1626
1638
|
B.color('Link color', 'link'),
|
|
1627
1639
|
]));
|
package/src/core/export.js
CHANGED
|
@@ -136,9 +136,38 @@ export function decorateLogicTags(html) {
|
|
|
136
136
|
return s;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
export function buildHtml(state, root, boxCss) {
|
|
139
|
+
export function buildHtml(state, root, boxCss, opts) {
|
|
140
140
|
const d = state.doc; const t = d.theme;
|
|
141
141
|
const logic = logicPlan(d);
|
|
142
|
+
/*
|
|
143
|
+
* Fidelity markers. A handful of blocks render into markup that cannot be
|
|
144
|
+
* read back into the block it came from: a countdown bakes its digits, a
|
|
145
|
+
* video is just a linked image, a section box and a code sample are styled
|
|
146
|
+
* divs like any other, a raw-CSS block is a bare <style>, and a flex/grid
|
|
147
|
+
* row is a div no table walker can re-shape. For exactly those, the export
|
|
148
|
+
* stamps a compact attribute layer -- `data-mc` (the type), `data-mcp`
|
|
149
|
+
* (the props the DOM itself does not carry), `data-mcr` (a CSS-layout
|
|
150
|
+
* row's settings) and the inert `mc-keep` class -- which the importer
|
|
151
|
+
* trusts when present and ignores otherwise. Mail clients ignore unknown
|
|
152
|
+
* attributes wholesale, and everything else round-trips from its rendered
|
|
153
|
+
* shape alone, so the layer stays this small. Attribute names deliberately
|
|
154
|
+
* sit outside `grab()`'s `data-mc-[a-z-]+` strip (they live on exporter
|
|
155
|
+
* scaffolding, but belt and braces). A host that wants pristine HTML can
|
|
156
|
+
* pass `exportHtml({ markers: false })` and accept the lossy reload.
|
|
157
|
+
*/
|
|
158
|
+
const markers = !(opts && opts.markers === false);
|
|
159
|
+
const attrEsc = (v) => String(v).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
160
|
+
const MARKED = { countdown: 1, video: 1, box: 1, codeblock: 1 };
|
|
161
|
+
const marker = (b) => {
|
|
162
|
+
if (!markers || !MARKED[b.type]) return '';
|
|
163
|
+
const payload = { ...b.props };
|
|
164
|
+
// Content the rendered DOM already carries is not duplicated into the
|
|
165
|
+
// payload: a box's html IS the grabbed markup, a code sample's text IS
|
|
166
|
+
// the <pre>'s own.
|
|
167
|
+
if (b.type === 'box') delete payload.html;
|
|
168
|
+
if (b.type === 'codeblock') delete payload.code;
|
|
169
|
+
return ' data-mc="' + b.type + '" data-mcp="' + attrEsc(JSON.stringify(payload)) + '"';
|
|
170
|
+
};
|
|
142
171
|
const grab = (id) => {
|
|
143
172
|
const el = root.querySelector('[data-mc-content="' + id + '"]');
|
|
144
173
|
if (!el) return '';
|
|
@@ -174,7 +203,11 @@ export function buildHtml(state, root, boxCss) {
|
|
|
174
203
|
*/
|
|
175
204
|
const mobilePlan = (rp, cols) => {
|
|
176
205
|
const mode = rp.mobileCols === undefined ? 1 : rp.mobileCols;
|
|
177
|
-
if (cols < 2
|
|
206
|
+
if (cols < 2) return { row: '', cell: '' };
|
|
207
|
+
// `keep` exports NO functional class, which made it indistinguishable
|
|
208
|
+
// from foreign HTML (where stacking is the safer default) -- so it ships
|
|
209
|
+
// as the inert `mc-keep`, a marker with no stylesheet rule behind it.
|
|
210
|
+
if (mode === 'keep') return { row: markers ? 'mc-keep' : '', cell: '' };
|
|
178
211
|
const rowCls = [];
|
|
179
212
|
// Two-up and reverse both need the row to become a flex container, which
|
|
180
213
|
// is safe precisely because it only ever runs inside the media query: a
|
|
@@ -208,11 +241,15 @@ export function buildHtml(state, root, boxCss) {
|
|
|
208
241
|
return blocksOf.map((b) => logic.emit.get(b.id) || '').filter(Boolean).map((s) => ' ' + s).join('\n');
|
|
209
242
|
}
|
|
210
243
|
const colInner = (c) => c.blocks.map((b) => {
|
|
211
|
-
if (b.type === 'css') return '<style>' + (b.props.code || '') + '</style>';
|
|
244
|
+
if (b.type === 'css') return '<style' + (markers ? ' data-mc="css"' + (b.props.note ? ' data-mcn="' + attrEsc(b.props.note) + '"' : '') : '') + '>' + (b.props.code || '') + '</style>';
|
|
212
245
|
if (b.type === 'html') return b.props.code || '';
|
|
213
|
-
|
|
246
|
+
// The same width-carrying span the canvas draws (block-body.js), so the
|
|
247
|
+
// Width slider reaches the sent mail and the importer can read it back
|
|
248
|
+
// -- exported bare, the drawing shipped at its intrinsic size and the
|
|
249
|
+
// prop reset to 100 on every reload.
|
|
250
|
+
if (b.type === 'svg') return '<div style="text-align:' + b.props.align + ';padding:' + b.props.py + 'px 0"><span style="display:inline-block;width:' + (b.props.width || 100) + '%">' + (b.props.code || '') + '</span></div>';
|
|
214
251
|
if (b.type === 'condition' || b.type === 'loop') return logic.emit.get(b.id) || '';
|
|
215
|
-
return '<div' + visClass(b.props) + ' style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
|
|
252
|
+
return '<div' + marker(b) + visClass(b.props) + ' style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
|
|
216
253
|
}).filter(Boolean).join('\n ') || ' ';
|
|
217
254
|
const cells = r.cols.map((c) => {
|
|
218
255
|
// Column-level styling (bg/radius/inner padding) renders as a wrapper
|
|
@@ -228,9 +265,12 @@ export function buildHtml(state, root, boxCss) {
|
|
|
228
265
|
// for every wide client.
|
|
229
266
|
const wrapCls = [plan.cell ? 'mc-stack' : '', plan.row].filter(Boolean).join(' ');
|
|
230
267
|
const stackWrap = wrapCls ? ' class="' + wrapCls + '"' : '';
|
|
268
|
+
const mcr = markers && rp.layout && rp.layout !== 'columns'
|
|
269
|
+
? ' data-mcr="' + attrEsc(JSON.stringify({ layout: rp.layout, flexDir: rp.flexDir || 'row', justify: rp.justify || 'flex-start', alignItems: rp.alignItems || 'stretch', wrap: rp.wrap !== false, gridCols: rp.gridCols || 2, gap: rp.gap || 0, spans: r.cols.map((c) => c.span) })) + '"'
|
|
270
|
+
: '';
|
|
231
271
|
const cssBody = rp.layout === 'grid'
|
|
232
|
-
? '<div' + stackWrap + ' style="display:grid;grid-template-columns:repeat(' + (rp.gridCols || 2) + ',minmax(0,1fr));gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div>' + colInner(c) + '</div>').join('\n ') + '\n </div>'
|
|
233
|
-
: '<div' + stackWrap + ' style="display:flex;flex-direction:' + (rp.flexDir || 'row') + ';justify-content:' + (rp.justify || 'flex-start') + ';align-items:' + (rp.alignItems || 'stretch') + ';flex-wrap:' + (rp.wrap ? 'wrap' : 'nowrap') + ';gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div style="flex:' + c.span + ' 1 auto;min-width:0">' + colInner(c) + '</div>').join('\n ') + '\n </div>';
|
|
272
|
+
? '<div' + mcr + stackWrap + ' style="display:grid;grid-template-columns:repeat(' + (rp.gridCols || 2) + ',minmax(0,1fr));gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div>' + colInner(c) + '</div>').join('\n ') + '\n </div>'
|
|
273
|
+
: '<div' + mcr + stackWrap + ' style="display:flex;flex-direction:' + (rp.flexDir || 'row') + ';justify-content:' + (rp.justify || 'flex-start') + ';align-items:' + (rp.alignItems || 'stretch') + ';flex-wrap:' + (rp.wrap ? 'wrap' : 'nowrap') + ';gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div style="flex:' + c.span + ' 1 auto;min-width:0">' + colInner(c) + '</div>').join('\n ') + '\n </div>';
|
|
234
274
|
const body = rp.layout && rp.layout !== 'columns'
|
|
235
275
|
? cssBody
|
|
236
276
|
// The `<tr>` is what becomes the flex container for two-up and reverse;
|
|
@@ -244,8 +284,21 @@ export function buildHtml(state, root, boxCss) {
|
|
|
244
284
|
: 'background:' + (rp.bg || t.contentBg || 'transparent') + ';';
|
|
245
285
|
const tdBorder = rowBorderCss(rp)
|
|
246
286
|
+ (rp.radius ? 'border-radius:' + rp.radius + 'px;' : '')
|
|
247
|
-
+ (rp.shadow ? 'box-shadow:' + rp.shadow + ';' : '')
|
|
248
|
-
|
|
287
|
+
+ (rp.shadow ? 'box-shadow:' + rp.shadow + ';' : '');
|
|
288
|
+
// Outside margins and Max width need an element mail clients actually
|
|
289
|
+
// honour them on: margins on a <td> are ignored by every major client
|
|
290
|
+
// (the old shape wrote them there -- inert in sent mail AND never read
|
|
291
|
+
// back), and max-width was canvas-only. Both now ride a wrapper div
|
|
292
|
+
// INSIDE the cell, carrying the row's paint and padding with them so
|
|
293
|
+
// the margin sits outside the painted box, exactly as the canvas draws
|
|
294
|
+
// it. Rows using neither keep the old markup byte for byte.
|
|
295
|
+
const boxed = rp.mt || rp.mr || rp.mb || rp.ml || rp.my || (rp.maxW && rp.maxW < 100);
|
|
296
|
+
if (boxed) {
|
|
297
|
+
const cap = rp.maxW && rp.maxW < 100 ? 'max-width:' + rp.maxW + '%;' : '';
|
|
298
|
+
// centerEmpty only when capped: an uncapped box spans the cell anyway,
|
|
299
|
+
// and `auto` there would only read back as a lost slider value.
|
|
300
|
+
return ' <tr>\n <td style="padding:0;">\n <div style="' + cap + 'margin:' + rowMargin(rp, !!cap) + ';padding:' + rowPad(rp) + ';' + tdBg + tdBorder + '">\n ' + body + '\n </div>\n </td>\n </tr>';
|
|
301
|
+
}
|
|
249
302
|
return ' <tr>\n <td style="padding:' + rowPad(rp) + ';' + tdBg + tdBorder + '">\n ' + body + '\n </td>\n </tr>';
|
|
250
303
|
}).join('\n') + (logic.tail ? '\n ' + logic.tail : '');
|
|
251
304
|
// The page section -- the full-width area the content column sits on. Its
|
|
@@ -353,6 +406,26 @@ export function buildHtml(state, root, boxCss) {
|
|
|
353
406
|
// `text-size-adjust` at 100%, never `none`: both stop a mobile client
|
|
354
407
|
// inflating the type, but `none` also blocks legitimate scaling and leaves
|
|
355
408
|
// text unreadably small on some Android clients.
|
|
409
|
+
/*
|
|
410
|
+
* theme.link, made real: mail clients have no stylesheet to inherit a link
|
|
411
|
+
* color from (Gmail strips <style>; the sheet rule that paints the canvas
|
|
412
|
+
* never ships), so the value must ride every anchor inline. Stamped only
|
|
413
|
+
* where no inline color already stands -- a button label, a menu item, or
|
|
414
|
+
* a link the user recolored by hand keeps its own -- and skipped entirely
|
|
415
|
+
* when the theme has no link color. The canvas strips restated stamps at
|
|
416
|
+
* render time (overrideLinkColor), which is what keeps a reloaded document
|
|
417
|
+
* inheriting: export -> import -> export re-stamps the same bytes.
|
|
418
|
+
*/
|
|
419
|
+
const stampLinks = (html) => {
|
|
420
|
+
if (!t.link) return html;
|
|
421
|
+
return html.replace(/<a\b([^>]*)>/g, (m0, attrs) => {
|
|
422
|
+
if (/(?:[;"\s])color\s*:/.test(attrs)) return m0;
|
|
423
|
+
if (/style="/.test(attrs)) {
|
|
424
|
+
return '<a' + attrs.replace(/style="([^"]*)"/, (s0, css) => 'style="' + css + (!css.trim() || css.trim().endsWith(';') ? '' : ';') + 'color:' + t.link + ';"') + '>';
|
|
425
|
+
}
|
|
426
|
+
return '<a' + attrs + ' style="color:' + t.link + ';">';
|
|
427
|
+
});
|
|
428
|
+
};
|
|
356
429
|
const bodyStyle = 'margin:0;padding:0;background:' + pageBg + ';font-family:' + t.font.replace(/"/g, "'") + ';color:' + t.text + ';-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;text-size-adjust:100%;';
|
|
357
|
-
return msoHarden('<!doctype html>\n<html lang="en" xmlns:o="urn:schemas-microsoft-com:office:office">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light">\n<meta name="supported-color-schemes" content="light">\n<title>' + 'Email' + '</title>' + msoHead + stackCss + '\n</head>\n<body style="' + bodyStyle + '">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n ' + ghostOpen + '\n ' + shell + '\n ' + ghostClose + '\n </td></tr>\n</table>\n</body>\n</html>');
|
|
430
|
+
return msoHarden(stampLinks('<!doctype html>\n<html lang="en" xmlns:o="urn:schemas-microsoft-com:office:office">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light">\n<meta name="supported-color-schemes" content="light">\n<title>' + 'Email' + '</title>' + msoHead + stackCss + '\n</head>\n<body style="' + bodyStyle + '">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n ' + ghostOpen + '\n ' + shell + '\n ' + ghostClose + '\n </td></tr>\n</table>\n</body>\n</html>'));
|
|
358
431
|
}
|