@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seliseblocks/mailcraft",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
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
|
@@ -61,20 +61,24 @@ const RICH_OWNED_STYLE = { color: 'color', lh: 'line-height', weight: 'font-weig
|
|
|
61
61
|
*/
|
|
62
62
|
function syncRichContent(block, key, val) {
|
|
63
63
|
const prop = RICH_HTML_PROP[block.type];
|
|
64
|
-
if (!prop) return;
|
|
64
|
+
if (!prop) return false;
|
|
65
65
|
// list items are one fragment per line; the rewrite must not run across the joins.
|
|
66
66
|
const perLine = (src, fn) => (prop === 'items' ? String(src).split('\n').map(fn).join('\n') : fn(String(src)));
|
|
67
67
|
const src = block.props[prop];
|
|
68
|
-
if (src == null || src === '') return;
|
|
68
|
+
if (src == null || src === '') return false;
|
|
69
|
+
let out = src;
|
|
69
70
|
if (key === 'size') {
|
|
70
71
|
const cur = Number(block.props.size);
|
|
71
72
|
const next = Number(val);
|
|
72
73
|
// An unknown base (imports that carried no readable size) can't be scaled
|
|
73
74
|
// against -- the first explicit size just establishes the base.
|
|
74
|
-
if (cur > 0 && next > 0 && next !== cur)
|
|
75
|
+
if (cur > 0 && next > 0 && next !== cur) out = perLine(src, (s) => scaleInlineSizes(s, next / cur));
|
|
75
76
|
} else if (RICH_OWNED_STYLE[key]) {
|
|
76
|
-
|
|
77
|
+
out = perLine(src, (s) => stripInlineStyle(s, RICH_OWNED_STYLE[key]));
|
|
77
78
|
}
|
|
79
|
+
if (out === src) return false;
|
|
80
|
+
block.props[prop] = out;
|
|
81
|
+
return true;
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
const BORDER_STYLES = [
|
|
@@ -557,15 +561,19 @@ export class EditorCore {
|
|
|
557
561
|
};
|
|
558
562
|
|
|
559
563
|
size(b, delta) {
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
564
|
+
// Uncommitted inline formatting is folded into props by `setProp` below
|
|
565
|
+
// (`onFoldLiveEdit`), not here. This used to do its own fold, as a second
|
|
566
|
+
// commit: that read `editEl.innerHTML` unconditionally, so a second click
|
|
567
|
+
// landing in the same frame as the first -- before the rebuild had put the
|
|
568
|
+
// rescaled html into the DOM -- wrote the pre-scale markup straight back
|
|
569
|
+
// over it, and two quick clicks on a mixed-size block moved nothing. It
|
|
570
|
+
// also cost an extra undo step per click.
|
|
571
|
+
// Read the size off the live document, not off the `b` the toolbar closed
|
|
572
|
+
// over when it was built: two clicks landing before the next rebuild both
|
|
573
|
+
// saw the same stale base, so the second one re-applied the first one's
|
|
574
|
+
// value and the pair counted as a single step.
|
|
575
|
+
const live = this.find(this.state.doc, b.id).block || b;
|
|
576
|
+
const cur = Number(live.props.size) || 16;
|
|
569
577
|
const [lo, hi] = SIZE_SPAN[b.type] || [10, 64];
|
|
570
578
|
this.setProp(b.id, 'size', Math.max(lo, Math.min(hi, cur + delta)));
|
|
571
579
|
}
|
|
@@ -620,15 +628,28 @@ export class EditorCore {
|
|
|
620
628
|
this._persistTimer = setTimeout(() => this.persist(doc), 400);
|
|
621
629
|
}
|
|
622
630
|
|
|
631
|
+
/**
|
|
632
|
+
* Both directions of history have the same two obligations when a block is
|
|
633
|
+
* being edited, because the live contenteditable is a second copy of that
|
|
634
|
+
* block's content: fold it into props *before* the current state is pushed
|
|
635
|
+
* onto the opposite stack (or the step back carries markup a few keystrokes
|
|
636
|
+
* behind what was on screen), and mark it stale afterwards (or the render
|
|
637
|
+
* that follows syncs the pre-undo DOM straight back over the restored doc --
|
|
638
|
+
* which is what made undo look like it skipped the focused block).
|
|
639
|
+
*/
|
|
623
640
|
undo() {
|
|
624
641
|
const hist = this.state.history.slice(); const prev = hist.pop(); if (!prev) return;
|
|
642
|
+
if (this.state.editing && this.onFoldLiveEdit) this.onFoldLiveEdit();
|
|
625
643
|
const doc = JSON.parse(prev);
|
|
644
|
+
if (this.state.editing) this.editStale = this.state.editing;
|
|
626
645
|
this.setState({ doc, history: hist, future: this.state.future.concat(JSON.stringify(this.state.doc)), sel: null }, () => this.persist(doc));
|
|
627
646
|
}
|
|
628
647
|
|
|
629
648
|
redo() {
|
|
630
649
|
const fut = this.state.future.slice(); const next = fut.pop(); if (!next) return;
|
|
650
|
+
if (this.state.editing && this.onFoldLiveEdit) this.onFoldLiveEdit();
|
|
631
651
|
const doc = JSON.parse(next);
|
|
652
|
+
if (this.state.editing) this.editStale = this.state.editing;
|
|
632
653
|
this.setState({ doc, future: fut, history: this.state.history.concat(JSON.stringify(this.state.doc)), sel: null }, () => this.persist(doc));
|
|
633
654
|
}
|
|
634
655
|
|
|
@@ -660,14 +681,28 @@ export class EditorCore {
|
|
|
660
681
|
selObj() { return this.state.sel ? this.find(this.state.doc, this.state.sel.id) : {}; }
|
|
661
682
|
|
|
662
683
|
setProp(id, key, val) {
|
|
684
|
+
// The rewrite below works from the block's *committed* html, so anything
|
|
685
|
+
// still living only in the focused contenteditable is folded into props
|
|
686
|
+
// first -- otherwise it would both rewrite stale content and lose the
|
|
687
|
+
// uncommitted edit the moment the rebuilt node reads props back.
|
|
688
|
+
if (this.state.editing === id && this.onFoldLiveEdit) this.onFoldLiveEdit();
|
|
689
|
+
let rewrote = false;
|
|
663
690
|
this.commit((doc) => {
|
|
664
691
|
const f = this.find(doc, id);
|
|
665
692
|
const target = f.block ? f.block.props : (f.row ? f.row.props : null);
|
|
666
693
|
if (!target) return;
|
|
667
694
|
// Before the write: the size rewrite needs the outgoing value as its base.
|
|
668
|
-
if (f.block) syncRichContent(f.block, key, val);
|
|
695
|
+
if (f.block) rewrote = syncRichContent(f.block, key, val);
|
|
669
696
|
target[key] = val;
|
|
670
697
|
});
|
|
698
|
+
// props are now *ahead* of the live contenteditable, which still holds the
|
|
699
|
+
// pre-rewrite html. Flagged so the render that follows syncs nothing back
|
|
700
|
+
// over them (`syncLiveEdit`, mailcraft-editor.js): without this, every
|
|
701
|
+
// Text size / color / spacing change made while the block was focused --
|
|
702
|
+
// i.e. every change made from the RTE's own +/- pair -- was silently
|
|
703
|
+
// reverted one frame later, so a mixed-size block ended up with a climbing
|
|
704
|
+
// `size` prop and untouched inline sizes.
|
|
705
|
+
if (rewrote) this.editStale = id;
|
|
671
706
|
}
|
|
672
707
|
|
|
673
708
|
setTheme(key, val) { this.commit((doc) => { doc.theme[key] = val; }); }
|
|
@@ -1224,7 +1259,7 @@ export class EditorCore {
|
|
|
1224
1259
|
|
|
1225
1260
|
// ---- export / code view --------------------------------------------------
|
|
1226
1261
|
|
|
1227
|
-
buildHtml() { return buildHtmlFn(this.state, this.exportRoot, boxCss); }
|
|
1262
|
+
buildHtml(opts) { return buildHtmlFn(this.state, this.exportRoot, boxCss, opts); }
|
|
1228
1263
|
|
|
1229
1264
|
openExport = () => {
|
|
1230
1265
|
this.setState({ exportOpen: true, exportCode: this.buildHtml(), copied: false, libraryOpen: false, aiOpen: false, codeOpen: false });
|
|
@@ -1373,9 +1408,9 @@ export class EditorCore {
|
|
|
1373
1408
|
/<a(?:\s|>)/i.test(String(b.props.html || ''))
|
|
1374
1409
|
? [{ kind: 'richLinks', label: 'Links', html: b.props.html || '', onChange: (v) => this.setProp(b.id, 'html', v) }]
|
|
1375
1410
|
: [],
|
|
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));
|
|
1411
|
+
[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
1412
|
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')])));
|
|
1413
|
+
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
1414
|
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
1415
|
case 'spacer': return decorate(base.concat([B.range('Height', 'height', 0, 400, 2, 'px')]));
|
|
1381
1416
|
// Dynamic-content markers: the expression lives on the start marker;
|
|
@@ -1400,15 +1435,15 @@ export class EditorCore {
|
|
|
1400
1435
|
B.seg('Icon palette', 'palette', [{ value: 'custom', label: 'Custom' }, { value: 'brand', label: 'Brand' }]),
|
|
1401
1436
|
B.seg('Icon shape', 'shape', [{ value: 'outline', label: 'Outline' }, { value: 'bare', label: 'Bare' }, { value: 'circle', label: 'Circle' }, { value: 'square', label: 'Square' }]),
|
|
1402
1437
|
B.tog('Show network names', 'showLabel'),
|
|
1403
|
-
B.sel('Font', 'fontFamily', this.fontOptions(true)),
|
|
1438
|
+
B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)),
|
|
1404
1439
|
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
1440
|
]));
|
|
1406
1441
|
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
1442
|
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));
|
|
1443
|
+
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')]));
|
|
1444
|
+
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')]));
|
|
1445
|
+
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));
|
|
1446
|
+
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
1447
|
case 'table': return decorate(base.concat([
|
|
1413
1448
|
// Custom kind (render/fields.js `renderTableGrid`): a real grid of
|
|
1414
1449
|
// cell inputs with add/remove row+column controls, replacing the
|
|
@@ -1416,11 +1451,10 @@ export class EditorCore {
|
|
|
1416
1451
|
// `data` string underneath.
|
|
1417
1452
|
{ kind: 'tablegrid', label: 'Cells', value: b.props.data || '', header: !!b.props.header, onChange: (v) => this.setProp(b.id, 'data', v) },
|
|
1418
1453
|
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)),
|
|
1454
|
+
B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)),
|
|
1420
1455
|
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
1456
|
B.color('Header color', 'headBg'), B.color('Line color', 'lineColor'), B.seg('Align', 'align', ALIGN),
|
|
1422
1457
|
]));
|
|
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
1458
|
case 'css': return decorate(base.concat([B.area('CSS', 'code'), B.text('Note to yourself', 'note')]));
|
|
1425
1459
|
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
1460
|
B.sel('Border style', 'borderStyle', BORDER_STYLES), B.color('Border color', 'lineColor'),
|
|
@@ -1576,7 +1610,7 @@ export class EditorCore {
|
|
|
1576
1610
|
* an empty value meaning "use the theme font" (renderers read
|
|
1577
1611
|
* `p.fontFamily || theme.font`, so old documents need no migration).
|
|
1578
1612
|
*/
|
|
1579
|
-
fontOptions(inherit) {
|
|
1613
|
+
fontOptions(inherit, current) {
|
|
1580
1614
|
const stacks = [
|
|
1581
1615
|
{ value: '"Helvetica Neue", Helvetica, Arial, sans-serif', label: 'Helvetica Neue' },
|
|
1582
1616
|
{ value: 'Helvetica, Arial, sans-serif', label: 'Helvetica / Arial' },
|
|
@@ -1589,7 +1623,20 @@ export class EditorCore {
|
|
|
1589
1623
|
{ value: '"Palatino Linotype", Palatino, Georgia, serif', label: 'Palatino' },
|
|
1590
1624
|
{ value: 'ui-monospace, "Courier New", monospace', label: 'Monospace' },
|
|
1591
1625
|
];
|
|
1592
|
-
|
|
1626
|
+
const opts = inherit ? [{ value: '', label: 'Inherit — theme font' }].concat(stacks) : stacks;
|
|
1627
|
+
// An imported email brings its own stack, and it is almost never one of
|
|
1628
|
+
// the ten above. A <select> whose value matches no option shows nothing
|
|
1629
|
+
// selected -- the control read 'Inherit — theme font' (or the first
|
|
1630
|
+
// stack) about a block that was really set in Georgia, and the only way
|
|
1631
|
+
// to answer "what font is this?" was to overwrite it. Kept as an option
|
|
1632
|
+
// of its own, matched loosely because quoting and spacing differ
|
|
1633
|
+
// between what CSSOM hands back and what the list declares ('DM Sans'
|
|
1634
|
+
// vs "DM Sans").
|
|
1635
|
+
const key = (v) => String(v || '').replace(/["']/g, '').replace(/\s*,\s*/g, ',').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
1636
|
+
if (current && !opts.some((o) => key(o.value) === key(current))) {
|
|
1637
|
+
opts.push({ value: current, label: String(current).split(',')[0].replace(/["']/g, '').trim() + ' — from the email' });
|
|
1638
|
+
}
|
|
1639
|
+
return opts;
|
|
1593
1640
|
}
|
|
1594
1641
|
|
|
1595
1642
|
themeFields() {
|
|
@@ -1621,7 +1668,7 @@ export class EditorCore {
|
|
|
1621
1668
|
B.color('Border color', 'borderColor'),
|
|
1622
1669
|
] : []).concat([
|
|
1623
1670
|
B.head('Type & color'),
|
|
1624
|
-
B.sel('Default font', 'font', this.fontOptions(false)),
|
|
1671
|
+
B.sel('Default font', 'font', this.fontOptions(false, this.state.doc.theme.font)),
|
|
1625
1672
|
B.color('Text color', 'text'),
|
|
1626
1673
|
B.color('Link color', 'link'),
|
|
1627
1674
|
]));
|