@seliseblocks/mailcraft 0.2.15 → 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 +40 -0
- package/DOCS.md +70 -67
- package/README.md +8 -8
- package/README.md.txt +8 -8
- package/dist/mailcraft-editor.bundle.js +86 -78
- 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 +14 -2
- 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 +40 -5
- package/src/render/story.js +46 -18
- package/src/render/style.js +1 -1
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.
|
package/src/render/screenshot.js
CHANGED
|
@@ -62,8 +62,15 @@ function encodeCanvas(canvas, mime, quality) {
|
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Inlined bytes are cached per URL for the session: every capture (open,
|
|
66
|
+
// retry, reopen) used to re-fetch each remote image, which is most of a slow
|
|
67
|
+
// open on a photo-heavy template. A failed fetch is evicted so a transient
|
|
68
|
+
// network error does not pin the blank-pixel fallback forever.
|
|
69
|
+
const inlineCache = new Map();
|
|
70
|
+
|
|
65
71
|
function toDataUri(url) {
|
|
66
|
-
|
|
72
|
+
if (inlineCache.has(url)) return inlineCache.get(url);
|
|
73
|
+
const job = fetch(url, { mode: 'cors' }).then((res) => {
|
|
67
74
|
if (!res.ok) throw new Error('fetch failed: ' + res.status);
|
|
68
75
|
return res.blob();
|
|
69
76
|
}).then((blob) => new Promise((resolve, reject) => {
|
|
@@ -71,7 +78,12 @@ function toDataUri(url) {
|
|
|
71
78
|
fr.onload = () => resolve(fr.result);
|
|
72
79
|
fr.onerror = reject;
|
|
73
80
|
fr.readAsDataURL(blob);
|
|
74
|
-
}))
|
|
81
|
+
})).catch((err) => {
|
|
82
|
+
inlineCache.delete(url);
|
|
83
|
+
throw err;
|
|
84
|
+
});
|
|
85
|
+
inlineCache.set(url, job);
|
|
86
|
+
return job;
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
async function inlineExternalImages(root) {
|
|
@@ -85,11 +97,34 @@ async function inlineExternalImages(root) {
|
|
|
85
97
|
));
|
|
86
98
|
});
|
|
87
99
|
root.querySelectorAll('[style]').forEach((el) => {
|
|
88
|
-
|
|
100
|
+
/*
|
|
101
|
+
* Only the `url(...)` layer is rewritten, never the whole declaration.
|
|
102
|
+
* A tinted section paints `linear-gradient(rgba(20,22,24,α),…),url(…)`
|
|
103
|
+
* (core/layout-style.js), and replacing the property wholesale dropped
|
|
104
|
+
* the gradient -- so every darkened hero came out of the screenshot
|
|
105
|
+
* lighter than the canvas it was captured from. A fetch that fails
|
|
106
|
+
* leaves a transparent pixel in the layer's place rather than `none`,
|
|
107
|
+
* which would be invalid inside a layer list and would take the tint
|
|
108
|
+
* down with it a second way.
|
|
109
|
+
*/
|
|
110
|
+
const decl = el.style.backgroundImage || '';
|
|
111
|
+
const m = decl.match(/url\(["']?(?!data:)([^"')]+)["']?\)/);
|
|
89
112
|
if (!m) return;
|
|
90
113
|
jobs.push(toDataUri(m[1]).then(
|
|
91
|
-
(uri) => { el.style.backgroundImage = 'url("' + uri + '")'; },
|
|
92
|
-
() => { el.style.backgroundImage = '
|
|
114
|
+
(uri) => { el.style.backgroundImage = decl.replace(m[0], 'url("' + uri + '")'); },
|
|
115
|
+
() => { el.style.backgroundImage = decl.replace(m[0], 'url("' + BLANK_PIXEL + '")'); },
|
|
116
|
+
));
|
|
117
|
+
});
|
|
118
|
+
// The legacy `background` attribute -- what an imported Outlook-first
|
|
119
|
+
// template, and the exporter's own rows, carry alongside the CSS. Read only
|
|
120
|
+
// where no CSS image was inlined above, so the two never fetch twice.
|
|
121
|
+
root.querySelectorAll('[background]').forEach((el) => {
|
|
122
|
+
const src = el.getAttribute('background') || '';
|
|
123
|
+
if (!src || src.startsWith('data:')) return;
|
|
124
|
+
if (/url\(/.test(el.style.backgroundImage || '')) return;
|
|
125
|
+
jobs.push(toDataUri(src).then(
|
|
126
|
+
(uri) => { el.setAttribute('background', uri); el.style.backgroundImage = 'url("' + uri + '")'; },
|
|
127
|
+
() => { el.removeAttribute('background'); },
|
|
93
128
|
));
|
|
94
129
|
});
|
|
95
130
|
await Promise.all(jobs);
|
package/src/render/story.js
CHANGED
|
@@ -83,14 +83,17 @@ export const createStoryViewer = function (core, hooks) {
|
|
|
83
83
|
open: false, page: 0, pages: 1, playing: true, done: false, held: false,
|
|
84
84
|
remaining: PAGE_MS, startedAt: 0, timer: 0, token: 0,
|
|
85
85
|
url: '', blob: null, natW: 0, natH: 0, cardW: 0, cardH: 0, showH: 0,
|
|
86
|
-
format: 0, saving: false,
|
|
86
|
+
format: 0, saving: false, onClose: null,
|
|
87
87
|
};
|
|
88
88
|
|
|
89
89
|
// ---- chrome -----------------------------------------------------------
|
|
90
90
|
|
|
91
91
|
// Dim only, no backdrop blur -- a full-screen blur is a many-frame stall on
|
|
92
92
|
// weak GPUs each time the overlay fades in; the deeper dim carries the focus.
|
|
93
|
-
|
|
93
|
+
// Deep dim rather than translucent: bright chrome underneath (the export
|
|
94
|
+
// dialog's buttons, a colourful canvas) bled through 0.86 right where the
|
|
95
|
+
// viewer's own header sits and read as clutter.
|
|
96
|
+
const overlay = el('div', 'position: absolute; inset: 0; z-index: 78; display: none; flex-direction: column; align-items: center; justify-content: center; gap: 11px; padding: 22px; box-sizing: border-box; background: rgba(8,10,15,0.94); opacity: 0; transition: opacity 0.22s ease;', { class: 'mc-story' });
|
|
94
97
|
// Clicking the dark surround closes; clicks on the card are the tap zones'
|
|
95
98
|
// business and must not fall through to here.
|
|
96
99
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
|
@@ -108,13 +111,19 @@ export const createStoryViewer = function (core, hooks) {
|
|
|
108
111
|
headRow.append(headText, closeBtn);
|
|
109
112
|
|
|
110
113
|
const card = el('div', 'position: relative; overflow: hidden; border-radius: 16px; background: #fff; flex: none; box-shadow: 0 26px 70px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.09); transform: scale(0.965) translateY(10px); opacity: 0; transition: transform 0.34s cubic-bezier(0.22,0.61,0.36,1), opacity 0.24s ease;');
|
|
111
|
-
|
|
114
|
+
// `will-change` keeps the tall capture promoted to its own layer for the
|
|
115
|
+
// life of the viewer -- without it the browser may demote between slides
|
|
116
|
+
// and re-rasterize megapixels on the next one, which is the visible hitch.
|
|
117
|
+
const shot = el('img', 'position: absolute; left: 0; top: 0; width: 100%; display: block; opacity: 0; transform: translate3d(0,0,0); transition: opacity 0.32s ease; will-change: transform;', { alt: '', decoding: 'async' });
|
|
112
118
|
card.appendChild(shot);
|
|
113
119
|
|
|
114
120
|
// Skeleton: shown while the capture renders and cross-faded out when the
|
|
115
121
|
// image arrives, so the card never flashes empty or resizes under the eye.
|
|
116
122
|
const skeleton = el('div', 'position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 11px; background: var(--ed-panel); color: var(--ed-faint); transition: opacity 0.28s ease;');
|
|
117
|
-
|
|
123
|
+
// The sweep animates `transform`, not `background-position`: the capture
|
|
124
|
+
// work saturates the main thread exactly while this plays, and a composited
|
|
125
|
+
// transform keeps gliding where a per-frame background repaint stutters.
|
|
126
|
+
const shimmer = el('div', 'position: absolute; top: 0; bottom: 0; left: 0; width: 60%; background: linear-gradient(100deg, transparent, var(--ed-soft), transparent); transform: translateX(-110%); animation: mcStoryShimmer 1.6s linear infinite; will-change: transform;');
|
|
118
127
|
const skelText = el('div', 'position: relative; font-family: var(--ed-font); font-size: 12.5px; font-weight: 600; color: var(--ed-text);');
|
|
119
128
|
const skelHint = el('div', 'position: relative; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.07em; line-height: 1.7; color: var(--ed-faint); max-width: 76%; text-align: center;');
|
|
120
129
|
const retryBtn = el('button', 'position: relative; display: none; border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 13px; border-radius: 8px; align-items: center; gap: 7px; font-family: var(--ed-font); font-size: 11.5px; font-weight: 600;', { type: 'button' });
|
|
@@ -409,18 +418,24 @@ export const createStoryViewer = function (core, hooks) {
|
|
|
409
418
|
s.url = URL.createObjectURL(blob);
|
|
410
419
|
shot.onload = () => {
|
|
411
420
|
if (token !== s.token) return;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
421
|
+
// Decode the megapixels off-screen first: fading in an undecoded
|
|
422
|
+
// image rasterizes it inside the fade and drops its first frames.
|
|
423
|
+
const ready = shot.decode ? shot.decode().catch(() => {}) : Promise.resolve();
|
|
424
|
+
ready.then(() => {
|
|
425
|
+
if (token !== s.token) return;
|
|
426
|
+
s.natW = shot.naturalWidth;
|
|
427
|
+
s.natH = shot.naturalHeight;
|
|
428
|
+
layout();
|
|
429
|
+
shot.style.opacity = '1';
|
|
430
|
+
skeleton.style.opacity = '0';
|
|
431
|
+
setTimeout(() => { if (token === s.token) skeleton.style.display = 'none'; }, 300);
|
|
432
|
+
setActionsEnabled(true);
|
|
433
|
+
// A template that fits on one screen has nothing to advance through:
|
|
434
|
+
// land on "replay" rather than running an invisible timer.
|
|
435
|
+
if (s.pages < 2) { s.done = true; s.playing = false; }
|
|
436
|
+
syncPlayBtn();
|
|
437
|
+
arm();
|
|
438
|
+
});
|
|
424
439
|
};
|
|
425
440
|
shot.src = s.url;
|
|
426
441
|
}, () => {
|
|
@@ -460,9 +475,10 @@ export const createStoryViewer = function (core, hooks) {
|
|
|
460
475
|
step(e.deltaY > 0 ? 1 : -1);
|
|
461
476
|
}, { passive: false });
|
|
462
477
|
|
|
463
|
-
function open() {
|
|
478
|
+
function open(opts) {
|
|
464
479
|
if (s.open) return;
|
|
465
480
|
s.open = true;
|
|
481
|
+
s.onClose = (opts && opts.onClose) || null;
|
|
466
482
|
overlay.style.display = 'flex';
|
|
467
483
|
overlay.removeAttribute('aria-hidden');
|
|
468
484
|
kickerEl.textContent = t('story.kicker');
|
|
@@ -471,7 +487,14 @@ export const createStoryViewer = function (core, hooks) {
|
|
|
471
487
|
card.style.transform = 'scale(1) translateY(0)';
|
|
472
488
|
card.style.opacity = '1';
|
|
473
489
|
layout();
|
|
474
|
-
|
|
490
|
+
// The capture blocks the main thread in bursts (render, layout, serialize,
|
|
491
|
+
// rasterize, encode) -- started immediately those bursts land inside the
|
|
492
|
+
// entrance transition and visibly stutter it. The skeleton goes up now;
|
|
493
|
+
// the capture starts once the chrome has finished arriving (0.34s card
|
|
494
|
+
// transition, plus a beat).
|
|
495
|
+
showSkeleton(false);
|
|
496
|
+
renderMeta();
|
|
497
|
+
setTimeout(() => { if (s.open) load(); }, 380);
|
|
475
498
|
window.addEventListener('keydown', onKey, true);
|
|
476
499
|
window.addEventListener('resize', layout);
|
|
477
500
|
closeBtn.focus({ preventScroll: true });
|
|
@@ -482,6 +505,11 @@ export const createStoryViewer = function (core, hooks) {
|
|
|
482
505
|
s.open = false;
|
|
483
506
|
s.token++;
|
|
484
507
|
clearTimeout(s.timer);
|
|
508
|
+
// Restore whatever the host parked for the viewer (the export dialog) as
|
|
509
|
+
// the fade-out starts, so leaving reads as returning, not as a blank beat.
|
|
510
|
+
const restore = s.onClose;
|
|
511
|
+
s.onClose = null;
|
|
512
|
+
if (restore) restore();
|
|
485
513
|
window.removeEventListener('keydown', onKey, true);
|
|
486
514
|
window.removeEventListener('resize', layout);
|
|
487
515
|
overlay.style.opacity = '0';
|
package/src/render/style.js
CHANGED
|
@@ -108,7 +108,7 @@ export const STYLE = `
|
|
|
108
108
|
@keyframes mcIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
|
109
109
|
@keyframes mcFade { from { opacity: 0; } to { opacity: 1; } }
|
|
110
110
|
@keyframes mcPulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
|
|
111
|
-
@keyframes mcStoryShimmer { from {
|
|
111
|
+
@keyframes mcStoryShimmer { from { transform: translateX(-110%); } to { transform: translateX(280%); } }
|
|
112
112
|
|
|
113
113
|
/*
|
|
114
114
|
* Hover/selection outlines on canvas rows and blocks. Re-rendering on every
|