@seliseblocks/mailcraft 0.2.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seliseblocks/mailcraft",
3
- "version": "0.2.8",
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",
@@ -49,6 +49,7 @@
49
49
  "examples",
50
50
  "README.md",
51
51
  "DOCS.md",
52
+ "CHANGELOG.md",
52
53
  "LICENSE"
53
54
  ],
54
55
  "engines": {
@@ -59,7 +60,7 @@
59
60
  },
60
61
  "scripts": {
61
62
  "build": "node build.js",
62
- "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",
63
64
  "prepublishOnly": "npm run build && npm test",
64
65
  "coverage": "c8 --reporter=text --reporter=text-summary --src=src --all --exclude=\"test/**\" --exclude=\"examples/**\" --exclude=\"build.js\" npm test"
65
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
  };
@@ -58,7 +58,10 @@ function specificity(sel) {
58
58
  }
59
59
 
60
60
  export function inlineStylesheets(doc) {
61
- const sheets = Array.from(doc.querySelectorAll('style'));
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')));
62
65
  if (!sheets.length) return;
63
66
  const css = stripAtRules(stripComments(sheets.map((s) => s.textContent || '').join('\n')));
64
67
 
@@ -3,7 +3,7 @@ import { mk, blk, mkRow, GROUPS, LAYOUTS, migrateDoc, normalizeDoc, blankDoc } f
3
3
  import { binder, decorate, group } from './binder.js';
4
4
  import { ALL_FOLDER_ID, normalizeAsset, resolveLimits, providerProblems } from './storage.js';
5
5
  import { validateFiles, limitsProblem } from './storage-limits.js';
6
- import { migrateTokens, cleanHtml, escHtml, linkHref } from './sanitize.js';
6
+ import { migrateTokens, cleanHtml, escHtml, linkHref, scaleInlineSizes, stripInlineStyle } from './sanitize.js';
7
7
  import { buildHtml as buildHtmlFn } from './export.js';
8
8
  import { htmlToDoc } from './import-html.js';
9
9
  import { boxCss } from './layout-style.js';
@@ -35,6 +35,48 @@ const UPLOAD_CONCURRENCY = 3;
35
35
  */
36
36
  const RETIRED_SEED = /^data:image\/svg\+xml;utf8,.*%3Cpattern%20id%3D%22s%22/;
37
37
  const withoutRetiredSeeds = (assets) => (Array.isArray(assets) ? assets.filter((a) => !RETIRED_SEED.test(String((a && a.url) || ''))) : []);
38
+ /**
39
+ * The one span Text size moves in, per block type -- shared by the inspector
40
+ * range and the RTE's ± buttons. They used to disagree: the panel allowed
41
+ * 8–96 (text) and 12–120 (heading) while `size()` clamped to a private
42
+ * 10–64, so a single "Larger text" click on a 96px block *shrank* it to 64.
43
+ */
44
+ const SIZE_SPAN = { text: [8, 96], heading: [12, 120] };
45
+ /** The blocks whose content is rich HTML in props, and the prop it lives in. Only these need the descendant rewrites below. */
46
+ const RICH_HTML_PROP = { text: 'html', list: 'items' };
47
+ /** Inspector key -> the inline CSS property whose descendant copies mask it (see `stripInlineStyle`). */
48
+ const RICH_OWNED_STYLE = { color: 'color', lh: 'line-height', weight: 'font-weight', align: 'text-align' };
49
+
50
+ /**
51
+ * Keeps a rich block's descendants in agreement with the block-level control
52
+ * being moved. Imported HTML deliberately keeps per-element inline typography
53
+ * (sanitize.js `cleanImportHtml`), and a descendant's own declaration beats
54
+ * inheritance from the wrapper -- so without this, Text size / color / Line
55
+ * spacing / weight / Align silently did nothing on any imported (or
56
+ * AI-drafted) block. Size *scales* descendants so a 15/26/15px hierarchy
57
+ * survives as 45/78/45 instead of flattening; the others strip the descendant
58
+ * property, exactly the semantics the Font control already shipped with.
59
+ * Runs inside the same `commit` as the prop write: one undo step, and a
60
+ * document nobody touches is never rewritten.
61
+ */
62
+ function syncRichContent(block, key, val) {
63
+ const prop = RICH_HTML_PROP[block.type];
64
+ if (!prop) return;
65
+ // list items are one fragment per line; the rewrite must not run across the joins.
66
+ const perLine = (src, fn) => (prop === 'items' ? String(src).split('\n').map(fn).join('\n') : fn(String(src)));
67
+ const src = block.props[prop];
68
+ if (src == null || src === '') return;
69
+ if (key === 'size') {
70
+ const cur = Number(block.props.size);
71
+ const next = Number(val);
72
+ // An unknown base (imports that carried no readable size) can't be scaled
73
+ // 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
+ } else if (RICH_OWNED_STYLE[key]) {
76
+ block.props[prop] = perLine(src, (s) => stripInlineStyle(s, RICH_OWNED_STYLE[key]));
77
+ }
78
+ }
79
+
38
80
  const BORDER_STYLES = [
39
81
  { value: 'solid', label: 'Solid' },
40
82
  { value: 'dashed', label: 'Dashed' },
@@ -524,7 +566,8 @@ export class EditorCore {
524
566
  if (b.props[key] !== val) this.setProp(b.id, key, val);
525
567
  }
526
568
  const cur = Number(b.props.size) || 16;
527
- this.setProp(b.id, 'size', Math.max(10, Math.min(64, cur + delta)));
569
+ const [lo, hi] = SIZE_SPAN[b.type] || [10, 64];
570
+ this.setProp(b.id, 'size', Math.max(lo, Math.min(hi, cur + delta)));
528
571
  }
529
572
 
530
573
  hasCountdown() { return this.state.doc.rows.some((r) => r.cols.some((c) => c.blocks.some((b) => b.type === 'countdown'))); }
@@ -620,7 +663,10 @@ export class EditorCore {
620
663
  this.commit((doc) => {
621
664
  const f = this.find(doc, id);
622
665
  const target = f.block ? f.block.props : (f.row ? f.row.props : null);
623
- if (target) target[key] = val;
666
+ if (!target) return;
667
+ // Before the write: the size rewrite needs the outgoing value as its base.
668
+ if (f.block) syncRichContent(f.block, key, val);
669
+ target[key] = val;
624
670
  });
625
671
  }
626
672
 
@@ -1178,7 +1224,7 @@ export class EditorCore {
1178
1224
 
1179
1225
  // ---- export / code view --------------------------------------------------
1180
1226
 
1181
- buildHtml() { return buildHtmlFn(this.state, this.exportRoot, boxCss); }
1227
+ buildHtml(opts) { return buildHtmlFn(this.state, this.exportRoot, boxCss, opts); }
1182
1228
 
1183
1229
  openExport = () => {
1184
1230
  this.setState({ exportOpen: true, exportCode: this.buildHtml(), copied: false, libraryOpen: false, aiOpen: false, codeOpen: false });
@@ -1190,6 +1236,12 @@ export class EditorCore {
1190
1236
  this.flash(this.t('toast.htmlCopied'));
1191
1237
  };
1192
1238
 
1239
+ /** Code view's source pane onto the clipboard, unsaved edits included: `codeSrc` is what the pane shows, which may be ahead of what Apply has pushed to the canvas. */
1240
+ copyCode = () => {
1241
+ if (navigator.clipboard) navigator.clipboard.writeText(this.state.codeSrc).catch(() => {});
1242
+ this.flash(this.t('toast.htmlCopied'));
1243
+ };
1244
+
1193
1245
  downloadExport = () => {
1194
1246
  const blob = new Blob([this.state.exportCode], { type: 'text/html' });
1195
1247
  const a = document.createElement('a');
@@ -1321,9 +1373,9 @@ export class EditorCore {
1321
1373
  /<a(?:\s|>)/i.test(String(b.props.html || ''))
1322
1374
  ? [{ kind: 'richLinks', label: 'Links', html: b.props.html || '', onChange: (v) => this.setProp(b.id, 'html', v) }]
1323
1375
  : [],
1324
- [B.area('Text', 'html'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 96, 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));
1325
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));
1326
- 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')])));
1327
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')]));
1328
1380
  case 'spacer': return decorate(base.concat([B.range('Height', 'height', 0, 400, 2, 'px')]));
1329
1381
  // Dynamic-content markers: the expression lives on the start marker;
@@ -1348,15 +1400,15 @@ export class EditorCore {
1348
1400
  B.seg('Icon palette', 'palette', [{ value: 'custom', label: 'Custom' }, { value: 'brand', label: 'Brand' }]),
1349
1401
  B.seg('Icon shape', 'shape', [{ value: 'outline', label: 'Outline' }, { value: 'bare', label: 'Bare' }, { value: 'circle', label: 'Circle' }, { value: 'square', label: 'Square' }]),
1350
1402
  B.tog('Show network names', 'showLabel'),
1351
- B.sel('Font', 'fontFamily', this.fontOptions(true)),
1403
+ B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)),
1352
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'),
1353
1405
  ]));
1354
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')]));
1355
1407
  case 'html': return decorate(base.concat([B.area('Raw HTML', 'code')]));
1356
- 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')]));
1357
- 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')]));
1358
- 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', 12, 120, 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));
1359
- 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));
1360
1412
  case 'table': return decorate(base.concat([
1361
1413
  // Custom kind (render/fields.js `renderTableGrid`): a real grid of
1362
1414
  // cell inputs with add/remove row+column controls, replacing the
@@ -1364,11 +1416,10 @@ export class EditorCore {
1364
1416
  // `data` string underneath.
1365
1417
  { kind: 'tablegrid', label: 'Cells', value: b.props.data || '', header: !!b.props.header, onChange: (v) => this.setProp(b.id, 'data', v) },
1366
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'),
1367
- B.sel('Font', 'fontFamily', this.fontOptions(true)),
1419
+ B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)),
1368
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, '%'),
1369
1421
  B.color('Header color', 'headBg'), B.color('Line color', 'lineColor'), B.seg('Align', 'align', ALIGN),
1370
1422
  ]));
1371
- 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')]));
1372
1423
  case 'css': return decorate(base.concat([B.area('CSS', 'code'), B.text('Note to yourself', 'note')]));
1373
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 ? [
1374
1425
  B.sel('Border style', 'borderStyle', BORDER_STYLES), B.color('Border color', 'lineColor'),
@@ -1524,7 +1575,7 @@ export class EditorCore {
1524
1575
  * an empty value meaning "use the theme font" (renderers read
1525
1576
  * `p.fontFamily || theme.font`, so old documents need no migration).
1526
1577
  */
1527
- fontOptions(inherit) {
1578
+ fontOptions(inherit, current) {
1528
1579
  const stacks = [
1529
1580
  { value: '"Helvetica Neue", Helvetica, Arial, sans-serif', label: 'Helvetica Neue' },
1530
1581
  { value: 'Helvetica, Arial, sans-serif', label: 'Helvetica / Arial' },
@@ -1537,7 +1588,20 @@ export class EditorCore {
1537
1588
  { value: '"Palatino Linotype", Palatino, Georgia, serif', label: 'Palatino' },
1538
1589
  { value: 'ui-monospace, "Courier New", monospace', label: 'Monospace' },
1539
1590
  ];
1540
- return inherit ? [{ value: '', label: 'Inherit — theme font' }].concat(stacks) : stacks;
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;
1541
1605
  }
1542
1606
 
1543
1607
  themeFields() {
@@ -1569,7 +1633,7 @@ export class EditorCore {
1569
1633
  B.color('Border color', 'borderColor'),
1570
1634
  ] : []).concat([
1571
1635
  B.head('Type & color'),
1572
- B.sel('Default font', 'font', this.fontOptions(false)),
1636
+ B.sel('Default font', 'font', this.fontOptions(false, this.state.doc.theme.font)),
1573
1637
  B.color('Text color', 'text'),
1574
1638
  B.color('Link color', 'link'),
1575
1639
  ]));
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
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 || mode === 'keep') return { row: '', cell: '' };
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
- if (b.type === 'svg') return '<div style="text-align:' + b.props.align + ';padding:' + b.props.py + 'px 0">' + (b.props.code || '') + '</div>';
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 ') || '&nbsp;';
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
- + ((rp.mt || rp.mr || rp.mb || rp.ml || rp.my) ? 'margin:' + rowMargin(rp) + ';' : '');
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
  }
package/src/core/icons.js CHANGED
@@ -67,7 +67,6 @@ const ICONS = {
67
67
  hero: [['rect', 3, 4, 18, 16], 'M6 11h12', 'M6 15h6', ['rect', 14.5, 13.5, 4, 3]],
68
68
  stats: ['M5 19v-7', 'M12 19V5', 'M19 19v-4', 'M3 19h18'],
69
69
  gallery: [['rect', 3, 4, 7.5, 7.5], ['rect', 13.5, 4, 7.5, 7.5], ['rect', 3, 12.5, 7.5, 7.5], ['rect', 13.5, 12.5, 7.5, 7.5]],
70
- embed: [['rect', 2.5, 5, 19, 14], 'M2.5 9h19', 'M10.5 11.5l3.5 2-3.5 2z'],
71
70
  css: ['M5 4h14l-1.5 15L12 21l-5.5-2z', 'M8.5 9h7', 'M9 13h5'],
72
71
  codeblock: [['rect', 3, 4, 18, 16], 'M9.5 10l-2 2 2 2', 'M14.5 10l2 2-2 2'],
73
72
  data: ['M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3z', 'M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6', 'M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3'],