@seliseblocks/mailcraft 0.2.16 → 0.2.19
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 +47 -0
- package/DOCS.md +70 -67
- package/README.md +8 -8
- package/README.md.txt +8 -8
- package/dist/mailcraft-editor.bundle.js +89 -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-order-shipped.html +2 -2
- package/examples/templates/your-password-was-changed.html +2 -2
- package/examples/templates/your-signin-code.html +1 -1
- package/examples/vanilla.html +66 -66
- package/package.json +1 -1
- package/src/core/binder.js +10 -0
- package/src/core/blocks.js +3 -3
- 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/index.js +83 -83
- 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/icons.js +17 -43
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +390 -20
- package/src/core/layout-style.js +25 -0
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/sanitize.js +4 -1
- package/src/core/theme.js +22 -1
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +11 -1
- 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/focus-preserve.js +158 -158
- package/src/render/screenshot.js +26 -3
- package/src/render/style.js +6 -1
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/core/variables.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
|
|
2
|
-
export const TOKEN = (t) => '{' + '{ ' + t + ' }' + '}';
|
|
3
|
-
|
|
4
|
-
/** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
|
|
5
|
-
export function vars(raw) {
|
|
6
|
-
const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
|
|
7
|
-
return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
|
|
11
|
-
export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };
|
|
1
|
+
export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
|
|
2
|
+
export const TOKEN = (t) => '{' + '{ ' + t + ' }' + '}';
|
|
3
|
+
|
|
4
|
+
/** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
|
|
5
|
+
export function vars(raw) {
|
|
6
|
+
const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
|
|
7
|
+
return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
|
|
11
|
+
export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };
|
package/src/mailcraft-editor.js
CHANGED
|
@@ -89,7 +89,9 @@ function tip(node, label, dir, align) {
|
|
|
89
89
|
* chrome; while present the built-in toggle is hidden), `footer` ("none" to
|
|
90
90
|
* remove the attribution strip, or any string to replace its text).
|
|
91
91
|
* Properties: `.variables`, `.toolbar` (which parts of the top bar are shown),
|
|
92
|
-
* `.aiProvider` (optional async fn
|
|
92
|
+
* `.aiProvider` (optional async fn -- the host's own model call; the AI
|
|
93
|
+
* features stay hidden until one is set, since the package ships no client
|
|
94
|
+
* and talks to no model service of its own),
|
|
93
95
|
* `.iconProvider` (optional social-icon override), `.storageProvider` (host-supplied
|
|
94
96
|
* file storage -- see `core/storage.js`), `.storageLimits` (host-set upload ceilings,
|
|
95
97
|
* required whenever a provider is set), `.messages` (UI string overrides --
|
|
@@ -2421,7 +2423,15 @@ export class MailCraftEditor extends ElementBase {
|
|
|
2421
2423
|
// grid -- for a transparent page, which is exactly what a client with no
|
|
2422
2424
|
// page colour of its own shows behind the email.
|
|
2423
2425
|
const pageBg = s.doc.theme.bg || '';
|
|
2426
|
+
const th = s.doc.theme;
|
|
2424
2427
|
this.previewBody.style.background = /^(transparent|none)$/i.test(pageBg.trim()) ? '' : pageBg;
|
|
2428
|
+
// The page background image, the same way the export paints it on the
|
|
2429
|
+
// body and the wrapper table (render/canvas.js does the same for the
|
|
2430
|
+
// editor's own page).
|
|
2431
|
+
this.previewBody.style.backgroundImage = th.bgImage ? 'url("' + cssUrl(th.bgImage) + '")' : '';
|
|
2432
|
+
this.previewBody.style.backgroundSize = th.bgImage ? (th.bgSize || 'cover') : '';
|
|
2433
|
+
this.previewBody.style.backgroundPosition = th.bgImage ? (th.bgPos || 'center') : '';
|
|
2434
|
+
this.previewBody.style.backgroundRepeat = th.bgImage ? (th.bgRepeat || 'no-repeat') : '';
|
|
2425
2435
|
// Rebuild the sheet only when what it renders from actually changed.
|
|
2426
2436
|
// renderPreviewModal runs on *every* render pass, and rebuilding the
|
|
2427
2437
|
// whole email document each time made the open preview visibly rough:
|
package/src/render/block-body.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { icon, brandIcon, socialKey, SOCIAL_BRAND, contrastInk } from '../core/icons.js';
|
|
2
2
|
import { pad } from '../core/layout-style.js';
|
|
3
3
|
import { parseItems, cellsOf } from '../core/parse.js';
|
|
4
|
-
import { linkHref } from '../core/sanitize.js';
|
|
4
|
+
import { linkHref, cssUrl } from '../core/sanitize.js';
|
|
5
5
|
|
|
6
6
|
function el(tag, style, attrs) {
|
|
7
7
|
const node = document.createElement(tag);
|
|
@@ -93,7 +93,7 @@ function overrideLinkColor(root, link) {
|
|
|
93
93
|
* toolbar node or null), `ctx.onTableCellBlur(block, ri, ci, value)`, `ctx.now`
|
|
94
94
|
* (for the countdown), `ctx.vars` and `ctx.onInsertVariable` for the code-view escape hatch.
|
|
95
95
|
*/
|
|
96
|
-
export function blockBody(b, theme, live, ctx) {
|
|
96
|
+
export function blockBody(b, theme, live, ctx, colPx) {
|
|
97
97
|
const p = b.props; const t = theme;
|
|
98
98
|
const attr = live ? { 'data-mc-content': b.id } : {};
|
|
99
99
|
|
|
@@ -119,20 +119,87 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
119
119
|
case 'image': {
|
|
120
120
|
const wrap = el('div', { padding: pad(p), textAlign: p.align, fontSize: '0' }, attr);
|
|
121
121
|
const imgHref = linkHref(p.href);
|
|
122
|
+
/*
|
|
123
|
+
* Two things every exported image needs and had neither of.
|
|
124
|
+
*
|
|
125
|
+
* `width` as an ATTRIBUTE, in px: the Word engine behind Classic
|
|
126
|
+
* Outlook does not resolve a percentage width on an image and falls
|
|
127
|
+
* back to the file's intrinsic size, so a 1200px hero authored at 60%
|
|
128
|
+
* shipped 1200px wide and tore the layout open. The CSS percentage
|
|
129
|
+
* stays for every client that can do better -- the attribute is only
|
|
130
|
+
* the floor Word reads -- and `height:auto` keeps the aspect ratio once
|
|
131
|
+
* a width is pinned.
|
|
132
|
+
*
|
|
133
|
+
* A real `font-size` on the IMG: the wrapper sets `font-size:0` to kill
|
|
134
|
+
* the inline-block whitespace gap, and alt text inherits it, so with
|
|
135
|
+
* images blocked (Outlook's default) the alt text rendered at 0px and
|
|
136
|
+
* the recipient saw nothing at all. The wrapper keeps its 0; the image
|
|
137
|
+
* carries type of its own for the blocked state.
|
|
138
|
+
*/
|
|
139
|
+
/*
|
|
140
|
+
* Two ways to size an image, and the second one is why a logo used to
|
|
141
|
+
* drift.
|
|
142
|
+
*
|
|
143
|
+
* The default is a PERCENTAGE of the column -- responsive, and the
|
|
144
|
+
* right answer for a hero. But a percentage cannot express "88px", and
|
|
145
|
+
* an import that met one had to round it into the nearest whole
|
|
146
|
+
* percent (88 of 600 -> 15% -> 93px). Every save re-derived the
|
|
147
|
+
* percent from the new pixel width, so the logo crept a little wider
|
|
148
|
+
* each time and never landed back on 88.
|
|
149
|
+
*
|
|
150
|
+
* `wUnit: 'px'` with `wpx` pins the real number instead: the CSS width,
|
|
151
|
+
* the Word `width` attribute and the read-back all carry the same
|
|
152
|
+
* integer, so the value is a fixed point. `max-width:100%` (plus the
|
|
153
|
+
* stylesheet's own `img` rule in the sent mail) keeps a pinned image
|
|
154
|
+
* from holding a narrow phone column open.
|
|
155
|
+
*/
|
|
156
|
+
const fixed = p.wUnit === 'px' && Number(p.wpx) > 0;
|
|
157
|
+
const pxW = fixed
|
|
158
|
+
? Math.max(1, Math.round(Number(p.wpx)))
|
|
159
|
+
: Math.max(1, Math.round(((Number(colPx) || Number(t.width) || 620)) * ((Number(p.width) || 100) / 100)));
|
|
160
|
+
const cssW = fixed ? pxW + 'px' : (Number(p.width) || 100) + '%';
|
|
161
|
+
/*
|
|
162
|
+
* `height` as an attribute, derived from the source's own aspect ratio
|
|
163
|
+
* rather than invented: with images blocked (Outlook's default, and
|
|
164
|
+
* Gmail's for an unknown sender) a width-only image collapses to a
|
|
165
|
+
* one-line strip and the layout below it jumps when the image loads.
|
|
166
|
+
* A width AND a height reserve the real box. The CSS `height:auto`
|
|
167
|
+
* still wins in every client that reads CSS, so a client that scales
|
|
168
|
+
* the image down keeps its proportions -- the attribute is only the
|
|
169
|
+
* floor Word and a blocked-image placeholder read.
|
|
170
|
+
*
|
|
171
|
+
* Absent unless the ratio is known (an import that carried both
|
|
172
|
+
* dimensions, or a library asset), so no image gains a guessed height.
|
|
173
|
+
*/
|
|
174
|
+
const ratio = Number(p.ratio) > 0 ? Number(p.ratio) : 0;
|
|
175
|
+
const hAttr = ratio ? String(Math.max(1, Math.round(pxW * ratio))) : undefined;
|
|
176
|
+
// Retina and responsive sources ride through untouched. Apple Mail and
|
|
177
|
+
// iOS pick from `srcset`; every other client ignores both attributes
|
|
178
|
+
// and uses `src`, which is why `src` stays the 1x fallback.
|
|
179
|
+
const srcAttrs = {
|
|
180
|
+
srcset: p.srcset || undefined,
|
|
181
|
+
sizes: p.srcset && p.sizes ? p.sizes : undefined,
|
|
182
|
+
title: p.title || undefined,
|
|
183
|
+
};
|
|
184
|
+
const altType = { fontSize: '13px', lineHeight: '1.4', fontFamily: t.font, color: t.text };
|
|
122
185
|
if (imgHref) {
|
|
123
186
|
// The % width must live on the anchor, not the img: a percentage on a
|
|
124
187
|
// child of a shrink-to-fit inline-block resolves against the image's
|
|
125
188
|
// own intrinsic size (i.e. not at all), which rendered every linked
|
|
126
189
|
// logo/icon at full intrinsic width no matter what `width` said.
|
|
127
|
-
|
|
190
|
+
// `max-width` only in pixel mode: a percentage anchor is already
|
|
191
|
+
// capped by its container, and adding the declaration unconditionally
|
|
192
|
+
// would rewrite the markup of every linked image that never asked for
|
|
193
|
+
// this feature.
|
|
194
|
+
const a = el('a', { display: 'inline-block', width: cssW, ...(fixed ? { maxWidth: '100%' } : {}) }, { href: imgHref });
|
|
128
195
|
// Same guard as every other anchor the canvas draws: without it a
|
|
129
196
|
// click on a linked logo navigates the host application away from the
|
|
130
197
|
// editor, taking the uncommitted document with it.
|
|
131
198
|
a.addEventListener('click', (e) => e.preventDefault());
|
|
132
|
-
a.appendChild(el('img', { width: '100%', borderRadius: p.radius + 'px', display: 'block', border: '0' }, { src: p.src, alt: p.alt }));
|
|
199
|
+
a.appendChild(el('img', { width: '100%', height: 'auto', borderRadius: p.radius + 'px', display: 'block', border: '0', ...altType }, { src: p.src, alt: p.alt, width: String(pxW), height: hAttr, border: '0', ...srcAttrs }));
|
|
133
200
|
wrap.appendChild(a);
|
|
134
201
|
} else {
|
|
135
|
-
wrap.appendChild(el('img', { width:
|
|
202
|
+
wrap.appendChild(el('img', { width: cssW, height: 'auto', maxWidth: '100%', borderRadius: p.radius + 'px', display: 'inline-block', border: '0', ...altType }, { src: p.src, alt: p.alt, width: String(pxW), height: hAttr, border: '0', ...srcAttrs }));
|
|
136
203
|
}
|
|
137
204
|
return wrap;
|
|
138
205
|
}
|
|
@@ -197,11 +264,29 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
197
264
|
}
|
|
198
265
|
case 'divider': {
|
|
199
266
|
const wrap = el('div', { padding: p.py + 'px 0' }, attr);
|
|
200
|
-
|
|
267
|
+
const rule = p.thickness + 'px ' + (p.lineStyle || 'solid') + ' ' + p.color;
|
|
268
|
+
// Word gives a zero-height div nothing to draw the border on, so the
|
|
269
|
+
// same rule ships twice: once on this div, which every other client
|
|
270
|
+
// renders and the importer reads (classifyDivider: a DIV bar with a
|
|
271
|
+
// border-top), and once on a <td> inside an MSO-only table, the shape
|
|
272
|
+
// Word does paint. `<!--[if !mso]><!-->` keeps the div out of Word so
|
|
273
|
+
// it never draws both. Comments are inert to the importer.
|
|
274
|
+
const doc = wrap.ownerDocument;
|
|
275
|
+
wrap.appendChild(doc.createComment('[if !mso]><!'));
|
|
276
|
+
wrap.appendChild(el('div', { height: '0', width: p.width + '%', margin: '0 auto', borderTop: rule, fontSize: '1px', lineHeight: '1px' }, { html: ' ' }));
|
|
277
|
+
wrap.appendChild(doc.createComment('<![endif]'));
|
|
278
|
+
wrap.appendChild(doc.createComment('[if mso]><table role="presentation" width="' + p.width + '%" align="center" cellpadding="0" cellspacing="0" border="0"><tr><td style="border-top:' + rule + ';font-size:1px;line-height:1px;"> </td></tr></table><![endif]'));
|
|
201
279
|
return wrap;
|
|
202
280
|
}
|
|
203
|
-
case 'spacer':
|
|
204
|
-
|
|
281
|
+
case 'spacer': {
|
|
282
|
+
// An empty div has no height in Word: it needs a character to give a
|
|
283
|
+
// line to, and an exact line-height to size that line (msoHarden adds
|
|
284
|
+
// mso-line-height-rule:exactly wherever a px line-height stands). This
|
|
285
|
+
// is mj-spacer's shape. The NBSP trims to nothing for the importer, so
|
|
286
|
+
// classifySpacer still sees an empty div with a height.
|
|
287
|
+
const h = Math.max(0, Number(p.height) || 0);
|
|
288
|
+
return el('div', { height: h + 'px', lineHeight: h + 'px', fontSize: '1px' }, { ...attr, html: ' ' });
|
|
289
|
+
}
|
|
205
290
|
case 'social': {
|
|
206
291
|
// Two independent axes cover every style the block offers without a
|
|
207
292
|
// second hand-drawn icon set per platform: `palette` picks the source
|
|
@@ -241,14 +326,32 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
241
326
|
}
|
|
242
327
|
case 'video': {
|
|
243
328
|
const wrap = el('div', { padding: '4px 0', textAlign: 'center' }, attr);
|
|
244
|
-
|
|
329
|
+
/*
|
|
330
|
+
* The play badge used to float over the thumbnail with
|
|
331
|
+
* position:absolute. Gmail strips `position` outright and Word ignores
|
|
332
|
+
* it, so in both the badge fell to a line below the picture. Now the
|
|
333
|
+
* thumbnail is the cell's background (the one overlay mechanism email
|
|
334
|
+
* has) and the badge is plain centred content inside it. The cell needs
|
|
335
|
+
* a height, since a background gives none: 16:9 of the column width,
|
|
336
|
+
* which is what a video thumbnail is. The whole cell is the link.
|
|
337
|
+
*/
|
|
338
|
+
const vw = Math.max(120, Number(colPx) || Number(t.width) || 620);
|
|
339
|
+
const vh = Math.round(vw * 9 / 16);
|
|
340
|
+
const vurl = cssUrl(p.src);
|
|
341
|
+
const table = el('table', { width: '100%', borderCollapse: 'collapse' }, { role: 'presentation', cellpadding: '0', cellspacing: '0', border: '0', width: '100%' });
|
|
342
|
+
const tr = el('tr'); table.appendChild(tr);
|
|
343
|
+
const cell = el('td', {
|
|
344
|
+
height: vh + 'px', textAlign: 'center', verticalAlign: 'middle',
|
|
345
|
+
backgroundColor: '#111111', backgroundImage: vurl ? 'url("' + vurl + '")' : 'none',
|
|
346
|
+
backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat',
|
|
347
|
+
}, { height: String(vh), align: 'center', valign: 'middle', background: p.src || undefined, bgcolor: '#111111' });
|
|
348
|
+
const a = el('a', { display: 'inline-block', textDecoration: 'none' }, { href: linkHref(p.href), title: p.caption || undefined });
|
|
245
349
|
a.addEventListener('click', (e) => e.preventDefault());
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
wrap.appendChild(a);
|
|
350
|
+
const circle = el('span', { display: 'inline-block', width: '54px', height: '54px', lineHeight: '54px', borderRadius: '50%', background: p.badge, color: '#fff', textAlign: 'center', fontSize: '18px', fontFamily: 'Arial, sans-serif' }, { text: '▶' });
|
|
351
|
+
a.appendChild(circle);
|
|
352
|
+
cell.appendChild(a);
|
|
353
|
+
tr.appendChild(cell);
|
|
354
|
+
wrap.appendChild(table);
|
|
252
355
|
wrap.appendChild(el('div', { fontFamily: t.font, fontSize: '12.5px', color: p.badge, opacity: '0.7', marginTop: '8px' }, { text: p.caption }));
|
|
253
356
|
return wrap;
|
|
254
357
|
}
|
|
@@ -385,8 +488,17 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
385
488
|
const edit = live;
|
|
386
489
|
const borderSide = (on) => (p.border && on !== false ? p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + p.lineColor : '0');
|
|
387
490
|
const box = el('div', {
|
|
388
|
-
|
|
389
|
-
|
|
491
|
+
// Colour and image as separate longhands, never one shorthand. The
|
|
492
|
+
// shorthand made `p.bg` unreachable the moment an image was set --
|
|
493
|
+
// there was no colour left underneath when a client refused the image
|
|
494
|
+
// -- and it opened with a no-op `linear-gradient()` that outlook.com's
|
|
495
|
+
// sanitiser drops the whole declaration over, taking the photo with
|
|
496
|
+
// it. Same rule as a row's background (core/export.js).
|
|
497
|
+
backgroundColor: p.bg || 'transparent',
|
|
498
|
+
backgroundImage: p.bgImage ? 'url("' + cssUrl(p.bgImage) + '")' : 'none',
|
|
499
|
+
// The same three controls a row has; hard-coded cover/center meant a
|
|
500
|
+
// box background could never tile or pin to an edge.
|
|
501
|
+
backgroundSize: p.bgSize || 'cover', backgroundPosition: p.bgPos || 'center', backgroundRepeat: p.bgRepeat || 'no-repeat',
|
|
390
502
|
borderTop: borderSide(p.topBorder), borderRight: borderSide(p.rightBorder),
|
|
391
503
|
borderBottom: borderSide(p.bottomBorder), borderLeft: borderSide(p.leftBorder),
|
|
392
504
|
borderRadius: p.radius + 'px', padding: p.pad + 'px', textAlign: p.align,
|
package/src/render/canvas.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DEF } from '../core/blocks.js';
|
|
2
|
-
import { boxStyle, rowBg, rowPad, colsWrap, colStyle } from '../core/layout-style.js';
|
|
3
|
-
import { scopeCss } from '../core/sanitize.js';
|
|
2
|
+
import { boxStyle, rowBg, rowPad, colsWrap, colStyle, colBg } from '../core/layout-style.js';
|
|
3
|
+
import { scopeCss, cssUrl } from '../core/sanitize.js';
|
|
4
4
|
import { cellsOf } from '../core/parse.js';
|
|
5
5
|
import { icon } from '../core/icons.js';
|
|
6
6
|
import { blockBody } from './block-body.js';
|
|
@@ -189,7 +189,14 @@ export function renderDoc(core, live) {
|
|
|
189
189
|
const radius = Number(theme.radius) || 0;
|
|
190
190
|
const borderW = Number(theme.borderW) || 0;
|
|
191
191
|
const root = el('div', {
|
|
192
|
-
width: width + 'px', maxWidth: '100%',
|
|
192
|
+
width: width + 'px', maxWidth: '100%', color: theme.text, fontFamily: theme.font,
|
|
193
|
+
// Colour and image as separate longhands, mirroring what the exporter
|
|
194
|
+
// writes -- the canvas has to show the same paint the recipient gets.
|
|
195
|
+
backgroundColor: theme.contentBg || 'transparent',
|
|
196
|
+
backgroundImage: theme.contentBgImage ? 'url("' + cssUrl(theme.contentBgImage) + '")' : 'none',
|
|
197
|
+
backgroundSize: theme.contentBgSize || 'cover',
|
|
198
|
+
backgroundPosition: theme.contentBgPos || 'center',
|
|
199
|
+
backgroundRepeat: theme.contentBgRepeat || 'no-repeat',
|
|
193
200
|
// Only when the document actually asks for a shape: an unconditional
|
|
194
201
|
// `0px` would override the editor chrome's own soft corner on the sheet
|
|
195
202
|
// (style.js) and square off every template that never touched the field.
|
|
@@ -224,6 +231,15 @@ export function renderDoc(core, live) {
|
|
|
224
231
|
const padX = Number(theme.padX) || 0;
|
|
225
232
|
const page = el('div', {
|
|
226
233
|
background: theme.bg || 'transparent',
|
|
234
|
+
// The page image, only when there is one: the longhands are added AFTER
|
|
235
|
+
// the shorthand so they override it, and are absent otherwise so a
|
|
236
|
+
// document with no image paints exactly as it always did.
|
|
237
|
+
...(theme.bgImage ? {
|
|
238
|
+
backgroundImage: 'url("' + cssUrl(theme.bgImage) + '")',
|
|
239
|
+
backgroundSize: theme.bgSize || 'cover',
|
|
240
|
+
backgroundPosition: theme.bgPos || 'center',
|
|
241
|
+
backgroundRepeat: theme.bgRepeat || 'no-repeat',
|
|
242
|
+
} : {}),
|
|
227
243
|
padding: padY + 'px ' + padX + 'px',
|
|
228
244
|
boxSizing: 'border-box', maxWidth: '100%', display: 'flex', justifyContent: 'center',
|
|
229
245
|
transition: 'background 0.2s, padding 0.22s cubic-bezier(0.22,0.61,0.36,1)',
|
|
@@ -310,7 +326,20 @@ export function renderDoc(core, live) {
|
|
|
310
326
|
}
|
|
311
327
|
: { display: 'block' })
|
|
312
328
|
: colsWrap(r.props));
|
|
329
|
+
/*
|
|
330
|
+
* What this column measures in a sent email, in px. Word cannot resolve a
|
|
331
|
+
* percentage width on an image (it falls back to the file's intrinsic
|
|
332
|
+
* size, which is how a 1200px hero tore the layout open in Outlook), so
|
|
333
|
+
* the image renderer needs a real number to put in a `width` attribute.
|
|
334
|
+
* Approximate by construction -- cell gutters and column padding are not
|
|
335
|
+
* modelled here -- but an approximate cap is the whole difference between
|
|
336
|
+
* a scaled image and a 1200px one.
|
|
337
|
+
*/
|
|
338
|
+
const rowPadX = (r.props.pl ?? r.props.px ?? 0) + (r.props.pr ?? r.props.px ?? 0);
|
|
339
|
+
const gapPx = (r.props.gap || 0) * Math.max(0, r.cols.length - 1);
|
|
340
|
+
const innerPx = Math.max(40, (Number(theme.width) || 620) - rowPadX - gapPx);
|
|
313
341
|
r.cols.forEach((c, ci) => {
|
|
342
|
+
const colPx = Math.max(20, Math.round(innerPx * ((c.span || 100) / 100)));
|
|
314
343
|
const colLines = [];
|
|
315
344
|
const items = [];
|
|
316
345
|
c.blocks.forEach((b, bi) => {
|
|
@@ -356,7 +385,7 @@ export function renderDoc(core, live) {
|
|
|
356
385
|
// few pixels from the RTE's own controls. The RTE stands in for it for
|
|
357
386
|
// the duration of the edit; it comes back on blur (still selected).
|
|
358
387
|
if (bSel && ctx.editingId !== b.id) bWrap.appendChild(toolbar(core, b.id, 'block', DEF(b.type).code));
|
|
359
|
-
bWrap.appendChild(blockBody(b, theme, live, ctx));
|
|
388
|
+
bWrap.appendChild(blockBody(b, theme, live, ctx, colPx));
|
|
360
389
|
items.push(bWrap);
|
|
361
390
|
});
|
|
362
391
|
let colTracker;
|
|
@@ -379,9 +408,9 @@ export function renderDoc(core, live) {
|
|
|
379
408
|
// background must stop at the column's visual edge, not bleed across
|
|
380
409
|
// the gutter. `host` is also what the drag listeners and
|
|
381
410
|
// `indexFromPoint` must use -- the block slots are its children.
|
|
382
|
-
const styled = c.bg || c.border || c.radius || c.padY || c.padX;
|
|
411
|
+
const styled = c.bg || c.bgImage || c.border || c.radius || c.padY || c.padX;
|
|
383
412
|
const host = styled
|
|
384
|
-
? el('div', {
|
|
413
|
+
? el('div', { ...colBg(c), border: c.border ? c.border + 'px ' + (c.borderStyle || 'solid') + ' ' + (c.lineColor || '#e2e2e5') : '0', borderRadius: (c.radius || 0) + 'px', padding: (c.padY || 0) + 'px ' + (c.padX || 0) + 'px', height: '100%', boxSizing: 'border-box' })
|
|
385
414
|
: colEl;
|
|
386
415
|
if (live) {
|
|
387
416
|
host.addEventListener('dragover', (e) => {
|
package/src/render/fields.js
CHANGED
|
@@ -447,6 +447,25 @@ export function renderField(f) {
|
|
|
447
447
|
return wrap;
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
+
/*
|
|
451
|
+
* A note is prose, not a control: no label row, no focus key, nothing to
|
|
452
|
+
* commit. It sits in the flow where the panel put it, tinted by tone --
|
|
453
|
+
* amber for a warning the author should act on before sending, muted for
|
|
454
|
+
* an aside. The left rule rather than a filled panel keeps it from reading
|
|
455
|
+
* as a field the user failed to fill in.
|
|
456
|
+
*/
|
|
457
|
+
if (f.isNote) {
|
|
458
|
+
const warn = f.tone !== 'info';
|
|
459
|
+
wrap.appendChild(el('div', {
|
|
460
|
+
fontFamily: 'var(--ed-font)', fontSize: '11px', lineHeight: '1.5',
|
|
461
|
+
color: warn ? 'var(--ed-text)' : 'var(--ed-muted)',
|
|
462
|
+
background: warn ? 'rgba(214,141,32,0.10)' : 'var(--ed-panel-2)',
|
|
463
|
+
borderLeft: '2px solid ' + (warn ? 'rgba(214,141,32,0.85)' : 'var(--ed-line)'),
|
|
464
|
+
padding: '7px 9px', boxSizing: 'border-box',
|
|
465
|
+
}, { text: f.label, class: 'mc-field-note', role: warn ? 'status' : undefined }));
|
|
466
|
+
return wrap;
|
|
467
|
+
}
|
|
468
|
+
|
|
450
469
|
// No isHead branch: heads never reach renderField -- renderFieldCards
|
|
451
470
|
// consumes them as card kickers. A field list rendered without the card
|
|
452
471
|
// grouper would drop its headings, which is the loud failure we want.
|