@seliseblocks/mailcraft 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/DOCS.md +27 -27
- package/README.md +1 -1
- package/README.md.txt +1 -1
- package/dist/mailcraft-editor.bundle.js +76 -48
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -1
- package/src/core/blocks.js +15 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +88 -6
- package/src/core/export.js +195 -6
- package/src/core/i18n/index.js +83 -83
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +482 -31
- 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 +69 -1
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +41 -2
- package/src/render/block-body.js +109 -12
- package/src/render/canvas.js +45 -3
- package/src/render/fields.js +28 -2
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +6 -0
- package/src/render/story.js +415 -415
package/src/core/import-html.js
CHANGED
|
@@ -88,7 +88,33 @@ function cellPadOf(tb) {
|
|
|
88
88
|
* zero font. A zero font-size ALONE is not hidden: emails set it on
|
|
89
89
|
* whitespace-collapsing wrappers around buttons and menus.
|
|
90
90
|
*/
|
|
91
|
+
/**
|
|
92
|
+
* A device-visibility wrapper, as a `vis` value -- or '' for ordinary content.
|
|
93
|
+
*
|
|
94
|
+
* Both this exporter's `mc-only-d`/`mc-only-m` and the `desktop_hide`/
|
|
95
|
+
* `mobile_hide` convention every other builder uses (BEE, Stripo, Mailchimp)
|
|
96
|
+
* are recognised, so a foreign template's device variants import as an
|
|
97
|
+
* editable property instead of being silently thrown away.
|
|
98
|
+
*
|
|
99
|
+
* This has to be consulted *before* `isHidden`, and that is the whole point:
|
|
100
|
+
* a mobile-only block is deliberately `display:none` in the base stylesheet
|
|
101
|
+
* so Classic Outlook -- which never reads a media query -- does not show it.
|
|
102
|
+
* The importer folds those base rules inline (core/css-cascade.js drops the
|
|
103
|
+
* `@media` block that would have un-hidden it), so without this the block
|
|
104
|
+
* looked exactly like a hidden preheader and was dropped on re-import,
|
|
105
|
+
* losing content the user had authored.
|
|
106
|
+
*/
|
|
107
|
+
function visibilityOf(el) {
|
|
108
|
+
const cls = (el && el.getAttribute && el.getAttribute('class')) || '';
|
|
109
|
+
if (!cls) return '';
|
|
110
|
+
if (/\b(?:mc-only-m|mobile_hide|mobile-hide)\b/.test(cls)) return 'mobile';
|
|
111
|
+
if (/\b(?:mc-only-d|desktop_hide|desktop-hide)\b/.test(cls)) return 'desktop';
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
|
|
91
115
|
function isHidden(el) {
|
|
116
|
+
// Hidden *on this device* is not hidden: see visibilityOf.
|
|
117
|
+
if (visibilityOf(el)) return false;
|
|
92
118
|
const st = el.style;
|
|
93
119
|
if (!st) return false;
|
|
94
120
|
if (st.display === 'none' || st.visibility === 'hidden') return true;
|
|
@@ -124,6 +150,43 @@ function applyBgImage(rows, el) {
|
|
|
124
150
|
return rows;
|
|
125
151
|
}
|
|
126
152
|
|
|
153
|
+
/**
|
|
154
|
+
* A wrapper that paints one background image is one visual band: builders
|
|
155
|
+
* (Beefree, Stripo) write a hero section as `table.row[background-image]`
|
|
156
|
+
* holding a single-column stack of per-block tables, and letting the generic
|
|
157
|
+
* walk turn each block into its own row stamped the image onto every one --
|
|
158
|
+
* so the export re-drew the hero's top slice once per block instead of one
|
|
159
|
+
* image flowing behind the section. Folds those rows back into a single row,
|
|
160
|
+
* moving each row's padding onto its blocks (same convention as the
|
|
161
|
+
* block-table unwrap in `blocksFromNodes`). Deliberately conservative: only
|
|
162
|
+
* single-column rows with no styling of their own (a differing background,
|
|
163
|
+
* frame or image on a row is a real band boundary) are merged, so everything
|
|
164
|
+
* that isn't the hero shape walks exactly as before.
|
|
165
|
+
*/
|
|
166
|
+
function mergeBandRows(rows, el) {
|
|
167
|
+
if (rows.length < 2 || !bgImageOf(el)) return rows;
|
|
168
|
+
const bg0 = rows[0].props.bg || '';
|
|
169
|
+
const plain = rows.every((r) => r.cols.length === 1
|
|
170
|
+
&& !r.props.bgImage && !r.props.border && !r.props.radius && !r.props.shadow
|
|
171
|
+
&& (r.props.bg || '') === bg0);
|
|
172
|
+
if (!plain) return rows;
|
|
173
|
+
const merged = mkRow([100]);
|
|
174
|
+
merged.props.py = 0; merged.props.px = 0; merged.props.gap = 0;
|
|
175
|
+
if (bg0) merged.props.bg = bg0;
|
|
176
|
+
merged.cols[0].blocks = rows.reduce((acc, r) => {
|
|
177
|
+
const padded = r.props.py || r.props.px || r.props.pt !== undefined;
|
|
178
|
+
r.cols[0].blocks.forEach((b) => {
|
|
179
|
+
if (padded && b.type !== 'button' && 'py' in b.props) {
|
|
180
|
+
b.props.py = r.props.py;
|
|
181
|
+
if ('px' in b.props) b.props.px = r.props.px;
|
|
182
|
+
}
|
|
183
|
+
acc.push(b);
|
|
184
|
+
});
|
|
185
|
+
return acc;
|
|
186
|
+
}, []);
|
|
187
|
+
return merged.cols[0].blocks.length ? [merged] : rows;
|
|
188
|
+
}
|
|
189
|
+
|
|
127
190
|
/** Padding read from the longhands, which are populated by the `padding` shorthand too -- but not vice versa: builders that write `padding-top/-left/...` individually (Beefree et al.) read back an empty `style.padding`, which is how every one of their cells imported with zero padding. Carries the exact per-side values plus the averaged py/px pair for consumers that only have a pair to store. */
|
|
128
191
|
function paddingOf(st) {
|
|
129
192
|
if (!st) return null;
|
|
@@ -209,10 +272,25 @@ function classifyImage(el) {
|
|
|
209
272
|
}
|
|
210
273
|
|
|
211
274
|
function classifyButton(el) {
|
|
212
|
-
let a = null; let outerAlign = '';
|
|
275
|
+
let a = null; let outerAlign = ''; let pillTable = null;
|
|
213
276
|
if (el.tagName === 'A') a = el;
|
|
214
277
|
else if (el.tagName === 'DIV' || el.tagName === 'TD') {
|
|
215
278
|
a = onlyChild(el, 'A');
|
|
279
|
+
// The bulletproof shape: the anchor is wrapped in a one-cell table so
|
|
280
|
+
// Word has a `<td>` to paint and pad (this exporter emits that, and so
|
|
281
|
+
// does every hand-written bulletproof button). Reached through the
|
|
282
|
+
// wrapper rather than the cell because that is the node the row walker
|
|
283
|
+
// offers, and the wrapper is also what carries the alignment.
|
|
284
|
+
if (!a) {
|
|
285
|
+
pillTable = onlyChild(el, 'TABLE');
|
|
286
|
+
// Direct row cells only. A descendant search would count the `<td>`s
|
|
287
|
+
// inside whatever the cell contains, so a layout row holding one block
|
|
288
|
+
// could look like a one-cell button table -- or stop looking like one
|
|
289
|
+
// the moment the block itself contained a table.
|
|
290
|
+
const cells = pillTable ? pillTable.querySelectorAll(':scope > tbody > tr > td, :scope > tr > td') : [];
|
|
291
|
+
if (cells.length === 1) a = onlyChild(cells[0], 'A');
|
|
292
|
+
if (!a) pillTable = null;
|
|
293
|
+
}
|
|
216
294
|
if (a) outerAlign = el.style.textAlign || el.getAttribute('align') || '';
|
|
217
295
|
}
|
|
218
296
|
if (!a) return null;
|
|
@@ -221,11 +299,16 @@ function classifyButton(el) {
|
|
|
221
299
|
// generators (Beefree et al.) leave the `<a>` bare and hang the background,
|
|
222
300
|
// radius and padding on nested `<span>`s inside it. Whichever element
|
|
223
301
|
// carries the background is the pill; padding may sit a level deeper still.
|
|
224
|
-
const hasBg = (e) => !!(e.style && (e.style.backgroundColor || e.style.background));
|
|
225
|
-
|
|
302
|
+
const hasBg = (e) => !!(e.style && (e.style.backgroundColor || e.style.background)) || !!(e.getAttribute && e.getAttribute('bgcolor'));
|
|
303
|
+
// The cell is checked last and only as a fallback: an anchor that paints
|
|
304
|
+
// its own pill still describes the button best (that is where the radius
|
|
305
|
+
// and the label colour sit), and the cell is what carries them when the
|
|
306
|
+
// source put the paint and the padding on the `<td>` instead.
|
|
307
|
+
const cell = a.closest ? a.closest('td') : null;
|
|
308
|
+
const pill = hasBg(a) ? a : (Array.from(a.querySelectorAll('span')).find(hasBg) || (cell && hasBg(cell) ? cell : null));
|
|
226
309
|
if (!pill) return null;
|
|
227
310
|
const st = pill.style;
|
|
228
|
-
let pad = paddingOf(a.style) || paddingOf(st);
|
|
311
|
+
let pad = paddingOf(a.style) || paddingOf(st) || (cell ? paddingOf(cell.style) : null);
|
|
229
312
|
if (!pad) {
|
|
230
313
|
const padded = Array.from(pill.querySelectorAll('span')).find((s) => paddingOf(s.style));
|
|
231
314
|
if (padded) pad = paddingOf(padded.style);
|
|
@@ -235,8 +318,8 @@ function classifyButton(el) {
|
|
|
235
318
|
const over = {
|
|
236
319
|
label: (a.textContent || '').trim() || 'Button',
|
|
237
320
|
href: a.getAttribute('href') || '#',
|
|
238
|
-
bg: st.backgroundColor || st.background,
|
|
239
|
-
color:
|
|
321
|
+
bg: st.backgroundColor || st.background || (pill.getAttribute && pill.getAttribute('bgcolor')) || '',
|
|
322
|
+
color: a.style.color || st.color || '#ffffff',
|
|
240
323
|
radius: radiusOf(st),
|
|
241
324
|
py: (pad && pad.py) || 13,
|
|
242
325
|
px: (pad && pad.px) || 26,
|
|
@@ -245,13 +328,23 @@ function classifyButton(el) {
|
|
|
245
328
|
// about where the pill sits in the row -- that's the container's call,
|
|
246
329
|
// so alignment is read starting at the parent, never at the anchor.
|
|
247
330
|
align: outerAlign || textAlignOf(a.parentElement || a),
|
|
248
|
-
|
|
331
|
+
// In the one-cell shape the anchor is always `display:block` (it fills
|
|
332
|
+
// the padded cell), so full-width has to be read off the table instead --
|
|
333
|
+
// otherwise every bulletproof button imports as a full-width one.
|
|
334
|
+
full: pillTable
|
|
335
|
+
? /100%/.test((pillTable.style && pillTable.style.width) || pillTable.getAttribute('width') || '')
|
|
336
|
+
: a.style.display === 'block',
|
|
249
337
|
};
|
|
250
338
|
const size = fontPx(st.fontSize) || fontPx(a.style.fontSize);
|
|
251
339
|
if (size) over.size = size;
|
|
252
|
-
// Outline buttons: transparent fill, the pill drawn by its border.
|
|
253
|
-
|
|
254
|
-
|
|
340
|
+
// Outline buttons: transparent fill, the pill drawn by its border. The
|
|
341
|
+
// border is looked for on the cell as well as the pill, because in the
|
|
342
|
+
// one-cell shape the paint and the frame are on different elements -- the
|
|
343
|
+
// anchor is the pill (it carries the background) while the `<td>` draws the
|
|
344
|
+
// outline. Reading only the pill dropped `borderW` on every round trip.
|
|
345
|
+
const frame = borderSidesOf(st).width ? st : (cell && cell.style && borderSidesOf(cell.style).width ? cell.style : st);
|
|
346
|
+
const bw = borderSidesOf(frame).width;
|
|
347
|
+
if (bw) { over.borderW = bw; over.borderStyle = borderStyleOf(frame); over.borderColor = borderColorOf(frame) || over.color; }
|
|
255
348
|
return blk('button', over);
|
|
256
349
|
}
|
|
257
350
|
|
|
@@ -359,15 +452,29 @@ function classifyHeading(el) {
|
|
|
359
452
|
const st = el.style;
|
|
360
453
|
const over = { text: (el.textContent || '').trim(), level: el.tagName.toLowerCase() };
|
|
361
454
|
const size = fontPx(st.fontSize); if (size) over.size = size;
|
|
362
|
-
|
|
363
|
-
|
|
455
|
+
// Color and alignment inherit in CSS, and hero sections declare them on
|
|
456
|
+
// the section `<td>` -- a white heading over a dark photo whose color
|
|
457
|
+
// lived on the cell imported in the default dark ink and vanished into
|
|
458
|
+
// the image. Own values still win; the walk up only fills the gaps,
|
|
459
|
+
// exactly like the text-run path.
|
|
460
|
+
const align = st.textAlign || inheritedStyle(el.parentElement, 'textAlign');
|
|
461
|
+
if (align) over.align = align;
|
|
462
|
+
const color = st.color || inheritedStyle(el.parentElement, 'color');
|
|
463
|
+
if (color) over.color = hexOf(color);
|
|
364
464
|
if (st.fontWeight) over.weight = st.fontWeight;
|
|
365
465
|
return blk('heading', over);
|
|
366
466
|
}
|
|
367
467
|
|
|
368
468
|
function classifyList(el) {
|
|
369
469
|
if (el.tagName !== 'UL' && el.tagName !== 'OL') return null;
|
|
370
|
-
|
|
470
|
+
// Through the same whitelist as a text run: this was the one classifier
|
|
471
|
+
// that carried raw source innerHTML into props, so classes, ids and event
|
|
472
|
+
// handlers (`onerror` on an <img> in a list item) rode into the canvas DOM
|
|
473
|
+
// and the export while every other path stripped them. The newline swap is
|
|
474
|
+
// because `items` is a one-fragment-per-line string -- a linebreak inside a
|
|
475
|
+
// source <li> would otherwise split it into two items on render.
|
|
476
|
+
const items = Array.from(el.children).filter((c) => c.tagName === 'LI')
|
|
477
|
+
.map((li) => cleanImportHtml(li.innerHTML).replace(/\n/g, ' '));
|
|
371
478
|
if (!items.length) return null;
|
|
372
479
|
return blk('list', { items: items.join('\n'), ordered: el.tagName === 'OL' });
|
|
373
480
|
}
|
|
@@ -397,7 +504,18 @@ const CLASSIFIERS = [classifyImage, classifyButton, classifySocial, classifyMenu
|
|
|
397
504
|
function classifyNode(el) {
|
|
398
505
|
for (const fn of CLASSIFIERS) {
|
|
399
506
|
const b = fn(el);
|
|
400
|
-
if (b)
|
|
507
|
+
if (!b) continue;
|
|
508
|
+
// The class sits on the wrapper the exporter puts around each block, and
|
|
509
|
+
// on a foreign template it can be a level or two further out, so a short
|
|
510
|
+
// walk up finds it. Only ever set when a wrapper actually says so --
|
|
511
|
+
// absent means "all devices", which is what every ordinary block wants.
|
|
512
|
+
let n = el;
|
|
513
|
+
for (let i = 0; n && i < 4; i += 1) {
|
|
514
|
+
const v = visibilityOf(n);
|
|
515
|
+
if (v) { b.props.vis = v; break; }
|
|
516
|
+
n = n.parentElement;
|
|
517
|
+
}
|
|
518
|
+
return b;
|
|
401
519
|
}
|
|
402
520
|
return null;
|
|
403
521
|
}
|
|
@@ -409,7 +527,18 @@ function isStructural(el) {
|
|
|
409
527
|
/** `core/export.js` wraps every block in a column with `<div style="{boxCss(b.props)}">`, which for every block type shipped today (none define the `bBg/bBorder/bLine/bRadius/bPad` props `boxCss` reads) always resolves to a bare `<div style="margin:0">` -- 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 (an intentionally-styled container someone pasted in) alone, since that's real content, not framework wrapper. */
|
|
410
528
|
function unwrapBoxDiv(el) {
|
|
411
529
|
if (el.tagName !== 'DIV' || el.children.length !== 1) return el;
|
|
412
|
-
|
|
530
|
+
// A device-visibility wrapper is framework too, and the declarations that
|
|
531
|
+
// hide it are the framework's, not the author's: a mobile-only block is
|
|
532
|
+
// `display:none` in the base stylesheet so Classic Outlook never shows it,
|
|
533
|
+
// and css-cascade folds that inline on import. Without this exemption the
|
|
534
|
+
// wrapper looked like a deliberately styled container, never unwrapped, and
|
|
535
|
+
// the heading inside it came back as an untyped run of text. `classifyNode`
|
|
536
|
+
// reads the visibility off this same wrapper by walking back up, so the
|
|
537
|
+
// property survives the unwrap.
|
|
538
|
+
const framework = visibilityOf(el)
|
|
539
|
+
? /^(?:margin|display|max-height|overflow|mso-)/
|
|
540
|
+
: /^margin/;
|
|
541
|
+
const extra = Array.from(el.style).filter((prop) => !framework.test(prop));
|
|
413
542
|
if (extra.length) return el;
|
|
414
543
|
const child = el.firstElementChild;
|
|
415
544
|
if ((el.textContent || '') !== (child.textContent || '')) return el;
|
|
@@ -461,10 +590,30 @@ function blocksFromNodes(nodes) {
|
|
|
461
590
|
const out = [];
|
|
462
591
|
let buf = [];
|
|
463
592
|
let bufFirstEl = null;
|
|
593
|
+
// The parent of a leading bare text node. A run with no element of its own
|
|
594
|
+
// (`<td style="font-size:30px;...">{{Code}}</td>`) still has real
|
|
595
|
+
// typography -- it lives on the ancestors, and with no bufFirstEl the
|
|
596
|
+
// inheritedStyle reads below were skipped wholesale, so the run imported
|
|
597
|
+
// at the theme default (a 30px/800 verification code became 16px plain).
|
|
598
|
+
let bufTextEl = null;
|
|
464
599
|
const flush = () => {
|
|
465
600
|
const html = cleanImportHtml(buf.join(''));
|
|
466
|
-
|
|
601
|
+
// A run with nothing visible in it -- no text beyond whitespace/hair
|
|
602
|
+
// spaces, no image or line break (an `<nbsp;>` still counts as a blank
|
|
603
|
+
// line someone wrote) -- is markup residue (`<p style="margin:0"></p>`),
|
|
604
|
+
// not content; as a block it rendered as a phantom padded row.
|
|
605
|
+
const visible = html && (/<(img|br|hr)\b/i.test(html)
|
|
606
|
+
|| html.replace(/<[^>]*>/g, '').replace(/[ \t\r\n\u2009\u200a\u200b]/g, '') !== '');
|
|
607
|
+
if (visible) {
|
|
467
608
|
const over = { html };
|
|
609
|
+
if (!bufFirstEl && bufTextEl) {
|
|
610
|
+
const size = fontPx(inheritedStyle(bufTextEl, 'fontSize')); if (size) over.size = size;
|
|
611
|
+
const color = inheritedStyle(bufTextEl, 'color'); if (color) over.color = hexOf(color);
|
|
612
|
+
over.align = textAlignOf(bufTextEl);
|
|
613
|
+
const weight = inheritedStyle(bufTextEl, 'fontWeight'); if (weight) over.weight = weight;
|
|
614
|
+
const lh = lineHeightRatio(inheritedStyle(bufTextEl, 'lineHeight'), size);
|
|
615
|
+
if (lh) over.lh = lh;
|
|
616
|
+
}
|
|
468
617
|
if (bufFirstEl) {
|
|
469
618
|
// The block's *base* style: the first buffered element when it's a
|
|
470
619
|
// block-level thing (a styled <p>/<div> speaks for the run), else its
|
|
@@ -490,7 +639,7 @@ function blocksFromNodes(nodes) {
|
|
|
490
639
|
) { baseEl = baseEl.firstElementChild; if (!runPad) runPad = paddingOf(baseEl.style); }
|
|
491
640
|
if (runPad) { over.py = runPad.py; over.px = runPad.px; }
|
|
492
641
|
const size = fontPx(inheritedStyle(baseEl, 'fontSize')); if (size) over.size = size;
|
|
493
|
-
const color = inheritedStyle(baseEl, 'color'); if (color) over.color = color;
|
|
642
|
+
const color = inheritedStyle(baseEl, 'color'); if (color) over.color = hexOf(color);
|
|
494
643
|
over.align = textAlignOf(baseEl);
|
|
495
644
|
const weight = inheritedStyle(baseEl, 'fontWeight'); if (weight) over.weight = weight;
|
|
496
645
|
const lh = lineHeightRatio(inheritedStyle(baseEl, 'lineHeight'), size);
|
|
@@ -498,13 +647,16 @@ function blocksFromNodes(nodes) {
|
|
|
498
647
|
}
|
|
499
648
|
out.push(blk('text', over));
|
|
500
649
|
}
|
|
501
|
-
buf = []; bufFirstEl = null;
|
|
650
|
+
buf = []; bufFirstEl = null; bufTextEl = null;
|
|
502
651
|
};
|
|
503
652
|
nodes.forEach((n) => {
|
|
504
653
|
if (n.nodeType === 3) {
|
|
505
654
|
const markers = logicMarkersOf(n.textContent);
|
|
506
655
|
if (markers) { flush(); markers.forEach((mb) => out.push(mb)); return; }
|
|
507
|
-
if (n.textContent && n.textContent.trim())
|
|
656
|
+
if (n.textContent && n.textContent.trim()) {
|
|
657
|
+
if (!bufFirstEl && !bufTextEl) bufTextEl = n.parentElement;
|
|
658
|
+
buf.push(escapeText(n.textContent));
|
|
659
|
+
}
|
|
508
660
|
return;
|
|
509
661
|
}
|
|
510
662
|
if (n.nodeType !== 1) return;
|
|
@@ -557,10 +709,16 @@ function normalizeSpans(spans) {
|
|
|
557
709
|
function spansFromCells(cells, tableWidthPx) {
|
|
558
710
|
const parsed = cells.map((td) => {
|
|
559
711
|
const sw = td.style.width || '';
|
|
712
|
+
// `max-width` before `width`, but only where `width` says nothing useful.
|
|
713
|
+
// A CSS-layout column is written `width:100%;max-width:50%` -- the 100%
|
|
714
|
+
// is the fluid instruction and the cap is the actual share of the row, so
|
|
715
|
+
// reading `width` first would score every column at 100.
|
|
716
|
+
const mw = td.style.maxWidth || '';
|
|
717
|
+
if (mw.endsWith('%') && (!sw || PX(sw) >= 100)) return { pct: PX(mw) };
|
|
560
718
|
if (sw.endsWith('%')) return { pct: PX(sw) };
|
|
561
719
|
const aw = td.getAttribute('width') || '';
|
|
562
720
|
if (aw.endsWith('%')) return { pct: PX(aw) };
|
|
563
|
-
const px = PX(sw) || PX(aw);
|
|
721
|
+
const px = PX(sw) || PX(aw) || (mw.endsWith('px') ? PX(mw) : 0);
|
|
564
722
|
return px ? { px } : null;
|
|
565
723
|
});
|
|
566
724
|
if (parsed.every((p) => p && p.pct != null)) {
|
|
@@ -574,10 +732,140 @@ function spansFromCells(cells, tableWidthPx) {
|
|
|
574
732
|
return spans;
|
|
575
733
|
}
|
|
576
734
|
|
|
735
|
+
/*
|
|
736
|
+
* Columns that are not table cells.
|
|
737
|
+
*
|
|
738
|
+
* A row's columns do not have to be `<td>`s. MJML, and to a degree Unlayer and
|
|
739
|
+
* Stripo, emit them as sibling `<div>`s that sit side by side through
|
|
740
|
+
* `display:inline-block` and a percentage cap, with the table version hidden
|
|
741
|
+
* inside `<!--[if mso]>` conditionals for Outlook. Those comments are comments
|
|
742
|
+
* to a DOM parser, so all the walker ever saw was one `<td>` holding two
|
|
743
|
+
* divs -- and a real 50/50 row imported as two stacked full-width rows. The
|
|
744
|
+
* blocks all survived; the layout relationship did not.
|
|
745
|
+
*
|
|
746
|
+
* Returning the divs here hands them to exactly the same machinery the table
|
|
747
|
+
* path already uses -- `spansFromCells` for the widths, gap and gutter
|
|
748
|
+
* detection, per-column background and padding -- so there is no second
|
|
749
|
+
* column model, only a second way of recognising one.
|
|
750
|
+
*
|
|
751
|
+
* Deliberately conservative, because a wrong grouping is worse than none: a
|
|
752
|
+
* false positive welds unrelated sections into one row, which the user then
|
|
753
|
+
* has to take apart by hand. Every one of these has to hold.
|
|
754
|
+
*/
|
|
755
|
+
/*
|
|
756
|
+
* A column's share of its row is not always in its inline style.
|
|
757
|
+
*
|
|
758
|
+
* MJML writes the column as `width:100%` inline and puts the real share in a
|
|
759
|
+
* class rule inside `@media only screen and (min-width:480px)` -- a *desktop*
|
|
760
|
+
* query, so it carries the layout rather than overriding it. `css-cascade.js`
|
|
761
|
+
* strips every at-rule before folding, by design (a mobile override cannot be
|
|
762
|
+
* represented in the model), which means that share never reaches the element
|
|
763
|
+
* and three columns looked like three widthless divs.
|
|
764
|
+
*
|
|
765
|
+
* So the stylesheets are read once per document for `.class { width: N% }`,
|
|
766
|
+
* from top-level rules and from `min-width` blocks -- never from `max-width`
|
|
767
|
+
* blocks, which are the mobile overrides the cascade is right to drop.
|
|
768
|
+
*/
|
|
769
|
+
const CLASS_WIDTHS = new WeakMap();
|
|
770
|
+
|
|
771
|
+
function scanWidthRules(css, map) {
|
|
772
|
+
let i = 0;
|
|
773
|
+
while (i < css.length) {
|
|
774
|
+
const at = css.indexOf('@', i);
|
|
775
|
+
const plain = css.slice(i, at < 0 ? css.length : at);
|
|
776
|
+
plain.replace(/([^{}]+)\{([^{}]*)\}/g, (m0, sel, decl) => {
|
|
777
|
+
const w = decl.match(/(?:^|;)\s*width\s*:\s*([\d.]+)%/i);
|
|
778
|
+
if (w) {
|
|
779
|
+
sel.split(',').forEach((one) => {
|
|
780
|
+
const cm = one.trim().match(/^\.([-\w]+)$/);
|
|
781
|
+
if (cm && !map[cm[1]]) map[cm[1]] = parseFloat(w[1]);
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
return m0;
|
|
785
|
+
});
|
|
786
|
+
if (at < 0) break;
|
|
787
|
+
const brace = css.indexOf('{', at);
|
|
788
|
+
if (brace < 0) break;
|
|
789
|
+
const prelude = css.slice(at, brace);
|
|
790
|
+
let depth = 0;
|
|
791
|
+
let j = brace;
|
|
792
|
+
for (; j < css.length; j += 1) {
|
|
793
|
+
if (css[j] === '{') depth += 1;
|
|
794
|
+
else if (css[j] === '}') { depth -= 1; if (!depth) break; }
|
|
795
|
+
}
|
|
796
|
+
// `max-width` is a narrow-screen override, which says nothing about the
|
|
797
|
+
// desktop layout this is trying to recover.
|
|
798
|
+
if (!/max-width/i.test(prelude)) scanWidthRules(css.slice(brace + 1, j), map);
|
|
799
|
+
i = j + 1;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function classWidths(doc) {
|
|
804
|
+
if (!doc || !doc.querySelectorAll) return {};
|
|
805
|
+
const hit = CLASS_WIDTHS.get(doc);
|
|
806
|
+
if (hit) return hit;
|
|
807
|
+
const map = {};
|
|
808
|
+
Array.from(doc.querySelectorAll('style')).forEach((st) => {
|
|
809
|
+
scanWidthRules(String(st.textContent || '').replace(/\/\*[\s\S]*?\*\//g, ''), map);
|
|
810
|
+
});
|
|
811
|
+
CLASS_WIDTHS.set(doc, map);
|
|
812
|
+
return map;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function inlineColumnGroup(nodes) {
|
|
816
|
+
const kids = Array.from(nodes).filter((n) => {
|
|
817
|
+
if (n.nodeType === 8) return false; // the MSO conditionals themselves
|
|
818
|
+
if (n.nodeType === 3) return !!String(n.textContent || '').trim(); // layout whitespace between the divs
|
|
819
|
+
return n.nodeType === 1 && !/^(STYLE|SCRIPT)$/.test(n.tagName);
|
|
820
|
+
});
|
|
821
|
+
// Two to six siblings, all plain containers. A run of one is not a row, and
|
|
822
|
+
// a run of many is far more likely to be stacked content than columns.
|
|
823
|
+
if (kids.length < 2 || kids.length > 6) return null;
|
|
824
|
+
if (!kids.every((k) => k.nodeType === 1 && k.tagName === 'DIV')) return null;
|
|
825
|
+
// Side-by-side has to be stated, not guessed. `inline-block` is the email
|
|
826
|
+
// idiom; floats are the older one. A plain block div is stacked content and
|
|
827
|
+
// must not be swept up.
|
|
828
|
+
const sideBySide = (k) => {
|
|
829
|
+
const s = k.style || {};
|
|
830
|
+
return /inline-block|inline-flex/.test(s.display || '') || /^(left|right)$/.test(s.cssFloat || s.float || '');
|
|
831
|
+
};
|
|
832
|
+
if (!kids.every(sideBySide)) return null;
|
|
833
|
+
// Every sibling must declare a share of the row, and the shares must add up
|
|
834
|
+
// to one row. Anything else is a layout this cannot claim to understand.
|
|
835
|
+
const shareOf = (k) => {
|
|
836
|
+
const s = k.style || {};
|
|
837
|
+
const mw = s.maxWidth || '';
|
|
838
|
+
if (mw.endsWith('%')) return PX(mw);
|
|
839
|
+
const w = s.width || '';
|
|
840
|
+
if (w.endsWith('%') && PX(w) < 100) return PX(w);
|
|
841
|
+
// Nothing inline: the share may be carried by one of the element's
|
|
842
|
+
// classes (see classWidths).
|
|
843
|
+
const byClass = classWidths(k.ownerDocument);
|
|
844
|
+
const named = ((k.getAttribute && k.getAttribute('class')) || '').split(/\s+/);
|
|
845
|
+
for (let i = 0; i < named.length; i += 1) {
|
|
846
|
+
const v = byClass[named[i]];
|
|
847
|
+
if (v && v < 100) return v;
|
|
848
|
+
}
|
|
849
|
+
return 0;
|
|
850
|
+
};
|
|
851
|
+
const shares = kids.map(shareOf);
|
|
852
|
+
if (!shares.every((w) => w > 0)) return null;
|
|
853
|
+
const total = shares.reduce((a, b) => a + b, 0);
|
|
854
|
+
if (total < 90 || total > 110) return null;
|
|
855
|
+
kids.shares = shares;
|
|
856
|
+
return kids;
|
|
857
|
+
}
|
|
858
|
+
|
|
577
859
|
/** Detects MailCraft's own two-table row shape -- an outer `<td>` (row padding/bg/border) wrapping a single-row `role="presentation"` table that holds the actual column(s), per `core/export.js` -- and widens to its cells (one or many) so rows round-trip back into real columns instead of one opaque `html` block. */
|
|
578
860
|
function unwrapNestedLayout(td) {
|
|
579
861
|
const only = onlyChild(td, 'TABLE');
|
|
580
862
|
if (!only) return null;
|
|
863
|
+
// Not every single-row table is layout. A bulletproof button is a one-cell
|
|
864
|
+
// table too, and reading it as a row made its cell the row's `bgSource` --
|
|
865
|
+
// so a pink button in a white section repainted the whole band pink and
|
|
866
|
+
// the section's own background was lost. A component table is one block,
|
|
867
|
+
// never a row; the same guard covers a social strip built the same way.
|
|
868
|
+
if (classifyButton(td) || classifySocial(only)) return null;
|
|
581
869
|
const trs = only.querySelectorAll(':scope > tbody > tr, :scope > tr');
|
|
582
870
|
if (trs.length !== 1) return null;
|
|
583
871
|
if (only.querySelector('th')) return null;
|
|
@@ -623,6 +911,39 @@ function padOf(el) {
|
|
|
623
911
|
return el.style ? paddingOf(el.style) : null;
|
|
624
912
|
}
|
|
625
913
|
|
|
914
|
+
/*
|
|
915
|
+
* One column of a row, on its own.
|
|
916
|
+
*
|
|
917
|
+
* The same container the multi-column detector groups, but appearing singly --
|
|
918
|
+
* a full-width MJML section is one of these. It matters because of what is
|
|
919
|
+
* *inside* it: a table whose every `<tr>` is one block. The generic walk turns
|
|
920
|
+
* every `<tr>` into a MailCraft row, so a section holding a heading and a
|
|
921
|
+
* subheading came back as two rows, each repainted with the section's
|
|
922
|
+
* background and each carrying the inner cell's padding while the section's
|
|
923
|
+
* own padding was dropped. A source `<tr>` inside a column is a block slot,
|
|
924
|
+
* not a row.
|
|
925
|
+
*
|
|
926
|
+
* Recognising the boundary keeps the column's blocks together in one row, and
|
|
927
|
+
* lets the section's `<td>` supply that row's padding, which is where it
|
|
928
|
+
* belonged all along. `tr -> row` is untouched everywhere else, which is what
|
|
929
|
+
* keeps MailCraft's own export round-tripping.
|
|
930
|
+
*/
|
|
931
|
+
function isColumnContainer(el) {
|
|
932
|
+
if (!el || el.nodeType !== 1 || el.tagName !== 'DIV') return false;
|
|
933
|
+
const s = el.style || {};
|
|
934
|
+
const sideBySide = /inline-block|inline-flex/.test(s.display || '') || /^(left|right)$/.test(s.cssFloat || s.float || '');
|
|
935
|
+
if (!sideBySide) return false;
|
|
936
|
+
if (!(s.width || s.maxWidth)) return false;
|
|
937
|
+
// A column holds its blocks in a table; a lone inline-block div with no
|
|
938
|
+
// table is a badge or a pill, and is content in its own right.
|
|
939
|
+
return Array.from(el.children).some((c) => c.tagName === 'TABLE');
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** Every block a set of rows holds, in order -- a MailCraft column is a flat list. */
|
|
943
|
+
function blocksOfRows(rows) {
|
|
944
|
+
return rows.reduce((acc, r) => acc.concat(r.cols.reduce((a, c) => a.concat(c.blocks), [])), []);
|
|
945
|
+
}
|
|
946
|
+
|
|
626
947
|
/** A div/center that itself nests a div/table/center is acting as a structural container (a section wrapper, an outer page wrapper holding several sections) rather than as one piece of content -- its children need to be walked in their own right, not folded into one block. A div holding only inline content (text, spans, an image not otherwise recognized) is real content and is left to `blocksFromNodes`. */
|
|
627
948
|
function looksLikeContainer(el) {
|
|
628
949
|
return Array.from(el.children).some((c) => c.tagName === 'TABLE' || c.tagName === 'DIV' || c.tagName === 'CENTER');
|
|
@@ -735,8 +1056,14 @@ function rowsFromContentTable(table) {
|
|
|
735
1056
|
const bgSource = outerCells[0];
|
|
736
1057
|
let cells = outerCells;
|
|
737
1058
|
if (outerCells.length === 1) {
|
|
1059
|
+
// The table shape first, since it is unambiguous; the CSS-layout shape
|
|
1060
|
+
// only when there is no table row to read.
|
|
738
1061
|
const nested = unwrapNestedLayout(outerCells[0]);
|
|
739
1062
|
if (nested) cells = nested;
|
|
1063
|
+
else {
|
|
1064
|
+
const inline = inlineColumnGroup(outerCells[0].childNodes);
|
|
1065
|
+
if (inline) cells = inline;
|
|
1066
|
+
}
|
|
740
1067
|
}
|
|
741
1068
|
// Spacer columns: a content-free `<td>` (often `class="column gap"`,
|
|
742
1069
|
// holding only an empty fixed-width table) between real columns exists
|
|
@@ -792,7 +1119,10 @@ function rowsFromContentTable(table) {
|
|
|
792
1119
|
// outer wrapper around all columns. When bgSource IS cells[0] (flat rows),
|
|
793
1120
|
// falling back to it would re-promote the first card's color to the row.
|
|
794
1121
|
const outerBg = cells.indexOf(bgSource) > -1 ? '' : bgOf(bgSource);
|
|
795
|
-
|
|
1122
|
+
// The tr between the cells and the table carries the legacy per-row
|
|
1123
|
+
// `bgcolor` old-school templates still set; it sits between the two in
|
|
1124
|
+
// specificity.
|
|
1125
|
+
const bg = (cellsUniform && cellBg) || bgOf(tr) || outerBg || bgOf(table);
|
|
796
1126
|
if (bg) row.props.bg = bg;
|
|
797
1127
|
// Differently-styled cells become per-column styling: each column keeps
|
|
798
1128
|
// its own background/radius/padding (the pastel-cards pattern).
|
|
@@ -889,6 +1219,36 @@ function rowsFromContentTable(table) {
|
|
|
889
1219
|
* its ancestor's.
|
|
890
1220
|
*/
|
|
891
1221
|
function collectRows(nodes) {
|
|
1222
|
+
/*
|
|
1223
|
+
* Before anything else: are these siblings one row's columns rather than a
|
|
1224
|
+
* sequence of sections? This has to be asked here, of the whole list, and
|
|
1225
|
+
* not inside the loop below -- that loop's job is to walk each structural
|
|
1226
|
+
* container in its own right, which is exactly what flattened a real 50/50
|
|
1227
|
+
* row into two stacked full-width ones. The relationship between the
|
|
1228
|
+
* siblings is only visible while they are still siblings.
|
|
1229
|
+
*/
|
|
1230
|
+
const columns = inlineColumnGroup(nodes);
|
|
1231
|
+
if (columns) {
|
|
1232
|
+
// The resolved shares win over the inline widths: a CSS-layout column is
|
|
1233
|
+
// `width:100%` inline, so reading the elements again would score every
|
|
1234
|
+
// column at 100 and fall back to an even split -- right for 33/33/34,
|
|
1235
|
+
// wrong for 40/60.
|
|
1236
|
+
const row = mkRow(columns.shares ? normalizeSpans(columns.shares) : spansFromCells(columns, 0));
|
|
1237
|
+
row.props.py = 0; row.props.px = 0; row.props.gap = 0;
|
|
1238
|
+
columns.forEach((k, i) => {
|
|
1239
|
+
// A MailCraft column holds a flat list of blocks, so whatever the
|
|
1240
|
+
// column div decomposes into is flattened into it. Recursing through
|
|
1241
|
+
// collectRows rather than blocksFromNodes is what lets a column hold a
|
|
1242
|
+
// content table (the usual MJML shape) instead of one opaque blob.
|
|
1243
|
+
row.cols[i].blocks = blocksOfRows(collectRows(Array.from(k.childNodes)));
|
|
1244
|
+
const cbg = bgOf(k); if (cbg) row.cols[i].bg = cbg;
|
|
1245
|
+
const crad = radiusOf(k.style); if (crad) row.cols[i].radius = crad;
|
|
1246
|
+
const cpd = padOf(k); if (cpd) { row.cols[i].padY = cpd.py; row.cols[i].padX = cpd.px; }
|
|
1247
|
+
});
|
|
1248
|
+
// If nothing recognisable came out of it, it was not a row after all --
|
|
1249
|
+
// fall through and let the ordinary walk have it.
|
|
1250
|
+
if (row.cols.some((c) => c.blocks.length)) return [row];
|
|
1251
|
+
}
|
|
892
1252
|
const rows = [];
|
|
893
1253
|
let buf = [];
|
|
894
1254
|
const flushBuf = () => {
|
|
@@ -905,9 +1265,21 @@ function collectRows(nodes) {
|
|
|
905
1265
|
if (n.nodeType === 8) return; // HTML comments (Outlook/MSO conditionals) -- inert
|
|
906
1266
|
if (n.nodeType === 1 && /^(SCRIPT|STYLE)$/.test(n.tagName)) return;
|
|
907
1267
|
if (n.nodeType === 1 && isHidden(n)) return;
|
|
1268
|
+
// A lone column: its blocks belong to one row, not one row each.
|
|
1269
|
+
if (n.nodeType === 1 && isColumnContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
|
|
1270
|
+
flushBuf();
|
|
1271
|
+
const blocks = blocksOfRows(collectRows(Array.from(n.childNodes)));
|
|
1272
|
+
if (blocks.length) {
|
|
1273
|
+
const row = mkRow([100]);
|
|
1274
|
+
row.props.py = 0; row.props.px = 0; row.props.gap = 0;
|
|
1275
|
+
row.cols[0].blocks = blocks;
|
|
1276
|
+
rows.push(...applyBgImage(applyFrame(applyPad(applyBg([row], bgOf(n)), padOf(n)), n), n));
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
908
1280
|
if (n.nodeType === 1 && (n.tagName === 'DIV' || n.tagName === 'CENTER') && looksLikeContainer(n) && !classifyNode(unwrapBoxDiv(n))) {
|
|
909
1281
|
flushBuf();
|
|
910
|
-
const inner = collectRows(Array.from(n.childNodes));
|
|
1282
|
+
const inner = mergeBandRows(collectRows(Array.from(n.childNodes)), n);
|
|
911
1283
|
rows.push(...applyBgImage(applyFrame(applyPad(applyBg(inner, bgOf(n)), padOf(n)), n), n));
|
|
912
1284
|
return;
|
|
913
1285
|
}
|
|
@@ -948,11 +1320,37 @@ function collectRows(nodes) {
|
|
|
948
1320
|
rows.push(row);
|
|
949
1321
|
return;
|
|
950
1322
|
}
|
|
951
|
-
|
|
1323
|
+
// The other table-email rule idiom: a content-free cell whose only
|
|
1324
|
+
// drawing is a `border-top` (Beefree's `td.divider_inner`, usually in
|
|
1325
|
+
// a width="20%" inner table). As a text run it imported as a junk row
|
|
1326
|
+
// holding one hair space; recognized, it is the divider it draws.
|
|
1327
|
+
const dSides = borderSidesOf(td.style);
|
|
1328
|
+
if (dSides.sides.top > 0 && dSides.sides.top <= 10
|
|
1329
|
+
&& !dSides.sides.right && !dSides.sides.bottom && !dSides.sides.left
|
|
1330
|
+
&& !bgOf(td) && !bgImageOf(td)
|
|
1331
|
+
&& !Array.from(td.children).some((c) => c.tagName !== 'SPAN' && c.tagName !== 'BR')
|
|
1332
|
+
&& !(td.textContent || '').replace(/\s/g, '')) {
|
|
1333
|
+
const dw = String(n.getAttribute('width') || n.style.width || '');
|
|
1334
|
+
const row = mkRow([100]);
|
|
1335
|
+
row.props.py = 0; row.props.px = 0; row.props.gap = 0;
|
|
1336
|
+
row.cols[0].blocks = [blk('divider', {
|
|
1337
|
+
thickness: dSides.sides.top,
|
|
1338
|
+
lineStyle: borderStyleOf(td.style),
|
|
1339
|
+
color: borderColorOf(td.style) || '#d9dade',
|
|
1340
|
+
width: dw.endsWith('%') ? PX(dw) : 100,
|
|
1341
|
+
})];
|
|
1342
|
+
rows.push(row);
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
const inner = mergeBandRows(mergeBandRows(collectRows(Array.from(td.childNodes)), td), n);
|
|
952
1346
|
// Frame styles live on the td for some builders and on the table
|
|
953
1347
|
// itself for others (`table.row-content` carries the card border) --
|
|
954
1348
|
// applyFrame fills only what's still unset, so trying both is safe.
|
|
955
|
-
|
|
1349
|
+
// The td is the more specific background-color source -- a dark hero
|
|
1350
|
+
// cell inside a white content table keeps its own color, not the
|
|
1351
|
+
// wrapper's (the image already reads td-first via the applyBgImage
|
|
1352
|
+
// order). The tr between them carries the legacy per-row bgcolor.
|
|
1353
|
+
rows.push(...applyBgImage(applyBgImage(applyFrame(applyFrame(applyPad(applyBg(inner, bgOf(td) || bgOf(tr) || bgOf(n)), padOf(td) || padOf(n) || cellPadOf(n)), td), n), td), n));
|
|
956
1354
|
return;
|
|
957
1355
|
}
|
|
958
1356
|
flushBuf();
|
|
@@ -974,6 +1372,36 @@ function collectRows(nodes) {
|
|
|
974
1372
|
* document's theme -- a DM Sans email on #F1F5F9 came back in the default
|
|
975
1373
|
* Georgia on the default parchment, which read as "the import broke".
|
|
976
1374
|
*/
|
|
1375
|
+
/**
|
|
1376
|
+
* A layout table's committed pixel width, however it declares one: the
|
|
1377
|
+
* `width` attribute, a `width` style, or -- for a responsive template -- the
|
|
1378
|
+
* `max-width` that caps a fluid `width:100%`.
|
|
1379
|
+
*
|
|
1380
|
+
* `max-width` is not a nicety. It is how this exporter now writes the content
|
|
1381
|
+
* column (`width:100%;max-width:620px`, so the email can narrow to a phone),
|
|
1382
|
+
* and it is the shape every other modern email builder emits too. Reading
|
|
1383
|
+
* only the fixed forms meant an export -> import round trip came back with no
|
|
1384
|
+
* `theme.width` at all and silently fell to the default. Returns 0 for a
|
|
1385
|
+
* purely proportional table, which the callers already skip.
|
|
1386
|
+
*/
|
|
1387
|
+
function fixedWidthOf(tb) {
|
|
1388
|
+
const w = tb.getAttribute('width') || (tb.style && tb.style.width) || '';
|
|
1389
|
+
if (w && !String(w).endsWith('%')) return PX(w);
|
|
1390
|
+
const cap = tb.style && tb.style.maxWidth;
|
|
1391
|
+
if (cap && !String(cap).endsWith('%')) return PX(cap);
|
|
1392
|
+
return 0;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/** A border present on some sides but not all, or a radius rounding some corners but not all -- the signature of one piece of a card that was drawn across several stacked tables, never of a standalone card. */
|
|
1396
|
+
function partialFrame(st) {
|
|
1397
|
+
if (!st) return false;
|
|
1398
|
+
const { sides } = borderSidesOf(st);
|
|
1399
|
+
const on = ['top', 'right', 'bottom', 'left'].filter((k) => sides[k] > 0).length;
|
|
1400
|
+
const corners = String(st.borderRadius || '').split(/\s+/).map(PX);
|
|
1401
|
+
const uneven = corners.length > 1 && corners.some((v) => v > 0) && corners.some((v) => !v);
|
|
1402
|
+
return (on > 0 && on < 4) || uneven;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
977
1405
|
function themeFromParsedDoc(doc) {
|
|
978
1406
|
const theme = {};
|
|
979
1407
|
const body = doc.body;
|
|
@@ -985,9 +1413,7 @@ function themeFromParsedDoc(doc) {
|
|
|
985
1413
|
if (bg) theme.bg = bg;
|
|
986
1414
|
const widthCounts = {};
|
|
987
1415
|
body.querySelectorAll('table').forEach((tb) => {
|
|
988
|
-
const
|
|
989
|
-
if (!w || String(w).endsWith('%')) return;
|
|
990
|
-
const px = PX(w);
|
|
1416
|
+
const px = fixedWidthOf(tb);
|
|
991
1417
|
if (px >= 320 && px <= 900) widthCounts[px] = (widthCounts[px] || 0) + 1;
|
|
992
1418
|
});
|
|
993
1419
|
const bestWidth = Object.keys(widthCounts).sort((a, b) => widthCounts[b] - widthCounts[a])[0];
|
|
@@ -998,11 +1424,12 @@ function themeFromParsedDoc(doc) {
|
|
|
998
1424
|
// column's corner. Without this an import flattened both, and the next
|
|
999
1425
|
// export silently squared the template off and closed the gap around it.
|
|
1000
1426
|
if (bestWidth) {
|
|
1001
|
-
const content = Array.from(body.querySelectorAll('table')).find((tb) =>
|
|
1002
|
-
const w = tb.getAttribute('width') || (tb.style && tb.style.width) || '';
|
|
1003
|
-
return w && !String(w).endsWith('%') && PX(w) === Number(bestWidth);
|
|
1004
|
-
});
|
|
1427
|
+
const content = Array.from(body.querySelectorAll('table')).find((tb) => fixedWidthOf(tb) === Number(bestWidth));
|
|
1005
1428
|
if (content) {
|
|
1429
|
+
// Read before the frame is consumed below -- a fragment here means the
|
|
1430
|
+
// sibling tables hold the rest of the same card (see the pass further
|
|
1431
|
+
// down).
|
|
1432
|
+
const contentPartial = partialFrame(content.style);
|
|
1006
1433
|
// The content column's own background -- including the literal
|
|
1007
1434
|
// `transparent` the exporter always writes for a see-through column.
|
|
1008
1435
|
// Without this the round trip lost it: export wrote
|
|
@@ -1044,6 +1471,30 @@ function themeFromParsedDoc(doc) {
|
|
|
1044
1471
|
if (padX > 0) theme.padX = padX;
|
|
1045
1472
|
cell.style.padding = '';
|
|
1046
1473
|
}
|
|
1474
|
+
// A card drawn across several sibling content-width tables (top piece:
|
|
1475
|
+
// `border-radius:16px 16px 0 0; border-bottom:none`, middle pieces:
|
|
1476
|
+
// side borders only, bottom piece: `border-radius:0 0 16px 16px`) is
|
|
1477
|
+
// ONE visual frame -- the one just claimed. Left in place, the sibling
|
|
1478
|
+
// fragments imported as row borders inside the already-framed canvas:
|
|
1479
|
+
// doubled verticals down every row and a second rounded, shadowed box
|
|
1480
|
+
// around the last section. Only the split-card idiom trips this: the
|
|
1481
|
+
// claimed table must itself be a fragment (partial border or uneven
|
|
1482
|
+
// corners), and only fragments are stripped -- a template of genuinely
|
|
1483
|
+
// separate full cards has neither and is untouched. Horizontal edges
|
|
1484
|
+
// stay, since between two sections they read as a real separator; the
|
|
1485
|
+
// last fragment's bottom edge coincides with the frame's and goes.
|
|
1486
|
+
if (contentPartial) {
|
|
1487
|
+
const same = Array.from(body.querySelectorAll('table'))
|
|
1488
|
+
.filter((tb) => tb !== content && tb.style && fixedWidthOf(tb) === Number(bestWidth));
|
|
1489
|
+
same.forEach((tb, k) => {
|
|
1490
|
+
if (!partialFrame(tb.style)) return;
|
|
1491
|
+
tb.style.borderLeft = '';
|
|
1492
|
+
tb.style.borderRight = '';
|
|
1493
|
+
tb.style.borderRadius = '';
|
|
1494
|
+
tb.style.boxShadow = '';
|
|
1495
|
+
if (k === same.length - 1) tb.style.borderBottom = '';
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1047
1498
|
}
|
|
1048
1499
|
}
|
|
1049
1500
|
// Prefer the first *real* stack (has a comma or quotes) over a lone generic
|