@seliseblocks/mailcraft 0.2.6 → 0.2.8
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/DOCS.md +4 -3
- package/README.md +4 -1
- package/README.md.txt +4 -1
- package/dist/mailcraft-editor.bundle.js +84 -59
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/examples/vanilla.html +19 -18
- package/package.json +1 -1
- package/src/core/blocks.js +15 -1
- package/src/core/brand.js +81 -0
- package/src/core/editor-core.js +66 -9
- package/src/core/export.js +195 -6
- package/src/core/i18n/ar.js +2 -2
- package/src/core/i18n/bg.js +2 -2
- package/src/core/i18n/bn.js +2 -2
- package/src/core/i18n/ca.js +2 -2
- package/src/core/i18n/cs.js +2 -2
- package/src/core/i18n/da.js +2 -2
- package/src/core/i18n/de-CH.js +2 -2
- package/src/core/i18n/de.js +2 -2
- package/src/core/i18n/dz.js +2 -2
- package/src/core/i18n/el.js +2 -2
- package/src/core/i18n/en.js +2 -2
- package/src/core/i18n/es.js +2 -2
- package/src/core/i18n/et.js +2 -2
- package/src/core/i18n/fi.js +2 -2
- package/src/core/i18n/fr.js +2 -2
- package/src/core/i18n/hr.js +2 -2
- package/src/core/i18n/hu.js +2 -2
- package/src/core/i18n/it.js +2 -2
- package/src/core/i18n/lt.js +2 -2
- package/src/core/i18n/lv.js +2 -2
- package/src/core/i18n/nb.js +2 -2
- package/src/core/i18n/nl.js +2 -2
- package/src/core/i18n/pl.js +2 -2
- package/src/core/i18n/pt.js +2 -2
- package/src/core/i18n/ro.js +2 -2
- package/src/core/i18n/ru.js +2 -2
- package/src/core/i18n/sk.js +2 -2
- package/src/core/i18n/sl.js +2 -2
- package/src/core/i18n/sv.js +2 -2
- package/src/core/i18n/tr.js +2 -2
- package/src/core/i18n/uk.js +2 -2
- package/src/core/icons.js +1 -0
- package/src/core/import-html.js +330 -20
- package/src/core/sanitize.js +41 -0
- package/src/mailcraft-editor.js +20 -10
- package/src/render/block-body.js +120 -17
- package/src/render/canvas.js +50 -4
- package/src/render/fields.js +9 -1
- package/src/render/screenshot.js +13 -2
- package/src/render/style.js +10 -11
package/src/render/block-body.js
CHANGED
|
@@ -1,6 +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
5
|
|
|
5
6
|
function el(tag, style, attrs) {
|
|
6
7
|
const node = document.createElement(tag);
|
|
@@ -39,6 +40,31 @@ function editableAttrs(on) {
|
|
|
39
40
|
return on ? { contenteditable: 'true', spellcheck: 'false', ...NO_ASSIST } : { spellcheck: 'false' };
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* What keeps a text-bearing block inside the column it was dropped in.
|
|
45
|
+
*
|
|
46
|
+
* A long unbroken token -- a tracking URL, a concatenated word -- has a
|
|
47
|
+
* min-content width of the whole token, and nothing above clips it: the block
|
|
48
|
+
* wrapper (render/canvas.js) and the exported `<td>` both let it through, so
|
|
49
|
+
* the run either paints across the next column on the canvas or, in the sent
|
|
50
|
+
* email, widens its own cell and steals the width from its siblings (a 25%
|
|
51
|
+
* column measured 511px against its neighbours' 24px).
|
|
52
|
+
*
|
|
53
|
+
* `anywhere` rather than the more familiar `break-word` on purpose: only
|
|
54
|
+
* `anywhere` lowers the min-content contribution, which is the number table
|
|
55
|
+
* and flex layout actually size against -- `break-word` wraps the glyphs but
|
|
56
|
+
* leaves the box's intrinsic width at the full token, so the column blows out
|
|
57
|
+
* exactly as before. `word-break` carries the same meaning for engines that
|
|
58
|
+
* predate `anywhere`. Neither breaks a token that already fits.
|
|
59
|
+
*
|
|
60
|
+
* It has to live here, in the inline style, rather than in the editor's
|
|
61
|
+
* stylesheet: this is the DOM `core/export.js` reads back and sends. The
|
|
62
|
+
* canvas *looked* fine without it only because Chrome's UA sheet gives
|
|
63
|
+
* `[contenteditable]` an `overflow-wrap: break-word` -- and export strips
|
|
64
|
+
* `contenteditable`, so the editor was hiding the defect that shipped.
|
|
65
|
+
*/
|
|
66
|
+
const FIT = { overflowWrap: 'anywhere', wordBreak: 'break-word' };
|
|
67
|
+
|
|
42
68
|
/** An explicit block font owns every rich descendant; an empty value deliberately leaves imported inline typography untouched. */
|
|
43
69
|
function overrideRichFont(root, fontFamily) {
|
|
44
70
|
if (!fontFamily) return;
|
|
@@ -61,7 +87,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
61
87
|
case 'text': {
|
|
62
88
|
const edit = live;
|
|
63
89
|
const content = el('div', {
|
|
64
|
-
padding: pad(p), fontSize: p.size + 'px', lineHeight: p.lh, textAlign: p.align, fontWeight: p.weight, color: p.color || t.text, outline: 'none', fontFamily: p.fontFamily || t.font,
|
|
90
|
+
padding: pad(p), fontSize: p.size + 'px', lineHeight: p.lh, textAlign: p.align, fontWeight: p.weight, color: p.color || t.text, outline: 'none', fontFamily: p.fontFamily || t.font, ...FIT,
|
|
65
91
|
}, { ...attr, ...editableAttrs(edit), html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
66
92
|
// Imported email HTML keeps inline font-family declarations so an
|
|
67
93
|
// untouched block retains its source typography. Once the user chooses
|
|
@@ -77,12 +103,17 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
77
103
|
}
|
|
78
104
|
case 'image': {
|
|
79
105
|
const wrap = el('div', { padding: pad(p), textAlign: p.align, fontSize: '0' }, attr);
|
|
80
|
-
|
|
106
|
+
const imgHref = linkHref(p.href);
|
|
107
|
+
if (imgHref) {
|
|
81
108
|
// The % width must live on the anchor, not the img: a percentage on a
|
|
82
109
|
// child of a shrink-to-fit inline-block resolves against the image's
|
|
83
110
|
// own intrinsic size (i.e. not at all), which rendered every linked
|
|
84
111
|
// logo/icon at full intrinsic width no matter what `width` said.
|
|
85
|
-
const a = el('a', { display: 'inline-block', width: p.width + '%' }, { href:
|
|
112
|
+
const a = el('a', { display: 'inline-block', width: p.width + '%' }, { href: imgHref });
|
|
113
|
+
// Same guard as every other anchor the canvas draws: without it a
|
|
114
|
+
// click on a linked logo navigates the host application away from the
|
|
115
|
+
// editor, taking the uncommitted document with it.
|
|
116
|
+
a.addEventListener('click', (e) => e.preventDefault());
|
|
86
117
|
a.appendChild(el('img', { width: '100%', borderRadius: p.radius + 'px', display: 'block', border: '0' }, { src: p.src, alt: p.alt }));
|
|
87
118
|
wrap.appendChild(a);
|
|
88
119
|
} else {
|
|
@@ -90,22 +121,57 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
90
121
|
}
|
|
91
122
|
return wrap;
|
|
92
123
|
}
|
|
124
|
+
/*
|
|
125
|
+
* A one-cell table, not a bare padded anchor.
|
|
126
|
+
*
|
|
127
|
+
* Classic Outlook's Word engine does not lay out `display:inline-block`
|
|
128
|
+
* and treats padding on an inline `<a>` inconsistently, so the pill drawn
|
|
129
|
+
* by an anchor alone collapses to bare coloured text there -- the single
|
|
130
|
+
* most-reported defect in hand-written email. A `<td>` is the one box
|
|
131
|
+
* Word sizes and paints reliably, so the background, the padding and the
|
|
132
|
+
* corner live on the cell and the anchor becomes the label inside it.
|
|
133
|
+
* Every other client renders the two identically.
|
|
134
|
+
*
|
|
135
|
+
* VML `<v:roundrect>` is the usual alternative and is deliberately not
|
|
136
|
+
* used: it needs an explicit pixel width and height, which an auto-width
|
|
137
|
+
* button sized by its own label does not have. The only thing this gives
|
|
138
|
+
* up against VML is the rounded corner in Classic Outlook, which squares
|
|
139
|
+
* off there and is correct everywhere else.
|
|
140
|
+
*/
|
|
93
141
|
case 'button': {
|
|
94
142
|
const wrap = el('div', { textAlign: p.align, padding: '4px 0' }, attr);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
143
|
+
// `inline-table` so the wrapper's text-align still positions it; the
|
|
144
|
+
// `align` attribute is the same instruction for Word, which ignores the
|
|
145
|
+
// display value. A full-width button is a plain 100% table instead.
|
|
146
|
+
const table = el('table', {
|
|
147
|
+
display: p.full ? 'table' : 'inline-table', width: p.full ? '100%' : 'auto', borderCollapse: 'separate',
|
|
148
|
+
}, { role: 'presentation', cellpadding: '0', cellspacing: '0', border: '0', align: p.full ? undefined : p.align });
|
|
149
|
+
const td = el('td', {
|
|
150
|
+
background: p.bg, borderRadius: p.radius + 'px', padding: p.py + 'px ' + p.px + 'px', textAlign: 'center',
|
|
99
151
|
// Outline-style buttons (transparent fill + border) are a standard
|
|
100
152
|
// email pattern; the color falls back to the label color so a bare
|
|
101
153
|
// "outline thickness" bump looks right without a second step.
|
|
102
154
|
border: p.borderW ? p.borderW + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.borderColor || p.color) : '0',
|
|
103
|
-
}, {
|
|
155
|
+
}, { align: 'center', bgcolor: p.bg || undefined });
|
|
156
|
+
const a = el('a', {
|
|
157
|
+
display: 'block', color: p.color, textDecoration: 'none',
|
|
158
|
+
// Kept on the anchor as well as the cell: `classifyButton` reads the
|
|
159
|
+
// element carrying the background to decide something is a button at
|
|
160
|
+
// all, and a foreign client that drops the cell's paint still shows
|
|
161
|
+
// the pill. It costs one declaration.
|
|
162
|
+
background: p.bg, borderRadius: p.radius + 'px',
|
|
163
|
+
fontFamily: p.fontFamily || t.font, fontSize: p.size + 'px',
|
|
164
|
+
fontWeight: '600', letterSpacing: '0.02em', outline: 'none', ...FIT,
|
|
165
|
+
}, { href: linkHref(p.href), ...editableAttrs(live), text: p.label });
|
|
104
166
|
a.addEventListener('click', (e) => e.preventDefault());
|
|
105
167
|
// Button editing is deliberately plain: no focus-tracked `editing` state, no
|
|
106
168
|
// paste handling, no floating RTE toolbar -- only its label commits on blur.
|
|
107
169
|
if (live) a.addEventListener('blur', (e) => { if (e.target.textContent !== p.label) ctx.onBlur(b, 'label', e.target.textContent); });
|
|
108
|
-
|
|
170
|
+
td.appendChild(a);
|
|
171
|
+
const tr = el('tr'); tr.appendChild(td);
|
|
172
|
+
const tbody = el('tbody'); tbody.appendChild(tr);
|
|
173
|
+
table.appendChild(tbody);
|
|
174
|
+
wrap.appendChild(table);
|
|
109
175
|
return wrap;
|
|
110
176
|
}
|
|
111
177
|
case 'divider': {
|
|
@@ -139,7 +205,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
139
205
|
borderRadius: shape === 'circle' ? '50%' : shape === 'square' ? Math.round(box * 0.28) + 'px' : '0',
|
|
140
206
|
color: iconColor, background: badge ? source : 'transparent',
|
|
141
207
|
border: shape === 'outline' ? '1px solid ' + source : '0',
|
|
142
|
-
}, { href: it.href, title: it.label });
|
|
208
|
+
}, { href: linkHref(it.href), title: it.label });
|
|
143
209
|
a.addEventListener('click', (e) => e.preventDefault());
|
|
144
210
|
let provided = null;
|
|
145
211
|
if (ctx && ctx.iconProvider) { try { provided = ctx.iconProvider(key, { label: it.label, size: p.size, color: iconColor }); } catch { provided = null; } }
|
|
@@ -154,7 +220,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
154
220
|
}
|
|
155
221
|
case 'video': {
|
|
156
222
|
const wrap = el('div', { padding: '4px 0', textAlign: 'center' }, attr);
|
|
157
|
-
const a = el('a', { display: 'block', position: 'relative' }, { href: p.href });
|
|
223
|
+
const a = el('a', { display: 'block', position: 'relative' }, { href: linkHref(p.href) });
|
|
158
224
|
a.addEventListener('click', (e) => e.preventDefault());
|
|
159
225
|
a.appendChild(el('img', { width: '100%', display: 'block', border: '0' }, { src: p.src, alt: p.caption }));
|
|
160
226
|
const badge = el('span', { position: 'absolute', inset: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' });
|
|
@@ -184,7 +250,13 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
184
250
|
// instead of re-rendering the editor (see the tick in editor-core.js).
|
|
185
251
|
const wrap = el('div', { padding: '10px 0', textAlign: 'center', fontFamily: p.fontFamily || t.font, color: p.color }, live ? Object.assign({ 'data-mc-countdown': p.target }, attr) : attr);
|
|
186
252
|
wrap.appendChild(el('div', { fontSize: '12px', letterSpacing: '0.14em', textTransform: 'uppercase', opacity: '0.6', marginBottom: '10px' }, { text: p.label }));
|
|
187
|
-
|
|
253
|
+
// Wraps rather than overhangs. Four 84px boxes plus their gaps need
|
|
254
|
+
// 366px, so in any column narrower than that -- a 50/50 split of a
|
|
255
|
+
// 620px sheet is 296px -- the unwrapped row used to run out over the
|
|
256
|
+
// next column, and in the sent email it dragged its own cell open to
|
|
257
|
+
// 386px against its neighbours' 94px. Wrapping keeps the digits legible
|
|
258
|
+
// at any width; a row with the space for one line still gets one line.
|
|
259
|
+
const row = el('div', { display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '10px' });
|
|
188
260
|
parts.forEach(([lab, v]) => {
|
|
189
261
|
const box = el('div', { border: '1px solid ' + p.color + '33', padding: '9px 12px', minWidth: '58px' });
|
|
190
262
|
box.appendChild(el('div', { fontSize: '25px', fontFamily: p.fontFamily || t.font, fontWeight: '700', lineHeight: '1' }, live ? { text: String(v).padStart(2, '0'), 'data-mc-count': lab } : { text: String(v).padStart(2, '0') }));
|
|
@@ -197,7 +269,14 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
197
269
|
case 'menu': {
|
|
198
270
|
const wrap = el('div', { textAlign: p.align, padding: '10px 0' }, attr);
|
|
199
271
|
parseItems(p.items).forEach((it) => {
|
|
200
|
-
|
|
272
|
+
// `inline-block`, not the default inline: adjacent inline *text* runs
|
|
273
|
+
// offer no soft-wrap opportunity between them, and these anchors are
|
|
274
|
+
// appended with no whitespace in between -- so a menu too wide for its
|
|
275
|
+
// column ran straight off the edge instead of wrapping onto a second
|
|
276
|
+
// line (item 3 of a 3-item menu started 18px past a 148px column). An
|
|
277
|
+
// inline-block is an atomic inline, which does get a break opportunity
|
|
278
|
+
// either side of it, exactly as the social row already relied on.
|
|
279
|
+
const a = el('a', { display: 'inline-block', color: p.color, fontSize: p.size + 'px', fontFamily: p.fontFamily || t.font, textDecoration: 'none', margin: '0 ' + p.gap / 2 + 'px', letterSpacing: '0.12em', textTransform: 'uppercase' }, { href: linkHref(it.href), text: it.label });
|
|
201
280
|
a.addEventListener('click', (e) => e.preventDefault());
|
|
202
281
|
wrap.appendChild(a);
|
|
203
282
|
});
|
|
@@ -208,7 +287,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
208
287
|
const head = el(p.level || 'h2', {
|
|
209
288
|
margin: '0', padding: pad(p), fontSize: p.size + 'px', lineHeight: p.lh, textAlign: p.align,
|
|
210
289
|
fontWeight: p.weight, letterSpacing: p.font === 'condensed' ? '0.005em' : '-0.01em',
|
|
211
|
-
color: p.color || t.text, outline: 'none',
|
|
290
|
+
color: p.color || t.text, outline: 'none', ...FIT,
|
|
212
291
|
// An explicit per-block font beats the Condensed/Body style toggle.
|
|
213
292
|
fontFamily: p.fontFamily || (p.font === 'condensed' ? "'Arial Narrow', 'Helvetica Neue Condensed', Helvetica, Arial, sans-serif" : t.font),
|
|
214
293
|
}, { ...attr, ...editableAttrs(edit), text: m(p.text), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
@@ -218,7 +297,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
218
297
|
}
|
|
219
298
|
case 'list': {
|
|
220
299
|
const items = String(p.items || '').split('\n').filter((l) => l.trim());
|
|
221
|
-
const list = el(p.ordered ? 'ol' : 'ul', { margin: '0', padding: (p.py || 0) + 'px 0 ' + (p.py || 0) + 'px 22px', fontFamily: p.fontFamily || t.font, fontSize: p.size + 'px', lineHeight: p.lh, color: p.color || t.text }, attr);
|
|
300
|
+
const list = el(p.ordered ? 'ol' : 'ul', { margin: '0', padding: (p.py || 0) + 'px 0 ' + (p.py || 0) + 'px 22px', fontFamily: p.fontFamily || t.font, fontSize: p.size + 'px', lineHeight: p.lh, color: p.color || t.text, ...FIT }, attr);
|
|
222
301
|
items.forEach((it) => {
|
|
223
302
|
const li = el('li', { marginBottom: p.gap + 'px' }, { html: m(it) });
|
|
224
303
|
list.appendChild(li);
|
|
@@ -241,6 +320,17 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
241
320
|
fontWeight: isHead ? '600' : '400', outline: 'none',
|
|
242
321
|
fontFamily: p.fontFamily || t.font,
|
|
243
322
|
letterSpacing: isHead ? '0.06em' : 'normal', textTransform: isHead ? 'uppercase' : 'none', fontSize: isHead ? p.size + 1 + 'px' : p.size + 'px',
|
|
323
|
+
// See FIT: a `<table>` cannot be laid out narrower than its
|
|
324
|
+
// min-content width, so `width:100%` alone never made it fit a
|
|
325
|
+
// column smaller than the sum of its longest cells (224px for the
|
|
326
|
+
// stock three-column table -- wider than a 3- or 4-way split).
|
|
327
|
+
// Letting the cells break brings that floor down to the column.
|
|
328
|
+
// Deliberately *not* `table-layout:fixed`, the usual reflex here:
|
|
329
|
+
// fixed would also fit, but it divides the width evenly and would
|
|
330
|
+
// silently re-proportion every table already in a saved document.
|
|
331
|
+
// Breaking keeps the content-proportional columns (measured
|
|
332
|
+
// 53/42/39 against fixed's 45/45/45).
|
|
333
|
+
...FIT,
|
|
244
334
|
}, { ...editableAttrs(edit), text: m(cell), 'data-focus-key': edit ? 'block:' + b.id + ':c' + ri + '-' + ci : undefined });
|
|
245
335
|
if (edit) {
|
|
246
336
|
// stopPropagation is load-bearing (a bubbled click would select
|
|
@@ -283,9 +373,15 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
283
373
|
borderTop: borderSide(p.topBorder), borderRight: borderSide(p.rightBorder),
|
|
284
374
|
borderBottom: borderSide(p.bottomBorder), borderLeft: borderSide(p.leftBorder),
|
|
285
375
|
borderRadius: p.radius + 'px', padding: p.pad + 'px', textAlign: p.align,
|
|
376
|
+
// `maxWidth` has to cap the box the reader sees, not its text area:
|
|
377
|
+
// under the default content-box sizing the padding and border were
|
|
378
|
+
// added *outside* the cap, so a box set to 60% of a 296px column
|
|
379
|
+
// painted 212px wide instead of 178. The default 100% is unaffected
|
|
380
|
+
// either way -- an auto-width block already stops at the column edge.
|
|
381
|
+
boxSizing: 'border-box',
|
|
286
382
|
minHeight: p.minH ? p.minH + 'px' : 'auto', maxWidth: p.maxW + '%', margin: p.align === 'center' ? '0 auto' : '0',
|
|
287
383
|
boxShadow: p.shadow ? '0 10px 30px rgba(29,31,32,0.12)' : 'none',
|
|
288
|
-
fontFamily: t.font, color: t.text, fontSize: '15px', lineHeight: '1.6',
|
|
384
|
+
fontFamily: t.font, color: t.text, fontSize: '15px', lineHeight: '1.6', ...FIT,
|
|
289
385
|
}, { ...attr, ...editableAttrs(edit), html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
290
386
|
if (edit) wireEditable(box, b, 'html', ctx, false);
|
|
291
387
|
if (!live) return box;
|
|
@@ -312,8 +408,15 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
312
408
|
if (!p.end) band.appendChild(el('span', { fontFamily: 'ui-monospace,monospace', fontSize: '11.5px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, { text: '{{ ' + (p.expr || '…') + ' }}' }));
|
|
313
409
|
return band;
|
|
314
410
|
}
|
|
411
|
+
// `pre-wrap`, not `pre`: `overflow-x:auto` gives the canvas a scrollbar
|
|
412
|
+
// and so looked contained here, but a mail client has no scrollbar to
|
|
413
|
+
// offer and sizes the cell to the longest line anyway -- a 33% column
|
|
414
|
+
// measured 371px against its neighbours' ~100px. Wrapping the long lines
|
|
415
|
+
// is the one option that keeps the sample readable and the row intact;
|
|
416
|
+
// `anywhere` covers a single unbroken line with no spaces to break at.
|
|
417
|
+
// The horizontal scroll stays for the canvas, where it still helps.
|
|
315
418
|
case 'codeblock':
|
|
316
|
-
return el('pre', { margin: '0', padding: p.pad + 'px', background: p.bg, color: p.color, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: p.size + 'px', lineHeight: '1.6', overflowX: 'auto', whiteSpace: 'pre' }, { ...attr, text: m(p.code) });
|
|
419
|
+
return el('pre', { margin: '0', padding: p.pad + 'px', background: p.bg, color: p.color, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: p.size + 'px', lineHeight: '1.6', overflowX: 'auto', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, { ...attr, text: m(p.code) });
|
|
317
420
|
default:
|
|
318
421
|
return el('div');
|
|
319
422
|
}
|
package/src/render/canvas.js
CHANGED
|
@@ -231,7 +231,11 @@ export function renderDoc(core, live) {
|
|
|
231
231
|
showLine(rowDropTracker, rowLines[index]);
|
|
232
232
|
});
|
|
233
233
|
page.addEventListener('dragleave', (e) => { if (e.target === root || e.target === page) hideActive(rowDropTracker); });
|
|
234
|
-
|
|
234
|
+
// Measured against `root`, exactly as the line above it was: the slots are
|
|
235
|
+
// the sheet's children, not the page's, and deriving the index a second
|
|
236
|
+
// time from the event's own target is what used to send every dropped
|
|
237
|
+
// section to index 0 while the line sat where the pointer actually was.
|
|
238
|
+
page.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e, core.indexFromPoint(root, e.clientY)); });
|
|
235
239
|
}
|
|
236
240
|
|
|
237
241
|
d.rows.forEach((r, ri) => {
|
|
@@ -265,14 +269,50 @@ export function renderDoc(core, live) {
|
|
|
265
269
|
rowEl.appendChild(grip);
|
|
266
270
|
}
|
|
267
271
|
|
|
268
|
-
|
|
272
|
+
/*
|
|
273
|
+
* The mobile preview lays out exactly the way the sent email does. The
|
|
274
|
+
* device switch used to do nothing but narrow the sheet to 375px, so a
|
|
275
|
+
* 4-column row previewed as four 60px slivers -- a layout no recipient
|
|
276
|
+
* would ever see, since the exported stylesheet collapses that row below
|
|
277
|
+
* the content width. Previewing the wide layout at a narrow width was
|
|
278
|
+
* showing a page that does not exist.
|
|
279
|
+
*
|
|
280
|
+
* The three modes mirror `mobilePlan` in core/export.js: one-up stack,
|
|
281
|
+
* two-up grid, or the desktop layout kept as-is. Reverse flips the order,
|
|
282
|
+
* which is why this uses flex here too -- the same mechanism the media
|
|
283
|
+
* query uses, so the preview cannot drift from the output.
|
|
284
|
+
*/
|
|
285
|
+
const mobile = core.state.device === 'mobile' && r.cols.length > 1;
|
|
286
|
+
const mMode = r.props.mobileCols === undefined ? 1 : r.props.mobileCols;
|
|
287
|
+
const stacked = mobile && mMode !== 'keep';
|
|
288
|
+
const twoUp = stacked && String(mMode) === '2';
|
|
289
|
+
const reversed = stacked && r.props.mobileOrder === 'reverse';
|
|
290
|
+
const colsEl = el('div', stacked
|
|
291
|
+
? (twoUp || reversed
|
|
292
|
+
? {
|
|
293
|
+
display: 'flex',
|
|
294
|
+
flexWrap: twoUp && reversed ? 'wrap-reverse' : 'wrap',
|
|
295
|
+
flexDirection: twoUp ? (reversed ? 'row-reverse' : 'row') : (reversed ? 'column-reverse' : 'column'),
|
|
296
|
+
}
|
|
297
|
+
: { display: 'block' })
|
|
298
|
+
: colsWrap(r.props));
|
|
269
299
|
r.cols.forEach((c, ci) => {
|
|
270
300
|
const colLines = [];
|
|
271
301
|
const items = [];
|
|
272
302
|
c.blocks.forEach((b, bi) => {
|
|
303
|
+
/*
|
|
304
|
+
* Device visibility. In the static preview -- the honest picture of
|
|
305
|
+
* what is sent -- a block the current device would not receive is not
|
|
306
|
+
* drawn at all, matching the exported `.mc-only-d` / `.mc-only-m`
|
|
307
|
+
* rules. On the editable canvas it is drawn faded instead: hiding it
|
|
308
|
+
* outright would leave the user with a block they cannot select,
|
|
309
|
+
* move or set back to "all devices".
|
|
310
|
+
*/
|
|
311
|
+
const hiddenHere = b.props.vis === (core.state.device === 'mobile' ? 'desktop' : 'mobile');
|
|
312
|
+
if (!live && hiddenHere) return;
|
|
273
313
|
if (live) { const line = dropLine(); colLines.push(line); items.push(line); }
|
|
274
314
|
const bSel = live && sel && sel.id === b.id;
|
|
275
|
-
const bWrap = el('div', Object.assign({ position: 'relative' }, boxStyle(b.props)), { 'data-mc-slot': '1', draggable: live ? 'true' : undefined, class: live ? `mc-block-el${bSel ? ' is-selected' : ''}` : undefined });
|
|
315
|
+
const bWrap = el('div', Object.assign({ position: 'relative', opacity: hiddenHere ? '0.4' : '' }, boxStyle(b.props)), { 'data-mc-slot': '1', draggable: live ? 'true' : undefined, class: live ? `mc-block-el${bSel ? ' is-selected' : ''}` : undefined });
|
|
276
316
|
if (live) {
|
|
277
317
|
bWrap.addEventListener('dragstart', core.startDrag({ kind: 'move-block', id: b.id }));
|
|
278
318
|
bWrap.addEventListener('dragend', () => { core.drag = null; hideActive(rowDropTracker); hideActive(colTracker); });
|
|
@@ -313,7 +353,13 @@ export function renderDoc(core, live) {
|
|
|
313
353
|
if (live && !c.blocks.length) {
|
|
314
354
|
items.push(el('div', { border: '1px dashed var(--ed-accent-sheet-line)', borderRadius: 'var(--ed-radius-sm)', color: 'var(--ed-faint)', fontFamily: 'ui-monospace,monospace', fontSize: '9.5px', letterSpacing: '0.14em', textTransform: 'uppercase', padding: '24px 8px', textAlign: 'center' }, { text: 'drop block' }));
|
|
315
355
|
}
|
|
316
|
-
|
|
356
|
+
// Stacked, a column is simply a full-width block -- the flex sizing and
|
|
357
|
+
// the horizontal gutter both belong to the side-by-side layout only.
|
|
358
|
+
// Two-up takes half, box-sized so its own padding cannot push it over
|
|
359
|
+
// the line and wrap every cell onto a row of its own.
|
|
360
|
+
const colEl = el('div', stacked
|
|
361
|
+
? (twoUp ? { flex: '0 0 50%', maxWidth: '50%', boxSizing: 'border-box' } : { width: '100%' })
|
|
362
|
+
: colStyle(r.props, c));
|
|
317
363
|
// Column-level styling lives on an inner wrapper, not on colEl itself:
|
|
318
364
|
// colEl's padding is the inter-column gutter (colStyle), and a painted
|
|
319
365
|
// background must stop at the column's visual edge, not bleed across
|
package/src/render/fields.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { icon, brandIcon, socialKey, SOCIAL_BRAND, contrastInk } from '../core/icons.js';
|
|
2
2
|
import { parseItems } from '../core/parse.js';
|
|
3
|
+
import { linkHref } from '../core/sanitize.js';
|
|
3
4
|
|
|
4
5
|
function el(tag, style, attrs) {
|
|
5
6
|
const node = document.createElement(tag);
|
|
@@ -226,7 +227,14 @@ function renderRichLinks(f) {
|
|
|
226
227
|
const urlCommit = typeCommit((v) => change(i, (a) => a.setAttribute('href', v)));
|
|
227
228
|
url.addEventListener('input', (e) => urlCommit.call(e.target.value));
|
|
228
229
|
url.addEventListener('focus', () => { url.style.borderColor = 'var(--ed-accent)'; url.style.outline = 'none'; });
|
|
229
|
-
url.addEventListener('blur', () => {
|
|
230
|
+
url.addEventListener('blur', () => {
|
|
231
|
+
url.style.borderColor = 'var(--ed-line)';
|
|
232
|
+
// Scheme normalization happens on the way out, never per keystroke: a
|
|
233
|
+
// half-typed "h" rewritten to "https://h" under the caret is unusable.
|
|
234
|
+
url.value = linkHref(url.value);
|
|
235
|
+
urlCommit.call(url.value);
|
|
236
|
+
urlCommit.flush();
|
|
237
|
+
});
|
|
230
238
|
|
|
231
239
|
const targetLabel = el('label', { display: 'flex', alignItems: 'center', gap: '6px', marginTop: '7px', width: 'fit-content', color: 'var(--ed-muted)', cursor: 'pointer', fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '500' });
|
|
232
240
|
const target = el('input', { accentColor: 'var(--ed-accent)', cursor: 'pointer' }, { type: 'checkbox', 'data-focus-key': `f${f.key}-link-target-${i}` });
|
package/src/render/screenshot.js
CHANGED
|
@@ -124,9 +124,20 @@ export const captureTemplatePng = async function (core, mountInto, scale = 2) {
|
|
|
124
124
|
img.onerror = () => reject(new Error('SVG rasterization failed'));
|
|
125
125
|
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
|
126
126
|
});
|
|
127
|
+
// WebKit fires `load` on an SVG-as-image before it is reliably
|
|
128
|
+
// drawable, and drawing on that tick yields a blank or half-laid-out
|
|
129
|
+
// frame. Same `decode()` guard the inner images get above.
|
|
130
|
+
if (img.decode) await img.decode().catch(() => {});
|
|
131
|
+
// Safari caps total canvas area, and past the cap it hands back a
|
|
132
|
+
// *blank* canvas that still encodes successfully -- a silently empty
|
|
133
|
+
// PNG with nothing thrown to catch. Long newsletters reach it easily
|
|
134
|
+
// at 2x (a 680x4000 template is 10.9M device pixels). Scale the
|
|
135
|
+
// capture down to fit instead of returning an empty image.
|
|
136
|
+
const MAX_CANVAS_AREA = 16e6;
|
|
137
|
+
const fit = Math.min(scale, Math.sqrt(MAX_CANVAS_AREA / Math.max(1, w * h)));
|
|
127
138
|
const canvas = document.createElement('canvas');
|
|
128
|
-
canvas.width = Math.max(1, Math.round(w *
|
|
129
|
-
canvas.height = Math.max(1, Math.round(h *
|
|
139
|
+
canvas.width = Math.max(1, Math.round(w * fit));
|
|
140
|
+
canvas.height = Math.max(1, Math.round(h * fit));
|
|
130
141
|
const ctx = canvas.getContext('2d');
|
|
131
142
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
132
143
|
return await new Promise((resolve, reject) => {
|
package/src/render/style.js
CHANGED
|
@@ -23,6 +23,10 @@ export const STYLE = `
|
|
|
23
23
|
/* The accent as it must read on the email sheet, which is a white page in
|
|
24
24
|
both themes -- so this family does NOT flip with the chrome (see the dark
|
|
25
25
|
palette below, where it is deliberately not overridden). */
|
|
26
|
+
/* Logo ink. Deliberately literal rather than accent-derived: the host can
|
|
27
|
+
repaint the chrome through the brand attribute, and a logo that follows
|
|
28
|
+
it stops being the logo. Values are the official light artwork. */
|
|
29
|
+
--ed-logo-mark: #2D86C7; --ed-logo-text: #3C3939;
|
|
26
30
|
--ed-accent-sheet: #0065b3; --ed-accent-sheet-strong: #004f8c; --ed-accent-sheet-ink: #ffffff;
|
|
27
31
|
--ed-accent-sheet-line: rgba(0,101,179,0.5);
|
|
28
32
|
--ed-grid: rgba(71,85,105,0.10);
|
|
@@ -43,6 +47,8 @@ export const STYLE = `
|
|
|
43
47
|
--ed-panel-label: #e2e8f0; --ed-panel-value: #c0cad8; --ed-panel-meta: #8d9aae;
|
|
44
48
|
--ed-accent: #58a8e3; --ed-accent-strong: #8fc4ec; --ed-accent-ink: #08111f; --ed-soft: rgba(88,168,227,0.13);
|
|
45
49
|
--ed-accent-tint: #8fcdf2; --ed-glow: rgba(88,168,227,0.28); --ed-select: rgba(88,168,227,0.3);
|
|
50
|
+
/* The official dark-mode logo artwork: mark and wordmark both go light. */
|
|
51
|
+
--ed-logo-mark: #F2F2F2; --ed-logo-text: #EEEEEE;
|
|
46
52
|
--ed-grid: rgba(148,163,184,0.08);
|
|
47
53
|
--ed-danger: #f2777c; --ed-danger-soft: rgba(242,119,124,0.14);
|
|
48
54
|
--ed-success: #4ade95; --ed-success-soft: rgba(74,222,149,0.14);
|
|
@@ -151,14 +157,9 @@ export const STYLE = `
|
|
|
151
157
|
.mc-footer-link { cursor: pointer; transition: color 0.14s ease; }
|
|
152
158
|
.mc-footer-link:hover { color: var(--ed-accent); text-decoration: underline; }
|
|
153
159
|
|
|
154
|
-
/*
|
|
155
|
-
.
|
|
156
|
-
|
|
157
|
-
width: 26px; height: 26px; border-radius: 8px;
|
|
158
|
-
background: linear-gradient(145deg, var(--ed-accent-tint), var(--ed-accent) 55%, var(--ed-accent-strong));
|
|
159
|
-
box-shadow: 0 5px 14px var(--ed-glow);
|
|
160
|
-
color: #ffffff;
|
|
161
|
-
}
|
|
160
|
+
/* The header logo. It is the official lockup artwork, so it gets no chip, no
|
|
161
|
+
gradient and no recolouring -- only a height. */
|
|
162
|
+
.mc-brand { display: flex; align-items: center; flex: none; }
|
|
162
163
|
|
|
163
164
|
/*
|
|
164
165
|
* Shared segmented-control shell -- one bordered/backed track that owns the
|
|
@@ -269,9 +270,7 @@ export const STYLE = `
|
|
|
269
270
|
/* Opaque, no backdrop-filter: the header sits over the scrolling canvas, so a
|
|
270
271
|
backdrop blur re-composites every scroll frame -- visible scroll jank. */
|
|
271
272
|
#mc .mc-header { padding: 0 18px !important; background: var(--ed-panel) !important; }
|
|
272
|
-
#mc .mc-brand
|
|
273
|
-
#mc .mc-brand-mark svg { width: 20px; height: 20px; display: block; color: #fff; }
|
|
274
|
-
#mc .mc-brand-name { font-family: var(--ed-font) !important; font-size: 17px !important; letter-spacing: -.03em !important; }
|
|
273
|
+
#mc .mc-brand svg { height: 34px; width: auto; display: block; }
|
|
275
274
|
#mc .mc-header button { font-family: var(--ed-font) !important; font-size: 10.5px !important; font-weight: 600; letter-spacing: .015em !important; text-transform: none !important; }
|
|
276
275
|
#mc .mc-header > div { border-radius: 10px; }
|
|
277
276
|
#mc .mc-segment { gap: 3px !important; padding: 3px !important; border: 0 !important; border-radius: 10px; background: var(--ed-panel-2) !important; }
|