@seliseblocks/mailcraft 0.2.5 → 0.2.6

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.5",
3
+ "version": "0.2.6",
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",
@@ -1501,6 +1501,12 @@ export class EditorCore {
1501
1501
  B.color('Content area background color', 'contentBg', { transparent: true, solid: '#ffffff' }),
1502
1502
  B.range('Corner radius', 'radius', 0, 48, 1, 'px'),
1503
1503
  B.range('Border thickness', 'borderW', 0, 12, 1, 'px'),
1504
+ B.sel('Drop shadow', 'shadow', [
1505
+ { value: '', label: 'None' },
1506
+ { value: '0 2px 8px rgba(23,32,51,0.08)', label: 'Soft' },
1507
+ { value: '0 8px 28px rgba(23,32,51,0.14)', label: 'Medium' },
1508
+ { value: '0 18px 48px rgba(23,32,51,0.22)', label: 'Deep' },
1509
+ ]),
1504
1510
  ].concat(this.state.doc.theme.borderW ? [
1505
1511
  B.sel('Border style', 'borderStyle', BORDER_STYLES),
1506
1512
  B.color('Border color', 'borderColor'),
@@ -93,6 +93,10 @@ export function buildHtml(state, root, boxCss) {
93
93
  if (!el) return '';
94
94
  return el.outerHTML
95
95
  .replace(/\scontenteditable="[^"]*"/g, '')
96
+ // Editor bookkeeping (caret restoration): a fresh random id every
97
+ // import, so leaving it in shipped mail also made export -> import ->
98
+ // export never converge byte-for-byte.
99
+ .replace(/\sdata-focus-key="[^"]*"/g, '')
96
100
  .replace(/\sdata-mc-[a-z-]+="[^"]*"/g, '')
97
101
  .replace(/\sspellcheck="[^"]*"/g, '')
98
102
  .replace(/\sdata-(?:gramm|gramm_editor|enable-grammarly|lt-active)="[^"]*"/g, '')
@@ -157,6 +161,9 @@ export function buildHtml(state, root, boxCss) {
157
161
  // `overflow:hidden` is what actually clips a row's own background to the
158
162
  // rounded corner; without it the first and last rows paint square over it.
159
163
  const contentShape = (t.radius ? 'border-radius:' + t.radius + 'px;overflow:hidden;' : '')
160
- + (t.borderW ? 'border:' + t.borderW + 'px ' + (t.borderStyle || 'solid') + ' ' + (t.borderColor || '#e2e2e5') + ';' : '');
164
+ + (t.borderW ? 'border:' + t.borderW + 'px ' + (t.borderStyle || 'solid') + ' ' + (t.borderColor || '#e2e2e5') + ';' : '')
165
+ // Patchy in mail clients (Outlook drops it), but honest: what the user
166
+ // styled ships, and capable clients render it.
167
+ + (t.shadow ? 'box-shadow:' + t.shadow + ';' : '');
161
168
  return '<!doctype html>\n<html lang="en">\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 dark">\n<title>' + 'Email' + '</title>\n</head>\n<body style="margin:0;padding:0;background:' + pageBg + ';font-family:' + t.font.replace(/"/g, "'") + ';color:' + t.text + ';-webkit-font-smoothing:antialiased;">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n <table role="presentation" width="' + t.width + '" cellpadding="0" cellspacing="0" border="0" style="width:' + t.width + 'px;max-width:100%;background:' + (t.contentBg || 'transparent') + ';' + contentShape + '">\n' + rows + '\n </table>\n </td></tr>\n</table>\n</body>\n</html>';
162
169
  }
@@ -442,6 +442,21 @@ function logicMarkersOf(text) {
442
442
  return out;
443
443
  }
444
444
 
445
+ /** Whether an element (or the transparent single-child chain under it) carries its own padding -- the shape the exporter writes for a block's py/px, and a builder section's own spacing. Such a wrapper is one block, never part of a text run. */
446
+ function hasOwnRunPad(el) {
447
+ let e = el;
448
+ if (e.style && paddingOf(e.style)) return true;
449
+ while (
450
+ e.children && e.children.length === 1
451
+ && !INLINE_TAGS.test(e.firstElementChild.tagName)
452
+ && (e.textContent || '') === (e.firstElementChild.textContent || '')
453
+ ) {
454
+ e = e.firstElementChild;
455
+ if (e.style && paddingOf(e.style)) return true;
456
+ }
457
+ return false;
458
+ }
459
+
445
460
  function blocksFromNodes(nodes) {
446
461
  const out = [];
447
462
  let buf = [];
@@ -458,6 +473,11 @@ function blocksFromNodes(nodes) {
458
473
  // first-element-only read never saw. Per-element differences inside
459
474
  // the run survive as the inline styles `cleanImportHtml` now keeps.
460
475
  let baseEl = INLINE_TAGS.test(bufFirstEl.tagName) ? (bufFirstEl.parentElement || bufFirstEl) : bufFirstEl;
476
+ // The run's own spacing: the exporter writes a text block's py/px as
477
+ // padding on the wrapper div the descent below walks straight past
478
+ // (it lands on the <p>), so a padded block came back at the default
479
+ // 10px/0 on every save. First padding on the wrapper chain wins.
480
+ let runPad = paddingOf(baseEl.style);
461
481
  // Then descend through transparent single-child wrappers: builders
462
482
  // nest a `font-family:sans-serif` shim div around the div that
463
483
  // carries the real typography, and `inheritedStyle` below walks *up*
@@ -467,7 +487,8 @@ function blocksFromNodes(nodes) {
467
487
  baseEl.children.length === 1
468
488
  && !INLINE_TAGS.test(baseEl.firstElementChild.tagName)
469
489
  && (baseEl.textContent || '') === (baseEl.firstElementChild.textContent || '')
470
- ) baseEl = baseEl.firstElementChild;
490
+ ) { baseEl = baseEl.firstElementChild; if (!runPad) runPad = paddingOf(baseEl.style); }
491
+ if (runPad) { over.py = runPad.py; over.px = runPad.px; }
471
492
  const size = fontPx(inheritedStyle(baseEl, 'fontSize')); if (size) over.size = size;
472
493
  const color = inheritedStyle(baseEl, 'color'); if (color) over.color = color;
473
494
  over.align = textAlignOf(baseEl);
@@ -513,8 +534,14 @@ function blocksFromNodes(nodes) {
513
534
  return;
514
535
  }
515
536
  if (isStructural(target)) { flush(); out.push(blk('html', { code: n.outerHTML })); return; }
537
+ // A wrapper carrying its own padding is a block boundary, not part of a
538
+ // run: the exporter writes every text block as exactly such a padded div,
539
+ // and buffering two of them together merged neighbouring blocks into one
540
+ // -- the second lost its padding, size, everything -- on every save.
541
+ if (n.nodeType === 1 && !INLINE_TAGS.test(target.tagName) && hasOwnRunPad(target) && buf.length) flush();
516
542
  if (!bufFirstEl) bufFirstEl = target;
517
543
  buf.push(n.outerHTML);
544
+ if (n.nodeType === 1 && !INLINE_TAGS.test(target.tagName) && hasOwnRunPad(target)) flush();
518
545
  });
519
546
  flush();
520
547
  return out;
@@ -570,16 +597,26 @@ function isPassthroughTable(tb) {
570
597
  return cells.length === 1;
571
598
  }
572
599
 
600
+ /** CSSOM hands colors back as `rgb(r, g, b)` even when the source (and the
601
+ * user's own value) was hex -- so every save visibly rewrote colour fields.
602
+ * Solid rgb() flattens back to hex; anything else (rgba, keywords,
603
+ * `transparent`) passes through untouched. */
604
+ function hexOf(value) {
605
+ const m = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(String(value == null ? '' : value).trim());
606
+ if (!m) return value;
607
+ return '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('');
608
+ }
609
+
573
610
  function bgOf(el) {
574
611
  const st = el.style;
575
- if (st && st.backgroundColor) return st.backgroundColor;
612
+ if (st && st.backgroundColor) return hexOf(st.backgroundColor);
576
613
  // The `background` shorthand is only a usable *color* when it holds no
577
614
  // image/gradient -- browsers populate `backgroundColor` from a shorthand
578
615
  // that includes a color, so reaching here with a url() means there is no
579
616
  // color to take (and taking the raw string set broken values).
580
617
  const short = (st && st.background) || '';
581
- if (short && short.indexOf('url(') < 0 && short.indexOf('gradient(') < 0) return short;
582
- return (el.getAttribute && el.getAttribute('bgcolor')) || '';
618
+ if (short && short.indexOf('url(') < 0 && short.indexOf('gradient(') < 0) return hexOf(short);
619
+ return hexOf((el.getAttribute && el.getAttribute('bgcolor')) || '');
583
620
  }
584
621
 
585
622
  function padOf(el) {
@@ -604,7 +641,7 @@ function applyPad(rows, pad) {
604
641
 
605
642
  /** Longhand side colors are always a single value; the `borderColor` shorthand reads back as a per-side list when the sides differ (`rgb(...) rgb(...) currentcolor`), which is not a usable color. */
606
643
  function borderColorOf(st) {
607
- const ok = (v) => (v && v !== 'currentcolor' ? v : '');
644
+ const ok = (v) => (v && v !== 'currentcolor' ? hexOf(v) : '');
608
645
  return ok(st.borderTopColor) || ok(st.borderLeftColor) || ok(st.borderRightColor) || ok(st.borderBottomColor) || '';
609
646
  }
610
647
 
@@ -940,7 +977,7 @@ function collectRows(nodes) {
940
977
  function themeFromParsedDoc(doc) {
941
978
  const theme = {};
942
979
  const body = doc.body;
943
- let bg = (body.style && (body.style.backgroundColor || body.style.background)) || body.getAttribute('bgcolor') || '';
980
+ let bg = hexOf((body.style && (body.style.backgroundColor || body.style.background)) || body.getAttribute('bgcolor') || '');
944
981
  if (!bg) {
945
982
  const outer = body.querySelector('table');
946
983
  if (outer) bg = bgOf(outer) || (outer.querySelector('td') ? bgOf(outer.querySelector('td')) : '');
@@ -986,12 +1023,26 @@ function themeFromParsedDoc(doc) {
986
1023
  theme.borderStyle = borderStyleOf(content.style);
987
1024
  theme.borderColor = borderColorOf(content.style) || '';
988
1025
  }
1026
+ const sh = content.style && content.style.boxShadow;
1027
+ if (sh && sh !== 'none') theme.shadow = sh;
1028
+ // CLAIMED MEANS CONSUMED. These styles now live on the theme; leaving
1029
+ // them on the node let the row walker's card-folding (applyFrame /
1030
+ // applyPad) absorb the very same border, radius, shadow and page
1031
+ // padding into the rows -- so one save doubled every frame the user
1032
+ // had set at canvas level. htmlToDoc runs this pass before the row
1033
+ // pass for exactly this reason.
1034
+ if (content.style) {
1035
+ content.style.border = '';
1036
+ content.style.borderRadius = '';
1037
+ content.style.boxShadow = '';
1038
+ }
989
1039
  const cell = content.closest ? content.closest('td') : null;
990
1040
  if (cell && cell.style) {
991
1041
  const padY = PX(cell.style.paddingTop);
992
1042
  const padX = PX(cell.style.paddingLeft);
993
1043
  if (padY > 0) theme.padY = padY;
994
1044
  if (padX > 0) theme.padX = padX;
1045
+ cell.style.padding = '';
995
1046
  }
996
1047
  }
997
1048
  }
@@ -1018,8 +1069,8 @@ function themeFromParsedDoc(doc) {
1018
1069
  linkCounts[c] = (linkCounts[c] || 0) + 1;
1019
1070
  });
1020
1071
  const link = Object.keys(linkCounts).sort((a, b) => linkCounts[b] - linkCounts[a])[0];
1021
- if (link) theme.link = link;
1022
- if (body.style && body.style.color) theme.text = body.style.color;
1072
+ if (link) theme.link = hexOf(link);
1073
+ if (body.style && body.style.color) theme.text = hexOf(body.style.color);
1023
1074
  return theme;
1024
1075
  }
1025
1076
 
@@ -1057,7 +1108,10 @@ export function htmlToDoc(src) {
1057
1108
  // (never-inlined exports, hand-written emails) classify like inlined ones.
1058
1109
  // Best-effort: a pathological stylesheet must never block the import.
1059
1110
  try { inlineStylesheets(doc); } catch { /* proceed with inline styles only */ }
1060
- return { rows: collectRows(Array.from(doc.body.childNodes)), theme: themeFromParsedDoc(doc) };
1111
+ // Theme first: themeFromParsedDoc consumes the styles it claims off the
1112
+ // scaffold nodes, and the row walker must see the cleaned DOM.
1113
+ const theme = themeFromParsedDoc(doc);
1114
+ return { rows: collectRows(Array.from(doc.body.childNodes)), theme };
1061
1115
  }
1062
1116
 
1063
1117
  export function htmlToRows(src) {
package/src/core/theme.js CHANGED
@@ -12,4 +12,4 @@
12
12
  * accept the literal `transparent` (and any rgba()/#rrggbbaa value) as well
13
13
  * as a hex colour.
14
14
  */
15
- export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, padY: 0, padX: 0, radius: 0, borderW: 0, borderStyle: 'solid', borderColor: '#e2e2e5', font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
15
+ export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, padY: 0, padX: 0, radius: 0, borderW: 0, borderStyle: 'solid', borderColor: '#e2e2e5', shadow: '', font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
@@ -186,6 +186,8 @@ export function renderDoc(core, live) {
186
186
  // Same contract for the full content-area border: set only when asked,
187
187
  // so the chrome's own subtle frame keeps marking the sheet otherwise.
188
188
  border: borderW ? borderW + 'px ' + (theme.borderStyle || 'solid') + ' ' + (theme.borderColor || '#e2e2e5') : '',
189
+ // And for the canvas-wide drop shadow -- unset keeps the chrome's own.
190
+ boxShadow: theme.shadow || '',
189
191
  // Clipping the rows to that corner is right for the sent email (export
190
192
  // emits `overflow:hidden` alongside the radius) and for the static
191
193
  // preview, but not for the editable canvas: the sheet is also what the
@@ -304,6 +304,10 @@ export const STYLE = `
304
304
  #mc .mc-inspector .mc-section-body { padding: 12px 14px 16px !important; }
305
305
  #mc .mc-inspector .mc-section-body > :first-child { margin-top: 0 !important; }
306
306
  #mc .mc-field-list { display: grid; align-content: start; gap: 12px; }
307
+ /* The author display:grid above beats the UA rule [hidden]{display:none},
308
+ which is exactly how the accordion silently stopped collapsing: the bar
309
+ toggled the hidden attribute and the grid stayed painted. Hidden wins. */
310
+ #mc .mc-field-list[hidden], #mc .mc-section-body[hidden] { display: none !important; }
307
311
  #mc .mc-field-list .mc-field { min-width: 0; }
308
312
  #mc .mc-field-list .mc-field-row {
309
313
  display: grid !important; grid-template-columns: minmax(0, 1fr) 168px;