@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.
- package/CHANGELOG.md +99 -0
- package/DOCS.md +27 -27
- package/README.md +1 -1
- package/README.md.txt +1 -1
- package/dist/mailcraft-editor.bundle.js +58 -56
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +57 -5
- package/src/core/i18n/index.js +83 -83
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +152 -11
- package/src/core/layout-style.js +100 -100
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/sanitize.js +61 -0
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +41 -2
- package/src/render/fields.js +28 -2
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +6 -0
- package/src/render/story.js +415 -415
package/src/core/import-html.js
CHANGED
|
@@ -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
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/core/layout-style.js
CHANGED
|
@@ -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
|
+
}
|
package/src/core/placeholder.js
CHANGED
|
@@ -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
|
+
}
|
package/src/core/sanitize.js
CHANGED
|
@@ -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
|
*
|
package/src/core/variables.js
CHANGED
|
@@ -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' };
|