@seliseblocks/mailcraft 0.2.8 → 0.2.9

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.
@@ -150,6 +150,43 @@ function applyBgImage(rows, el) {
150
150
  return rows;
151
151
  }
152
152
 
153
+ /**
154
+ * A wrapper that paints one background image is one visual band: builders
155
+ * (Beefree, Stripo) write a hero section as `table.row[background-image]`
156
+ * holding a single-column stack of per-block tables, and letting the generic
157
+ * walk turn each block into its own row stamped the image onto every one --
158
+ * so the export re-drew the hero's top slice once per block instead of one
159
+ * image flowing behind the section. Folds those rows back into a single row,
160
+ * moving each row's padding onto its blocks (same convention as the
161
+ * block-table unwrap in `blocksFromNodes`). Deliberately conservative: only
162
+ * single-column rows with no styling of their own (a differing background,
163
+ * frame or image on a row is a real band boundary) are merged, so everything
164
+ * that isn't the hero shape walks exactly as before.
165
+ */
166
+ function mergeBandRows(rows, el) {
167
+ if (rows.length < 2 || !bgImageOf(el)) return rows;
168
+ const bg0 = rows[0].props.bg || '';
169
+ const plain = rows.every((r) => r.cols.length === 1
170
+ && !r.props.bgImage && !r.props.border && !r.props.radius && !r.props.shadow
171
+ && (r.props.bg || '') === bg0);
172
+ if (!plain) return rows;
173
+ const merged = mkRow([100]);
174
+ merged.props.py = 0; merged.props.px = 0; merged.props.gap = 0;
175
+ if (bg0) merged.props.bg = bg0;
176
+ merged.cols[0].blocks = rows.reduce((acc, r) => {
177
+ const padded = r.props.py || r.props.px || r.props.pt !== undefined;
178
+ r.cols[0].blocks.forEach((b) => {
179
+ if (padded && b.type !== 'button' && 'py' in b.props) {
180
+ b.props.py = r.props.py;
181
+ if ('px' in b.props) b.props.px = r.props.px;
182
+ }
183
+ acc.push(b);
184
+ });
185
+ return acc;
186
+ }, []);
187
+ return merged.cols[0].blocks.length ? [merged] : rows;
188
+ }
189
+
153
190
  /** Padding read from the longhands, which are populated by the `padding` shorthand too -- but not vice versa: builders that write `padding-top/-left/...` individually (Beefree et al.) read back an empty `style.padding`, which is how every one of their cells imported with zero padding. Carries the exact per-side values plus the averaged py/px pair for consumers that only have a pair to store. */
154
191
  function paddingOf(st) {
155
192
  if (!st) return null;
@@ -415,15 +452,29 @@ function classifyHeading(el) {
415
452
  const st = el.style;
416
453
  const over = { text: (el.textContent || '').trim(), level: el.tagName.toLowerCase() };
417
454
  const size = fontPx(st.fontSize); if (size) over.size = size;
418
- if (st.textAlign) over.align = st.textAlign;
419
- if (st.color) over.color = st.color;
455
+ // Color and alignment inherit in CSS, and hero sections declare them on
456
+ // the section `<td>` -- a white heading over a dark photo whose color
457
+ // lived on the cell imported in the default dark ink and vanished into
458
+ // the image. Own values still win; the walk up only fills the gaps,
459
+ // exactly like the text-run path.
460
+ const align = st.textAlign || inheritedStyle(el.parentElement, 'textAlign');
461
+ if (align) over.align = align;
462
+ const color = st.color || inheritedStyle(el.parentElement, 'color');
463
+ if (color) over.color = hexOf(color);
420
464
  if (st.fontWeight) over.weight = st.fontWeight;
421
465
  return blk('heading', over);
422
466
  }
423
467
 
424
468
  function classifyList(el) {
425
469
  if (el.tagName !== 'UL' && el.tagName !== 'OL') return null;
426
- const items = Array.from(el.children).filter((c) => c.tagName === 'LI').map((li) => li.innerHTML.trim());
470
+ // Through the same whitelist as a text run: this was the one classifier
471
+ // that carried raw source innerHTML into props, so classes, ids and event
472
+ // handlers (`onerror` on an <img> in a list item) rode into the canvas DOM
473
+ // and the export while every other path stripped them. The newline swap is
474
+ // because `items` is a one-fragment-per-line string -- a linebreak inside a
475
+ // source <li> would otherwise split it into two items on render.
476
+ const items = Array.from(el.children).filter((c) => c.tagName === 'LI')
477
+ .map((li) => cleanImportHtml(li.innerHTML).replace(/\n/g, ' '));
427
478
  if (!items.length) return null;
428
479
  return blk('list', { items: items.join('\n'), ordered: el.tagName === 'OL' });
429
480
  }
@@ -539,10 +590,30 @@ function blocksFromNodes(nodes) {
539
590
  const out = [];
540
591
  let buf = [];
541
592
  let bufFirstEl = null;
593
+ // The parent of a leading bare text node. A run with no element of its own
594
+ // (`<td style="font-size:30px;...">{{Code}}</td>`) still has real
595
+ // typography -- it lives on the ancestors, and with no bufFirstEl the
596
+ // inheritedStyle reads below were skipped wholesale, so the run imported
597
+ // at the theme default (a 30px/800 verification code became 16px plain).
598
+ let bufTextEl = null;
542
599
  const flush = () => {
543
600
  const html = cleanImportHtml(buf.join(''));
544
- if (html) {
601
+ // A run with nothing visible in it -- no text beyond whitespace/hair
602
+ // spaces, no image or line break (an `<nbsp;>` still counts as a blank
603
+ // line someone wrote) -- is markup residue (`<p style="margin:0"></p>`),
604
+ // not content; as a block it rendered as a phantom padded row.
605
+ const visible = html && (/<(img|br|hr)\b/i.test(html)
606
+ || html.replace(/<[^>]*>/g, '').replace(/[ \t\r\n\u2009\u200a\u200b]/g, '') !== '');
607
+ if (visible) {
545
608
  const over = { html };
609
+ if (!bufFirstEl && bufTextEl) {
610
+ const size = fontPx(inheritedStyle(bufTextEl, 'fontSize')); if (size) over.size = size;
611
+ const color = inheritedStyle(bufTextEl, 'color'); if (color) over.color = hexOf(color);
612
+ over.align = textAlignOf(bufTextEl);
613
+ const weight = inheritedStyle(bufTextEl, 'fontWeight'); if (weight) over.weight = weight;
614
+ const lh = lineHeightRatio(inheritedStyle(bufTextEl, 'lineHeight'), size);
615
+ if (lh) over.lh = lh;
616
+ }
546
617
  if (bufFirstEl) {
547
618
  // The block's *base* style: the first buffered element when it's a
548
619
  // block-level thing (a styled <p>/<div> speaks for the run), else its
@@ -568,7 +639,7 @@ function blocksFromNodes(nodes) {
568
639
  ) { baseEl = baseEl.firstElementChild; if (!runPad) runPad = paddingOf(baseEl.style); }
569
640
  if (runPad) { over.py = runPad.py; over.px = runPad.px; }
570
641
  const size = fontPx(inheritedStyle(baseEl, 'fontSize')); if (size) over.size = size;
571
- const color = inheritedStyle(baseEl, 'color'); if (color) over.color = color;
642
+ const color = inheritedStyle(baseEl, 'color'); if (color) over.color = hexOf(color);
572
643
  over.align = textAlignOf(baseEl);
573
644
  const weight = inheritedStyle(baseEl, 'fontWeight'); if (weight) over.weight = weight;
574
645
  const lh = lineHeightRatio(inheritedStyle(baseEl, 'lineHeight'), size);
@@ -576,13 +647,16 @@ function blocksFromNodes(nodes) {
576
647
  }
577
648
  out.push(blk('text', over));
578
649
  }
579
- buf = []; bufFirstEl = null;
650
+ buf = []; bufFirstEl = null; bufTextEl = null;
580
651
  };
581
652
  nodes.forEach((n) => {
582
653
  if (n.nodeType === 3) {
583
654
  const markers = logicMarkersOf(n.textContent);
584
655
  if (markers) { flush(); markers.forEach((mb) => out.push(mb)); return; }
585
- if (n.textContent && n.textContent.trim()) buf.push(escapeText(n.textContent));
656
+ if (n.textContent && n.textContent.trim()) {
657
+ if (!bufFirstEl && !bufTextEl) bufTextEl = n.parentElement;
658
+ buf.push(escapeText(n.textContent));
659
+ }
586
660
  return;
587
661
  }
588
662
  if (n.nodeType !== 1) return;
@@ -1045,7 +1119,10 @@ function rowsFromContentTable(table) {
1045
1119
  // outer wrapper around all columns. When bgSource IS cells[0] (flat rows),
1046
1120
  // falling back to it would re-promote the first card's color to the row.
1047
1121
  const outerBg = cells.indexOf(bgSource) > -1 ? '' : bgOf(bgSource);
1048
- const bg = (cellsUniform && cellBg) || outerBg || bgOf(table);
1122
+ // The tr between the cells and the table carries the legacy per-row
1123
+ // `bgcolor` old-school templates still set; it sits between the two in
1124
+ // specificity.
1125
+ const bg = (cellsUniform && cellBg) || bgOf(tr) || outerBg || bgOf(table);
1049
1126
  if (bg) row.props.bg = bg;
1050
1127
  // Differently-styled cells become per-column styling: each column keeps
1051
1128
  // its own background/radius/padding (the pastel-cards pattern).
@@ -1202,7 +1279,7 @@ function collectRows(nodes) {
1202
1279
  }
1203
1280
  if (n.nodeType === 1 && (n.tagName === 'DIV' || n.tagName === 'CENTER') && looksLikeContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
1204
1281
  flushBuf();
1205
- const inner = collectRows(Array.from(n.childNodes));
1282
+ const inner = mergeBandRows(collectRows(Array.from(n.childNodes)), n);
1206
1283
  rows.push(...applyBgImage(applyFrame(applyPad(applyBg(inner, bgOf(n)), padOf(n)), n), n));
1207
1284
  return;
1208
1285
  }
@@ -1243,11 +1320,37 @@ function collectRows(nodes) {
1243
1320
  rows.push(row);
1244
1321
  return;
1245
1322
  }
1246
- const inner = collectRows(Array.from(td.childNodes));
1323
+ // The other table-email rule idiom: a content-free cell whose only
1324
+ // drawing is a `border-top` (Beefree's `td.divider_inner`, usually in
1325
+ // a width="20%" inner table). As a text run it imported as a junk row
1326
+ // holding one hair space; recognized, it is the divider it draws.
1327
+ const dSides = borderSidesOf(td.style);
1328
+ if (dSides.sides.top > 0 && dSides.sides.top <= 10
1329
+ && !dSides.sides.right && !dSides.sides.bottom && !dSides.sides.left
1330
+ && !bgOf(td) && !bgImageOf(td)
1331
+ && !Array.from(td.children).some((c) => c.tagName !== 'SPAN' && c.tagName !== 'BR')
1332
+ && !(td.textContent || '').replace(/\s/g, '')) {
1333
+ const dw = String(n.getAttribute('width') || n.style.width || '');
1334
+ const row = mkRow([100]);
1335
+ row.props.py = 0; row.props.px = 0; row.props.gap = 0;
1336
+ row.cols[0].blocks = [blk('divider', {
1337
+ thickness: dSides.sides.top,
1338
+ lineStyle: borderStyleOf(td.style),
1339
+ color: borderColorOf(td.style) || '#d9dade',
1340
+ width: dw.endsWith('%') ? PX(dw) : 100,
1341
+ })];
1342
+ rows.push(row);
1343
+ return;
1344
+ }
1345
+ const inner = mergeBandRows(mergeBandRows(collectRows(Array.from(td.childNodes)), td), n);
1247
1346
  // Frame styles live on the td for some builders and on the table
1248
1347
  // itself for others (`table.row-content` carries the card border) --
1249
1348
  // applyFrame fills only what's still unset, so trying both is safe.
1250
- rows.push(...applyBgImage(applyBgImage(applyFrame(applyFrame(applyPad(applyBg(inner, bgOf(n) || bgOf(td)), padOf(td) || padOf(n) || cellPadOf(n)), td), n), td), n));
1349
+ // The td is the more specific background-color source -- a dark hero
1350
+ // cell inside a white content table keeps its own color, not the
1351
+ // wrapper's (the image already reads td-first via the applyBgImage
1352
+ // order). The tr between them carries the legacy per-row bgcolor.
1353
+ rows.push(...applyBgImage(applyBgImage(applyFrame(applyFrame(applyPad(applyBg(inner, bgOf(td) || bgOf(tr) || bgOf(n)), padOf(td) || padOf(n) || cellPadOf(n)), td), n), td), n));
1251
1354
  return;
1252
1355
  }
1253
1356
  flushBuf();
@@ -1289,6 +1392,16 @@ function fixedWidthOf(tb) {
1289
1392
  return 0;
1290
1393
  }
1291
1394
 
1395
+ /** A border present on some sides but not all, or a radius rounding some corners but not all -- the signature of one piece of a card that was drawn across several stacked tables, never of a standalone card. */
1396
+ function partialFrame(st) {
1397
+ if (!st) return false;
1398
+ const { sides } = borderSidesOf(st);
1399
+ const on = ['top', 'right', 'bottom', 'left'].filter((k) => sides[k] > 0).length;
1400
+ const corners = String(st.borderRadius || '').split(/\s+/).map(PX);
1401
+ const uneven = corners.length > 1 && corners.some((v) => v > 0) && corners.some((v) => !v);
1402
+ return (on > 0 && on < 4) || uneven;
1403
+ }
1404
+
1292
1405
  function themeFromParsedDoc(doc) {
1293
1406
  const theme = {};
1294
1407
  const body = doc.body;
@@ -1313,6 +1426,10 @@ function themeFromParsedDoc(doc) {
1313
1426
  if (bestWidth) {
1314
1427
  const content = Array.from(body.querySelectorAll('table')).find((tb) => fixedWidthOf(tb) === Number(bestWidth));
1315
1428
  if (content) {
1429
+ // Read before the frame is consumed below -- a fragment here means the
1430
+ // sibling tables hold the rest of the same card (see the pass further
1431
+ // down).
1432
+ const contentPartial = partialFrame(content.style);
1316
1433
  // The content column's own background -- including the literal
1317
1434
  // `transparent` the exporter always writes for a see-through column.
1318
1435
  // Without this the round trip lost it: export wrote
@@ -1354,6 +1471,30 @@ function themeFromParsedDoc(doc) {
1354
1471
  if (padX > 0) theme.padX = padX;
1355
1472
  cell.style.padding = '';
1356
1473
  }
1474
+ // A card drawn across several sibling content-width tables (top piece:
1475
+ // `border-radius:16px 16px 0 0; border-bottom:none`, middle pieces:
1476
+ // side borders only, bottom piece: `border-radius:0 0 16px 16px`) is
1477
+ // ONE visual frame -- the one just claimed. Left in place, the sibling
1478
+ // fragments imported as row borders inside the already-framed canvas:
1479
+ // doubled verticals down every row and a second rounded, shadowed box
1480
+ // around the last section. Only the split-card idiom trips this: the
1481
+ // claimed table must itself be a fragment (partial border or uneven
1482
+ // corners), and only fragments are stripped -- a template of genuinely
1483
+ // separate full cards has neither and is untouched. Horizontal edges
1484
+ // stay, since between two sections they read as a real separator; the
1485
+ // last fragment's bottom edge coincides with the frame's and goes.
1486
+ if (contentPartial) {
1487
+ const same = Array.from(body.querySelectorAll('table'))
1488
+ .filter((tb) => tb !== content && tb.style && fixedWidthOf(tb) === Number(bestWidth));
1489
+ same.forEach((tb, k) => {
1490
+ if (!partialFrame(tb.style)) return;
1491
+ tb.style.borderLeft = '';
1492
+ tb.style.borderRight = '';
1493
+ tb.style.borderRadius = '';
1494
+ tb.style.boxShadow = '';
1495
+ if (k === same.length - 1) tb.style.borderBottom = '';
1496
+ });
1497
+ }
1357
1498
  }
1358
1499
  }
1359
1500
  // Prefer the first *real* stack (has a comma or quotes) over a lone generic
@@ -1,46 +1,46 @@
1
- /**
2
- * Pure style-computation functions shared by the live renderer and the export
3
- * builder, ported verbatim. Objects use camelCase keys so they can be applied
4
- * directly via `Object.assign(el.style, obj)`.
5
- */
6
-
7
- import { cssUrl } from './sanitize.js';
8
- export function pad(p) {
9
- return (p.py || 0) + 'px ' + (p.px || 0) + 'px';
10
- }
11
-
12
- export function boxStyle(p) {
13
- const on = (key) => p[key] !== false;
14
- const side = (key) => (p.bBorder && on(key) ? p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5') : '0');
15
- return {
16
- background: p.bBg || 'transparent',
17
- borderTop: side('bTop'), borderRight: side('bRight'), borderBottom: side('bBottom'), borderLeft: side('bLeft'),
18
- borderRadius: (p.bRadius || 0) + 'px',
19
- padding: (p.bPad || 0) + 'px',
20
- };
21
- }
22
-
23
- export function boxCss(p) {
24
- const bits = [];
25
- if (p.bBg) bits.push('background:' + p.bBg);
26
- if (p.bBorder) {
27
- const value = p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5');
28
- const sides = { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
29
- if (sides.top && sides.right && sides.bottom && sides.left) bits.push('border:' + value);
30
- else Object.keys(sides).filter((key) => sides[key]).forEach((key) => bits.push('border-' + key + ':' + value));
31
- }
32
- if (p.bRadius) bits.push('border-radius:' + p.bRadius + 'px');
33
- if (p.bPad) bits.push('padding:' + p.bPad + 'px');
34
- return bits.length ? bits.join(';') + ';' : 'margin:0';
35
- }
36
-
37
- /** A row's effective padding as a four-value CSS shorthand. `pt/pb/pl/pr` are optional per-side overrides (set by the inspector's "Per-side padding" split, or by the importer for asymmetric source padding); wherever a side is absent it follows the linked `py`/`px` pair, so documents that never split keep behaving exactly as before. */
1
+ /**
2
+ * Pure style-computation functions shared by the live renderer and the export
3
+ * builder, ported verbatim. Objects use camelCase keys so they can be applied
4
+ * directly via `Object.assign(el.style, obj)`.
5
+ */
6
+
7
+ import { cssUrl } from './sanitize.js';
8
+ export function pad(p) {
9
+ return (p.py || 0) + 'px ' + (p.px || 0) + 'px';
10
+ }
11
+
12
+ export function boxStyle(p) {
13
+ const on = (key) => p[key] !== false;
14
+ const side = (key) => (p.bBorder && on(key) ? p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5') : '0');
15
+ return {
16
+ background: p.bBg || 'transparent',
17
+ borderTop: side('bTop'), borderRight: side('bRight'), borderBottom: side('bBottom'), borderLeft: side('bLeft'),
18
+ borderRadius: (p.bRadius || 0) + 'px',
19
+ padding: (p.bPad || 0) + 'px',
20
+ };
21
+ }
22
+
23
+ export function boxCss(p) {
24
+ const bits = [];
25
+ if (p.bBg) bits.push('background:' + p.bBg);
26
+ if (p.bBorder) {
27
+ const value = p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5');
28
+ const sides = { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
29
+ if (sides.top && sides.right && sides.bottom && sides.left) bits.push('border:' + value);
30
+ else Object.keys(sides).filter((key) => sides[key]).forEach((key) => bits.push('border-' + key + ':' + value));
31
+ }
32
+ if (p.bRadius) bits.push('border-radius:' + p.bRadius + 'px');
33
+ if (p.bPad) bits.push('padding:' + p.bPad + 'px');
34
+ return bits.length ? bits.join(';') + ';' : 'margin:0';
35
+ }
36
+
37
+ /** A row's effective padding as a four-value CSS shorthand. `pt/pb/pl/pr` are optional per-side overrides (set by the inspector's "Per-side padding" split, or by the importer for asymmetric source padding); wherever a side is absent it follows the linked `py`/`px` pair, so documents that never split keep behaving exactly as before. */
38
38
  export function rowPad(p) {
39
- const t = p.pt ?? p.py ?? 0;
40
- const b = p.pb ?? p.py ?? 0;
41
- const l = p.pl ?? p.px ?? 0;
42
- const r = p.pr ?? p.px ?? 0;
43
- return t + 'px ' + r + 'px ' + b + 'px ' + l + 'px';
39
+ const t = p.pt ?? p.py ?? 0;
40
+ const b = p.pb ?? p.py ?? 0;
41
+ const l = p.pl ?? p.px ?? 0;
42
+ const r = p.pr ?? p.px ?? 0;
43
+ return t + 'px ' + r + 'px ' + b + 'px ' + l + 'px';
44
44
  }
45
45
 
46
46
  /** A row's outside spacing in CSS clockwise order, with the old vertical `my` value as a saved-document fallback. Empty horizontal margins can remain `auto` in the live canvas so the advanced max-width control stays centered. */
@@ -52,64 +52,64 @@ export function rowMargin(p, centerEmpty) {
52
52
  const emptyHorizontal = centerEmpty && !r && !l;
53
53
  return t + 'px ' + (emptyHorizontal ? 'auto' : r + 'px') + ' ' + b + 'px ' + (emptyHorizontal ? 'auto' : l + 'px');
54
54
  }
55
-
56
- /** Which sides a row's border draws on. Sides default ON (`!== false`) so documents saved before per-side toggles existed keep their full border. */
57
- export function rowBorderSides(p) {
58
- return { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
59
- }
60
-
61
- /** The row border as an inline-CSS string (export path). Empty when the width is 0 or every side is toggled off. */
62
- export function rowBorderCss(p) {
63
- if (!p.border) return '';
64
- const s = rowBorderSides(p);
65
- const value = p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5');
66
- if (s.top && s.right && s.bottom && s.left) return 'border:' + value + ';';
67
- return ['top', 'right', 'bottom', 'left'].filter((k) => s[k]).map((k) => 'border-' + k + ':' + value + ';').join('');
68
- }
69
-
70
- export function rowBg(p) {
71
- const ov = (p.overlay || 0) / 100;
72
- const layers = [];
73
- if (p.bgImage && ov) layers.push('linear-gradient(rgba(20,22,24,' + ov + '),rgba(20,22,24,' + ov + '))');
74
- if (p.bgImage) layers.push('url("' + cssUrl(p.bgImage) + '")');
75
- const s = rowBorderSides(p);
76
- const side = (on) => (p.border && on ? p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5') : '0');
77
- return {
78
- backgroundColor: p.bg || 'transparent',
79
- backgroundImage: layers.length ? layers.join(',') : 'none',
80
- backgroundSize: p.bgSize || 'cover',
81
- backgroundPosition: p.bgPos || 'center',
82
- backgroundRepeat: p.bgRepeat || 'no-repeat',
83
- borderTop: side(s.top),
84
- borderRight: side(s.right),
85
- borderBottom: side(s.bottom),
86
- borderLeft: side(s.left),
87
- borderRadius: (p.radius || 0) + 'px',
88
- // A raw CSS string, not a boolean: imports keep the source's exact
89
- // shadow; the inspector toggle writes/clears a standard one.
90
- boxShadow: p.shadow || 'none',
91
- maxWidth: (p.maxW || 100) + '%',
55
+
56
+ /** Which sides a row's border draws on. Sides default ON (`!== false`) so documents saved before per-side toggles existed keep their full border. */
57
+ export function rowBorderSides(p) {
58
+ return { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
59
+ }
60
+
61
+ /** The row border as an inline-CSS string (export path). Empty when the width is 0 or every side is toggled off. */
62
+ export function rowBorderCss(p) {
63
+ if (!p.border) return '';
64
+ const s = rowBorderSides(p);
65
+ const value = p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5');
66
+ if (s.top && s.right && s.bottom && s.left) return 'border:' + value + ';';
67
+ return ['top', 'right', 'bottom', 'left'].filter((k) => s[k]).map((k) => 'border-' + k + ':' + value + ';').join('');
68
+ }
69
+
70
+ export function rowBg(p) {
71
+ const ov = (p.overlay || 0) / 100;
72
+ const layers = [];
73
+ if (p.bgImage && ov) layers.push('linear-gradient(rgba(20,22,24,' + ov + '),rgba(20,22,24,' + ov + '))');
74
+ if (p.bgImage) layers.push('url("' + cssUrl(p.bgImage) + '")');
75
+ const s = rowBorderSides(p);
76
+ const side = (on) => (p.border && on ? p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5') : '0');
77
+ return {
78
+ backgroundColor: p.bg || 'transparent',
79
+ backgroundImage: layers.length ? layers.join(',') : 'none',
80
+ backgroundSize: p.bgSize || 'cover',
81
+ backgroundPosition: p.bgPos || 'center',
82
+ backgroundRepeat: p.bgRepeat || 'no-repeat',
83
+ borderTop: side(s.top),
84
+ borderRight: side(s.right),
85
+ borderBottom: side(s.bottom),
86
+ borderLeft: side(s.left),
87
+ borderRadius: (p.radius || 0) + 'px',
88
+ // A raw CSS string, not a boolean: imports keep the source's exact
89
+ // shadow; the inspector toggle writes/clears a standard one.
90
+ boxShadow: p.shadow || 'none',
91
+ maxWidth: (p.maxW || 100) + '%',
92
92
  margin: rowMargin(p, true),
93
93
  };
94
94
  }
95
-
96
- export function colsWrap(p) {
97
- const gap = p.gap || 0;
98
- if (p.layout === 'grid') return { display: 'grid', gridTemplateColumns: 'repeat(' + (p.gridCols || 2) + ', minmax(0, 1fr))', gap: gap + 'px' };
99
- if (p.layout === 'flex') {
100
- return {
101
- display: 'flex', flexDirection: p.flexDir || 'row', justifyContent: p.justify || 'flex-start',
102
- alignItems: p.alignItems || 'stretch', flexWrap: p.wrap ? 'wrap' : 'nowrap', gap: gap + 'px',
103
- };
104
- }
105
- return { display: 'flex', alignItems: 'stretch', margin: '0 ' + (-gap / 2) + 'px' };
106
- }
107
-
108
- export function colStyle(p, c) {
109
- if (p.layout === 'grid') return { minWidth: 0 };
110
- if (p.layout === 'flex') return { flex: (p.flexDir || 'row').indexOf('column') === 0 ? '0 0 auto' : c.span + ' 1 auto', minWidth: 0 };
111
- return {
112
- flex: c.span + ' 1 0%', minWidth: 0, padding: '0 ' + (p.gap || 0) / 2 + 'px',
113
- alignSelf: p.valign === 'middle' ? 'center' : (p.valign === 'bottom' ? 'flex-end' : 'flex-start'),
114
- };
115
- }
95
+
96
+ export function colsWrap(p) {
97
+ const gap = p.gap || 0;
98
+ if (p.layout === 'grid') return { display: 'grid', gridTemplateColumns: 'repeat(' + (p.gridCols || 2) + ', minmax(0, 1fr))', gap: gap + 'px' };
99
+ if (p.layout === 'flex') {
100
+ return {
101
+ display: 'flex', flexDirection: p.flexDir || 'row', justifyContent: p.justify || 'flex-start',
102
+ alignItems: p.alignItems || 'stretch', flexWrap: p.wrap ? 'wrap' : 'nowrap', gap: gap + 'px',
103
+ };
104
+ }
105
+ return { display: 'flex', alignItems: 'stretch', margin: '0 ' + (-gap / 2) + 'px' };
106
+ }
107
+
108
+ export function colStyle(p, c) {
109
+ if (p.layout === 'grid') return { minWidth: 0 };
110
+ if (p.layout === 'flex') return { flex: (p.flexDir || 'row').indexOf('column') === 0 ? '0 0 auto' : c.span + ' 1 auto', minWidth: 0 };
111
+ return {
112
+ flex: c.span + ' 1 0%', minWidth: 0, padding: '0 ' + (p.gap || 0) / 2 + 'px',
113
+ alignSelf: p.valign === 'middle' ? 'center' : (p.valign === 'bottom' ? 'flex-end' : 'flex-start'),
114
+ };
115
+ }
package/src/core/parse.js CHANGED
@@ -1,10 +1,10 @@
1
- export function parseItems(s) {
2
- return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
3
- const i = l.indexOf('|');
4
- return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
5
- });
6
- }
7
-
8
- export function cellsOf(p) {
9
- return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
10
- }
1
+ export function parseItems(s) {
2
+ return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
3
+ const i = l.indexOf('|');
4
+ return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
5
+ });
6
+ }
7
+
8
+ export function cellsOf(p) {
9
+ return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
10
+ }
@@ -1,15 +1,15 @@
1
- /** Data-URI placeholder image generator, ported verbatim from the original. */
2
- function enc(s) {
3
- return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
4
- }
5
-
6
- export function PH(label, w, ht) {
7
- return 'data:image/svg+xml;utf8,' + enc(
8
- '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
9
- '<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
10
- '<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
11
- '<rect width="100%" height="100%" fill="url(#s)"/>' +
12
- '<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
13
- '<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
14
- );
15
- }
1
+ /** Data-URI placeholder image generator, ported verbatim from the original. */
2
+ function enc(s) {
3
+ return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
4
+ }
5
+
6
+ export function PH(label, w, ht) {
7
+ return 'data:image/svg+xml;utf8,' + enc(
8
+ '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
9
+ '<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
10
+ '<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
11
+ '<rect width="100%" height="100%" fill="url(#s)"/>' +
12
+ '<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
13
+ '<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
14
+ );
15
+ }
@@ -102,6 +102,67 @@ export const cleanImportHtml = (html) => {
102
102
  return doc.body.innerHTML.replace(/<!--[\s\S]*?-->/g, '').replace(/\s{2,}/g, ' ').trim();
103
103
  };
104
104
 
105
+ /**
106
+ * Mutation-time repairs for rich block HTML. Imported content keeps its
107
+ * per-element inline typography on purpose (`cleanImportHtml` above), but a
108
+ * descendant's own `font-size`/`color`/`line-height` always outranks the
109
+ * inherited value from the block wrapper -- so the inspector's Text size,
110
+ * Text color, Line spacing and Text weight controls were dead on any block
111
+ * whose paragraphs carried their own styles. The render-time strip that fixed
112
+ * the Font control (`overrideRichFont`, render/block-body.js) can't be reused
113
+ * here: `fontFamily` defaults to empty so "unset" means "leave the import
114
+ * alone", while `size`/`color`/`lh`/`weight` always hold a value -- stripping
115
+ * at render time would flatten every imported design on first paint. These
116
+ * run once, at the moment the user moves the control (core `setProp`), so an
117
+ * untouched document renders byte-identical and the rewrite lands in the same
118
+ * undo step as the prop change.
119
+ *
120
+ * Both return the input string untouched (same reference, no reparse churn)
121
+ * when there is nothing to rewrite.
122
+ */
123
+
124
+ /** Drops trailing float noise without inventing integers: 60.75 stays, 78.00 becomes 78. */
125
+ const trimNum = (n) => String(Number(n.toFixed(2)));
126
+
127
+ /**
128
+ * Multiplies every absolute inline font-size by `ratio`, so the block's Text
129
+ * size acts as a master scale and a 15/26/15px imported hierarchy survives a
130
+ * base change instead of being flattened. Only px/pt scale -- em/% are
131
+ * relative and already follow the wrapper. A px line-height sitting beside a
132
+ * scaled font-size scales with it, or the imported `line-height:22px` would
133
+ * strangle 45px glyphs.
134
+ */
135
+ export const scaleInlineSizes = (html, ratio) => {
136
+ const src = String(html == null ? '' : html);
137
+ if (!src || !Number.isFinite(ratio) || ratio <= 0 || ratio === 1 || !/font-size/i.test(src)) return src;
138
+ const doc = new DOMParser().parseFromString(src, 'text/html');
139
+ let changed = false;
140
+ doc.body.querySelectorAll('[style]').forEach((node) => {
141
+ const size = /^([\d.]+)(px|pt)$/.exec(node.style.fontSize || '');
142
+ if (!size) return;
143
+ node.style.fontSize = trimNum(parseFloat(size[1]) * ratio) + size[2];
144
+ const lh = /^([\d.]+)px$/.exec(node.style.lineHeight || '');
145
+ if (lh) node.style.lineHeight = trimNum(parseFloat(lh[1]) * ratio) + 'px';
146
+ changed = true;
147
+ });
148
+ return changed ? doc.body.innerHTML : src;
149
+ };
150
+
151
+ /** Removes one CSS property from every descendant's inline style -- the block-level control now owns it. The property name is exact (`color` never touches `background-color`). */
152
+ export const stripInlineStyle = (html, prop) => {
153
+ const src = String(html == null ? '' : html);
154
+ if (!src || src.indexOf(prop) === -1) return src;
155
+ const doc = new DOMParser().parseFromString(src, 'text/html');
156
+ let changed = false;
157
+ doc.body.querySelectorAll('[style]').forEach((node) => {
158
+ if (!node.style.getPropertyValue(prop)) return;
159
+ node.style.removeProperty(prop);
160
+ if (!node.getAttribute('style')) node.removeAttribute('style');
161
+ changed = true;
162
+ });
163
+ return changed ? doc.body.innerHTML : src;
164
+ };
165
+
105
166
  /**
106
167
  * Makes an image URL safe to drop inside `url("...")`.
107
168
  *
@@ -1,11 +1,11 @@
1
- export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
2
- export const TOKEN = (t) => '{' + '{ ' + t + ' }' + '}';
3
-
4
- /** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
5
- export function vars(raw) {
6
- const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
7
- return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
8
- }
9
-
10
- /** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
11
- export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };
1
+ export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
2
+ export const TOKEN = (t) => '{' + '{ ' + t + ' }' + '}';
3
+
4
+ /** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
5
+ export function vars(raw) {
6
+ const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
7
+ return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
8
+ }
9
+
10
+ /** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
11
+ export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };