@seliseblocks/mailcraft 0.2.9 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/DOCS.md +5 -5
- package/README.md +3 -0
- package/README.md.txt +3 -0
- package/dist/mailcraft-editor.bundle.js +75 -60
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -2
- package/src/core/blocks.js +30 -11
- package/src/core/css-cascade.js +120 -117
- package/src/core/editor-core.js +74 -27
- package/src/core/export.js +83 -10
- package/src/core/i18n/index.js +83 -83
- package/src/core/icons.js +0 -1
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +604 -48
- 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 +11 -1
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +25 -2
- package/src/render/block-body.js +17 -5
- package/src/render/canvas.js +14 -0
- package/src/render/focus-preserve.js +158 -158
- package/src/render/story.js +415 -415
- package/src/render/style.js +7 -0
- package/types/index.d.ts +10 -3
package/src/core/import-html.js
CHANGED
|
@@ -29,6 +29,22 @@ function onlyChild(el, tag) {
|
|
|
29
29
|
return el.children.length === 1 && el.firstElementChild.tagName === tag ? el.firstElementChild : null;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** Font stacks for equality only: CSSOM and the exporter disagree on quoting (`"DM Sans"` vs `'DM Sans'`), spacing and case, so families are compared with quotes stripped, whitespace collapsed and case folded. Never used as a value -- the original string is what gets stored. */
|
|
33
|
+
function fontKey(v) {
|
|
34
|
+
return String(v || '').replace(/["']/g, '').replace(/\s*,\s*/g, ',').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether `el` (its own style, or any styled descendant) declares a font family different from `family`. The guard that keeps a block-level font read from flattening a mixed-typography run: once a block owns a font, the renderer's overrideRichFont strips every descendant's family, so a run whose pieces genuinely differ must stay font-less at block level and keep its inline declarations instead. */
|
|
38
|
+
function mixedFamily(el, family) {
|
|
39
|
+
const key = fontKey(family);
|
|
40
|
+
const off = (v) => { const k = fontKey(v); return !!k && k !== key; };
|
|
41
|
+
if (el.style && off(el.style.fontFamily)) return true;
|
|
42
|
+
return !!el.querySelectorAll && Array.from(el.querySelectorAll('[style*="font-family"]')).some((d) => d.style && off(d.style.fontFamily));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The heading block's Condensed style ships as this exact stack (render/block-body.js); read back, it becomes the `font: 'condensed'` toggle again rather than an opaque per-block family. */
|
|
46
|
+
const CONDENSED_KEY = fontKey("'Arial Narrow', 'Helvetica Neue Condensed', Helvetica, Arial, sans-serif");
|
|
47
|
+
|
|
32
48
|
function textAlignOf(el) {
|
|
33
49
|
let n = el;
|
|
34
50
|
while (n && n.nodeType === 1) {
|
|
@@ -135,14 +151,24 @@ function bgImageOf(el) {
|
|
|
135
151
|
return (el.getAttribute && el.getAttribute('background')) || '';
|
|
136
152
|
}
|
|
137
153
|
|
|
154
|
+
/** The exporter's own overlay idiom, read back: a section image ships as `linear-gradient(rgba(20,22,24,a),rgba(20,22,24,a)),url(...)` (core/export.js), and the alpha is the row's `overlay` percentage. Only that exact neutral-dark signature is folded back -- a foreign gradient says nothing about MailCraft's tint and stays out of the model, exactly as before. Without this the tint silently vanished on every save/reload while the photo survived. */
|
|
155
|
+
function overlayOf(el) {
|
|
156
|
+
const st = el.style;
|
|
157
|
+
if (!st) return 0;
|
|
158
|
+
const m = ((st.backgroundImage || '') + ' ' + (st.background || '')).match(/linear-gradient\(rgba\(20,\s*22,\s*24,\s*(0?\.\d+|1|0)\s*\)/);
|
|
159
|
+
return m ? Math.round(parseFloat(m[1]) * 100) : 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
138
162
|
/** Carries a wrapper's background image (hero photo sections) onto the rows it produced, mirroring applyBg -- fit/position/repeat come along when declared. */
|
|
139
163
|
function applyBgImage(rows, el) {
|
|
140
164
|
const url = bgImageOf(el);
|
|
141
165
|
if (!url) return rows;
|
|
142
166
|
const st = el.style || {};
|
|
167
|
+
const ov = overlayOf(el);
|
|
143
168
|
rows.forEach((r) => {
|
|
144
169
|
if (r.props.bgImage) return;
|
|
145
170
|
r.props.bgImage = url;
|
|
171
|
+
if (ov) r.props.overlay = ov;
|
|
146
172
|
if (st.backgroundSize) r.props.bgSize = st.backgroundSize;
|
|
147
173
|
if (st.backgroundPosition) r.props.bgPos = st.backgroundPosition;
|
|
148
174
|
if (st.backgroundRepeat) r.props.bgRepeat = st.backgroundRepeat;
|
|
@@ -212,12 +238,27 @@ function lineHeightRatio(raw, fontPx) {
|
|
|
212
238
|
let ratio;
|
|
213
239
|
if (raw.endsWith('%')) ratio = PX(raw) / 100;
|
|
214
240
|
else if (raw.endsWith('em')) ratio = PX(raw); // already a multiplier of the font size, by definition
|
|
215
|
-
else if (raw.endsWith('pt')) ratio = (PX(raw) * 4 / 3
|
|
216
|
-
else if (raw.endsWith('px')) ratio = PX(raw)
|
|
241
|
+
else if (raw.endsWith('pt')) ratio = snapRatio(PX(raw) * 4 / 3, size);
|
|
242
|
+
else if (raw.endsWith('px')) ratio = snapRatio(PX(raw), size);
|
|
217
243
|
else ratio = parseFloat(raw);
|
|
218
244
|
return Number.isFinite(ratio) && ratio > 0 && ratio <= 4 ? ratio : null;
|
|
219
245
|
}
|
|
220
246
|
|
|
247
|
+
/** The exporter ships line-height as `round(ratio * size)` px (msoHarden needs a length Outlook will honour), so dividing back rarely lands on the ratio the slider actually held -- 1.6 at 16px ships as 26px and returned as 1.625, drifting further every save. Any ratio inside the half-pixel window reproduces the same px on the next export, so the round trip stays converged; the shortest-decimal one in that window is the value the slider can actually hold, i.e. the one the user chose. */
|
|
248
|
+
function snapRatio(px, size) {
|
|
249
|
+
if (!size || !px) return px / (size || 16);
|
|
250
|
+
const r = px / size;
|
|
251
|
+
// Coarsest grid first -- tenths, then the sliders' own 0.05 step, then
|
|
252
|
+
// hundredths -- because several grid values can sit inside the same
|
|
253
|
+
// half-pixel window (1.35 and 1.33 both ship as 36px at 27px type) and the
|
|
254
|
+
// coarser one is the value a slider actually lands on.
|
|
255
|
+
for (const step of [0.1, 0.05, 0.01]) {
|
|
256
|
+
const cand = Math.round(r / step) * step;
|
|
257
|
+
if (Math.abs(cand * size - px) < 0.5) return Math.round(cand * 100) / 100;
|
|
258
|
+
}
|
|
259
|
+
return r;
|
|
260
|
+
}
|
|
261
|
+
|
|
221
262
|
function escapeText(s) {
|
|
222
263
|
const d = document.createElement('div');
|
|
223
264
|
d.textContent = s;
|
|
@@ -246,20 +287,27 @@ function classifyImage(el) {
|
|
|
246
287
|
// <img style="width:100%" width="88">`), and trusting the percent first is
|
|
247
288
|
// exactly what blew an 88px logo up to the full content width.
|
|
248
289
|
const isPct = (v) => String(v || '').endsWith('%');
|
|
290
|
+
// A linked image carries its % width on the anchor, not the img: the
|
|
291
|
+
// renderer sizes the <a> and lets the img fill it (block-body.js explains
|
|
292
|
+
// why), so reading only the img saw `width:100%` and every linked image
|
|
293
|
+
// reloaded at full width.
|
|
294
|
+
const a = img.parentElement && img.parentElement.tagName === 'A' ? img.parentElement : null;
|
|
249
295
|
const pxCandidates = [
|
|
250
296
|
img.style.width, img.getAttribute('width'), img.style.maxWidth,
|
|
297
|
+
a && a.style ? a.style.width : '', a && a.style ? a.style.maxWidth : '',
|
|
251
298
|
el !== img && el.style ? el.style.width : '', el !== img && el.style ? el.style.maxWidth : '',
|
|
252
299
|
];
|
|
253
300
|
const pxHint = pxCandidates.find((v) => v && !isPct(v) && PX(v));
|
|
301
|
+
const pctHint = [a && a.style ? a.style.width : '', img.style.width].find((v) => isPct(v) && PX(v));
|
|
254
302
|
let width = 100;
|
|
255
303
|
if (pxHint) {
|
|
256
304
|
// Convert to % of the nearest fixed-width ancestor.
|
|
257
305
|
const colPx = ancestorPxWidth(img) || 600;
|
|
258
306
|
width = Math.max(2, Math.min(100, Math.round((PX(pxHint) / colPx) * 100)));
|
|
259
|
-
} else if (
|
|
260
|
-
width = PX(
|
|
307
|
+
} else if (pctHint) {
|
|
308
|
+
width = PX(pctHint);
|
|
261
309
|
}
|
|
262
|
-
|
|
310
|
+
const over = {
|
|
263
311
|
src: img.getAttribute('src') || '',
|
|
264
312
|
alt: img.getAttribute('alt') || '',
|
|
265
313
|
href,
|
|
@@ -268,7 +316,14 @@ function classifyImage(el) {
|
|
|
268
316
|
// Always explicit: the block *default* is 10px, which quietly rounded
|
|
269
317
|
// the corners of every imported image that had none.
|
|
270
318
|
radius: PX(img.style.borderRadius) || 0,
|
|
271
|
-
}
|
|
319
|
+
};
|
|
320
|
+
// The block's own spacing lives on the wrapper the exporter writes
|
|
321
|
+
// (`padding: py px`); unread, it reset to 0/0 on every save.
|
|
322
|
+
if (el !== img && el.style) {
|
|
323
|
+
const pd = paddingOf(el.style);
|
|
324
|
+
if (pd) { over.py = pd.py; over.px = pd.px; }
|
|
325
|
+
}
|
|
326
|
+
return blk('image', over);
|
|
272
327
|
}
|
|
273
328
|
|
|
274
329
|
function classifyButton(el) {
|
|
@@ -318,8 +373,11 @@ function classifyButton(el) {
|
|
|
318
373
|
const over = {
|
|
319
374
|
label: (a.textContent || '').trim() || 'Button',
|
|
320
375
|
href: a.getAttribute('href') || '#',
|
|
321
|
-
|
|
322
|
-
|
|
376
|
+
// hexOf throughout: CSSOM serializes every hex the renderer wrote back as
|
|
377
|
+
// `rgb(...)`, and the raw string both fails the inspector's native picker
|
|
378
|
+
// and breaks the theme-equality folds' string comparisons.
|
|
379
|
+
bg: hexOf(st.backgroundColor || st.background || (pill.getAttribute && pill.getAttribute('bgcolor')) || ''),
|
|
380
|
+
color: hexOf(a.style.color || st.color || '#ffffff'),
|
|
323
381
|
radius: radiusOf(st),
|
|
324
382
|
py: (pad && pad.py) || 13,
|
|
325
383
|
px: (pad && pad.px) || 26,
|
|
@@ -337,6 +395,11 @@ function classifyButton(el) {
|
|
|
337
395
|
};
|
|
338
396
|
const size = fontPx(st.fontSize) || fontPx(a.style.fontSize);
|
|
339
397
|
if (size) over.size = size;
|
|
398
|
+
// The label's family sits on the anchor in this exporter's shape, on the
|
|
399
|
+
// pill in most foreign ones. The theme-equality fold in htmlToDoc turns a
|
|
400
|
+
// value that merely restates the document font back into "inherit".
|
|
401
|
+
const family = a.style.fontFamily || st.fontFamily || inheritedStyle(a, 'fontFamily');
|
|
402
|
+
if (family) over.fontFamily = family;
|
|
340
403
|
// Outline buttons: transparent fill, the pill drawn by its border. The
|
|
341
404
|
// border is looked for on the cell as well as the pill, because in the
|
|
342
405
|
// one-cell shape the paint and the frame are on different elements -- the
|
|
@@ -353,9 +416,15 @@ const SOCIAL_HOSTS = /facebook|twitter|x\.com|instagram|linkedin|youtube|tiktok|
|
|
|
353
416
|
/** A run of small image-links, mostly pointing at social networks (every footer's icon strip), becomes a native social block -- MailCraft draws its own icon art from the network name (alt text, or the link's hostname). Imported as loose images they'd neither line up nor be editable as a set. */
|
|
354
417
|
function classifySocial(el) {
|
|
355
418
|
if (!/^(DIV|TD|TABLE)$/.test(el.tagName)) return null;
|
|
356
|
-
if ((el.textContent || '').trim()) return null;
|
|
357
419
|
const anchors = Array.from(el.querySelectorAll('a'));
|
|
358
420
|
if (anchors.length < 2) return null;
|
|
421
|
+
// Text is disqualifying only when it is *prose around* the icons. A strip
|
|
422
|
+
// with "Show network names" on carries each network's name in a span inside
|
|
423
|
+
// its anchor -- all of the wrapper's text -- and rejecting that shape sent
|
|
424
|
+
// the block to classifyMenu, which reloaded it as a menu of links.
|
|
425
|
+
const squash = (s) => String(s || '').replace(/\s+/g, '');
|
|
426
|
+
const ownText = squash(el.textContent);
|
|
427
|
+
if (ownText && ownText !== squash(anchors.map((a) => a.textContent).join(''))) return null;
|
|
359
428
|
// `svg` alongside `img`: MailCraft's own social block renders inline-SVG
|
|
360
429
|
// icons, so accepting both is what lets an exported strip round-trip back
|
|
361
430
|
// into a social block instead of dissolving.
|
|
@@ -385,15 +454,37 @@ function classifySocial(el) {
|
|
|
385
454
|
// closest match to how such strips actually look. The block *default*
|
|
386
455
|
// (outlined boxes at 1.9x the icon size) both looked wrong and wrapped in
|
|
387
456
|
// narrow footer columns.
|
|
388
|
-
|
|
457
|
+
// `background:transparent` is what the renderer writes on every NON-badge
|
|
458
|
+
// anchor -- it is the absence of a fill, not a fill. Reading it as painted
|
|
459
|
+
// flipped every Outline/Bare strip into filled Square badges on reload.
|
|
460
|
+
const paintOf = (a) => {
|
|
461
|
+
const v = (a.style && (a.style.backgroundColor || a.style.background)) || '';
|
|
462
|
+
return v === 'transparent' || /^rgba\(0,\s*0,\s*0,\s*0\)/.test(v) ? '' : v;
|
|
463
|
+
};
|
|
464
|
+
const aBg = paintOf(a0);
|
|
389
465
|
const aBorder = a0.style ? PX(a0.style.borderWidth) || PX(a0.style.borderTopWidth) : 0;
|
|
390
466
|
over.shape = aBg ? (String(a0.style.borderRadius || '').indexOf('50%') > -1 ? 'circle' : 'square') : (aBorder ? 'outline' : 'bare');
|
|
391
|
-
|
|
392
|
-
|
|
467
|
+
if (ownText) over.showLabel = true;
|
|
468
|
+
const label0 = a0.querySelector('span');
|
|
469
|
+
if (label0 && label0.style.fontFamily) over.fontFamily = label0.style.fontFamily;
|
|
470
|
+
// The block's one color: a badge's is its FILL (the anchor's text color is
|
|
471
|
+
// the auto-contrast ink painted over it -- reading that turned a blue badge
|
|
472
|
+
// strip white); outline/bare's is the icon color. Anchors that disagree
|
|
473
|
+
// with each other are the brand palette -- the custom palette paints every
|
|
474
|
+
// anchor alike, so per-network colors can only mean per-network brands.
|
|
475
|
+
const chromaOf = (a) => hexOf(aBg ? paintOf(a) : (a.style && a.style.color) || '');
|
|
476
|
+
const chroma = anchors.map(chromaOf).filter(Boolean);
|
|
477
|
+
if (chroma.length && chroma.some((c) => c !== chroma[0])) over.palette = 'brand';
|
|
478
|
+
else if (chroma[0]) over.color = chroma[0];
|
|
479
|
+
else over.palette = 'brand';
|
|
393
480
|
// Icon spacing: inter-cell padding on image strips, anchor margins on
|
|
394
|
-
// exported ones.
|
|
481
|
+
// exported ones. A cell only describes ICON spacing when it holds exactly
|
|
482
|
+
// one icon (the one-td-per-icon strip); a single cell around the whole
|
|
483
|
+
// strip is layout -- its padding is the row's gutter, and reading it here
|
|
484
|
+
// overwrote the block's own 18px with the row's 20 on every reload.
|
|
395
485
|
const cell = a0.closest ? a0.closest('td') : null;
|
|
396
|
-
const cellGap = cell && cell !== el && el.contains(cell)
|
|
486
|
+
const cellGap = cell && cell !== el && el.contains(cell) && cell.querySelectorAll('a').length === 1
|
|
487
|
+
? PX(cell.style.paddingRight) + PX(cell.style.paddingLeft) : 0;
|
|
397
488
|
const marginGap = a0.style ? PX(a0.style.marginRight) + PX(a0.style.marginLeft) : 0;
|
|
398
489
|
const gap = cellGap || marginGap;
|
|
399
490
|
if (gap) over.gap = gap;
|
|
@@ -403,7 +494,9 @@ function classifySocial(el) {
|
|
|
403
494
|
/** A run of two or more sibling links with nothing else in the container (Beefree/MJML nav bars) is a menu block, not a text run -- as text, each link imports mid-paragraph with the wrapper's junk around it. */
|
|
404
495
|
function classifyMenu(el) {
|
|
405
496
|
if (el.tagName !== 'DIV' && el.tagName !== 'TD') return null;
|
|
406
|
-
|
|
497
|
+
// `svg` in the blocker list: a labeled social strip is anchors of svg+span,
|
|
498
|
+
// and without it this classifier claimed the strip as a menu of links.
|
|
499
|
+
if (el.querySelector('img,svg,table,div,p,input,h1,h2,h3,h4,h5,h6')) return null;
|
|
407
500
|
const anchors = Array.from(el.children).filter((c) => c.tagName === 'A');
|
|
408
501
|
if (anchors.length < 2) return null;
|
|
409
502
|
const linkText = anchors.map((a) => (a.textContent || '')).join('').replace(/\s+/g, '');
|
|
@@ -415,7 +508,13 @@ function classifyMenu(el) {
|
|
|
415
508
|
const size = fontPx(inheritedStyle(anchors[0], 'fontSize'));
|
|
416
509
|
if (size) over.size = size;
|
|
417
510
|
const color = inheritedStyle(anchors[0], 'color');
|
|
418
|
-
if (color) over.color = color;
|
|
511
|
+
if (color) over.color = hexOf(color);
|
|
512
|
+
const family = inheritedStyle(anchors[0], 'fontFamily');
|
|
513
|
+
if (family) over.fontFamily = family;
|
|
514
|
+
// Item spacing ships as symmetric horizontal margins on each anchor
|
|
515
|
+
// (`margin: 0 gap/2`); unread, a chosen gap snapped back to the default.
|
|
516
|
+
const mg = PX(anchors[0].style.marginLeft) + PX(anchors[0].style.marginRight);
|
|
517
|
+
if (mg) over.gap = mg;
|
|
419
518
|
return blk('menu', over);
|
|
420
519
|
}
|
|
421
520
|
|
|
@@ -425,7 +524,7 @@ function classifyDivider(el) {
|
|
|
425
524
|
return blk('divider', {
|
|
426
525
|
thickness: PX(el.style.height) || PX(el.style.borderTopWidth) || 1,
|
|
427
526
|
lineStyle: borderStyleOf(el.style),
|
|
428
|
-
color: el.style.backgroundColor || el.style.borderTopColor || el.style.borderColor || '#d9dade',
|
|
527
|
+
color: hexOf(el.style.backgroundColor || el.style.borderTopColor || el.style.borderColor) || '#d9dade',
|
|
429
528
|
width: sw.endsWith('%') ? PX(sw) : 100,
|
|
430
529
|
});
|
|
431
530
|
}
|
|
@@ -436,7 +535,12 @@ function classifyDivider(el) {
|
|
|
436
535
|
const bg = bar.style.backgroundColor || bar.style.background || bar.style.borderTopColor;
|
|
437
536
|
if (!(h > 0 && h <= 10 && bg)) return null;
|
|
438
537
|
const sw = bar.style.width || '';
|
|
439
|
-
|
|
538
|
+
const over = { thickness: h, lineStyle: borderStyleOf(bar.style), color: hexOf(bg), width: sw.endsWith('%') ? PX(sw) : 100 };
|
|
539
|
+
// The wrapper's vertical padding is the block's own spacing (the exporter
|
|
540
|
+
// writes `padding: py 0` around the rule); a declared zero counts too, so
|
|
541
|
+
// a tightened divider does not spring back to the 14px default on reload.
|
|
542
|
+
if (bar !== el && el.style && el.style.paddingTop !== '') over.py = PX(el.style.paddingTop);
|
|
543
|
+
return blk('divider', over);
|
|
440
544
|
}
|
|
441
545
|
|
|
442
546
|
function classifySpacer(el) {
|
|
@@ -462,6 +566,23 @@ function classifyHeading(el) {
|
|
|
462
566
|
const color = st.color || inheritedStyle(el.parentElement, 'color');
|
|
463
567
|
if (color) over.color = hexOf(color);
|
|
464
568
|
if (st.fontWeight) over.weight = st.fontWeight;
|
|
569
|
+
// The family, like color, may live on the section cell. The renderer's own
|
|
570
|
+
// Condensed stack folds back into the style toggle it came from; any other
|
|
571
|
+
// family is a per-block font (the theme-equality fold in htmlToDoc turns a
|
|
572
|
+
// restated document font back into "inherit").
|
|
573
|
+
const family = st.fontFamily || inheritedStyle(el.parentElement, 'fontFamily');
|
|
574
|
+
if (fontKey(family) === CONDENSED_KEY) over.font = 'condensed';
|
|
575
|
+
else if (family) over.fontFamily = family;
|
|
576
|
+
// Line spacing and the block's own padding, read the way size/color are --
|
|
577
|
+
// both have inspector sliders, and both silently reset to the defaults
|
|
578
|
+
// (1.12, 8/0) on every save before this.
|
|
579
|
+
const lh = lineHeightRatio(st.lineHeight || inheritedStyle(el.parentElement, 'lineHeight'), size);
|
|
580
|
+
if (lh) over.lh = lh;
|
|
581
|
+
const pd = paddingOf(st);
|
|
582
|
+
if (pd) { over.py = pd.py; over.px = pd.px; }
|
|
583
|
+
// A declared all-zero padding is a choice (paddingOf returns null for it);
|
|
584
|
+
// absence is what falls to the 8px default.
|
|
585
|
+
else if (st.paddingTop !== '') { over.py = 0; over.px = 0; }
|
|
465
586
|
return blk('heading', over);
|
|
466
587
|
}
|
|
467
588
|
|
|
@@ -473,10 +594,34 @@ function classifyList(el) {
|
|
|
473
594
|
// and the export while every other path stripped them. The newline swap is
|
|
474
595
|
// because `items` is a one-fragment-per-line string -- a linebreak inside a
|
|
475
596
|
// source <li> would otherwise split it into two items on render.
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
597
|
+
const lis = Array.from(el.children).filter((c) => c.tagName === 'LI');
|
|
598
|
+
if (!lis.length) return null;
|
|
599
|
+
const over = { ordered: el.tagName === 'OL' };
|
|
600
|
+
// The list's own typography lives on the UL/OL (this exporter stamps it
|
|
601
|
+
// there; table emails may hang it on the cell above). Unread, a styled
|
|
602
|
+
// list reset to the 15px default ink on every save/reload.
|
|
603
|
+
const st = el.style;
|
|
604
|
+
const size = fontPx(st.fontSize) || fontPx(inheritedStyle(el.parentElement, 'fontSize'));
|
|
605
|
+
if (size) over.size = size;
|
|
606
|
+
const color = st.color || inheritedStyle(el.parentElement, 'color');
|
|
607
|
+
if (color) over.color = hexOf(color);
|
|
608
|
+
const lh = lineHeightRatio(st.lineHeight || inheritedStyle(el.parentElement, 'lineHeight'), size);
|
|
609
|
+
if (lh) over.lh = lh;
|
|
610
|
+
// Same mixed-run guard as a text block: a block-level family makes the
|
|
611
|
+
// renderer strip descendant families, so it is only claimed when no item
|
|
612
|
+
// declares a different one -- and a claimed family folds the items' own
|
|
613
|
+
// restated declarations away (same byte-stability reasoning as flush).
|
|
614
|
+
const family = st.fontFamily || inheritedStyle(el.parentElement, 'fontFamily');
|
|
615
|
+
if (family && !mixedFamily(el, family)) over.fontFamily = family;
|
|
616
|
+
over.items = lis
|
|
617
|
+
.map((li) => cleanImportHtml(li.innerHTML, over.fontFamily ? ['font-family'] : null).replace(/\n/g, ' '))
|
|
618
|
+
.join('\n');
|
|
619
|
+
// Declared zeros count: `padding-top:0` / `margin-bottom:0` are choices,
|
|
620
|
+
// absence is what falls to the defaults.
|
|
621
|
+
if (st.paddingTop !== '') over.py = PX(st.paddingTop);
|
|
622
|
+
const li0 = el.querySelector(':scope > li');
|
|
623
|
+
if (li0 && li0.style.marginBottom !== '') over.gap = PX(li0.style.marginBottom);
|
|
624
|
+
return blk('list', over);
|
|
480
625
|
}
|
|
481
626
|
|
|
482
627
|
function classifyTable(el) {
|
|
@@ -492,14 +637,98 @@ function classifyTable(el) {
|
|
|
492
637
|
const cellStyle = firstCell && firstCell.style;
|
|
493
638
|
const borderWidth = cellStyle ? borderSidesOf(cellStyle).width : 0;
|
|
494
639
|
const borders = !!borderWidth;
|
|
495
|
-
|
|
640
|
+
const over = {
|
|
496
641
|
data, header, borders, borderWidth: borderWidth || 1,
|
|
497
642
|
borderStyle: cellStyle ? borderStyleOf(cellStyle) : 'solid',
|
|
498
643
|
lineColor: cellStyle ? (borderColorOf(cellStyle) || '#e2e8f0') : '#e2e8f0',
|
|
499
|
-
}
|
|
644
|
+
};
|
|
645
|
+
// Presentation the renderer writes on the table and its cells, read back so
|
|
646
|
+
// a styled table stops resetting to the stock look on every save/reload.
|
|
647
|
+
const st = el.style;
|
|
648
|
+
const size = fontPx(st.fontSize);
|
|
649
|
+
if (size) over.size = size;
|
|
650
|
+
if (st.fontFamily) over.fontFamily = st.fontFamily;
|
|
651
|
+
const w = st.width || el.getAttribute('width') || '';
|
|
652
|
+
if (String(w).endsWith('%') && PX(w)) over.width = PX(w);
|
|
653
|
+
if (cellStyle) {
|
|
654
|
+
// The renderer's cell padding is `pad` on top, `pad*1.2` at the sides --
|
|
655
|
+
// the top edge is the stored prop. A declared zero is a choice.
|
|
656
|
+
if (cellStyle.paddingTop !== '') over.pad = PX(cellStyle.paddingTop);
|
|
657
|
+
if (cellStyle.textAlign) over.align = cellStyle.textAlign;
|
|
658
|
+
}
|
|
659
|
+
if (header) {
|
|
660
|
+
const hb = bgOf(rows[0]) || bgOf(firstCell);
|
|
661
|
+
if (hb && hb !== 'transparent') over.headBg = hb;
|
|
662
|
+
}
|
|
663
|
+
// Striping is claimed only from this renderer's own signature tint; with
|
|
664
|
+
// enough body rows to tell, its absence means the source is not striped --
|
|
665
|
+
// the old behaviour striped every imported table by default.
|
|
666
|
+
const stripeAt = header ? 2 : 0;
|
|
667
|
+
if (rows.length > stripeAt) {
|
|
668
|
+
over.striped = rows.some((r, i) => !(header && i === 0) && i % 2 === 0
|
|
669
|
+
&& /rgba\(29,\s*31,\s*32/.test((r.style && (r.style.backgroundColor || r.style.background)) || ''));
|
|
670
|
+
}
|
|
671
|
+
return blk('table', over);
|
|
500
672
|
}
|
|
501
673
|
|
|
502
|
-
|
|
674
|
+
/** The svg block's export shape (core/export.js writes it directly, not via grab): a div whose only element child is an inline <svg> -- optionally through the width-carrying span the canvas draws -- and whose text is nothing but the svg's own. Read back as the block it was, code verbatim. Previously the sanitizer's whitelist had no SVG tags, so the run flushed empty and the block -- the whole row, when it stood alone -- silently vanished on every save. A bare <svg> at content level gets the same treatment. */
|
|
675
|
+
function classifySvg(el) {
|
|
676
|
+
const isSvg = (e) => !!e && String(e.tagName).toLowerCase() === 'svg';
|
|
677
|
+
const over = {};
|
|
678
|
+
let holder = el;
|
|
679
|
+
if (el.tagName === 'DIV' && el.children.length === 1) {
|
|
680
|
+
if (el.style.textAlign) over.align = el.style.textAlign;
|
|
681
|
+
if (el.style.paddingTop !== '') over.py = PX(el.style.paddingTop);
|
|
682
|
+
holder = el.firstElementChild;
|
|
683
|
+
if (holder.tagName === 'SPAN' && holder.children.length === 1 && isSvg(holder.firstElementChild)) {
|
|
684
|
+
const w = holder.style.width || '';
|
|
685
|
+
if (w.endsWith('%') && PX(w)) over.width = PX(w);
|
|
686
|
+
holder = holder.firstElementChild;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (!isSvg(holder)) return null;
|
|
690
|
+
// Prose around the drawing means a text run with an svg glyph in it, not
|
|
691
|
+
// an svg block -- only the svg's own text (<text> labels etc.) may appear.
|
|
692
|
+
if ((el.textContent || '').trim() !== (holder.textContent || '').trim()) return null;
|
|
693
|
+
over.code = holder.outerHTML;
|
|
694
|
+
return blk('svg', over);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Fidelity markers (core/export.js): the few blocks whose rendered shape
|
|
699
|
+
* cannot be read back -- a countdown bakes its digits, a video is a linked
|
|
700
|
+
* image, a section box and a code sample are styled divs like any other, a
|
|
701
|
+
* raw-CSS block is a bare <style> -- ship `data-mc` (type) and `data-mcp`
|
|
702
|
+
* (the props the DOM itself does not carry). Trusted when present, with the
|
|
703
|
+
* content halves still read from the DOM and sanitized exactly like any
|
|
704
|
+
* import: a payload is data about a block, never markup to inject.
|
|
705
|
+
*/
|
|
706
|
+
const MARKER_TYPES = { countdown: 1, video: 1, box: 1, codeblock: 1 };
|
|
707
|
+
function markerBlock(el) {
|
|
708
|
+
if (!el.getAttribute) return null;
|
|
709
|
+
const type = el.getAttribute('data-mc') || '';
|
|
710
|
+
if (type === 'css' && el.tagName === 'STYLE') {
|
|
711
|
+
const over = { code: el.textContent || '' };
|
|
712
|
+
const note = el.getAttribute('data-mcn');
|
|
713
|
+
if (note) over.note = note;
|
|
714
|
+
return blk('css', over);
|
|
715
|
+
}
|
|
716
|
+
if (!MARKER_TYPES[type]) return null;
|
|
717
|
+
let props = {};
|
|
718
|
+
try { props = JSON.parse(el.getAttribute('data-mcp') || '{}') || {}; } catch { props = {}; }
|
|
719
|
+
delete props.html; delete props.code; // content only ever comes from the DOM, sanitized
|
|
720
|
+
const inner = el.firstElementChild;
|
|
721
|
+
if (type === 'box') {
|
|
722
|
+
// The box's own display/margin pair is vouched for: its template writes
|
|
723
|
+
// `<strong style="display:block;margin-bottom:6px">` and the shared
|
|
724
|
+
// whitelist rightly refuses those from arbitrary paste.
|
|
725
|
+
props.html = cleanImportHtml(inner ? inner.innerHTML : '', null, ['display', 'margin', 'margin-top', 'margin-bottom']);
|
|
726
|
+
}
|
|
727
|
+
if (type === 'codeblock') props.code = (inner ? inner.textContent : el.textContent) || '';
|
|
728
|
+
return blk(type, props);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const CLASSIFIERS = [classifyImage, classifyButton, classifySocial, classifyMenu, classifyDivider, classifySpacer, classifySvg, classifyHeading, classifyList, classifyTable];
|
|
503
732
|
|
|
504
733
|
function classifyNode(el) {
|
|
505
734
|
for (const fn of CLASSIFIERS) {
|
|
@@ -524,7 +753,7 @@ function isStructural(el) {
|
|
|
524
753
|
return /^(TABLE|FORM|IFRAME|SCRIPT|STYLE|VIDEO|OBJECT|EMBED)$/.test(el.tagName) || !!el.querySelector('table,form,iframe,script,video,object,embed');
|
|
525
754
|
}
|
|
526
755
|
|
|
527
|
-
/** `core/export.js` wraps every block in a column with `<div style="{boxCss(b.props)}">`, which
|
|
756
|
+
/** `core/export.js` wraps every block in a column with `<div style="{boxCss(b.props)}">`, which resolves to a bare `<div style="margin:0">` for any block whose "Box & border" panel is untouched -- a see-through spacing wrapper, not real content. Unwraps that one level so the classifiers see the block's own signature div directly; leaves any div that carries other styling alone, since that's either a set box (read back by `boxPropsOf`) or an intentionally-styled container someone pasted in -- real content, not framework wrapper. */
|
|
528
757
|
function unwrapBoxDiv(el) {
|
|
529
758
|
if (el.tagName !== 'DIV' || el.children.length !== 1) return el;
|
|
530
759
|
// A device-visibility wrapper is framework too, and the declarations that
|
|
@@ -571,6 +800,48 @@ function logicMarkersOf(text) {
|
|
|
571
800
|
return out;
|
|
572
801
|
}
|
|
573
802
|
|
|
803
|
+
/**
|
|
804
|
+
* The container styling of a `<div>` that paints a panel around its content.
|
|
805
|
+
*
|
|
806
|
+
* `cleanImportHtml`'s tag whitelist has no DIV (it never could have one --
|
|
807
|
+
* arbitrary paste brings div soup), so a `<div
|
|
808
|
+
* style="background-color:#eff6fc;border-radius:8px">` wrapping a run of
|
|
809
|
+
* text -- the verification-code panel every transactional template has --
|
|
810
|
+
* was flattened away on import and the box came back unpainted on the very
|
|
811
|
+
* first save. Read here instead, into the exact props `boxCss`/`boxStyle`
|
|
812
|
+
* already write for a block's own box, so the shape round-trips and the
|
|
813
|
+
* color lands under the inspector's "Box & border" controls where it can be
|
|
814
|
+
* edited.
|
|
815
|
+
*
|
|
816
|
+
* A color or a border is what makes a div a panel; a radius alone paints
|
|
817
|
+
* nothing, so it is picked up alongside but never claimed on its own.
|
|
818
|
+
* Padding is deliberately not read: it already reaches the block as its
|
|
819
|
+
* py/px run padding, which keeps the source's asymmetry (`18px 24px`) that
|
|
820
|
+
* the single-value `bPad` cannot express.
|
|
821
|
+
*/
|
|
822
|
+
function boxPropsOf(el) {
|
|
823
|
+
if (!el || el.tagName !== 'DIV' || !el.style) return null;
|
|
824
|
+
const out = {};
|
|
825
|
+
const bg = bgOf(el);
|
|
826
|
+
// `background:transparent` is what the exporter writes on every styled
|
|
827
|
+
// column wrapper, and CSSOM hands the keyword back as `rgba(0, 0, 0, 0)`;
|
|
828
|
+
// claiming either as a box color paints nothing and only makes the run
|
|
829
|
+
// look like a panel.
|
|
830
|
+
if (bg && bg !== 'transparent' && !/^rgba\([^)]*,\s*0\s*\)$/.test(bg)) out.bBg = bg;
|
|
831
|
+
const frame = borderSidesOf(el.style);
|
|
832
|
+
if (frame.width) {
|
|
833
|
+
out.bBorder = frame.width;
|
|
834
|
+
out.bStyle = borderStyleOf(el.style);
|
|
835
|
+
out.bLine = borderColorOf(el.style) || '#e2e2e5';
|
|
836
|
+
out.bTop = frame.sides.top > 0; out.bRight = frame.sides.right > 0;
|
|
837
|
+
out.bBottom = frame.sides.bottom > 0; out.bLeft = frame.sides.left > 0;
|
|
838
|
+
}
|
|
839
|
+
if (!out.bBg && !out.bBorder) return null;
|
|
840
|
+
const radius = radiusOf(el.style);
|
|
841
|
+
if (radius) out.bRadius = radius;
|
|
842
|
+
return out;
|
|
843
|
+
}
|
|
844
|
+
|
|
574
845
|
/** 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. */
|
|
575
846
|
function hasOwnRunPad(el) {
|
|
576
847
|
let e = el;
|
|
@@ -590,14 +861,34 @@ function blocksFromNodes(nodes) {
|
|
|
590
861
|
const out = [];
|
|
591
862
|
let buf = [];
|
|
592
863
|
let bufFirstEl = null;
|
|
864
|
+
// Every element buffered into the current run, for the mixed-family check
|
|
865
|
+
// below: the first element alone cannot say whether a *later* piece of the
|
|
866
|
+
// run declares its own font.
|
|
867
|
+
let bufEls = [];
|
|
868
|
+
// The run's device visibility: the class sits on the box wrapper the
|
|
869
|
+
// exporter puts around a text block (never inside the sanitized html, which
|
|
870
|
+
// strips classes), so only this walk can carry it. `undefined` = not seen
|
|
871
|
+
// yet; '' = pieces disagreed, and a mixed run claims nothing rather than
|
|
872
|
+
// hide half its content along with the other half.
|
|
873
|
+
let bufVis;
|
|
874
|
+
const visUp = (start) => {
|
|
875
|
+
let e = start;
|
|
876
|
+
for (let i = 0; e && i < 4; i += 1) { const v = visibilityOf(e); if (v) return v; e = e.parentElement; }
|
|
877
|
+
return '';
|
|
878
|
+
};
|
|
593
879
|
// The parent of a leading bare text node. A run with no element of its own
|
|
594
880
|
// (`<td style="font-size:30px;...">{{Code}}</td>`) still has real
|
|
595
881
|
// typography -- it lives on the ancestors, and with no bufFirstEl the
|
|
596
882
|
// inheritedStyle reads below were skipped wholesale, so the run imported
|
|
597
883
|
// at the theme default (a 30px/800 verification code became 16px plain).
|
|
598
884
|
let bufTextEl = null;
|
|
885
|
+
// The container styling of the run's own wrapper (see `boxPropsOf`), kept
|
|
886
|
+
// out of the html because the sanitizer's whitelist has no DIV to hang it
|
|
887
|
+
// on.
|
|
888
|
+
let bufBox = null;
|
|
599
889
|
const flush = () => {
|
|
600
|
-
const
|
|
890
|
+
const raw = buf.join('');
|
|
891
|
+
const html = cleanImportHtml(raw);
|
|
601
892
|
// A run with nothing visible in it -- no text beyond whitespace/hair
|
|
602
893
|
// spaces, no image or line break (an `<nbsp;>` still counts as a blank
|
|
603
894
|
// line someone wrote) -- is markup residue (`<p style="margin:0"></p>`),
|
|
@@ -613,6 +904,10 @@ function blocksFromNodes(nodes) {
|
|
|
613
904
|
const weight = inheritedStyle(bufTextEl, 'fontWeight'); if (weight) over.weight = weight;
|
|
614
905
|
const lh = lineHeightRatio(inheritedStyle(bufTextEl, 'lineHeight'), size);
|
|
615
906
|
if (lh) over.lh = lh;
|
|
907
|
+
// A bare run has no inline tags to carry a family, so the ancestor's
|
|
908
|
+
// is the block's -- no mixed-run risk here.
|
|
909
|
+
const family = inheritedStyle(bufTextEl, 'fontFamily');
|
|
910
|
+
if (family) over.fontFamily = family;
|
|
616
911
|
}
|
|
617
912
|
if (bufFirstEl) {
|
|
618
913
|
// The block's *base* style: the first buffered element when it's a
|
|
@@ -627,6 +922,7 @@ function blocksFromNodes(nodes) {
|
|
|
627
922
|
// (it lands on the <p>), so a padded block came back at the default
|
|
628
923
|
// 10px/0 on every save. First padding on the wrapper chain wins.
|
|
629
924
|
let runPad = paddingOf(baseEl.style);
|
|
925
|
+
let padDeclared = !!baseEl.style && baseEl.style.paddingTop !== '';
|
|
630
926
|
// Then descend through transparent single-child wrappers: builders
|
|
631
927
|
// nest a `font-family:sans-serif` shim div around the div that
|
|
632
928
|
// carries the real typography, and `inheritedStyle` below walks *up*
|
|
@@ -636,18 +932,45 @@ function blocksFromNodes(nodes) {
|
|
|
636
932
|
baseEl.children.length === 1
|
|
637
933
|
&& !INLINE_TAGS.test(baseEl.firstElementChild.tagName)
|
|
638
934
|
&& (baseEl.textContent || '') === (baseEl.firstElementChild.textContent || '')
|
|
639
|
-
) { baseEl = baseEl.firstElementChild; if (!runPad) runPad = paddingOf(baseEl.style); }
|
|
935
|
+
) { baseEl = baseEl.firstElementChild; if (!runPad) runPad = paddingOf(baseEl.style); padDeclared = padDeclared || baseEl.style.paddingTop !== ''; }
|
|
640
936
|
if (runPad) { over.py = runPad.py; over.px = runPad.px; }
|
|
937
|
+
// A declared all-zero padding is a choice (paddingOf returns null for
|
|
938
|
+
// it); absence is what falls to the 10px default.
|
|
939
|
+
else if (padDeclared) { over.py = 0; over.px = 0; }
|
|
641
940
|
const size = fontPx(inheritedStyle(baseEl, 'fontSize')); if (size) over.size = size;
|
|
642
941
|
const color = inheritedStyle(baseEl, 'color'); if (color) over.color = hexOf(color);
|
|
643
942
|
over.align = textAlignOf(baseEl);
|
|
644
943
|
const weight = inheritedStyle(baseEl, 'fontWeight'); if (weight) over.weight = weight;
|
|
645
944
|
const lh = lineHeightRatio(inheritedStyle(baseEl, 'lineHeight'), size);
|
|
646
945
|
if (lh) over.lh = lh;
|
|
946
|
+
// The run's base family, read exactly like size/color -- but claimed
|
|
947
|
+
// at block level only when no piece of the run declares a different
|
|
948
|
+
// one, since the renderer strips descendant families the moment the
|
|
949
|
+
// block owns a font (overrideRichFont). A mixed run keeps its inline
|
|
950
|
+
// declarations instead, exactly as before. The theme-equality fold in
|
|
951
|
+
// htmlToDoc turns a restated document font back into "inherit".
|
|
952
|
+
const family = inheritedStyle(baseEl, 'fontFamily');
|
|
953
|
+
if (family && !bufEls.some((e) => mixedFamily(e, family))) {
|
|
954
|
+
over.fontFamily = family;
|
|
955
|
+
// Claimed means consumed: every family in the run restates the one
|
|
956
|
+
// just read (the mixed check above), so the inline declarations
|
|
957
|
+
// fold into the block prop instead of shipping twice. This is also
|
|
958
|
+
// what keeps export -> import -> export byte-stable -- left in the
|
|
959
|
+
// html, the renderer strips them from the live DOM (overrideRichFont)
|
|
960
|
+
// and the re-serialized attributes drift on the next save.
|
|
961
|
+
over.html = cleanImportHtml(raw, ['font-family']);
|
|
962
|
+
}
|
|
647
963
|
}
|
|
964
|
+
// Same walk classifyNode does for recognized blocks; text runs never
|
|
965
|
+
// went through it, so a mobile-only paragraph reloaded visible
|
|
966
|
+
// everywhere.
|
|
967
|
+
if (bufVis) over.vis = bufVis;
|
|
968
|
+
// The wrapper's own box, last: these are block-level props, so nothing
|
|
969
|
+
// read off the content above can collide with them.
|
|
970
|
+
if (bufBox) Object.assign(over, bufBox);
|
|
648
971
|
out.push(blk('text', over));
|
|
649
972
|
}
|
|
650
|
-
buf = []; bufFirstEl = null; bufTextEl = null;
|
|
973
|
+
buf = []; bufFirstEl = null; bufTextEl = null; bufEls = []; bufVis = undefined; bufBox = null;
|
|
651
974
|
};
|
|
652
975
|
nodes.forEach((n) => {
|
|
653
976
|
if (n.nodeType === 3) {
|
|
@@ -655,12 +978,22 @@ function blocksFromNodes(nodes) {
|
|
|
655
978
|
if (markers) { flush(); markers.forEach((mb) => out.push(mb)); return; }
|
|
656
979
|
if (n.textContent && n.textContent.trim()) {
|
|
657
980
|
if (!bufFirstEl && !bufTextEl) bufTextEl = n.parentElement;
|
|
981
|
+
const tv = visUp(n.parentElement);
|
|
982
|
+
bufVis = bufVis === undefined || bufVis === tv ? tv : '';
|
|
658
983
|
buf.push(escapeText(n.textContent));
|
|
659
984
|
}
|
|
660
985
|
return;
|
|
661
986
|
}
|
|
662
987
|
if (n.nodeType !== 1) return;
|
|
663
988
|
if (isHidden(n)) return;
|
|
989
|
+
const marked = markerBlock(n);
|
|
990
|
+
if (marked) {
|
|
991
|
+
flush();
|
|
992
|
+
const mv = visUp(n);
|
|
993
|
+
if (mv) marked.props.vis = mv;
|
|
994
|
+
out.push(marked);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
664
997
|
const target = unwrapBoxDiv(n);
|
|
665
998
|
const b = classifyNode(target);
|
|
666
999
|
if (b) { flush(); out.push(b); return; }
|
|
@@ -690,10 +1023,29 @@ function blocksFromNodes(nodes) {
|
|
|
690
1023
|
// run: the exporter writes every text block as exactly such a padded div,
|
|
691
1024
|
// and buffering two of them together merged neighbouring blocks into one
|
|
692
1025
|
// -- the second lost its padding, size, everything -- on every save.
|
|
693
|
-
|
|
1026
|
+
// A box-div unwrap (target !== n) marks a block boundary just as surely
|
|
1027
|
+
// as padding does: the exporter writes exactly one such wrapper per
|
|
1028
|
+
// block, and without this two zero-padded text blocks buffered into one
|
|
1029
|
+
// -- the second lost its size, weight, everything -- on every save.
|
|
1030
|
+
// A painted panel is one block, whatever it holds: its styling describes
|
|
1031
|
+
// the whole run, so it must neither merge with the prose around it (a
|
|
1032
|
+
// bg-only div carries no padding to make it a boundary on its own) nor
|
|
1033
|
+
// split, since only the first block of a split would keep the panel.
|
|
1034
|
+
const box = boxPropsOf(target);
|
|
1035
|
+
const boundary = n.nodeType === 1 && !INLINE_TAGS.test(target.tagName) && (hasOwnRunPad(target) || target !== n || !!box);
|
|
1036
|
+
if (boundary && buf.length) flush();
|
|
1037
|
+
// Claimed only when the panel opens the run, which after that flush is
|
|
1038
|
+
// always -- a box nested inside a longer run is content, and its color
|
|
1039
|
+
// must not be promoted to the block around it.
|
|
1040
|
+
if (box && !bufFirstEl) bufBox = box;
|
|
694
1041
|
if (!bufFirstEl) bufFirstEl = target;
|
|
1042
|
+
bufEls.push(target);
|
|
1043
|
+
// Read off `n`, not `target`: the visibility class rides the wrapper
|
|
1044
|
+
// unwrapBoxDiv deliberately sees through.
|
|
1045
|
+
const ev = visUp(n);
|
|
1046
|
+
bufVis = bufVis === undefined || bufVis === ev ? ev : '';
|
|
695
1047
|
buf.push(n.outerHTML);
|
|
696
|
-
if (
|
|
1048
|
+
if (boundary) flush();
|
|
697
1049
|
});
|
|
698
1050
|
flush();
|
|
699
1051
|
return out;
|
|
@@ -868,7 +1220,10 @@ function unwrapNestedLayout(td) {
|
|
|
868
1220
|
if (classifyButton(td) || classifySocial(only)) return null;
|
|
869
1221
|
const trs = only.querySelectorAll(':scope > tbody > tr, :scope > tr');
|
|
870
1222
|
if (trs.length !== 1) return null;
|
|
871
|
-
|
|
1223
|
+
// Header cells veto the unwrap only on the candidate's OWN row: a th
|
|
1224
|
+
// anywhere deeper is some block's content (a data table in a column), and
|
|
1225
|
+
// vetoing on it left the gap cell around that block unread.
|
|
1226
|
+
if (only.querySelector(':scope > tbody > tr > th, :scope > tr > th')) return null;
|
|
872
1227
|
const cells = Array.from(trs[0].children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
|
|
873
1228
|
return cells.length ? cells : null;
|
|
874
1229
|
}
|
|
@@ -949,14 +1304,20 @@ function looksLikeContainer(el) {
|
|
|
949
1304
|
return Array.from(el.children).some((c) => c.tagName === 'TABLE' || c.tagName === 'DIV' || c.tagName === 'CENTER');
|
|
950
1305
|
}
|
|
951
1306
|
|
|
1307
|
+
/** A row holding nothing but condition/loop markers. The exporter emits only the tags at its position (no <tr> scaffolding), so wrapper styling must never stick to one -- a page background stamped onto a marker row painted a colored band in the canvas that no sent mail would ever show. */
|
|
1308
|
+
function isMarkerRow(r) {
|
|
1309
|
+
const blocks = r.cols.reduce((a, c) => a.concat(c.blocks), []);
|
|
1310
|
+
return blocks.length > 0 && blocks.every((b) => b.type === 'condition' || b.type === 'loop');
|
|
1311
|
+
}
|
|
1312
|
+
|
|
952
1313
|
function applyBg(rows, bg) {
|
|
953
|
-
if (bg) rows.forEach((r) => { if (!r.props.bg) r.props.bg = bg; });
|
|
1314
|
+
if (bg) rows.forEach((r) => { if (!r.props.bg && !isMarkerRow(r)) r.props.bg = bg; });
|
|
954
1315
|
return rows;
|
|
955
1316
|
}
|
|
956
1317
|
|
|
957
1318
|
/** Rows built from a genuine content table (`rowsFromContentTable`) already carry real, per-line padding read off their own `<td>`; rows built from a plain buffered run of content (`collectRows`'s `flushBuf`) start at a neutral zero, since there's no source padding to point to at that level. Once one of those zeroed rows bubbles up through a passthrough table or container div that DOES carry padding (the common shape for a single-line MJML/ESP section), that's the closest real signal available, and gets applied -- but only to rows still at zero, so it never overwrites a more specific value a deeper table already set. */
|
|
958
1319
|
function applyPad(rows, pad) {
|
|
959
|
-
if (pad) rows.forEach((r) => { if (!r.props.py && !r.props.px && r.props.pt === undefined) setRowPad(r, pad); });
|
|
1320
|
+
if (pad) rows.forEach((r) => { if (!r.props.py && !r.props.px && r.props.pt === undefined && !isMarkerRow(r)) setRowPad(r, pad); });
|
|
960
1321
|
return rows;
|
|
961
1322
|
}
|
|
962
1323
|
|
|
@@ -1053,17 +1414,49 @@ function rowsFromContentTable(table) {
|
|
|
1053
1414
|
}
|
|
1054
1415
|
const outerCells = Array.from(tr.children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
|
|
1055
1416
|
if (!outerCells.length) return null;
|
|
1056
|
-
|
|
1417
|
+
let bgSource = outerCells[0];
|
|
1418
|
+
// The margin / Max-width wrapper (core/export.js `boxed`): margins on a
|
|
1419
|
+
// <td> are inert in mail clients, so a row using either ships them on a
|
|
1420
|
+
// div inside the cell -- which also carries the row's paint and padding.
|
|
1421
|
+
// When it is there, IT is the row: everything below reads styles off
|
|
1422
|
+
// `bgSource`, so re-pointing here is the whole unhoist. Detected by a
|
|
1423
|
+
// real margin component or a %-cap, never by `margin:0` alone -- that is
|
|
1424
|
+
// the exporter's see-through box div around a block.
|
|
1425
|
+
let rowCap = 0;
|
|
1426
|
+
{
|
|
1427
|
+
const lone = onlyChild(bgSource, 'DIV');
|
|
1428
|
+
const mw = (lone && lone.style.maxWidth) || '';
|
|
1429
|
+
const capped = String(mw).endsWith('%') && PX(mw) > 0 && PX(mw) < 100;
|
|
1430
|
+
if (lone && (capped || PX(lone.style.marginTop) || PX(lone.style.marginBottom) || PX(lone.style.marginLeft) || PX(lone.style.marginRight))) {
|
|
1431
|
+
if (capped) rowCap = PX(mw);
|
|
1432
|
+
bgSource = lone;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1057
1435
|
let cells = outerCells;
|
|
1058
|
-
|
|
1436
|
+
// A flex/grid row: no table walker can re-shape one (its columns are
|
|
1437
|
+
// divs), so the exporter stamps the row's settings on the wrapper as
|
|
1438
|
+
// `data-mcr` and the columns are its child divs. Without the marker the
|
|
1439
|
+
// old collapse-to-one-column behaviour stands (foreign HTML, or a host
|
|
1440
|
+
// exporting with markers off).
|
|
1441
|
+
let layoutCfg = null;
|
|
1442
|
+
let layoutDiv = onlyChild(bgSource, 'DIV');
|
|
1443
|
+
if (layoutDiv && !(layoutDiv.getAttribute && layoutDiv.getAttribute('data-mcr'))) layoutDiv = null;
|
|
1444
|
+
if (layoutDiv) {
|
|
1445
|
+
try { layoutCfg = JSON.parse(layoutDiv.getAttribute('data-mcr')) || null; } catch { layoutCfg = null; }
|
|
1446
|
+
const colDivs = Array.from(layoutDiv.children).filter((k) => k.tagName === 'DIV');
|
|
1447
|
+
if (layoutCfg && colDivs.length) cells = colDivs;
|
|
1448
|
+
else { layoutCfg = null; layoutDiv = null; }
|
|
1449
|
+
}
|
|
1450
|
+
if (!layoutCfg && outerCells.length === 1) {
|
|
1059
1451
|
// The table shape first, since it is unambiguous; the CSS-layout shape
|
|
1060
1452
|
// only when there is no table row to read.
|
|
1061
|
-
const nested = unwrapNestedLayout(
|
|
1453
|
+
const nested = unwrapNestedLayout(bgSource);
|
|
1062
1454
|
if (nested) cells = nested;
|
|
1063
1455
|
else {
|
|
1064
|
-
const inline = inlineColumnGroup(
|
|
1456
|
+
const inline = inlineColumnGroup(bgSource.childNodes);
|
|
1065
1457
|
if (inline) cells = inline;
|
|
1066
1458
|
}
|
|
1459
|
+
if (cells === outerCells && bgSource !== outerCells[0]) cells = [bgSource];
|
|
1067
1460
|
}
|
|
1068
1461
|
// Spacer columns: a content-free `<td>` (often `class="column gap"`,
|
|
1069
1462
|
// holding only an empty fixed-width table) between real columns exists
|
|
@@ -1092,16 +1485,74 @@ function rowsFromContentTable(table) {
|
|
|
1092
1485
|
// column gap) describe a gap, not row padding. Recognizing it keeps
|
|
1093
1486
|
// gap -> export -> import a fixed point instead of drifting into padding.
|
|
1094
1487
|
let gutter = false;
|
|
1095
|
-
|
|
1488
|
+
// Single-column rows carry the same gutter (the exporter writes
|
|
1489
|
+
// `padding:0 gap/2` on every cell, one column or four) -- but only a cell
|
|
1490
|
+
// an unwrap produced qualifies: a flat foreign `<td style="padding:0 24px">`
|
|
1491
|
+
// has always read as row padding, and visually the two are the same, so
|
|
1492
|
+
// that behaviour must not shift under existing imports.
|
|
1493
|
+
if (!gapPx && (cells.length > 1 || cells[0] !== bgSource)) {
|
|
1096
1494
|
const pads = cells.map((c) => paddingOf(c.style));
|
|
1097
1495
|
const p0 = pads[0];
|
|
1098
1496
|
gutter = !!(p0 && !p0.t && !p0.b && p0.l > 0 && p0.l === p0.r && p0.l <= 60
|
|
1099
1497
|
&& pads.every((pp) => pp && !pp.t && !pp.b && pp.l === p0.l && pp.r === p0.r));
|
|
1100
1498
|
if (gutter) gapPx = p0.l * 2;
|
|
1101
1499
|
}
|
|
1500
|
+
// The unwrap can be refused on purpose (a social strip's layout table
|
|
1501
|
+
// must stay one block -- see unwrapNestedLayout), which also hides the
|
|
1502
|
+
// gap cell inside it. Peek at that one cell for the same pure-horizontal
|
|
1503
|
+
// signature; the strip itself is still classified by the cell walk below.
|
|
1504
|
+
// Never past a bulletproof button, whose padded cell is the pill, not a
|
|
1505
|
+
// gutter.
|
|
1506
|
+
if (!gapPx && cells.length === 1 && cells[0] === bgSource && !classifyButton(bgSource)) {
|
|
1507
|
+
const only = onlyChild(bgSource, 'TABLE');
|
|
1508
|
+
const innerTds = only ? Array.from(only.querySelectorAll(':scope > tbody > tr > td, :scope > tr > td')) : [];
|
|
1509
|
+
if (innerTds.length === 1) {
|
|
1510
|
+
const pd = paddingOf(innerTds[0].style);
|
|
1511
|
+
if (pd && !pd.t && !pd.b && pd.l > 0 && pd.l === pd.r && pd.l <= 60) gapPx = pd.l * 2;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1102
1514
|
const spans = cells.length === 1 ? [100] : spansFromCells(cells, tableWidthPx);
|
|
1103
1515
|
const row = mkRow(spans);
|
|
1104
1516
|
row.props.py = 0; row.props.px = 0; row.props.gap = gapPx;
|
|
1517
|
+
// Vertical alignment ships as the attribute on every cell; it only means
|
|
1518
|
+
// something the cells agree on (a foreign row aligning each column its
|
|
1519
|
+
// own way has no single row value to take).
|
|
1520
|
+
const va = cells[0].getAttribute && cells[0].getAttribute('valign');
|
|
1521
|
+
if ((va === 'middle' || va === 'bottom') && cells.every((c) => c.getAttribute('valign') === va)) row.props.valign = va;
|
|
1522
|
+
// Mobile behaviour, from the classes the exporter's media query targets
|
|
1523
|
+
// (they survive css-cascade untouched -- @media rules are never folded).
|
|
1524
|
+
// Only ever set on a real multi-column row, and only from an explicit
|
|
1525
|
+
// class: `keep` exports NO class and so cannot be told apart from foreign
|
|
1526
|
+
// HTML, where stacking (the default) is the safer read.
|
|
1527
|
+
if (cells.length > 1) {
|
|
1528
|
+
const trCls = (cells[0].parentElement && cells[0].parentElement.getAttribute('class')) || '';
|
|
1529
|
+
if (/\bmc-2up\b/.test(trCls)) row.props.mobileCols = 2;
|
|
1530
|
+
if (/\bmc-rev\b/.test(trCls)) row.props.mobileOrder = 'reverse';
|
|
1531
|
+
// `keep` is inert-class-marked (mc-keep, no stylesheet rule): with no
|
|
1532
|
+
// explicit class it cannot be told apart from foreign HTML, where
|
|
1533
|
+
// stacking -- the default -- is the safer read.
|
|
1534
|
+
if (/\bmc-keep\b/.test(trCls)) row.props.mobileCols = 'keep';
|
|
1535
|
+
}
|
|
1536
|
+
if (layoutCfg) {
|
|
1537
|
+
Object.assign(row.props, {
|
|
1538
|
+
layout: layoutCfg.layout === 'grid' ? 'grid' : 'flex',
|
|
1539
|
+
flexDir: layoutCfg.flexDir || 'row', justify: layoutCfg.justify || 'flex-start',
|
|
1540
|
+
alignItems: layoutCfg.alignItems || 'stretch', wrap: layoutCfg.wrap !== false,
|
|
1541
|
+
gridCols: layoutCfg.gridCols || 2, gap: layoutCfg.gap || 0,
|
|
1542
|
+
});
|
|
1543
|
+
if (Array.isArray(layoutCfg.spans) && layoutCfg.spans.length === row.cols.length) {
|
|
1544
|
+
layoutCfg.spans.forEach((sp, i) => { if (Number(sp) > 0) row.cols[i].span = Number(sp); });
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
// The row's outside margins, straight off the style the exporter writes.
|
|
1548
|
+
// Mail clients ignore margins on a <td> (a known export gap), but the
|
|
1549
|
+
// values are the user's -- dropping them zeroed the four sliders on
|
|
1550
|
+
// every reload.
|
|
1551
|
+
if (bgSource.style) {
|
|
1552
|
+
const mg = { mt: PX(bgSource.style.marginTop), mr: PX(bgSource.style.marginRight), mb: PX(bgSource.style.marginBottom), ml: PX(bgSource.style.marginLeft) };
|
|
1553
|
+
if (mg.mt || mg.mr || mg.mb || mg.ml) Object.assign(row.props, mg);
|
|
1554
|
+
}
|
|
1555
|
+
if (rowCap) row.props.maxW = rowCap;
|
|
1105
1556
|
// Background and frame can live on the first cell OR on the table itself
|
|
1106
1557
|
// (builders style `table.row-content`, not its tds) -- read the cell
|
|
1107
1558
|
// first, the table as fallback. Cell-derived values only count when every
|
|
@@ -1180,11 +1631,20 @@ function rowsFromContentTable(table) {
|
|
|
1180
1631
|
// its children, or the whole card collapses into one opaque text blob.
|
|
1181
1632
|
let contentEl = cell;
|
|
1182
1633
|
const lone = onlyChild(cell, 'DIV');
|
|
1183
|
-
if (lone && (bgOf(lone) || radiusOf(lone.style)) && !classifyNode(lone)) {
|
|
1634
|
+
if (lone && (bgOf(lone) || radiusOf(lone.style) || borderSidesOf(lone.style).width) && !classifyNode(lone)) {
|
|
1184
1635
|
const col = row.cols[i];
|
|
1185
1636
|
if (col) {
|
|
1186
1637
|
const cbg = bgOf(lone); if (cbg && !col.bg) col.bg = cbg;
|
|
1187
1638
|
const crad = radiusOf(lone.style); if (crad && !col.radius) col.radius = crad;
|
|
1639
|
+
// The column's own border ships on this same wrapper (core/export.js
|
|
1640
|
+
// writes bg, border, radius and padding together); reading everything
|
|
1641
|
+
// but the border dropped a column frame on every save/reload.
|
|
1642
|
+
const cframe = borderSidesOf(lone.style);
|
|
1643
|
+
if (cframe.width && !col.border) {
|
|
1644
|
+
col.border = cframe.width;
|
|
1645
|
+
col.borderStyle = borderStyleOf(lone.style);
|
|
1646
|
+
col.lineColor = borderColorOf(lone.style) || '#e2e2e5';
|
|
1647
|
+
}
|
|
1188
1648
|
const cpd = paddingOf(lone.style);
|
|
1189
1649
|
if (cpd && col.padY === undefined) { col.padY = cpd.py; col.padX = cpd.px; }
|
|
1190
1650
|
}
|
|
@@ -1263,7 +1723,17 @@ function collectRows(nodes) {
|
|
|
1263
1723
|
};
|
|
1264
1724
|
nodes.forEach((n) => {
|
|
1265
1725
|
if (n.nodeType === 8) return; // HTML comments (Outlook/MSO conditionals) -- inert
|
|
1266
|
-
if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName))
|
|
1726
|
+
if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName)) {
|
|
1727
|
+
const mb = markerBlock(n);
|
|
1728
|
+
if (mb) {
|
|
1729
|
+
flushBuf();
|
|
1730
|
+
const row = mkRow([100]);
|
|
1731
|
+
row.props.py = 0; row.props.px = 0; row.props.gap = 0;
|
|
1732
|
+
row.cols[0].blocks = [mb];
|
|
1733
|
+
rows.push(row);
|
|
1734
|
+
}
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1267
1737
|
if (n.nodeType === 1 && isHidden(n)) return;
|
|
1268
1738
|
// A lone column: its blocks belong to one row, not one row each.
|
|
1269
1739
|
if (n.nodeType === 1 && isColumnContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
|
|
@@ -1277,7 +1747,15 @@ function collectRows(nodes) {
|
|
|
1277
1747
|
return;
|
|
1278
1748
|
}
|
|
1279
1749
|
}
|
|
1280
|
-
|
|
1750
|
+
// Container-ness is judged on the UNWRAPPED element where the unwrap
|
|
1751
|
+
// stays a div/center: a margin-only box div around one padded text div is
|
|
1752
|
+
// the exporter's block wrapper, and walking it as a section split every
|
|
1753
|
+
// block of a single-row document into its own row. An unwrap that lands
|
|
1754
|
+
// on something else (a table -- Beefree's divider ships as
|
|
1755
|
+
// div > 20%-table) keeps the old judgement of the wrapper itself.
|
|
1756
|
+
const seen = n.nodeType === 1 && (n.tagName === 'DIV' || n.tagName === 'CENTER') ? unwrapBoxDiv(n) : null;
|
|
1757
|
+
const containerish = seen && (seen.tagName === 'DIV' || seen.tagName === 'CENTER' ? looksLikeContainer(seen) : looksLikeContainer(n));
|
|
1758
|
+
if (seen && containerish && !classifyNode(seen)) {
|
|
1281
1759
|
flushBuf();
|
|
1282
1760
|
const inner = mergeBandRows(collectRows(Array.from(n.childNodes)), n);
|
|
1283
1761
|
rows.push(...applyBgImage(applyFrame(applyPad(applyBg(inner, bgOf(n)), padOf(n)), n), n));
|
|
@@ -1497,14 +1975,21 @@ function themeFromParsedDoc(doc) {
|
|
|
1497
1975
|
}
|
|
1498
1976
|
}
|
|
1499
1977
|
}
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1978
|
+
// The body's own family first: it is where this exporter writes the theme
|
|
1979
|
+
// font, and `querySelectorAll` never sees the body itself -- so the scan
|
|
1980
|
+
// below used to crown the *first block's* effective font instead, and one
|
|
1981
|
+
// custom-font heading at the top of a document flipped the whole theme on
|
|
1982
|
+
// reload. The element scan stays as the fallback for foreign emails that
|
|
1983
|
+
// declare nothing on the body, preferring the first *real* stack (has a
|
|
1984
|
+
// comma or quotes) over a lone generic keyword: builders wrap everything in
|
|
1985
|
+
// a `font-family:sans-serif` shim div with the actual `'DM Sans', Arial,
|
|
1986
|
+
// ...` declared a level deeper.
|
|
1987
|
+
const bodyFont = (body.style && body.style.fontFamily) || '';
|
|
1503
1988
|
const fonts = Array.from(body.querySelectorAll('[style*="font-family"]'))
|
|
1504
1989
|
.filter((el) => (el.textContent || '').trim())
|
|
1505
1990
|
.map((el) => el.style.fontFamily)
|
|
1506
1991
|
.filter(Boolean);
|
|
1507
|
-
const font = fonts.find((v) => /[,"']/.test(v)) || fonts[0];
|
|
1992
|
+
const font = bodyFont || fonts.find((v) => /[,"']/.test(v)) || fonts[0];
|
|
1508
1993
|
if (font) theme.font = font;
|
|
1509
1994
|
// Link color: the most common inline anchor color -- skipping button
|
|
1510
1995
|
// pills, whose (usually white) label color would otherwise dominate a
|
|
@@ -1515,8 +2000,16 @@ function themeFromParsedDoc(doc) {
|
|
|
1515
2000
|
body.querySelectorAll('a[style*="color"]').forEach((a) => {
|
|
1516
2001
|
const c = a.style.color;
|
|
1517
2002
|
// Only text links vote -- icon links (social strips) carry an icon
|
|
1518
|
-
// color, not the document's link color.
|
|
2003
|
+
// color, not the document's link color. An anchor holding an icon still
|
|
2004
|
+
// has text when its network name is shown beside the glyph, so the icon
|
|
2005
|
+
// check is structural, not textual. Menu items are navigation chrome in
|
|
2006
|
+
// the block's own color (every exported item carries the uppercase +
|
|
2007
|
+
// letter-spacing signature) -- counting them let a three-item menu
|
|
2008
|
+
// outvote the document's actual links and rewrite theme.link on every
|
|
2009
|
+
// reload.
|
|
1519
2010
|
if (!c || c === 'inherit' || !(a.textContent || '').trim() || isPill(a)) return;
|
|
2011
|
+
if (a.querySelector('svg,img')) return;
|
|
2012
|
+
if (a.style.textTransform === 'uppercase' && a.style.letterSpacing) return;
|
|
1520
2013
|
linkCounts[c] = (linkCounts[c] || 0) + 1;
|
|
1521
2014
|
});
|
|
1522
2015
|
const link = Object.keys(linkCounts).sort((a, b) => linkCounts[b] - linkCounts[a])[0];
|
|
@@ -1551,6 +2044,67 @@ function foldLogicWrappers(src) {
|
|
|
1551
2044
|
return s;
|
|
1552
2045
|
}
|
|
1553
2046
|
|
|
2047
|
+
/**
|
|
2048
|
+
* The exporter writes every inherited value as a concrete declaration
|
|
2049
|
+
* (`p.fontFamily || t.font`, `p.color || t.text`, a row's
|
|
2050
|
+
* `rp.bg || t.contentBg || 'transparent'`), so a round trip used to come back
|
|
2051
|
+
* with the theme stamped onto every block and row as an explicit override --
|
|
2052
|
+
* visually identical, but the inherit relationship was gone: a later theme
|
|
2053
|
+
* edit (font, text ink, content background) no longer reached anything. A
|
|
2054
|
+
* value that merely restates what the imported theme already says folds back
|
|
2055
|
+
* to "inherit"; anything genuinely different is a real override and stays.
|
|
2056
|
+
*/
|
|
2057
|
+
/** Strips an anchor's inline color where it merely restates the document link color, so the stored html inherits again -- the exporter stamps `theme.link` on every colorless anchor (mail needs the value inline), and left in the reloaded html that stamp froze links at whatever the theme said on the day of the save; a later Link color edit never reached them. A genuinely different inline color is the user's and stays. */
|
|
2058
|
+
function foldLinkColor(html, linkKey, ckey) {
|
|
2059
|
+
const src = String(html || '');
|
|
2060
|
+
if (!linkKey || src.indexOf('<a') < 0 || src.indexOf('color') < 0) return html;
|
|
2061
|
+
const doc = new DOMParser().parseFromString('<div id="mc-fold">' + src + '</div>', 'text/html');
|
|
2062
|
+
const root = doc.getElementById('mc-fold');
|
|
2063
|
+
if (!root) return html;
|
|
2064
|
+
let hit = false;
|
|
2065
|
+
root.querySelectorAll('a[style]').forEach((a) => {
|
|
2066
|
+
if (a.style.color && ckey(a.style.color) === linkKey) {
|
|
2067
|
+
a.style.removeProperty('color');
|
|
2068
|
+
if (!(a.getAttribute('style') || '').trim()) a.removeAttribute('style');
|
|
2069
|
+
hit = true;
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
return hit ? root.innerHTML : html;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
function foldThemeInherits(rows, theme) {
|
|
2076
|
+
const tFont = fontKey(theme.font);
|
|
2077
|
+
// Colors compare through hexOf on BOTH sides: CSSOM hands the walkers
|
|
2078
|
+
// `rgb(...)` for every hex the exporter wrote, so a bare string comparison
|
|
2079
|
+
// saw `#172033` != `rgb(23, 32, 51)` and the fold silently never fired.
|
|
2080
|
+
const ckey = (v) => String(hexOf(v) || '').toLowerCase();
|
|
2081
|
+
const tText = ckey(theme.text);
|
|
2082
|
+
const cBg = ckey(theme.contentBg);
|
|
2083
|
+
const tLink = ckey(theme.link);
|
|
2084
|
+
// Only the types whose renderer falls back `p.color || t.text` -- an empty
|
|
2085
|
+
// color means "theme ink" for exactly these; other blocks' colors are
|
|
2086
|
+
// structural (a button label, a divider line) and must stay explicit.
|
|
2087
|
+
const inheritsInk = { text: 1, heading: 1, list: 1 };
|
|
2088
|
+
rows.forEach((row) => {
|
|
2089
|
+
const bg = ckey(row.props.bg);
|
|
2090
|
+
if (bg && bg === cBg) row.props.bg = '';
|
|
2091
|
+
row.cols.forEach((col) => {
|
|
2092
|
+
// A see-through column wrapper is the exporter's own scaffolding
|
|
2093
|
+
// (`background: transparent` is always written on a styled column),
|
|
2094
|
+
// never a chosen paint.
|
|
2095
|
+
if (String(col.bg || '').toLowerCase() === 'transparent') col.bg = '';
|
|
2096
|
+
col.blocks.forEach((b) => {
|
|
2097
|
+
if (tFont && b.props.fontFamily && fontKey(b.props.fontFamily) === tFont) b.props.fontFamily = '';
|
|
2098
|
+
if (tText && inheritsInk[b.type] && ckey(b.props.color) === tText) b.props.color = '';
|
|
2099
|
+
if (tLink && theme.link && typeof b.props.html === 'string') b.props.html = foldLinkColor(b.props.html, tLink, ckey);
|
|
2100
|
+
if (tLink && theme.link && b.type === 'list' && b.props.items) {
|
|
2101
|
+
b.props.items = String(b.props.items).split('\n').map((l) => foldLinkColor(l, tLink, ckey)).join('\n');
|
|
2102
|
+
}
|
|
2103
|
+
});
|
|
2104
|
+
});
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
|
|
1554
2108
|
/** Full import entry point: the rows plus the theme patch read from the same source. `theme` only carries keys the source actually declared -- the caller merges it over the current theme so unspecified fields keep their values. */
|
|
1555
2109
|
export function htmlToDoc(src) {
|
|
1556
2110
|
let doc;
|
|
@@ -1562,7 +2116,9 @@ export function htmlToDoc(src) {
|
|
|
1562
2116
|
// Theme first: themeFromParsedDoc consumes the styles it claims off the
|
|
1563
2117
|
// scaffold nodes, and the row walker must see the cleaned DOM.
|
|
1564
2118
|
const theme = themeFromParsedDoc(doc);
|
|
1565
|
-
|
|
2119
|
+
const rows = collectRows(Array.from(doc.body.childNodes));
|
|
2120
|
+
foldThemeInherits(rows, theme);
|
|
2121
|
+
return { rows, theme };
|
|
1566
2122
|
}
|
|
1567
2123
|
|
|
1568
2124
|
export function htmlToRows(src) {
|