@seliseblocks/mailcraft 0.2.8 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +135 -0
- package/DOCS.md +32 -32
- package/README.md +4 -1
- package/README.md.txt +4 -1
- package/dist/mailcraft-editor.bundle.js +74 -57
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +3 -2
- package/src/core/blocks.js +30 -11
- package/src/core/css-cascade.js +4 -1
- package/src/core/editor-core.js +80 -16
- package/src/core/export.js +83 -10
- package/src/core/icons.js +0 -1
- package/src/core/import-html.js +694 -55
- package/src/core/sanitize.js +72 -1
- package/src/mailcraft-editor.js +44 -4
- package/src/render/block-body.js +17 -5
- package/src/render/canvas.js +3 -0
- package/src/render/fields.js +28 -2
- package/src/render/rte.js +6 -0
- 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;
|
|
@@ -150,6 +176,43 @@ function applyBgImage(rows, el) {
|
|
|
150
176
|
return rows;
|
|
151
177
|
}
|
|
152
178
|
|
|
179
|
+
/**
|
|
180
|
+
* A wrapper that paints one background image is one visual band: builders
|
|
181
|
+
* (Beefree, Stripo) write a hero section as `table.row[background-image]`
|
|
182
|
+
* holding a single-column stack of per-block tables, and letting the generic
|
|
183
|
+
* walk turn each block into its own row stamped the image onto every one --
|
|
184
|
+
* so the export re-drew the hero's top slice once per block instead of one
|
|
185
|
+
* image flowing behind the section. Folds those rows back into a single row,
|
|
186
|
+
* moving each row's padding onto its blocks (same convention as the
|
|
187
|
+
* block-table unwrap in `blocksFromNodes`). Deliberately conservative: only
|
|
188
|
+
* single-column rows with no styling of their own (a differing background,
|
|
189
|
+
* frame or image on a row is a real band boundary) are merged, so everything
|
|
190
|
+
* that isn't the hero shape walks exactly as before.
|
|
191
|
+
*/
|
|
192
|
+
function mergeBandRows(rows, el) {
|
|
193
|
+
if (rows.length < 2 || !bgImageOf(el)) return rows;
|
|
194
|
+
const bg0 = rows[0].props.bg || '';
|
|
195
|
+
const plain = rows.every((r) => r.cols.length === 1
|
|
196
|
+
&& !r.props.bgImage && !r.props.border && !r.props.radius && !r.props.shadow
|
|
197
|
+
&& (r.props.bg || '') === bg0);
|
|
198
|
+
if (!plain) return rows;
|
|
199
|
+
const merged = mkRow([100]);
|
|
200
|
+
merged.props.py = 0; merged.props.px = 0; merged.props.gap = 0;
|
|
201
|
+
if (bg0) merged.props.bg = bg0;
|
|
202
|
+
merged.cols[0].blocks = rows.reduce((acc, r) => {
|
|
203
|
+
const padded = r.props.py || r.props.px || r.props.pt !== undefined;
|
|
204
|
+
r.cols[0].blocks.forEach((b) => {
|
|
205
|
+
if (padded && b.type !== 'button' && 'py' in b.props) {
|
|
206
|
+
b.props.py = r.props.py;
|
|
207
|
+
if ('px' in b.props) b.props.px = r.props.px;
|
|
208
|
+
}
|
|
209
|
+
acc.push(b);
|
|
210
|
+
});
|
|
211
|
+
return acc;
|
|
212
|
+
}, []);
|
|
213
|
+
return merged.cols[0].blocks.length ? [merged] : rows;
|
|
214
|
+
}
|
|
215
|
+
|
|
153
216
|
/** 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
217
|
function paddingOf(st) {
|
|
155
218
|
if (!st) return null;
|
|
@@ -175,12 +238,27 @@ function lineHeightRatio(raw, fontPx) {
|
|
|
175
238
|
let ratio;
|
|
176
239
|
if (raw.endsWith('%')) ratio = PX(raw) / 100;
|
|
177
240
|
else if (raw.endsWith('em')) ratio = PX(raw); // already a multiplier of the font size, by definition
|
|
178
|
-
else if (raw.endsWith('pt')) ratio = (PX(raw) * 4 / 3
|
|
179
|
-
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);
|
|
180
243
|
else ratio = parseFloat(raw);
|
|
181
244
|
return Number.isFinite(ratio) && ratio > 0 && ratio <= 4 ? ratio : null;
|
|
182
245
|
}
|
|
183
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
|
+
|
|
184
262
|
function escapeText(s) {
|
|
185
263
|
const d = document.createElement('div');
|
|
186
264
|
d.textContent = s;
|
|
@@ -209,20 +287,27 @@ function classifyImage(el) {
|
|
|
209
287
|
// <img style="width:100%" width="88">`), and trusting the percent first is
|
|
210
288
|
// exactly what blew an 88px logo up to the full content width.
|
|
211
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;
|
|
212
295
|
const pxCandidates = [
|
|
213
296
|
img.style.width, img.getAttribute('width'), img.style.maxWidth,
|
|
297
|
+
a && a.style ? a.style.width : '', a && a.style ? a.style.maxWidth : '',
|
|
214
298
|
el !== img && el.style ? el.style.width : '', el !== img && el.style ? el.style.maxWidth : '',
|
|
215
299
|
];
|
|
216
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));
|
|
217
302
|
let width = 100;
|
|
218
303
|
if (pxHint) {
|
|
219
304
|
// Convert to % of the nearest fixed-width ancestor.
|
|
220
305
|
const colPx = ancestorPxWidth(img) || 600;
|
|
221
306
|
width = Math.max(2, Math.min(100, Math.round((PX(pxHint) / colPx) * 100)));
|
|
222
|
-
} else if (
|
|
223
|
-
width = PX(
|
|
307
|
+
} else if (pctHint) {
|
|
308
|
+
width = PX(pctHint);
|
|
224
309
|
}
|
|
225
|
-
|
|
310
|
+
const over = {
|
|
226
311
|
src: img.getAttribute('src') || '',
|
|
227
312
|
alt: img.getAttribute('alt') || '',
|
|
228
313
|
href,
|
|
@@ -231,7 +316,14 @@ function classifyImage(el) {
|
|
|
231
316
|
// Always explicit: the block *default* is 10px, which quietly rounded
|
|
232
317
|
// the corners of every imported image that had none.
|
|
233
318
|
radius: PX(img.style.borderRadius) || 0,
|
|
234
|
-
}
|
|
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);
|
|
235
327
|
}
|
|
236
328
|
|
|
237
329
|
function classifyButton(el) {
|
|
@@ -281,8 +373,11 @@ function classifyButton(el) {
|
|
|
281
373
|
const over = {
|
|
282
374
|
label: (a.textContent || '').trim() || 'Button',
|
|
283
375
|
href: a.getAttribute('href') || '#',
|
|
284
|
-
|
|
285
|
-
|
|
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'),
|
|
286
381
|
radius: radiusOf(st),
|
|
287
382
|
py: (pad && pad.py) || 13,
|
|
288
383
|
px: (pad && pad.px) || 26,
|
|
@@ -300,6 +395,11 @@ function classifyButton(el) {
|
|
|
300
395
|
};
|
|
301
396
|
const size = fontPx(st.fontSize) || fontPx(a.style.fontSize);
|
|
302
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;
|
|
303
403
|
// Outline buttons: transparent fill, the pill drawn by its border. The
|
|
304
404
|
// border is looked for on the cell as well as the pill, because in the
|
|
305
405
|
// one-cell shape the paint and the frame are on different elements -- the
|
|
@@ -316,9 +416,15 @@ const SOCIAL_HOSTS = /facebook|twitter|x\.com|instagram|linkedin|youtube|tiktok|
|
|
|
316
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. */
|
|
317
417
|
function classifySocial(el) {
|
|
318
418
|
if (!/^(DIV|TD|TABLE)$/.test(el.tagName)) return null;
|
|
319
|
-
if ((el.textContent || '').trim()) return null;
|
|
320
419
|
const anchors = Array.from(el.querySelectorAll('a'));
|
|
321
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;
|
|
322
428
|
// `svg` alongside `img`: MailCraft's own social block renders inline-SVG
|
|
323
429
|
// icons, so accepting both is what lets an exported strip round-trip back
|
|
324
430
|
// into a social block instead of dissolving.
|
|
@@ -348,15 +454,37 @@ function classifySocial(el) {
|
|
|
348
454
|
// closest match to how such strips actually look. The block *default*
|
|
349
455
|
// (outlined boxes at 1.9x the icon size) both looked wrong and wrapped in
|
|
350
456
|
// narrow footer columns.
|
|
351
|
-
|
|
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);
|
|
352
465
|
const aBorder = a0.style ? PX(a0.style.borderWidth) || PX(a0.style.borderTopWidth) : 0;
|
|
353
466
|
over.shape = aBg ? (String(a0.style.borderRadius || '').indexOf('50%') > -1 ? 'circle' : 'square') : (aBorder ? 'outline' : 'bare');
|
|
354
|
-
|
|
355
|
-
|
|
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';
|
|
356
480
|
// Icon spacing: inter-cell padding on image strips, anchor margins on
|
|
357
|
-
// 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.
|
|
358
485
|
const cell = a0.closest ? a0.closest('td') : null;
|
|
359
|
-
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;
|
|
360
488
|
const marginGap = a0.style ? PX(a0.style.marginRight) + PX(a0.style.marginLeft) : 0;
|
|
361
489
|
const gap = cellGap || marginGap;
|
|
362
490
|
if (gap) over.gap = gap;
|
|
@@ -366,7 +494,9 @@ function classifySocial(el) {
|
|
|
366
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. */
|
|
367
495
|
function classifyMenu(el) {
|
|
368
496
|
if (el.tagName !== 'DIV' && el.tagName !== 'TD') return null;
|
|
369
|
-
|
|
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;
|
|
370
500
|
const anchors = Array.from(el.children).filter((c) => c.tagName === 'A');
|
|
371
501
|
if (anchors.length < 2) return null;
|
|
372
502
|
const linkText = anchors.map((a) => (a.textContent || '')).join('').replace(/\s+/g, '');
|
|
@@ -378,7 +508,13 @@ function classifyMenu(el) {
|
|
|
378
508
|
const size = fontPx(inheritedStyle(anchors[0], 'fontSize'));
|
|
379
509
|
if (size) over.size = size;
|
|
380
510
|
const color = inheritedStyle(anchors[0], 'color');
|
|
381
|
-
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;
|
|
382
518
|
return blk('menu', over);
|
|
383
519
|
}
|
|
384
520
|
|
|
@@ -388,7 +524,7 @@ function classifyDivider(el) {
|
|
|
388
524
|
return blk('divider', {
|
|
389
525
|
thickness: PX(el.style.height) || PX(el.style.borderTopWidth) || 1,
|
|
390
526
|
lineStyle: borderStyleOf(el.style),
|
|
391
|
-
color: el.style.backgroundColor || el.style.borderTopColor || el.style.borderColor || '#d9dade',
|
|
527
|
+
color: hexOf(el.style.backgroundColor || el.style.borderTopColor || el.style.borderColor) || '#d9dade',
|
|
392
528
|
width: sw.endsWith('%') ? PX(sw) : 100,
|
|
393
529
|
});
|
|
394
530
|
}
|
|
@@ -399,7 +535,12 @@ function classifyDivider(el) {
|
|
|
399
535
|
const bg = bar.style.backgroundColor || bar.style.background || bar.style.borderTopColor;
|
|
400
536
|
if (!(h > 0 && h <= 10 && bg)) return null;
|
|
401
537
|
const sw = bar.style.width || '';
|
|
402
|
-
|
|
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);
|
|
403
544
|
}
|
|
404
545
|
|
|
405
546
|
function classifySpacer(el) {
|
|
@@ -415,17 +556,72 @@ function classifyHeading(el) {
|
|
|
415
556
|
const st = el.style;
|
|
416
557
|
const over = { text: (el.textContent || '').trim(), level: el.tagName.toLowerCase() };
|
|
417
558
|
const size = fontPx(st.fontSize); if (size) over.size = size;
|
|
418
|
-
|
|
419
|
-
|
|
559
|
+
// Color and alignment inherit in CSS, and hero sections declare them on
|
|
560
|
+
// the section `<td>` -- a white heading over a dark photo whose color
|
|
561
|
+
// lived on the cell imported in the default dark ink and vanished into
|
|
562
|
+
// the image. Own values still win; the walk up only fills the gaps,
|
|
563
|
+
// exactly like the text-run path.
|
|
564
|
+
const align = st.textAlign || inheritedStyle(el.parentElement, 'textAlign');
|
|
565
|
+
if (align) over.align = align;
|
|
566
|
+
const color = st.color || inheritedStyle(el.parentElement, 'color');
|
|
567
|
+
if (color) over.color = hexOf(color);
|
|
420
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; }
|
|
421
586
|
return blk('heading', over);
|
|
422
587
|
}
|
|
423
588
|
|
|
424
589
|
function classifyList(el) {
|
|
425
590
|
if (el.tagName !== 'UL' && el.tagName !== 'OL') return null;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
591
|
+
// Through the same whitelist as a text run: this was the one classifier
|
|
592
|
+
// that carried raw source innerHTML into props, so classes, ids and event
|
|
593
|
+
// handlers (`onerror` on an <img> in a list item) rode into the canvas DOM
|
|
594
|
+
// and the export while every other path stripped them. The newline swap is
|
|
595
|
+
// because `items` is a one-fragment-per-line string -- a linebreak inside a
|
|
596
|
+
// source <li> would otherwise split it into two items on render.
|
|
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);
|
|
429
625
|
}
|
|
430
626
|
|
|
431
627
|
function classifyTable(el) {
|
|
@@ -441,14 +637,98 @@ function classifyTable(el) {
|
|
|
441
637
|
const cellStyle = firstCell && firstCell.style;
|
|
442
638
|
const borderWidth = cellStyle ? borderSidesOf(cellStyle).width : 0;
|
|
443
639
|
const borders = !!borderWidth;
|
|
444
|
-
|
|
640
|
+
const over = {
|
|
445
641
|
data, header, borders, borderWidth: borderWidth || 1,
|
|
446
642
|
borderStyle: cellStyle ? borderStyleOf(cellStyle) : 'solid',
|
|
447
643
|
lineColor: cellStyle ? (borderColorOf(cellStyle) || '#e2e8f0') : '#e2e8f0',
|
|
448
|
-
}
|
|
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);
|
|
449
672
|
}
|
|
450
673
|
|
|
451
|
-
|
|
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];
|
|
452
732
|
|
|
453
733
|
function classifyNode(el) {
|
|
454
734
|
for (const fn of CLASSIFIERS) {
|
|
@@ -539,10 +819,50 @@ function blocksFromNodes(nodes) {
|
|
|
539
819
|
const out = [];
|
|
540
820
|
let buf = [];
|
|
541
821
|
let bufFirstEl = null;
|
|
822
|
+
// Every element buffered into the current run, for the mixed-family check
|
|
823
|
+
// below: the first element alone cannot say whether a *later* piece of the
|
|
824
|
+
// run declares its own font.
|
|
825
|
+
let bufEls = [];
|
|
826
|
+
// The run's device visibility: the class sits on the box wrapper the
|
|
827
|
+
// exporter puts around a text block (never inside the sanitized html, which
|
|
828
|
+
// strips classes), so only this walk can carry it. `undefined` = not seen
|
|
829
|
+
// yet; '' = pieces disagreed, and a mixed run claims nothing rather than
|
|
830
|
+
// hide half its content along with the other half.
|
|
831
|
+
let bufVis;
|
|
832
|
+
const visUp = (start) => {
|
|
833
|
+
let e = start;
|
|
834
|
+
for (let i = 0; e && i < 4; i += 1) { const v = visibilityOf(e); if (v) return v; e = e.parentElement; }
|
|
835
|
+
return '';
|
|
836
|
+
};
|
|
837
|
+
// The parent of a leading bare text node. A run with no element of its own
|
|
838
|
+
// (`<td style="font-size:30px;...">{{Code}}</td>`) still has real
|
|
839
|
+
// typography -- it lives on the ancestors, and with no bufFirstEl the
|
|
840
|
+
// inheritedStyle reads below were skipped wholesale, so the run imported
|
|
841
|
+
// at the theme default (a 30px/800 verification code became 16px plain).
|
|
842
|
+
let bufTextEl = null;
|
|
542
843
|
const flush = () => {
|
|
543
|
-
const
|
|
544
|
-
|
|
844
|
+
const raw = buf.join('');
|
|
845
|
+
const html = cleanImportHtml(raw);
|
|
846
|
+
// A run with nothing visible in it -- no text beyond whitespace/hair
|
|
847
|
+
// spaces, no image or line break (an `<nbsp;>` still counts as a blank
|
|
848
|
+
// line someone wrote) -- is markup residue (`<p style="margin:0"></p>`),
|
|
849
|
+
// not content; as a block it rendered as a phantom padded row.
|
|
850
|
+
const visible = html && (/<(img|br|hr)\b/i.test(html)
|
|
851
|
+
|| html.replace(/<[^>]*>/g, '').replace(/[ \t\r\n\u2009\u200a\u200b]/g, '') !== '');
|
|
852
|
+
if (visible) {
|
|
545
853
|
const over = { html };
|
|
854
|
+
if (!bufFirstEl && bufTextEl) {
|
|
855
|
+
const size = fontPx(inheritedStyle(bufTextEl, 'fontSize')); if (size) over.size = size;
|
|
856
|
+
const color = inheritedStyle(bufTextEl, 'color'); if (color) over.color = hexOf(color);
|
|
857
|
+
over.align = textAlignOf(bufTextEl);
|
|
858
|
+
const weight = inheritedStyle(bufTextEl, 'fontWeight'); if (weight) over.weight = weight;
|
|
859
|
+
const lh = lineHeightRatio(inheritedStyle(bufTextEl, 'lineHeight'), size);
|
|
860
|
+
if (lh) over.lh = lh;
|
|
861
|
+
// A bare run has no inline tags to carry a family, so the ancestor's
|
|
862
|
+
// is the block's -- no mixed-run risk here.
|
|
863
|
+
const family = inheritedStyle(bufTextEl, 'fontFamily');
|
|
864
|
+
if (family) over.fontFamily = family;
|
|
865
|
+
}
|
|
546
866
|
if (bufFirstEl) {
|
|
547
867
|
// The block's *base* style: the first buffered element when it's a
|
|
548
868
|
// block-level thing (a styled <p>/<div> speaks for the run), else its
|
|
@@ -556,6 +876,7 @@ function blocksFromNodes(nodes) {
|
|
|
556
876
|
// (it lands on the <p>), so a padded block came back at the default
|
|
557
877
|
// 10px/0 on every save. First padding on the wrapper chain wins.
|
|
558
878
|
let runPad = paddingOf(baseEl.style);
|
|
879
|
+
let padDeclared = !!baseEl.style && baseEl.style.paddingTop !== '';
|
|
559
880
|
// Then descend through transparent single-child wrappers: builders
|
|
560
881
|
// nest a `font-family:sans-serif` shim div around the div that
|
|
561
882
|
// carries the real typography, and `inheritedStyle` below walks *up*
|
|
@@ -565,28 +886,65 @@ function blocksFromNodes(nodes) {
|
|
|
565
886
|
baseEl.children.length === 1
|
|
566
887
|
&& !INLINE_TAGS.test(baseEl.firstElementChild.tagName)
|
|
567
888
|
&& (baseEl.textContent || '') === (baseEl.firstElementChild.textContent || '')
|
|
568
|
-
) { baseEl = baseEl.firstElementChild; if (!runPad) runPad = paddingOf(baseEl.style); }
|
|
889
|
+
) { baseEl = baseEl.firstElementChild; if (!runPad) runPad = paddingOf(baseEl.style); padDeclared = padDeclared || baseEl.style.paddingTop !== ''; }
|
|
569
890
|
if (runPad) { over.py = runPad.py; over.px = runPad.px; }
|
|
891
|
+
// A declared all-zero padding is a choice (paddingOf returns null for
|
|
892
|
+
// it); absence is what falls to the 10px default.
|
|
893
|
+
else if (padDeclared) { over.py = 0; over.px = 0; }
|
|
570
894
|
const size = fontPx(inheritedStyle(baseEl, 'fontSize')); if (size) over.size = size;
|
|
571
|
-
const color = inheritedStyle(baseEl, 'color'); if (color) over.color = color;
|
|
895
|
+
const color = inheritedStyle(baseEl, 'color'); if (color) over.color = hexOf(color);
|
|
572
896
|
over.align = textAlignOf(baseEl);
|
|
573
897
|
const weight = inheritedStyle(baseEl, 'fontWeight'); if (weight) over.weight = weight;
|
|
574
898
|
const lh = lineHeightRatio(inheritedStyle(baseEl, 'lineHeight'), size);
|
|
575
899
|
if (lh) over.lh = lh;
|
|
900
|
+
// The run's base family, read exactly like size/color -- but claimed
|
|
901
|
+
// at block level only when no piece of the run declares a different
|
|
902
|
+
// one, since the renderer strips descendant families the moment the
|
|
903
|
+
// block owns a font (overrideRichFont). A mixed run keeps its inline
|
|
904
|
+
// declarations instead, exactly as before. The theme-equality fold in
|
|
905
|
+
// htmlToDoc turns a restated document font back into "inherit".
|
|
906
|
+
const family = inheritedStyle(baseEl, 'fontFamily');
|
|
907
|
+
if (family && !bufEls.some((e) => mixedFamily(e, family))) {
|
|
908
|
+
over.fontFamily = family;
|
|
909
|
+
// Claimed means consumed: every family in the run restates the one
|
|
910
|
+
// just read (the mixed check above), so the inline declarations
|
|
911
|
+
// fold into the block prop instead of shipping twice. This is also
|
|
912
|
+
// what keeps export -> import -> export byte-stable -- left in the
|
|
913
|
+
// html, the renderer strips them from the live DOM (overrideRichFont)
|
|
914
|
+
// and the re-serialized attributes drift on the next save.
|
|
915
|
+
over.html = cleanImportHtml(raw, ['font-family']);
|
|
916
|
+
}
|
|
576
917
|
}
|
|
918
|
+
// Same walk classifyNode does for recognized blocks; text runs never
|
|
919
|
+
// went through it, so a mobile-only paragraph reloaded visible
|
|
920
|
+
// everywhere.
|
|
921
|
+
if (bufVis) over.vis = bufVis;
|
|
577
922
|
out.push(blk('text', over));
|
|
578
923
|
}
|
|
579
|
-
buf = []; bufFirstEl = null;
|
|
924
|
+
buf = []; bufFirstEl = null; bufTextEl = null; bufEls = []; bufVis = undefined;
|
|
580
925
|
};
|
|
581
926
|
nodes.forEach((n) => {
|
|
582
927
|
if (n.nodeType === 3) {
|
|
583
928
|
const markers = logicMarkersOf(n.textContent);
|
|
584
929
|
if (markers) { flush(); markers.forEach((mb) => out.push(mb)); return; }
|
|
585
|
-
if (n.textContent && n.textContent.trim())
|
|
930
|
+
if (n.textContent && n.textContent.trim()) {
|
|
931
|
+
if (!bufFirstEl && !bufTextEl) bufTextEl = n.parentElement;
|
|
932
|
+
const tv = visUp(n.parentElement);
|
|
933
|
+
bufVis = bufVis === undefined || bufVis === tv ? tv : '';
|
|
934
|
+
buf.push(escapeText(n.textContent));
|
|
935
|
+
}
|
|
586
936
|
return;
|
|
587
937
|
}
|
|
588
938
|
if (n.nodeType !== 1) return;
|
|
589
939
|
if (isHidden(n)) return;
|
|
940
|
+
const marked = markerBlock(n);
|
|
941
|
+
if (marked) {
|
|
942
|
+
flush();
|
|
943
|
+
const mv = visUp(n);
|
|
944
|
+
if (mv) marked.props.vis = mv;
|
|
945
|
+
out.push(marked);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
590
948
|
const target = unwrapBoxDiv(n);
|
|
591
949
|
const b = classifyNode(target);
|
|
592
950
|
if (b) { flush(); out.push(b); return; }
|
|
@@ -616,10 +974,20 @@ function blocksFromNodes(nodes) {
|
|
|
616
974
|
// run: the exporter writes every text block as exactly such a padded div,
|
|
617
975
|
// and buffering two of them together merged neighbouring blocks into one
|
|
618
976
|
// -- the second lost its padding, size, everything -- on every save.
|
|
619
|
-
|
|
977
|
+
// A box-div unwrap (target !== n) marks a block boundary just as surely
|
|
978
|
+
// as padding does: the exporter writes exactly one such wrapper per
|
|
979
|
+
// block, and without this two zero-padded text blocks buffered into one
|
|
980
|
+
// -- the second lost its size, weight, everything -- on every save.
|
|
981
|
+
const boundary = n.nodeType === 1 && !INLINE_TAGS.test(target.tagName) && (hasOwnRunPad(target) || target !== n);
|
|
982
|
+
if (boundary && buf.length) flush();
|
|
620
983
|
if (!bufFirstEl) bufFirstEl = target;
|
|
984
|
+
bufEls.push(target);
|
|
985
|
+
// Read off `n`, not `target`: the visibility class rides the wrapper
|
|
986
|
+
// unwrapBoxDiv deliberately sees through.
|
|
987
|
+
const ev = visUp(n);
|
|
988
|
+
bufVis = bufVis === undefined || bufVis === ev ? ev : '';
|
|
621
989
|
buf.push(n.outerHTML);
|
|
622
|
-
if (
|
|
990
|
+
if (boundary) flush();
|
|
623
991
|
});
|
|
624
992
|
flush();
|
|
625
993
|
return out;
|
|
@@ -794,7 +1162,10 @@ function unwrapNestedLayout(td) {
|
|
|
794
1162
|
if (classifyButton(td) || classifySocial(only)) return null;
|
|
795
1163
|
const trs = only.querySelectorAll(':scope > tbody > tr, :scope > tr');
|
|
796
1164
|
if (trs.length !== 1) return null;
|
|
797
|
-
|
|
1165
|
+
// Header cells veto the unwrap only on the candidate's OWN row: a th
|
|
1166
|
+
// anywhere deeper is some block's content (a data table in a column), and
|
|
1167
|
+
// vetoing on it left the gap cell around that block unread.
|
|
1168
|
+
if (only.querySelector(':scope > tbody > tr > th, :scope > tr > th')) return null;
|
|
798
1169
|
const cells = Array.from(trs[0].children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
|
|
799
1170
|
return cells.length ? cells : null;
|
|
800
1171
|
}
|
|
@@ -875,14 +1246,20 @@ function looksLikeContainer(el) {
|
|
|
875
1246
|
return Array.from(el.children).some((c) => c.tagName === 'TABLE' || c.tagName === 'DIV' || c.tagName === 'CENTER');
|
|
876
1247
|
}
|
|
877
1248
|
|
|
1249
|
+
/** 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. */
|
|
1250
|
+
function isMarkerRow(r) {
|
|
1251
|
+
const blocks = r.cols.reduce((a, c) => a.concat(c.blocks), []);
|
|
1252
|
+
return blocks.length > 0 && blocks.every((b) => b.type === 'condition' || b.type === 'loop');
|
|
1253
|
+
}
|
|
1254
|
+
|
|
878
1255
|
function applyBg(rows, bg) {
|
|
879
|
-
if (bg) rows.forEach((r) => { if (!r.props.bg) r.props.bg = bg; });
|
|
1256
|
+
if (bg) rows.forEach((r) => { if (!r.props.bg && !isMarkerRow(r)) r.props.bg = bg; });
|
|
880
1257
|
return rows;
|
|
881
1258
|
}
|
|
882
1259
|
|
|
883
1260
|
/** 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. */
|
|
884
1261
|
function applyPad(rows, pad) {
|
|
885
|
-
if (pad) rows.forEach((r) => { if (!r.props.py && !r.props.px && r.props.pt === undefined) setRowPad(r, pad); });
|
|
1262
|
+
if (pad) rows.forEach((r) => { if (!r.props.py && !r.props.px && r.props.pt === undefined && !isMarkerRow(r)) setRowPad(r, pad); });
|
|
886
1263
|
return rows;
|
|
887
1264
|
}
|
|
888
1265
|
|
|
@@ -979,17 +1356,49 @@ function rowsFromContentTable(table) {
|
|
|
979
1356
|
}
|
|
980
1357
|
const outerCells = Array.from(tr.children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
|
|
981
1358
|
if (!outerCells.length) return null;
|
|
982
|
-
|
|
1359
|
+
let bgSource = outerCells[0];
|
|
1360
|
+
// The margin / Max-width wrapper (core/export.js `boxed`): margins on a
|
|
1361
|
+
// <td> are inert in mail clients, so a row using either ships them on a
|
|
1362
|
+
// div inside the cell -- which also carries the row's paint and padding.
|
|
1363
|
+
// When it is there, IT is the row: everything below reads styles off
|
|
1364
|
+
// `bgSource`, so re-pointing here is the whole unhoist. Detected by a
|
|
1365
|
+
// real margin component or a %-cap, never by `margin:0` alone -- that is
|
|
1366
|
+
// the exporter's see-through box div around a block.
|
|
1367
|
+
let rowCap = 0;
|
|
1368
|
+
{
|
|
1369
|
+
const lone = onlyChild(bgSource, 'DIV');
|
|
1370
|
+
const mw = (lone && lone.style.maxWidth) || '';
|
|
1371
|
+
const capped = String(mw).endsWith('%') && PX(mw) > 0 && PX(mw) < 100;
|
|
1372
|
+
if (lone && (capped || PX(lone.style.marginTop) || PX(lone.style.marginBottom) || PX(lone.style.marginLeft) || PX(lone.style.marginRight))) {
|
|
1373
|
+
if (capped) rowCap = PX(mw);
|
|
1374
|
+
bgSource = lone;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
983
1377
|
let cells = outerCells;
|
|
984
|
-
|
|
1378
|
+
// A flex/grid row: no table walker can re-shape one (its columns are
|
|
1379
|
+
// divs), so the exporter stamps the row's settings on the wrapper as
|
|
1380
|
+
// `data-mcr` and the columns are its child divs. Without the marker the
|
|
1381
|
+
// old collapse-to-one-column behaviour stands (foreign HTML, or a host
|
|
1382
|
+
// exporting with markers off).
|
|
1383
|
+
let layoutCfg = null;
|
|
1384
|
+
let layoutDiv = onlyChild(bgSource, 'DIV');
|
|
1385
|
+
if (layoutDiv && !(layoutDiv.getAttribute && layoutDiv.getAttribute('data-mcr'))) layoutDiv = null;
|
|
1386
|
+
if (layoutDiv) {
|
|
1387
|
+
try { layoutCfg = JSON.parse(layoutDiv.getAttribute('data-mcr')) || null; } catch { layoutCfg = null; }
|
|
1388
|
+
const colDivs = Array.from(layoutDiv.children).filter((k) => k.tagName === 'DIV');
|
|
1389
|
+
if (layoutCfg && colDivs.length) cells = colDivs;
|
|
1390
|
+
else { layoutCfg = null; layoutDiv = null; }
|
|
1391
|
+
}
|
|
1392
|
+
if (!layoutCfg && outerCells.length === 1) {
|
|
985
1393
|
// The table shape first, since it is unambiguous; the CSS-layout shape
|
|
986
1394
|
// only when there is no table row to read.
|
|
987
|
-
const nested = unwrapNestedLayout(
|
|
1395
|
+
const nested = unwrapNestedLayout(bgSource);
|
|
988
1396
|
if (nested) cells = nested;
|
|
989
1397
|
else {
|
|
990
|
-
const inline = inlineColumnGroup(
|
|
1398
|
+
const inline = inlineColumnGroup(bgSource.childNodes);
|
|
991
1399
|
if (inline) cells = inline;
|
|
992
1400
|
}
|
|
1401
|
+
if (cells === outerCells && bgSource !== outerCells[0]) cells = [bgSource];
|
|
993
1402
|
}
|
|
994
1403
|
// Spacer columns: a content-free `<td>` (often `class="column gap"`,
|
|
995
1404
|
// holding only an empty fixed-width table) between real columns exists
|
|
@@ -1018,16 +1427,74 @@ function rowsFromContentTable(table) {
|
|
|
1018
1427
|
// column gap) describe a gap, not row padding. Recognizing it keeps
|
|
1019
1428
|
// gap -> export -> import a fixed point instead of drifting into padding.
|
|
1020
1429
|
let gutter = false;
|
|
1021
|
-
|
|
1430
|
+
// Single-column rows carry the same gutter (the exporter writes
|
|
1431
|
+
// `padding:0 gap/2` on every cell, one column or four) -- but only a cell
|
|
1432
|
+
// an unwrap produced qualifies: a flat foreign `<td style="padding:0 24px">`
|
|
1433
|
+
// has always read as row padding, and visually the two are the same, so
|
|
1434
|
+
// that behaviour must not shift under existing imports.
|
|
1435
|
+
if (!gapPx && (cells.length > 1 || cells[0] !== bgSource)) {
|
|
1022
1436
|
const pads = cells.map((c) => paddingOf(c.style));
|
|
1023
1437
|
const p0 = pads[0];
|
|
1024
1438
|
gutter = !!(p0 && !p0.t && !p0.b && p0.l > 0 && p0.l === p0.r && p0.l <= 60
|
|
1025
1439
|
&& pads.every((pp) => pp && !pp.t && !pp.b && pp.l === p0.l && pp.r === p0.r));
|
|
1026
1440
|
if (gutter) gapPx = p0.l * 2;
|
|
1027
1441
|
}
|
|
1442
|
+
// The unwrap can be refused on purpose (a social strip's layout table
|
|
1443
|
+
// must stay one block -- see unwrapNestedLayout), which also hides the
|
|
1444
|
+
// gap cell inside it. Peek at that one cell for the same pure-horizontal
|
|
1445
|
+
// signature; the strip itself is still classified by the cell walk below.
|
|
1446
|
+
// Never past a bulletproof button, whose padded cell is the pill, not a
|
|
1447
|
+
// gutter.
|
|
1448
|
+
if (!gapPx && cells.length === 1 && cells[0] === bgSource && !classifyButton(bgSource)) {
|
|
1449
|
+
const only = onlyChild(bgSource, 'TABLE');
|
|
1450
|
+
const innerTds = only ? Array.from(only.querySelectorAll(':scope > tbody > tr > td, :scope > tr > td')) : [];
|
|
1451
|
+
if (innerTds.length === 1) {
|
|
1452
|
+
const pd = paddingOf(innerTds[0].style);
|
|
1453
|
+
if (pd && !pd.t && !pd.b && pd.l > 0 && pd.l === pd.r && pd.l <= 60) gapPx = pd.l * 2;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1028
1456
|
const spans = cells.length === 1 ? [100] : spansFromCells(cells, tableWidthPx);
|
|
1029
1457
|
const row = mkRow(spans);
|
|
1030
1458
|
row.props.py = 0; row.props.px = 0; row.props.gap = gapPx;
|
|
1459
|
+
// Vertical alignment ships as the attribute on every cell; it only means
|
|
1460
|
+
// something the cells agree on (a foreign row aligning each column its
|
|
1461
|
+
// own way has no single row value to take).
|
|
1462
|
+
const va = cells[0].getAttribute && cells[0].getAttribute('valign');
|
|
1463
|
+
if ((va === 'middle' || va === 'bottom') && cells.every((c) => c.getAttribute('valign') === va)) row.props.valign = va;
|
|
1464
|
+
// Mobile behaviour, from the classes the exporter's media query targets
|
|
1465
|
+
// (they survive css-cascade untouched -- @media rules are never folded).
|
|
1466
|
+
// Only ever set on a real multi-column row, and only from an explicit
|
|
1467
|
+
// class: `keep` exports NO class and so cannot be told apart from foreign
|
|
1468
|
+
// HTML, where stacking (the default) is the safer read.
|
|
1469
|
+
if (cells.length > 1) {
|
|
1470
|
+
const trCls = (cells[0].parentElement && cells[0].parentElement.getAttribute('class')) || '';
|
|
1471
|
+
if (/\bmc-2up\b/.test(trCls)) row.props.mobileCols = 2;
|
|
1472
|
+
if (/\bmc-rev\b/.test(trCls)) row.props.mobileOrder = 'reverse';
|
|
1473
|
+
// `keep` is inert-class-marked (mc-keep, no stylesheet rule): with no
|
|
1474
|
+
// explicit class it cannot be told apart from foreign HTML, where
|
|
1475
|
+
// stacking -- the default -- is the safer read.
|
|
1476
|
+
if (/\bmc-keep\b/.test(trCls)) row.props.mobileCols = 'keep';
|
|
1477
|
+
}
|
|
1478
|
+
if (layoutCfg) {
|
|
1479
|
+
Object.assign(row.props, {
|
|
1480
|
+
layout: layoutCfg.layout === 'grid' ? 'grid' : 'flex',
|
|
1481
|
+
flexDir: layoutCfg.flexDir || 'row', justify: layoutCfg.justify || 'flex-start',
|
|
1482
|
+
alignItems: layoutCfg.alignItems || 'stretch', wrap: layoutCfg.wrap !== false,
|
|
1483
|
+
gridCols: layoutCfg.gridCols || 2, gap: layoutCfg.gap || 0,
|
|
1484
|
+
});
|
|
1485
|
+
if (Array.isArray(layoutCfg.spans) && layoutCfg.spans.length === row.cols.length) {
|
|
1486
|
+
layoutCfg.spans.forEach((sp, i) => { if (Number(sp) > 0) row.cols[i].span = Number(sp); });
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
// The row's outside margins, straight off the style the exporter writes.
|
|
1490
|
+
// Mail clients ignore margins on a <td> (a known export gap), but the
|
|
1491
|
+
// values are the user's -- dropping them zeroed the four sliders on
|
|
1492
|
+
// every reload.
|
|
1493
|
+
if (bgSource.style) {
|
|
1494
|
+
const mg = { mt: PX(bgSource.style.marginTop), mr: PX(bgSource.style.marginRight), mb: PX(bgSource.style.marginBottom), ml: PX(bgSource.style.marginLeft) };
|
|
1495
|
+
if (mg.mt || mg.mr || mg.mb || mg.ml) Object.assign(row.props, mg);
|
|
1496
|
+
}
|
|
1497
|
+
if (rowCap) row.props.maxW = rowCap;
|
|
1031
1498
|
// Background and frame can live on the first cell OR on the table itself
|
|
1032
1499
|
// (builders style `table.row-content`, not its tds) -- read the cell
|
|
1033
1500
|
// first, the table as fallback. Cell-derived values only count when every
|
|
@@ -1045,7 +1512,10 @@ function rowsFromContentTable(table) {
|
|
|
1045
1512
|
// outer wrapper around all columns. When bgSource IS cells[0] (flat rows),
|
|
1046
1513
|
// falling back to it would re-promote the first card's color to the row.
|
|
1047
1514
|
const outerBg = cells.indexOf(bgSource) > -1 ? '' : bgOf(bgSource);
|
|
1048
|
-
|
|
1515
|
+
// The tr between the cells and the table carries the legacy per-row
|
|
1516
|
+
// `bgcolor` old-school templates still set; it sits between the two in
|
|
1517
|
+
// specificity.
|
|
1518
|
+
const bg = (cellsUniform && cellBg) || bgOf(tr) || outerBg || bgOf(table);
|
|
1049
1519
|
if (bg) row.props.bg = bg;
|
|
1050
1520
|
// Differently-styled cells become per-column styling: each column keeps
|
|
1051
1521
|
// its own background/radius/padding (the pastel-cards pattern).
|
|
@@ -1103,11 +1573,20 @@ function rowsFromContentTable(table) {
|
|
|
1103
1573
|
// its children, or the whole card collapses into one opaque text blob.
|
|
1104
1574
|
let contentEl = cell;
|
|
1105
1575
|
const lone = onlyChild(cell, 'DIV');
|
|
1106
|
-
if (lone && (bgOf(lone) || radiusOf(lone.style)) && !classifyNode(lone)) {
|
|
1576
|
+
if (lone && (bgOf(lone) || radiusOf(lone.style) || borderSidesOf(lone.style).width) && !classifyNode(lone)) {
|
|
1107
1577
|
const col = row.cols[i];
|
|
1108
1578
|
if (col) {
|
|
1109
1579
|
const cbg = bgOf(lone); if (cbg && !col.bg) col.bg = cbg;
|
|
1110
1580
|
const crad = radiusOf(lone.style); if (crad && !col.radius) col.radius = crad;
|
|
1581
|
+
// The column's own border ships on this same wrapper (core/export.js
|
|
1582
|
+
// writes bg, border, radius and padding together); reading everything
|
|
1583
|
+
// but the border dropped a column frame on every save/reload.
|
|
1584
|
+
const cframe = borderSidesOf(lone.style);
|
|
1585
|
+
if (cframe.width && !col.border) {
|
|
1586
|
+
col.border = cframe.width;
|
|
1587
|
+
col.borderStyle = borderStyleOf(lone.style);
|
|
1588
|
+
col.lineColor = borderColorOf(lone.style) || '#e2e2e5';
|
|
1589
|
+
}
|
|
1111
1590
|
const cpd = paddingOf(lone.style);
|
|
1112
1591
|
if (cpd && col.padY === undefined) { col.padY = cpd.py; col.padX = cpd.px; }
|
|
1113
1592
|
}
|
|
@@ -1186,7 +1665,17 @@ function collectRows(nodes) {
|
|
|
1186
1665
|
};
|
|
1187
1666
|
nodes.forEach((n) => {
|
|
1188
1667
|
if (n.nodeType === 8) return; // HTML comments (Outlook/MSO conditionals) -- inert
|
|
1189
|
-
if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName))
|
|
1668
|
+
if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName)) {
|
|
1669
|
+
const mb = markerBlock(n);
|
|
1670
|
+
if (mb) {
|
|
1671
|
+
flushBuf();
|
|
1672
|
+
const row = mkRow([100]);
|
|
1673
|
+
row.props.py = 0; row.props.px = 0; row.props.gap = 0;
|
|
1674
|
+
row.cols[0].blocks = [mb];
|
|
1675
|
+
rows.push(row);
|
|
1676
|
+
}
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1190
1679
|
if (n.nodeType === 1 && isHidden(n)) return;
|
|
1191
1680
|
// A lone column: its blocks belong to one row, not one row each.
|
|
1192
1681
|
if (n.nodeType === 1 && isColumnContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
|
|
@@ -1200,9 +1689,17 @@ function collectRows(nodes) {
|
|
|
1200
1689
|
return;
|
|
1201
1690
|
}
|
|
1202
1691
|
}
|
|
1203
|
-
|
|
1692
|
+
// Container-ness is judged on the UNWRAPPED element where the unwrap
|
|
1693
|
+
// stays a div/center: a margin-only box div around one padded text div is
|
|
1694
|
+
// the exporter's block wrapper, and walking it as a section split every
|
|
1695
|
+
// block of a single-row document into its own row. An unwrap that lands
|
|
1696
|
+
// on something else (a table -- Beefree's divider ships as
|
|
1697
|
+
// div > 20%-table) keeps the old judgement of the wrapper itself.
|
|
1698
|
+
const seen = n.nodeType === 1 && (n.tagName === 'DIV' || n.tagName === 'CENTER') ? unwrapBoxDiv(n) : null;
|
|
1699
|
+
const containerish = seen && (seen.tagName === 'DIV' || seen.tagName === 'CENTER' ? looksLikeContainer(seen) : looksLikeContainer(n));
|
|
1700
|
+
if (seen && containerish && !classifyNode(seen)) {
|
|
1204
1701
|
flushBuf();
|
|
1205
|
-
const inner = collectRows(Array.from(n.childNodes));
|
|
1702
|
+
const inner = mergeBandRows(collectRows(Array.from(n.childNodes)), n);
|
|
1206
1703
|
rows.push(...applyBgImage(applyFrame(applyPad(applyBg(inner, bgOf(n)), padOf(n)), n), n));
|
|
1207
1704
|
return;
|
|
1208
1705
|
}
|
|
@@ -1243,11 +1740,37 @@ function collectRows(nodes) {
|
|
|
1243
1740
|
rows.push(row);
|
|
1244
1741
|
return;
|
|
1245
1742
|
}
|
|
1246
|
-
|
|
1743
|
+
// The other table-email rule idiom: a content-free cell whose only
|
|
1744
|
+
// drawing is a `border-top` (Beefree's `td.divider_inner`, usually in
|
|
1745
|
+
// a width="20%" inner table). As a text run it imported as a junk row
|
|
1746
|
+
// holding one hair space; recognized, it is the divider it draws.
|
|
1747
|
+
const dSides = borderSidesOf(td.style);
|
|
1748
|
+
if (dSides.sides.top > 0 && dSides.sides.top <= 10
|
|
1749
|
+
&& !dSides.sides.right && !dSides.sides.bottom && !dSides.sides.left
|
|
1750
|
+
&& !bgOf(td) && !bgImageOf(td)
|
|
1751
|
+
&& !Array.from(td.children).some((c) => c.tagName !== 'SPAN' && c.tagName !== 'BR')
|
|
1752
|
+
&& !(td.textContent || '').replace(/\s/g, '')) {
|
|
1753
|
+
const dw = String(n.getAttribute('width') || n.style.width || '');
|
|
1754
|
+
const row = mkRow([100]);
|
|
1755
|
+
row.props.py = 0; row.props.px = 0; row.props.gap = 0;
|
|
1756
|
+
row.cols[0].blocks = [blk('divider', {
|
|
1757
|
+
thickness: dSides.sides.top,
|
|
1758
|
+
lineStyle: borderStyleOf(td.style),
|
|
1759
|
+
color: borderColorOf(td.style) || '#d9dade',
|
|
1760
|
+
width: dw.endsWith('%') ? PX(dw) : 100,
|
|
1761
|
+
})];
|
|
1762
|
+
rows.push(row);
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
const inner = mergeBandRows(mergeBandRows(collectRows(Array.from(td.childNodes)), td), n);
|
|
1247
1766
|
// Frame styles live on the td for some builders and on the table
|
|
1248
1767
|
// itself for others (`table.row-content` carries the card border) --
|
|
1249
1768
|
// applyFrame fills only what's still unset, so trying both is safe.
|
|
1250
|
-
|
|
1769
|
+
// The td is the more specific background-color source -- a dark hero
|
|
1770
|
+
// cell inside a white content table keeps its own color, not the
|
|
1771
|
+
// wrapper's (the image already reads td-first via the applyBgImage
|
|
1772
|
+
// order). The tr between them carries the legacy per-row bgcolor.
|
|
1773
|
+
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
1774
|
return;
|
|
1252
1775
|
}
|
|
1253
1776
|
flushBuf();
|
|
@@ -1289,6 +1812,16 @@ function fixedWidthOf(tb) {
|
|
|
1289
1812
|
return 0;
|
|
1290
1813
|
}
|
|
1291
1814
|
|
|
1815
|
+
/** 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. */
|
|
1816
|
+
function partialFrame(st) {
|
|
1817
|
+
if (!st) return false;
|
|
1818
|
+
const { sides } = borderSidesOf(st);
|
|
1819
|
+
const on = ['top', 'right', 'bottom', 'left'].filter((k) => sides[k] > 0).length;
|
|
1820
|
+
const corners = String(st.borderRadius || '').split(/\s+/).map(PX);
|
|
1821
|
+
const uneven = corners.length > 1 && corners.some((v) => v > 0) && corners.some((v) => !v);
|
|
1822
|
+
return (on > 0 && on < 4) || uneven;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1292
1825
|
function themeFromParsedDoc(doc) {
|
|
1293
1826
|
const theme = {};
|
|
1294
1827
|
const body = doc.body;
|
|
@@ -1313,6 +1846,10 @@ function themeFromParsedDoc(doc) {
|
|
|
1313
1846
|
if (bestWidth) {
|
|
1314
1847
|
const content = Array.from(body.querySelectorAll('table')).find((tb) => fixedWidthOf(tb) === Number(bestWidth));
|
|
1315
1848
|
if (content) {
|
|
1849
|
+
// Read before the frame is consumed below -- a fragment here means the
|
|
1850
|
+
// sibling tables hold the rest of the same card (see the pass further
|
|
1851
|
+
// down).
|
|
1852
|
+
const contentPartial = partialFrame(content.style);
|
|
1316
1853
|
// The content column's own background -- including the literal
|
|
1317
1854
|
// `transparent` the exporter always writes for a see-through column.
|
|
1318
1855
|
// Without this the round trip lost it: export wrote
|
|
@@ -1354,16 +1891,47 @@ function themeFromParsedDoc(doc) {
|
|
|
1354
1891
|
if (padX > 0) theme.padX = padX;
|
|
1355
1892
|
cell.style.padding = '';
|
|
1356
1893
|
}
|
|
1894
|
+
// A card drawn across several sibling content-width tables (top piece:
|
|
1895
|
+
// `border-radius:16px 16px 0 0; border-bottom:none`, middle pieces:
|
|
1896
|
+
// side borders only, bottom piece: `border-radius:0 0 16px 16px`) is
|
|
1897
|
+
// ONE visual frame -- the one just claimed. Left in place, the sibling
|
|
1898
|
+
// fragments imported as row borders inside the already-framed canvas:
|
|
1899
|
+
// doubled verticals down every row and a second rounded, shadowed box
|
|
1900
|
+
// around the last section. Only the split-card idiom trips this: the
|
|
1901
|
+
// claimed table must itself be a fragment (partial border or uneven
|
|
1902
|
+
// corners), and only fragments are stripped -- a template of genuinely
|
|
1903
|
+
// separate full cards has neither and is untouched. Horizontal edges
|
|
1904
|
+
// stay, since between two sections they read as a real separator; the
|
|
1905
|
+
// last fragment's bottom edge coincides with the frame's and goes.
|
|
1906
|
+
if (contentPartial) {
|
|
1907
|
+
const same = Array.from(body.querySelectorAll('table'))
|
|
1908
|
+
.filter((tb) => tb !== content && tb.style && fixedWidthOf(tb) === Number(bestWidth));
|
|
1909
|
+
same.forEach((tb, k) => {
|
|
1910
|
+
if (!partialFrame(tb.style)) return;
|
|
1911
|
+
tb.style.borderLeft = '';
|
|
1912
|
+
tb.style.borderRight = '';
|
|
1913
|
+
tb.style.borderRadius = '';
|
|
1914
|
+
tb.style.boxShadow = '';
|
|
1915
|
+
if (k === same.length - 1) tb.style.borderBottom = '';
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1357
1918
|
}
|
|
1358
1919
|
}
|
|
1359
|
-
//
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1920
|
+
// The body's own family first: it is where this exporter writes the theme
|
|
1921
|
+
// font, and `querySelectorAll` never sees the body itself -- so the scan
|
|
1922
|
+
// below used to crown the *first block's* effective font instead, and one
|
|
1923
|
+
// custom-font heading at the top of a document flipped the whole theme on
|
|
1924
|
+
// reload. The element scan stays as the fallback for foreign emails that
|
|
1925
|
+
// declare nothing on the body, preferring the first *real* stack (has a
|
|
1926
|
+
// comma or quotes) over a lone generic keyword: builders wrap everything in
|
|
1927
|
+
// a `font-family:sans-serif` shim div with the actual `'DM Sans', Arial,
|
|
1928
|
+
// ...` declared a level deeper.
|
|
1929
|
+
const bodyFont = (body.style && body.style.fontFamily) || '';
|
|
1362
1930
|
const fonts = Array.from(body.querySelectorAll('[style*="font-family"]'))
|
|
1363
1931
|
.filter((el) => (el.textContent || '').trim())
|
|
1364
1932
|
.map((el) => el.style.fontFamily)
|
|
1365
1933
|
.filter(Boolean);
|
|
1366
|
-
const font = fonts.find((v) => /[,"']/.test(v)) || fonts[0];
|
|
1934
|
+
const font = bodyFont || fonts.find((v) => /[,"']/.test(v)) || fonts[0];
|
|
1367
1935
|
if (font) theme.font = font;
|
|
1368
1936
|
// Link color: the most common inline anchor color -- skipping button
|
|
1369
1937
|
// pills, whose (usually white) label color would otherwise dominate a
|
|
@@ -1374,8 +1942,16 @@ function themeFromParsedDoc(doc) {
|
|
|
1374
1942
|
body.querySelectorAll('a[style*="color"]').forEach((a) => {
|
|
1375
1943
|
const c = a.style.color;
|
|
1376
1944
|
// Only text links vote -- icon links (social strips) carry an icon
|
|
1377
|
-
// color, not the document's link color.
|
|
1945
|
+
// color, not the document's link color. An anchor holding an icon still
|
|
1946
|
+
// has text when its network name is shown beside the glyph, so the icon
|
|
1947
|
+
// check is structural, not textual. Menu items are navigation chrome in
|
|
1948
|
+
// the block's own color (every exported item carries the uppercase +
|
|
1949
|
+
// letter-spacing signature) -- counting them let a three-item menu
|
|
1950
|
+
// outvote the document's actual links and rewrite theme.link on every
|
|
1951
|
+
// reload.
|
|
1378
1952
|
if (!c || c === 'inherit' || !(a.textContent || '').trim() || isPill(a)) return;
|
|
1953
|
+
if (a.querySelector('svg,img')) return;
|
|
1954
|
+
if (a.style.textTransform === 'uppercase' && a.style.letterSpacing) return;
|
|
1379
1955
|
linkCounts[c] = (linkCounts[c] || 0) + 1;
|
|
1380
1956
|
});
|
|
1381
1957
|
const link = Object.keys(linkCounts).sort((a, b) => linkCounts[b] - linkCounts[a])[0];
|
|
@@ -1410,6 +1986,67 @@ function foldLogicWrappers(src) {
|
|
|
1410
1986
|
return s;
|
|
1411
1987
|
}
|
|
1412
1988
|
|
|
1989
|
+
/**
|
|
1990
|
+
* The exporter writes every inherited value as a concrete declaration
|
|
1991
|
+
* (`p.fontFamily || t.font`, `p.color || t.text`, a row's
|
|
1992
|
+
* `rp.bg || t.contentBg || 'transparent'`), so a round trip used to come back
|
|
1993
|
+
* with the theme stamped onto every block and row as an explicit override --
|
|
1994
|
+
* visually identical, but the inherit relationship was gone: a later theme
|
|
1995
|
+
* edit (font, text ink, content background) no longer reached anything. A
|
|
1996
|
+
* value that merely restates what the imported theme already says folds back
|
|
1997
|
+
* to "inherit"; anything genuinely different is a real override and stays.
|
|
1998
|
+
*/
|
|
1999
|
+
/** 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. */
|
|
2000
|
+
function foldLinkColor(html, linkKey, ckey) {
|
|
2001
|
+
const src = String(html || '');
|
|
2002
|
+
if (!linkKey || src.indexOf('<a') < 0 || src.indexOf('color') < 0) return html;
|
|
2003
|
+
const doc = new DOMParser().parseFromString('<div id="mc-fold">' + src + '</div>', 'text/html');
|
|
2004
|
+
const root = doc.getElementById('mc-fold');
|
|
2005
|
+
if (!root) return html;
|
|
2006
|
+
let hit = false;
|
|
2007
|
+
root.querySelectorAll('a[style]').forEach((a) => {
|
|
2008
|
+
if (a.style.color && ckey(a.style.color) === linkKey) {
|
|
2009
|
+
a.style.removeProperty('color');
|
|
2010
|
+
if (!(a.getAttribute('style') || '').trim()) a.removeAttribute('style');
|
|
2011
|
+
hit = true;
|
|
2012
|
+
}
|
|
2013
|
+
});
|
|
2014
|
+
return hit ? root.innerHTML : html;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
function foldThemeInherits(rows, theme) {
|
|
2018
|
+
const tFont = fontKey(theme.font);
|
|
2019
|
+
// Colors compare through hexOf on BOTH sides: CSSOM hands the walkers
|
|
2020
|
+
// `rgb(...)` for every hex the exporter wrote, so a bare string comparison
|
|
2021
|
+
// saw `#172033` != `rgb(23, 32, 51)` and the fold silently never fired.
|
|
2022
|
+
const ckey = (v) => String(hexOf(v) || '').toLowerCase();
|
|
2023
|
+
const tText = ckey(theme.text);
|
|
2024
|
+
const cBg = ckey(theme.contentBg);
|
|
2025
|
+
const tLink = ckey(theme.link);
|
|
2026
|
+
// Only the types whose renderer falls back `p.color || t.text` -- an empty
|
|
2027
|
+
// color means "theme ink" for exactly these; other blocks' colors are
|
|
2028
|
+
// structural (a button label, a divider line) and must stay explicit.
|
|
2029
|
+
const inheritsInk = { text: 1, heading: 1, list: 1 };
|
|
2030
|
+
rows.forEach((row) => {
|
|
2031
|
+
const bg = ckey(row.props.bg);
|
|
2032
|
+
if (bg && bg === cBg) row.props.bg = '';
|
|
2033
|
+
row.cols.forEach((col) => {
|
|
2034
|
+
// A see-through column wrapper is the exporter's own scaffolding
|
|
2035
|
+
// (`background: transparent` is always written on a styled column),
|
|
2036
|
+
// never a chosen paint.
|
|
2037
|
+
if (String(col.bg || '').toLowerCase() === 'transparent') col.bg = '';
|
|
2038
|
+
col.blocks.forEach((b) => {
|
|
2039
|
+
if (tFont && b.props.fontFamily && fontKey(b.props.fontFamily) === tFont) b.props.fontFamily = '';
|
|
2040
|
+
if (tText && inheritsInk[b.type] && ckey(b.props.color) === tText) b.props.color = '';
|
|
2041
|
+
if (tLink && theme.link && typeof b.props.html === 'string') b.props.html = foldLinkColor(b.props.html, tLink, ckey);
|
|
2042
|
+
if (tLink && theme.link && b.type === 'list' && b.props.items) {
|
|
2043
|
+
b.props.items = String(b.props.items).split('\n').map((l) => foldLinkColor(l, tLink, ckey)).join('\n');
|
|
2044
|
+
}
|
|
2045
|
+
});
|
|
2046
|
+
});
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
|
|
1413
2050
|
/** 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. */
|
|
1414
2051
|
export function htmlToDoc(src) {
|
|
1415
2052
|
let doc;
|
|
@@ -1421,7 +2058,9 @@ export function htmlToDoc(src) {
|
|
|
1421
2058
|
// Theme first: themeFromParsedDoc consumes the styles it claims off the
|
|
1422
2059
|
// scaffold nodes, and the row walker must see the cleaned DOM.
|
|
1423
2060
|
const theme = themeFromParsedDoc(doc);
|
|
1424
|
-
|
|
2061
|
+
const rows = collectRows(Array.from(doc.body.childNodes));
|
|
2062
|
+
foldThemeInherits(rows, theme);
|
|
2063
|
+
return { rows, theme };
|
|
1425
2064
|
}
|
|
1426
2065
|
|
|
1427
2066
|
export function htmlToRows(src) {
|