@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seliseblocks/mailcraft",
3
- "version": "0.2.9",
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
  },
@@ -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: 'embed' }, { t: 'menu' }, { g: 'footer' }, { t: 'html' }, { t: 'css' }, { t: 'svg' },
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
- id: c.id || uid(),
271
- span: Number(c.span) > 0 ? Number(c.span) : evenSpan,
272
- blocks: (Array.isArray(c.blocks) ? c.blocks : []).filter((b) => b && typeof b === 'object' && b.type).map((b) => (
273
- DEF(b.type)
274
- ? { id: b.id || uid(), type: b.type, props: Object.assign(DEF(b.type).make(), b.props || {}) }
275
- : { id: b.id || uid(), type: b.type, props: b.props || {} }
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
  };
@@ -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
- const sheets = Array.from(doc.querySelectorAll('style'));
62
- if (!sheets.length) return;
63
- const css = stripAtRules(stripComments(sheets.map((s) => s.textContent || '').join('\n')));
64
-
65
- const rules = [];
66
- const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
67
- let m;
68
- let order = 0;
69
- while ((m = ruleRe.exec(css))) {
70
- const decls = [];
71
- m[2].split(';').forEach((d) => {
72
- const at = d.indexOf(':');
73
- if (at < 0) return;
74
- const prop = d.slice(0, at).trim().toLowerCase();
75
- let value = d.slice(at + 1).trim();
76
- if (!prop || !value || prop.indexOf('--') === 0 || prop.indexOf('mso-') === 0) return;
77
- const important = /!important\s*$/i.test(value);
78
- if (important) value = value.replace(/!important\s*$/i, '').trim();
79
- if (value) decls.push({ prop, value, important });
80
- });
81
- if (!decls.length) continue;
82
- m[1].split(',').forEach((raw) => {
83
- const sel = raw.trim();
84
- if (!sel || sel === '*' || sel.indexOf(':') > -1) return;
85
- rules.push({ sel, decls, spec: specificity(sel), order: order++ });
86
- });
87
- }
88
- if (!rules.length) return;
89
-
90
- // Ascending: later (more specific / later-in-source) rules overwrite
91
- // earlier winners per property below.
92
- rules.sort((a, b) => a.spec - b.spec || a.order - b.order);
93
-
94
- const winners = new Map(); // element -> Map(prop -> {value, important})
95
- rules.forEach((rule) => {
96
- let matched;
97
- try { matched = doc.querySelectorAll(rule.sel); } catch { return; }
98
- matched.forEach((el) => {
99
- if (el !== doc.body && !doc.body.contains(el)) return;
100
- let bucket = winners.get(el);
101
- if (!bucket) { bucket = new Map(); winners.set(el, bucket); }
102
- rule.decls.forEach((d) => {
103
- const cur = bucket.get(d.prop);
104
- if (cur && cur.important && !d.important) return;
105
- bucket.set(d.prop, d);
106
- });
107
- });
108
- });
109
-
110
- winners.forEach((bucket, el) => {
111
- if (!el.style) return;
112
- bucket.forEach((d, prop) => {
113
- if (!d.important && el.style.getPropertyValue(prop)) return; // inline wins
114
- try { el.style.setProperty(prop, d.value); } catch { /* unparseable value -- skip */ }
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
+ }
@@ -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) block.props[prop] = perLine(src, (s) => scaleInlineSizes(s, 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
- block.props[prop] = perLine(src, (s) => stripInlineStyle(s, RICH_OWNED_STYLE[key]));
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
- // fold any uncommitted inline formatting into state first, or the re-render
561
- // from the size change would rewrite innerHTML from stale props
562
- const elNode = this.editEl;
563
- if (elNode) {
564
- const key = b.type === 'heading' ? 'text' : (b.type === 'html' ? 'code' : 'html');
565
- const val = b.type === 'heading' ? elNode.textContent : elNode.innerHTML;
566
- if (b.props[key] !== val) this.setProp(b.id, key, val);
567
- }
568
- const cur = Number(b.props.size) || 16;
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
- return inherit ? [{ value: '', label: 'Inherit — theme font' }].concat(stacks) : stacks;
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
  ]));