@seliseblocks/mailcraft 0.2.16 → 0.2.18
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 +33 -0
- package/DOCS.md +70 -67
- package/README.md +8 -8
- package/README.md.txt +8 -8
- package/dist/mailcraft-editor.bundle.js +84 -76
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/examples/templates/activate-your-account.html +1 -1
- package/examples/templates/back-in-stock.html +4 -4
- package/examples/templates/cart-left-behind.html +3 -3
- package/examples/templates/community-giveaway.html +3 -3
- package/examples/templates/frontend-futures-invite.html +3 -3
- package/examples/templates/give-25-get-25.html +2 -2
- package/examples/templates/invoice-paid.html +3 -3
- package/examples/templates/meet-nova-launch.html +7 -7
- package/examples/templates/mega-weekend-sale.html +1 -1
- package/examples/templates/order-confirmed.html +6 -6
- package/examples/templates/rate-your-headphones.html +3 -3
- package/examples/templates/reset-your-password.html +1 -1
- package/examples/templates/thankyou-promo-code.html +3 -3
- package/examples/templates/the-sunday-brief.html +4 -4
- package/examples/templates/welcome-to-your-workspace.html +4 -4
- package/examples/templates/your-password-was-changed.html +2 -2
- package/examples/templates/your-signin-code.html +1 -1
- package/examples/vanilla.html +63 -63
- package/package.json +1 -1
- package/src/core/binder.js +10 -0
- package/src/core/blocks.js +2 -2
- package/src/core/editor-core.js +195 -6
- package/src/core/export.js +311 -22
- package/src/core/i18n/ar.js +2 -0
- package/src/core/i18n/bg.js +2 -0
- package/src/core/i18n/bn.js +2 -0
- package/src/core/i18n/ca.js +2 -0
- package/src/core/i18n/cs.js +2 -0
- package/src/core/i18n/da.js +2 -0
- package/src/core/i18n/de-CH.js +2 -0
- package/src/core/i18n/de.js +2 -0
- package/src/core/i18n/dz.js +2 -0
- package/src/core/i18n/el.js +2 -0
- package/src/core/i18n/en.js +2 -0
- package/src/core/i18n/es.js +2 -0
- package/src/core/i18n/et.js +2 -0
- package/src/core/i18n/fi.js +2 -0
- package/src/core/i18n/fr.js +2 -0
- package/src/core/i18n/hr.js +2 -0
- package/src/core/i18n/hu.js +2 -0
- package/src/core/i18n/it.js +2 -0
- package/src/core/i18n/lt.js +2 -0
- package/src/core/i18n/lv.js +2 -0
- package/src/core/i18n/nb.js +2 -0
- package/src/core/i18n/nl.js +2 -0
- package/src/core/i18n/pl.js +2 -0
- package/src/core/i18n/pt.js +2 -0
- package/src/core/i18n/ro.js +2 -0
- package/src/core/i18n/ru.js +2 -0
- package/src/core/i18n/sk.js +2 -0
- package/src/core/i18n/sl.js +2 -0
- package/src/core/i18n/sv.js +2 -0
- package/src/core/i18n/tr.js +2 -0
- package/src/core/i18n/uk.js +2 -0
- package/src/core/import-html.js +390 -20
- package/src/core/layout-style.js +25 -0
- package/src/core/sanitize.js +4 -1
- package/src/core/theme.js +22 -1
- package/src/mailcraft-editor.js +8 -0
- package/src/render/block-body.js +129 -17
- package/src/render/canvas.js +35 -6
- package/src/render/fields.js +19 -0
- package/src/render/screenshot.js +26 -3
package/src/core/import-html.js
CHANGED
|
@@ -148,15 +148,153 @@ function bgImageOf(el) {
|
|
|
148
148
|
const m = ((st.backgroundImage || '') + ' ' + (st.background || '')).match(/url\(["']?([^"')]+)["']?\)/);
|
|
149
149
|
if (m) return m[1];
|
|
150
150
|
}
|
|
151
|
+
// CSSOM is the first read, not the only one. A declaration the parser could
|
|
152
|
+
// not fully digest -- the `center / cover` slash syntax under some engines,
|
|
153
|
+
// a vendor-prefixed layer, anything malformed -- comes back as an empty
|
|
154
|
+
// property, and the url with it. The attribute text still has it.
|
|
155
|
+
const raw = el.getAttribute ? (el.getAttribute('style') || '') : '';
|
|
156
|
+
const rm = raw.match(/background(?:-image)?\s*:[^;]*?url\(\s*["']?([^"')]+?)["']?\s*\)/i);
|
|
157
|
+
if (rm) return rm[1];
|
|
151
158
|
return (el.getAttribute && el.getAttribute('background')) || '';
|
|
152
159
|
}
|
|
153
160
|
|
|
154
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* The tint box a section image now ships behind: the painted element's only
|
|
163
|
+
* child, a `<div>` whose background is the exporter's own neutral-dark rgba
|
|
164
|
+
* (core/export.js). Recognized by that exact signature and nothing else, so a
|
|
165
|
+
* foreign wrapper that happens to be a single div is left alone -- and by CSS
|
|
166
|
+
* rather than by a marker attribute, so it survives `exportHtml({markers:false})`.
|
|
167
|
+
*
|
|
168
|
+
* It is scaffolding, not content: the walk descends through it, and the row
|
|
169
|
+
* padding it carries is the row's (the exporter moved it off the cell so the
|
|
170
|
+
* tint covers the whole band, not just the content area).
|
|
171
|
+
*/
|
|
172
|
+
function tintOf(el) {
|
|
173
|
+
if (!el || !el.children || el.children.length !== 1) return null;
|
|
174
|
+
const kid = el.children[0];
|
|
175
|
+
if (!kid || kid.tagName !== 'DIV' || !kid.style) return null;
|
|
176
|
+
const m = String(kid.style.backgroundColor || kid.style.background || '')
|
|
177
|
+
.match(/^rgba\(\s*20,\s*22,\s*24,\s*(0?\.\d+|1|0)\s*\)$/);
|
|
178
|
+
if (!m) return null;
|
|
179
|
+
return { el: kid, pct: Math.round(parseFloat(m[1]) * 100) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The exporter's overlay idiom, read back: the alpha of the neutral-dark tint
|
|
184
|
+
* over a section image is the row's `overlay` percentage. Only that exact
|
|
185
|
+
* signature is folded back -- a foreign gradient says nothing about
|
|
186
|
+
* MailCraft's tint and stays out of the model. Without this the tint silently
|
|
187
|
+
* vanished on every save/reload while the photo survived.
|
|
188
|
+
*
|
|
189
|
+
* One shape is read here, the layered `linear-gradient(...),url(...)` that the
|
|
190
|
+
* exporter shipped before the Outlook fix. Current exports carry the tint as
|
|
191
|
+
* its own box instead, and `unwrapTints` has already rewritten those into this
|
|
192
|
+
* shape by the time any of this runs -- so both eras arrive here identical.
|
|
193
|
+
*/
|
|
155
194
|
function overlayOf(el) {
|
|
156
|
-
const st = el.style;
|
|
195
|
+
const st = el && el.style;
|
|
157
196
|
if (!st) return 0;
|
|
158
|
-
const
|
|
159
|
-
|
|
197
|
+
const decl = (st.backgroundImage || '') + ' ' + (st.background || '');
|
|
198
|
+
const m = decl.match(/linear-gradient\(rgba\(20,\s*22,\s*24,\s*(0?\.\d+|1|0)\s*\)/);
|
|
199
|
+
if (m) return Math.round(parseFloat(m[1]) * 100);
|
|
200
|
+
// A foreign tint: two identical dark rgba stops over the photo, which is
|
|
201
|
+
// how every other builder writes "darken this image". The alpha maps to the
|
|
202
|
+
// Darken slider exactly, and a dark stop is what the slider means -- a light
|
|
203
|
+
// or coloured wash is a different effect and still stays out of the model
|
|
204
|
+
// rather than being misread as a darkening.
|
|
205
|
+
const f = decl.match(/linear-gradient\(\s*rgba\((\d+),\s*(\d+),\s*(\d+),\s*(0?\.\d+|1|0)\)\s*,\s*rgba\((\d+),\s*(\d+),\s*(\d+),\s*(0?\.\d+|1|0)\)\s*\)/);
|
|
206
|
+
if (f && f[1] === f[5] && f[2] === f[6] && f[3] === f[7] && f[4] === f[8]) {
|
|
207
|
+
const lum = (0.299 * +f[1] + 0.587 * +f[2] + 0.114 * +f[3]) / 255;
|
|
208
|
+
if (lum < 0.35) return Math.round(parseFloat(f[4]) * 100);
|
|
209
|
+
}
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Normalises the current export's tint box back into the legacy layered
|
|
215
|
+
* background, in the parsed DOM, before anything walks it.
|
|
216
|
+
*
|
|
217
|
+
* The exporter can no longer put the tint in the `background-image` (see
|
|
218
|
+
* core/export.js: outlook.com drops a layered value whole), so it ships the
|
|
219
|
+
* photo on the cell and the tint on a `<div>` inside it, which also carries
|
|
220
|
+
* the row padding so the tint covers the padded band rather than just the
|
|
221
|
+
* content. Left alone, that div reads as content: the walker took its rgba
|
|
222
|
+
* for the row's own background colour and the row padding vanished with it.
|
|
223
|
+
*
|
|
224
|
+
* Rewriting it here rather than teaching the walker about it keeps the change
|
|
225
|
+
* to one pre-pass -- every downstream path (bgOf, paddingOf, overlayOf, the
|
|
226
|
+
* column-wrapper and band-merge heuristics) then sees the shape it has always
|
|
227
|
+
* seen. Guarded on the host actually carrying a background image, so an
|
|
228
|
+
* unrelated div that happens to be this exact rgba is left alone.
|
|
229
|
+
*/
|
|
230
|
+
/**
|
|
231
|
+
* The image an Outlook-first template carries ONLY inside its VML. Builders
|
|
232
|
+
* that target Word write the photo as `<v:fill src="...">` (or `<v:image>`)
|
|
233
|
+
* inside an `<!--[if gte mso 9]>` conditional, plus a `background=` attribute
|
|
234
|
+
* if you are lucky -- and nothing else. Comments are inert to the row walk
|
|
235
|
+
* (correctly: they are Word's, not ours), so without this the image existed
|
|
236
|
+
* only for Outlook and vanished for everyone the moment the template was
|
|
237
|
+
* imported. Applied only where nothing else already declares an image on the
|
|
238
|
+
* comment's host, so a CSS/attribute background always wins.
|
|
239
|
+
*/
|
|
240
|
+
function readVmlBackgrounds(doc) {
|
|
241
|
+
const walker = doc.createTreeWalker(doc.body, 128 /* NodeFilter.SHOW_COMMENT */);
|
|
242
|
+
const hits = [];
|
|
243
|
+
for (let c = walker.nextNode(); c; c = walker.nextNode()) {
|
|
244
|
+
const text = String(c.nodeValue || '');
|
|
245
|
+
if (!/<v:(?:fill|image)\b/i.test(text)) continue;
|
|
246
|
+
const m = text.match(/<v:(?:fill|image)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/i);
|
|
247
|
+
if (m && c.parentElement) hits.push({ host: c.parentElement, url: m[1] });
|
|
248
|
+
}
|
|
249
|
+
hits.forEach(({ host, url }) => {
|
|
250
|
+
if (bgImageOf(host)) return;
|
|
251
|
+
if (!host.style) return;
|
|
252
|
+
host.style.backgroundImage = 'url("' + url + '")';
|
|
253
|
+
if (!host.style.backgroundSize) host.style.backgroundSize = 'cover';
|
|
254
|
+
if (!host.style.backgroundPosition) host.style.backgroundPosition = 'center';
|
|
255
|
+
if (!host.style.backgroundRepeat) host.style.backgroundRepeat = 'no-repeat';
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A column's background image, read off whichever element carries it -- the
|
|
261
|
+
* `<td>` itself in a foreign template, or the styled wrapper `<div>` this
|
|
262
|
+
* exporter writes inside it. Mirrors what `applyBgImage` does for a row, one
|
|
263
|
+
* level in, and writes nothing when the element has no image, so a
|
|
264
|
+
* colour-only column is untouched.
|
|
265
|
+
*
|
|
266
|
+
* The tint and the VML-only variants need no special case here: `unwrapTints`
|
|
267
|
+
* and `readVmlBackgrounds` have already normalised both into a plain layered
|
|
268
|
+
* background on this element by the time the column walk runs.
|
|
269
|
+
*/
|
|
270
|
+
function applyColBgImage(col, el) {
|
|
271
|
+
if (!col || !el) return;
|
|
272
|
+
const url = bgImageOf(el);
|
|
273
|
+
if (!url || col.bgImage) return;
|
|
274
|
+
const st = el.style || {};
|
|
275
|
+
col.bgImage = url;
|
|
276
|
+
const ov = overlayOf(el);
|
|
277
|
+
if (ov) col.overlay = ov;
|
|
278
|
+
if (st.backgroundSize) col.bgSize = st.backgroundSize;
|
|
279
|
+
if (st.backgroundPosition) col.bgPos = st.backgroundPosition;
|
|
280
|
+
if (st.backgroundRepeat) col.bgRepeat = st.backgroundRepeat;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function unwrapTints(doc) {
|
|
284
|
+
Array.from(doc.body.querySelectorAll('*')).forEach((host) => {
|
|
285
|
+
const url = bgImageOf(host);
|
|
286
|
+
if (!url) return;
|
|
287
|
+
const tint = tintOf(host);
|
|
288
|
+
if (!tint) return;
|
|
289
|
+
const a = tint.pct / 100;
|
|
290
|
+
host.style.backgroundImage = 'linear-gradient(rgba(20,22,24,' + a + '),rgba(20,22,24,' + a + ')),url("' + url + '")';
|
|
291
|
+
const st = tint.el.style;
|
|
292
|
+
const pad = st.padding
|
|
293
|
+
|| [st.paddingTop, st.paddingRight, st.paddingBottom, st.paddingLeft].filter(Boolean).join(' ');
|
|
294
|
+
if (pad) host.style.padding = pad;
|
|
295
|
+
while (tint.el.firstChild) host.insertBefore(tint.el.firstChild, tint.el);
|
|
296
|
+
tint.el.remove();
|
|
297
|
+
});
|
|
160
298
|
}
|
|
161
299
|
|
|
162
300
|
/** Carries a wrapper's background image (hero photo sections) onto the rows it produced, mirroring applyBg -- fit/position/repeat come along when declared. */
|
|
@@ -165,6 +303,20 @@ function applyBgImage(rows, el) {
|
|
|
165
303
|
if (!url) return rows;
|
|
166
304
|
const st = el.style || {};
|
|
167
305
|
const ov = overlayOf(el);
|
|
306
|
+
/*
|
|
307
|
+
* ONE image, ONE band. This is the only place a wrapper's background is
|
|
308
|
+
* carried onto the rows beneath it, so it is also the only place that can
|
|
309
|
+
* stop the same image being stamped onto every one of them -- which is what
|
|
310
|
+
* happened: a hero section that walked into six rows got six copies, each
|
|
311
|
+
* repainting the photo from its own top edge, and the recipient saw the
|
|
312
|
+
* image tiled down the email in bands. `mergeBandRows` was meant to fold
|
|
313
|
+
* such sections first, but it only wrapped two of the four paths into
|
|
314
|
+
* here, and gave up entirely if any single row differed. Runs of
|
|
315
|
+
* foldable rows are merged here instead, on every path; a row that cannot
|
|
316
|
+
* fold (two columns, its own frame) simply ends the run and starts a new
|
|
317
|
+
* one, so a footer no longer costs the five rows above it their band.
|
|
318
|
+
*/
|
|
319
|
+
rows = mergeBandRuns(rows);
|
|
168
320
|
rows.forEach((r) => {
|
|
169
321
|
if (r.props.bgImage) return;
|
|
170
322
|
r.props.bgImage = url;
|
|
@@ -189,17 +341,19 @@ function applyBgImage(rows, el) {
|
|
|
189
341
|
* frame or image on a row is a real band boundary) are merged, so everything
|
|
190
342
|
* that isn't the hero shape walks exactly as before.
|
|
191
343
|
*/
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
344
|
+
/** A row that can dissolve into a band: one column, no paint or frame of its own. Its padding moves onto its blocks when merged. */
|
|
345
|
+
function foldable(r) {
|
|
346
|
+
return r.cols.length === 1 && !r.props.bgImage && !r.props.border && !r.props.radius && !r.props.shadow;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Folds one run of foldable rows (same `bg`) into a single row, moving each row's padding onto its blocks -- the same convention as the block-table unwrap in `blocksFromNodes`. */
|
|
350
|
+
function foldRun(run) {
|
|
351
|
+
if (run.length < 2) return run;
|
|
352
|
+
const bg0 = run[0].props.bg || '';
|
|
199
353
|
const merged = mkRow([100]);
|
|
200
354
|
merged.props.py = 0; merged.props.px = 0; merged.props.gap = 0;
|
|
201
355
|
if (bg0) merged.props.bg = bg0;
|
|
202
|
-
merged.cols[0].blocks =
|
|
356
|
+
merged.cols[0].blocks = run.reduce((acc, r) => {
|
|
203
357
|
const padded = r.props.py || r.props.px || r.props.pt !== undefined;
|
|
204
358
|
r.cols[0].blocks.forEach((b) => {
|
|
205
359
|
if (padded && b.type !== 'button' && 'py' in b.props) {
|
|
@@ -210,7 +364,27 @@ function mergeBandRows(rows, el) {
|
|
|
210
364
|
});
|
|
211
365
|
return acc;
|
|
212
366
|
}, []);
|
|
213
|
-
return merged.cols[0].blocks.length ? [merged] :
|
|
367
|
+
return merged.cols[0].blocks.length ? [merged] : run;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Merges every run of consecutive foldable rows sharing a background colour; anything else passes through in place and ends the run. */
|
|
371
|
+
function mergeBandRuns(rows) {
|
|
372
|
+
if (rows.length < 2) return rows;
|
|
373
|
+
const out = [];
|
|
374
|
+
let run = [];
|
|
375
|
+
const flush = () => { if (run.length) out.push(...foldRun(run)); run = []; };
|
|
376
|
+
rows.forEach((r) => {
|
|
377
|
+
if (foldable(r) && (!run.length || (run[0].props.bg || '') === (r.props.bg || ''))) { run.push(r); return; }
|
|
378
|
+
flush();
|
|
379
|
+
if (foldable(r)) run.push(r); else out.push(r);
|
|
380
|
+
});
|
|
381
|
+
flush();
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function mergeBandRows(rows, el) {
|
|
386
|
+
if (rows.length < 2 || !bgImageOf(el)) return rows;
|
|
387
|
+
return mergeBandRuns(rows);
|
|
214
388
|
}
|
|
215
389
|
|
|
216
390
|
/** 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. */
|
|
@@ -281,6 +455,18 @@ function classifyImage(el) {
|
|
|
281
455
|
}
|
|
282
456
|
}
|
|
283
457
|
if (!img) return null;
|
|
458
|
+
/*
|
|
459
|
+
* A 1x1 is a tracking pixel or a spacer gif, never a picture. Classified as
|
|
460
|
+
* an image block it became a visible 2%-wide slab in the editor (and was
|
|
461
|
+
* unrepresentable there: the Width slider starts at 5%). It passes through
|
|
462
|
+
* as raw HTML instead, so the open-tracking survives the round trip and the
|
|
463
|
+
* canvas shows nothing for it -- exactly what it was in the source.
|
|
464
|
+
*/
|
|
465
|
+
const attrW = parseInt(img.getAttribute('width'), 10);
|
|
466
|
+
const attrH = parseInt(img.getAttribute('height'), 10);
|
|
467
|
+
const cssW = PX(img.style.width); const cssH = PX(img.style.height);
|
|
468
|
+
const tiny = (v) => v > 0 && v <= 1;
|
|
469
|
+
if ((tiny(attrW) || tiny(cssW)) && (tiny(attrH) || tiny(cssH))) return blk('html', { code: img.outerHTML });
|
|
284
470
|
// A pixel size wins over a percent one wherever either appears: builders
|
|
285
471
|
// routinely write `width:100%` on the img and put the real constraint on a
|
|
286
472
|
// `width` attribute or a wrapper's `max-width` (`<div style="max-width:88px">
|
|
@@ -299,16 +485,66 @@ function classifyImage(el) {
|
|
|
299
485
|
];
|
|
300
486
|
const pxHint = pxCandidates.find((v) => v && !isPct(v) && PX(v));
|
|
301
487
|
const pctHint = [a && a.style ? a.style.width : '', img.style.width].find((v) => isPct(v) && PX(v));
|
|
488
|
+
/*
|
|
489
|
+
* A MEANINGFUL percentage outranks the pixel hint; a 100% one does not.
|
|
490
|
+
*
|
|
491
|
+
* The rule above exists for the builder idiom `<div style="max-width:88px">
|
|
492
|
+
* <img style="width:100%" width="88">`, where the percentage says nothing
|
|
493
|
+
* and the real constraint is the px. But `width:100%` is exactly what makes
|
|
494
|
+
* that percentage meaningless -- any other value is a width someone chose,
|
|
495
|
+
* and since the exporter now also writes a px `width` attribute for Word
|
|
496
|
+
* (block-body.js), letting px win unconditionally meant every image came
|
|
497
|
+
* back a few percent off and no template was a save fixed point any more.
|
|
498
|
+
*/
|
|
499
|
+
const pctFirst = pctHint && PX(pctHint) !== 100 ? pctHint : '';
|
|
302
500
|
let width = 100;
|
|
303
|
-
if (
|
|
501
|
+
if (pctFirst) {
|
|
502
|
+
width = PX(pctFirst);
|
|
503
|
+
} else if (pxHint) {
|
|
304
504
|
// Convert to % of the nearest fixed-width ancestor.
|
|
305
505
|
const colPx = ancestorPxWidth(img) || 600;
|
|
306
506
|
width = Math.max(2, Math.min(100, Math.round((PX(pxHint) / colPx) * 100)));
|
|
307
507
|
} else if (pctHint) {
|
|
308
508
|
width = PX(pctHint);
|
|
309
509
|
}
|
|
510
|
+
/*
|
|
511
|
+
* A PIXEL width, kept as pixels.
|
|
512
|
+
*
|
|
513
|
+
* The percentage above is still computed -- it stays the responsive
|
|
514
|
+
* fallback and what the Width slider shows -- but a source that said
|
|
515
|
+
* `width="88"` and nothing else meant 88, and rounding that into 15% of a
|
|
516
|
+
* 600px column both lost 5px and made the value drift on every save (15%
|
|
517
|
+
* of the next column width is not 88). `wpx` pins the number the source
|
|
518
|
+
* actually gave; render/block-body.js writes it straight back out, so the
|
|
519
|
+
* document is a fixed point.
|
|
520
|
+
*
|
|
521
|
+
* Only when the source expressed NO percentage at all. `width:100%` on the
|
|
522
|
+
* image with a `width` attribute beside it is this exporter's own
|
|
523
|
+
* full-width shape (the attribute is the floor Word reads, not a pin), and
|
|
524
|
+
* reading that as a pixel width would freeze every responsive hero at the
|
|
525
|
+
* column width it happened to be exported from. The same guard, from the
|
|
526
|
+
* other end: a pixel hint that already fills the column is full-width, so
|
|
527
|
+
* it stays a percentage too.
|
|
528
|
+
*/
|
|
529
|
+
const colPxForPin = ancestorPxWidth(img) || 600;
|
|
530
|
+
const pinPx = !pctHint && pxHint && PX(pxHint) > 0 && PX(pxHint) < colPxForPin * 0.98
|
|
531
|
+
? Math.round(PX(pxHint))
|
|
532
|
+
: 0;
|
|
533
|
+
/*
|
|
534
|
+
* The source's aspect ratio, so the export can reserve the image's box
|
|
535
|
+
* with a `height` attribute (block-body.js explains why that matters with
|
|
536
|
+
* images blocked). Taken only where the source gave both dimensions --
|
|
537
|
+
* never derived from one of them -- and rounded, so it stays a short
|
|
538
|
+
* number in the saved JSON and a stable one across reloads.
|
|
539
|
+
*/
|
|
540
|
+
const ratioW = attrW > 1 ? attrW : (cssW > 1 ? cssW : 0);
|
|
541
|
+
const ratioH = attrH > 1 ? attrH : (cssH > 1 ? cssH : 0);
|
|
542
|
+
const ratio = ratioW && ratioH ? Math.round((ratioH / ratioW) * 10000) / 10000 : 0;
|
|
310
543
|
const over = {
|
|
311
|
-
|
|
544
|
+
// `//cdn/x.png` is a relative URL to a mail client (there is no page
|
|
545
|
+
// origin to resolve it against), so it gets the scheme the same way a
|
|
546
|
+
// link does (sanitize.js linkHref).
|
|
547
|
+
src: String(img.getAttribute('src') || '').replace(/^\/\//, 'https://'),
|
|
312
548
|
alt: img.getAttribute('alt') || '',
|
|
313
549
|
href,
|
|
314
550
|
width,
|
|
@@ -317,6 +553,22 @@ function classifyImage(el) {
|
|
|
317
553
|
// the corners of every imported image that had none.
|
|
318
554
|
radius: PX(img.style.borderRadius) || 0,
|
|
319
555
|
};
|
|
556
|
+
if (pinPx) { over.wUnit = 'px'; over.wpx = pinPx; }
|
|
557
|
+
if (ratio) over.ratio = ratio;
|
|
558
|
+
/*
|
|
559
|
+
* Retina sources and the tooltip, previously dropped on the floor: a
|
|
560
|
+
* template imported for a copy edit came back out having lost its 2x
|
|
561
|
+
* artwork. `sizes` only rides along with a `srcset` -- on its own it
|
|
562
|
+
* describes nothing.
|
|
563
|
+
*/
|
|
564
|
+
const srcset = String(img.getAttribute('srcset') || '').trim();
|
|
565
|
+
if (srcset) {
|
|
566
|
+
over.srcset = srcset;
|
|
567
|
+
const sizes = String(img.getAttribute('sizes') || '').trim();
|
|
568
|
+
if (sizes) over.sizes = sizes;
|
|
569
|
+
}
|
|
570
|
+
const title = String(img.getAttribute('title') || '').trim();
|
|
571
|
+
if (title) over.title = title;
|
|
320
572
|
// The block's own spacing lives on the wrapper the exporter writes
|
|
321
573
|
// (`padding: py px`); unread, it reset to 0/0 on every save.
|
|
322
574
|
if (el !== img && el.style) {
|
|
@@ -1237,7 +1489,20 @@ function isPassthroughTable(tb) {
|
|
|
1237
1489
|
// rowsFromContentTable mints from it.
|
|
1238
1490
|
if (trs[0].getAttribute('data-mc-logic')) return false;
|
|
1239
1491
|
const cells = Array.from(trs[0].children).filter((c) => c.tagName === 'TD' || c.tagName === 'TH');
|
|
1240
|
-
|
|
1492
|
+
if (cells.length !== 1) return false;
|
|
1493
|
+
/*
|
|
1494
|
+
* The same rule as the logic marker above, one level in. A flex/grid row
|
|
1495
|
+
* IS a row, and it is a div -- the exporter stamps its settings on that
|
|
1496
|
+
* div as `data-mcr`. Passing the cell through walks the div as ordinary
|
|
1497
|
+
* content, so each column div became a row of its own: a document whose
|
|
1498
|
+
* ONLY row was a flex or grid row reloaded as N stacked one-column rows
|
|
1499
|
+
* with the layout, the spans and any column paint gone. With two or more
|
|
1500
|
+
* rows the content table was never a passthrough, so the bug needed a
|
|
1501
|
+
* single-row document to show itself.
|
|
1502
|
+
*/
|
|
1503
|
+
const lone = onlyChild(cells[0], 'DIV');
|
|
1504
|
+
if (lone && lone.getAttribute && lone.getAttribute('data-mcr')) return false;
|
|
1505
|
+
return true;
|
|
1241
1506
|
}
|
|
1242
1507
|
|
|
1243
1508
|
/** CSSOM hands colors back as `rgb(r, g, b)` even when the source (and the
|
|
@@ -1399,7 +1664,7 @@ function applyFrame(rows, el) {
|
|
|
1399
1664
|
function rowsFromContentTable(table) {
|
|
1400
1665
|
const tableWidthPx = PX(table.getAttribute('width') || table.style.width || '0') || null;
|
|
1401
1666
|
const trs = Array.from(table.querySelectorAll(':scope > tbody > tr, :scope > tr'));
|
|
1402
|
-
|
|
1667
|
+
const built = trs.map((tr) => {
|
|
1403
1668
|
// A synthetic marker row minted by foldLogicWrappers: one dynamic-content
|
|
1404
1669
|
// marker block, at the exact place the tag held in the source.
|
|
1405
1670
|
const logicTag = tr.getAttribute('data-mc-logic');
|
|
@@ -1511,6 +1776,29 @@ function rowsFromContentTable(table) {
|
|
|
1511
1776
|
if (pd && !pd.t && !pd.b && pd.l > 0 && pd.l === pd.r && pd.l <= 60) gapPx = pd.l * 2;
|
|
1512
1777
|
}
|
|
1513
1778
|
}
|
|
1779
|
+
/*
|
|
1780
|
+
* A nested SECTION: one cell whose only child is a multi-row table. Not a
|
|
1781
|
+
* component (a button is one cell, a data table has <th>s, a social strip
|
|
1782
|
+
* classifies as one block) and not layout scaffolding (that is one row,
|
|
1783
|
+
* handled above) -- it is another band of rows, usually a hero or a card
|
|
1784
|
+
* stack with a background image of its own. It used to reach the
|
|
1785
|
+
* never-drop-content floor and import as ONE opaque html block: not a
|
|
1786
|
+
* row, no background controls, nothing inside it editable. It is walked
|
|
1787
|
+
* as rows instead, the section's own image applied once as a band
|
|
1788
|
+
* (applyBgImage folds the runs) and the cell's paint, padding and frame
|
|
1789
|
+
* carried onto the result -- the same composition as the td -> tr ->
|
|
1790
|
+
* table branch in collectRows.
|
|
1791
|
+
*/
|
|
1792
|
+
if (cells.length === 1 && !classifyButton(cells[0])) {
|
|
1793
|
+
const section = onlyChild(cells[0], 'TABLE');
|
|
1794
|
+
const secRows = section ? section.querySelectorAll(':scope > tbody > tr, :scope > tr') : [];
|
|
1795
|
+
if (section && secRows.length >= 2 && !section.closest('form,svg')
|
|
1796
|
+
&& !section.querySelector(':scope > tbody > tr > th, :scope > tr > th') && !classifySocial(section)) {
|
|
1797
|
+
const td = cells[0];
|
|
1798
|
+
const inner = rowsFromContentTable(section);
|
|
1799
|
+
if (inner.length) return applyBgImage(applyBgImage(applyFrame(applyPad(applyBg(inner, bgOf(td)), padOf(td)), td), td), section);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1514
1802
|
const spans = cells.length === 1 ? [100] : spansFromCells(cells, tableWidthPx);
|
|
1515
1803
|
const row = mkRow(spans);
|
|
1516
1804
|
row.props.py = 0; row.props.px = 0; row.props.gap = gapPx;
|
|
@@ -1581,6 +1869,7 @@ function rowsFromContentTable(table) {
|
|
|
1581
1869
|
cells.forEach((cell, i) => {
|
|
1582
1870
|
const col = row.cols[i]; if (!col) return;
|
|
1583
1871
|
const cbg = bgOf(cell); if (cbg) col.bg = cbg;
|
|
1872
|
+
applyColBgImage(col, cell);
|
|
1584
1873
|
const crad = radiusOf(cell.style); if (crad) col.radius = crad;
|
|
1585
1874
|
const cframe = borderSidesOf(cell.style);
|
|
1586
1875
|
if (cframe.width) {
|
|
@@ -1603,7 +1892,13 @@ function rowsFromContentTable(table) {
|
|
|
1603
1892
|
if (radius) row.props.radius = radius;
|
|
1604
1893
|
const shadow = (cellsUniform ? bgSource.style.boxShadow : '') || table.style.boxShadow || '';
|
|
1605
1894
|
if (shadow && shadow !== 'none') row.props.shadow = shadow;
|
|
1606
|
-
|
|
1895
|
+
// A cell's own image is this row's. The TABLE's image is only this row's
|
|
1896
|
+
// when the table IS one row; a multi-row table's image is the band's, and
|
|
1897
|
+
// is applied once to all its rows after they exist (see the return below)
|
|
1898
|
+
// -- stamping it here, row by row as they were built, is what put a fresh
|
|
1899
|
+
// copy of the section photo on every row, each repainting it from its own
|
|
1900
|
+
// top edge, and left nothing for the band merge to fold.
|
|
1901
|
+
const bgiEl = (cellsUniform && bgImageOf(bgSource)) ? bgSource : (trs.length === 1 && bgImageOf(table) ? table : null);
|
|
1607
1902
|
if (bgiEl) applyBgImage([row], bgiEl);
|
|
1608
1903
|
// The table itself can carry section padding (Beefree writes
|
|
1609
1904
|
// `padding-top: 60px` on `.row-content`) on top of the cell's own -- the
|
|
@@ -1631,10 +1926,15 @@ function rowsFromContentTable(table) {
|
|
|
1631
1926
|
// its children, or the whole card collapses into one opaque text blob.
|
|
1632
1927
|
let contentEl = cell;
|
|
1633
1928
|
const lone = onlyChild(cell, 'DIV');
|
|
1634
|
-
if (lone && (bgOf(lone) || radiusOf(lone.style) || borderSidesOf(lone.style).width) && !classifyNode(lone)) {
|
|
1929
|
+
if (lone && (bgOf(lone) || bgImageOf(lone) || radiusOf(lone.style) || borderSidesOf(lone.style).width) && !classifyNode(lone)) {
|
|
1635
1930
|
const col = row.cols[i];
|
|
1636
1931
|
if (col) {
|
|
1637
1932
|
const cbg = bgOf(lone); if (cbg && !col.bg) col.bg = cbg;
|
|
1933
|
+
// The column's own photo ships on this same wrapper (core/export.js
|
|
1934
|
+
// writes the paint, the border, the radius and the padding
|
|
1935
|
+
// together), so it is read back from here too -- otherwise a
|
|
1936
|
+
// two-up row with a photo in one column lost it on every reload.
|
|
1937
|
+
applyColBgImage(col, lone);
|
|
1638
1938
|
const crad = radiusOf(lone.style); if (crad && !col.radius) col.radius = crad;
|
|
1639
1939
|
// The column's own border ships on this same wrapper (core/export.js
|
|
1640
1940
|
// writes bg, border, radius and padding together); reading everything
|
|
@@ -1653,7 +1953,11 @@ function rowsFromContentTable(table) {
|
|
|
1653
1953
|
row.cols[i].blocks = blocksFromNodes(Array.from(contentEl.childNodes));
|
|
1654
1954
|
});
|
|
1655
1955
|
return row;
|
|
1656
|
-
}).filter((r) => r && r.cols.some((c) => c.blocks.length));
|
|
1956
|
+
}).flatMap((r) => (Array.isArray(r) ? r : [r])).filter((r) => r && r.cols.some((c) => c.blocks.length));
|
|
1957
|
+
// The multi-row table's own image, once, as a band (applyBgImage folds the
|
|
1958
|
+
// runs of foldable rows first). The content table never reaches this with
|
|
1959
|
+
// an image: themeFromParsedDoc has already claimed and consumed it.
|
|
1960
|
+
return trs.length > 1 && bgImageOf(table) ? applyBgImage(built, table) : built;
|
|
1657
1961
|
}
|
|
1658
1962
|
|
|
1659
1963
|
/**
|
|
@@ -1883,12 +2187,44 @@ function partialFrame(st) {
|
|
|
1883
2187
|
function themeFromParsedDoc(doc) {
|
|
1884
2188
|
const theme = {};
|
|
1885
2189
|
const body = doc.body;
|
|
2190
|
+
// Reading direction and preview line, both document-level. The preheader
|
|
2191
|
+
// div is removed once read -- left in place it walked as a text row of
|
|
2192
|
+
// invisible copy, and re-exported as a second preheader on every save.
|
|
2193
|
+
if (doc.documentElement && doc.documentElement.getAttribute('dir') === 'rtl') theme.dir = 'rtl';
|
|
2194
|
+
const pre = Array.from(body.children).find((n) => n.tagName === 'DIV' && n.style && n.style.display === 'none'
|
|
2195
|
+
&& (/mso-hide/i.test(n.getAttribute('style') || '') || PX(n.style.maxHeight) === 0 || n.style.fontSize === '1px'));
|
|
2196
|
+
if (pre) {
|
|
2197
|
+
const text = String(pre.textContent || '').replace(/[\u034f\u200c\u00a0]+$/g, '').trim();
|
|
2198
|
+
if (text) theme.preheader = text;
|
|
2199
|
+
pre.remove();
|
|
2200
|
+
}
|
|
1886
2201
|
let bg = hexOf((body.style && (body.style.backgroundColor || body.style.background)) || body.getAttribute('bgcolor') || '');
|
|
1887
2202
|
if (!bg) {
|
|
1888
2203
|
const outer = body.querySelector('table');
|
|
1889
2204
|
if (outer) bg = bgOf(outer) || (outer.querySelector('td') ? bgOf(outer.querySelector('td')) : '');
|
|
1890
2205
|
}
|
|
1891
2206
|
if (bg) theme.bg = bg;
|
|
2207
|
+
// The page's background IMAGE: on the body, or on the outermost wrapper
|
|
2208
|
+
// when that wrapper is full-width (a lone fixed-width table is the content
|
|
2209
|
+
// column, and its image belongs to the content read below, not here).
|
|
2210
|
+
// Claimed means consumed, so the row walk never stamps it onto rows.
|
|
2211
|
+
{
|
|
2212
|
+
const outer = body.querySelector('table');
|
|
2213
|
+
const outerW = outer ? String(outer.getAttribute('width') || (outer.style && outer.style.width) || '') : '';
|
|
2214
|
+
const outerFull = outer && (outerW === '100%' || (!outerW && !PX(outerW)));
|
|
2215
|
+
const host = bgImageOf(body) ? body : (outerFull && bgImageOf(outer) ? outer : null);
|
|
2216
|
+
if (host) {
|
|
2217
|
+
theme.bgImage = bgImageOf(host);
|
|
2218
|
+
if (host.style && host.style.backgroundSize) theme.bgSize = host.style.backgroundSize;
|
|
2219
|
+
if (host.style && host.style.backgroundPosition) theme.bgPos = host.style.backgroundPosition;
|
|
2220
|
+
if (host.style && host.style.backgroundRepeat) theme.bgRepeat = host.style.backgroundRepeat;
|
|
2221
|
+
[body, outer].forEach((n) => {
|
|
2222
|
+
if (!n) return;
|
|
2223
|
+
if (n.style) { n.style.backgroundImage = ''; n.style.backgroundSize = ''; n.style.backgroundPosition = ''; n.style.backgroundRepeat = ''; }
|
|
2224
|
+
if (n.removeAttribute) n.removeAttribute('background');
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
1892
2228
|
const widthCounts = {};
|
|
1893
2229
|
body.querySelectorAll('table').forEach((tb) => {
|
|
1894
2230
|
const px = fixedWidthOf(tb);
|
|
@@ -1917,6 +2253,36 @@ function themeFromParsedDoc(doc) {
|
|
|
1917
2253
|
// whose content table declares nothing keeps the white default.
|
|
1918
2254
|
const cbg = bgOf(content);
|
|
1919
2255
|
if (cbg) theme.contentBg = cbg;
|
|
2256
|
+
// The content column's background image, read back the same way a row's
|
|
2257
|
+
// is -- CSS url() or the `background` attribute, plus whichever of
|
|
2258
|
+
// fit/position/repeat the source actually declared.
|
|
2259
|
+
const cbgi = bgImageOf(content);
|
|
2260
|
+
if (cbgi) {
|
|
2261
|
+
theme.contentBgImage = cbgi;
|
|
2262
|
+
if (content.style && content.style.backgroundSize) theme.contentBgSize = content.style.backgroundSize;
|
|
2263
|
+
if (content.style && content.style.backgroundPosition) theme.contentBgPos = content.style.backgroundPosition;
|
|
2264
|
+
if (content.style && content.style.backgroundRepeat) theme.contentBgRepeat = content.style.backgroundRepeat;
|
|
2265
|
+
// Claimed means consumed, as below. Left on the node, `applyBgImage`
|
|
2266
|
+
// in the row pass reads the very same image off the content table and
|
|
2267
|
+
// stamps a copy onto every row it produces -- so one reload turned a
|
|
2268
|
+
// document-level background into a per-row one, and (because a row
|
|
2269
|
+
// with an image emits VML) changed the exported bytes on a document
|
|
2270
|
+
// nobody had edited.
|
|
2271
|
+
if (content.style) {
|
|
2272
|
+
content.style.backgroundImage = '';
|
|
2273
|
+
content.style.backgroundSize = '';
|
|
2274
|
+
content.style.backgroundPosition = '';
|
|
2275
|
+
content.style.backgroundRepeat = '';
|
|
2276
|
+
}
|
|
2277
|
+
// `bgcolor` goes with it: the exporter writes the pair together, and
|
|
2278
|
+
// the colour has already been claimed as `contentBg` just above --
|
|
2279
|
+
// left behind, the row walker reads the attribute and repaints it as
|
|
2280
|
+
// a row background on every reload.
|
|
2281
|
+
if (content.removeAttribute) {
|
|
2282
|
+
content.removeAttribute('background');
|
|
2283
|
+
content.removeAttribute('bgcolor');
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
1920
2286
|
const r = PX(content.style && content.style.borderRadius);
|
|
1921
2287
|
if (r > 0) theme.radius = r;
|
|
1922
2288
|
// The content column's full border, written by the exporter as a
|
|
@@ -2113,6 +2479,10 @@ export function htmlToDoc(src) {
|
|
|
2113
2479
|
// (never-inlined exports, hand-written emails) classify like inlined ones.
|
|
2114
2480
|
// Best-effort: a pathological stylesheet must never block the import.
|
|
2115
2481
|
try { inlineStylesheets(doc); } catch { /* proceed with inline styles only */ }
|
|
2482
|
+
// Then fold the tint box back into a layered background, so the walk below
|
|
2483
|
+
// sees one shape for section images regardless of which exporter wrote them.
|
|
2484
|
+
try { readVmlBackgrounds(doc); } catch { /* VML is a bonus read, never a blocker */ }
|
|
2485
|
+
try { unwrapTints(doc); } catch { /* an odd tree is not worth failing the import over */ }
|
|
2116
2486
|
// Theme first: themeFromParsedDoc consumes the styles it claims off the
|
|
2117
2487
|
// scaffold nodes, and the row walker must see the cleaned DOM.
|
|
2118
2488
|
const theme = themeFromParsedDoc(doc);
|
package/src/core/layout-style.js
CHANGED
|
@@ -93,6 +93,31 @@ export function rowBg(p) {
|
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* A column's paint for the canvas, the counterpart of `rowBg` one level in.
|
|
98
|
+
*
|
|
99
|
+
* Unpainted or colour-only columns keep the single `background` shorthand
|
|
100
|
+
* they have always drawn with, so nothing about an existing document's DOM
|
|
101
|
+
* changes. Only a column that actually carries an image switches to the
|
|
102
|
+
* longhands -- with the tint as a gradient layer, which is safe here because
|
|
103
|
+
* the canvas is a browser, not an email client (the exporter writes the same
|
|
104
|
+
* tint as its own rgba box instead; core/export.js says why).
|
|
105
|
+
*/
|
|
106
|
+
export function colBg(c) {
|
|
107
|
+
if (!c.bgImage) return { background: c.bg || 'transparent' };
|
|
108
|
+
const ov = (c.overlay || 0) / 100;
|
|
109
|
+
const layers = [];
|
|
110
|
+
if (ov) layers.push('linear-gradient(rgba(20,22,24,' + ov + '),rgba(20,22,24,' + ov + '))');
|
|
111
|
+
layers.push('url("' + cssUrl(c.bgImage) + '")');
|
|
112
|
+
return {
|
|
113
|
+
backgroundColor: c.bg || 'transparent',
|
|
114
|
+
backgroundImage: layers.join(','),
|
|
115
|
+
backgroundSize: c.bgSize || 'cover',
|
|
116
|
+
backgroundPosition: c.bgPos || 'center',
|
|
117
|
+
backgroundRepeat: c.bgRepeat || 'no-repeat',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
export function colsWrap(p) {
|
|
97
122
|
const gap = p.gap || 0;
|
|
98
123
|
if (p.layout === 'grid') return { display: 'grid', gridTemplateColumns: 'repeat(' + (p.gridCols || 2) + ', minmax(0, 1fr))', gap: gap + 'px' };
|
package/src/core/sanitize.js
CHANGED
|
@@ -102,7 +102,10 @@ export const cleanImportHtml = (html, dropProps, keepProps) => {
|
|
|
102
102
|
});
|
|
103
103
|
Array.from(el.attributes).forEach((at) => {
|
|
104
104
|
const keep = (el.tagName === 'A' && ['href', 'target', 'rel'].indexOf(at.name) > -1)
|
|
105
|
-
|
|
105
|
+
// `srcset`/`sizes` are the retina sources and `title` the tooltip:
|
|
106
|
+
// dropped, a template imported for a copy edit came back out
|
|
107
|
+
// having quietly lost its 2x artwork.
|
|
108
|
+
|| (el.tagName === 'IMG' && ['src', 'alt', 'width', 'height', 'srcset', 'sizes', 'title'].indexOf(at.name) > -1);
|
|
106
109
|
if (!keep) el.removeAttribute(at.name);
|
|
107
110
|
});
|
|
108
111
|
if (kept.length) el.setAttribute('style', kept.join(';'));
|
package/src/core/theme.js
CHANGED
|
@@ -11,5 +11,26 @@
|
|
|
11
11
|
* fault. Now it is off unless a document asks for it. Both background keys
|
|
12
12
|
* accept the literal `transparent` (and any rgba()/#rrggbbaa value) as well
|
|
13
13
|
* as a hex colour.
|
|
14
|
+
*
|
|
15
|
+
* `contentBgImage` paints a photo or pattern behind the content column. It
|
|
16
|
+
* lives at content level rather than page level on purpose: the content
|
|
17
|
+
* column is already a <table>, which is the one element every client paints a
|
|
18
|
+
* background on, whereas a page background has to ride <body> -- and Gmail
|
|
19
|
+
* discards the body element wholesale, so a page-level image is simply absent
|
|
20
|
+
* there. Same fit/position/repeat vocabulary as a row's background image.
|
|
21
|
+
*/
|
|
22
|
+
/*
|
|
23
|
+
* `preheader` is the inbox preview line -- the text a client shows next to the
|
|
24
|
+
* subject. Absent, every client scrapes the first visible copy instead, which
|
|
25
|
+
* is usually "View in browser" or a logo's alt text. `dir` is the document's
|
|
26
|
+
* own reading direction, set here and nowhere else: the editor chrome follows
|
|
27
|
+
* the UI locale, but a German-speaking author building an Arabic mailing (or
|
|
28
|
+
* the reverse) must not have the email flip with the menus.
|
|
29
|
+
*/
|
|
30
|
+
/*
|
|
31
|
+
* `bgImage` paints the full-width page behind everything. It rides the
|
|
32
|
+
* full-width wrapper <table> and <body> both: Gmail discards the body element,
|
|
33
|
+
* so the table is what makes it render there. Same fit/position/repeat
|
|
34
|
+
* vocabulary as a row and the content area.
|
|
14
35
|
*/
|
|
15
|
-
export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, padY: 0, padX: 0, radius: 0, borderW: 0, borderStyle: 'solid', borderColor: '#e2e2e5', shadow: '', font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
|
|
36
|
+
export const THEME = () => ({ preheader: '', dir: '', bg: '#eef2f7', bgImage: '', bgSize: 'cover', bgPos: 'center', bgRepeat: 'no-repeat', contentBg: '#ffffff', contentBgImage: '', contentBgSize: 'cover', contentBgPos: 'center', contentBgRepeat: 'no-repeat', width: 620, padY: 0, padX: 0, radius: 0, borderW: 0, borderStyle: 'solid', borderColor: '#e2e2e5', shadow: '', font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
|
package/src/mailcraft-editor.js
CHANGED
|
@@ -2421,7 +2421,15 @@ export class MailCraftEditor extends ElementBase {
|
|
|
2421
2421
|
// grid -- for a transparent page, which is exactly what a client with no
|
|
2422
2422
|
// page colour of its own shows behind the email.
|
|
2423
2423
|
const pageBg = s.doc.theme.bg || '';
|
|
2424
|
+
const th = s.doc.theme;
|
|
2424
2425
|
this.previewBody.style.background = /^(transparent|none)$/i.test(pageBg.trim()) ? '' : pageBg;
|
|
2426
|
+
// The page background image, the same way the export paints it on the
|
|
2427
|
+
// body and the wrapper table (render/canvas.js does the same for the
|
|
2428
|
+
// editor's own page).
|
|
2429
|
+
this.previewBody.style.backgroundImage = th.bgImage ? 'url("' + cssUrl(th.bgImage) + '")' : '';
|
|
2430
|
+
this.previewBody.style.backgroundSize = th.bgImage ? (th.bgSize || 'cover') : '';
|
|
2431
|
+
this.previewBody.style.backgroundPosition = th.bgImage ? (th.bgPos || 'center') : '';
|
|
2432
|
+
this.previewBody.style.backgroundRepeat = th.bgImage ? (th.bgRepeat || 'no-repeat') : '';
|
|
2425
2433
|
// Rebuild the sheet only when what it renders from actually changed.
|
|
2426
2434
|
// renderPreviewModal runs on *every* render pass, and rebuilding the
|
|
2427
2435
|
// whole email document each time made the open preview visibly rough:
|