@seliseblocks/mailcraft 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/DOCS.md +27 -27
- package/README.md +1 -1
- package/README.md.txt +1 -1
- package/dist/mailcraft-editor.bundle.js +76 -48
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -1
- package/src/core/blocks.js +15 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +88 -6
- package/src/core/export.js +195 -6
- package/src/core/i18n/index.js +83 -83
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +482 -31
- package/src/core/layout-style.js +100 -100
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/sanitize.js +69 -1
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +41 -2
- package/src/render/block-body.js +109 -12
- package/src/render/canvas.js +45 -3
- package/src/render/fields.js +28 -2
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +6 -0
- package/src/render/story.js +415 -415
package/src/render/block-body.js
CHANGED
|
@@ -40,6 +40,31 @@ function editableAttrs(on) {
|
|
|
40
40
|
return on ? { contenteditable: 'true', spellcheck: 'false', ...NO_ASSIST } : { spellcheck: 'false' };
|
|
41
41
|
}
|
|
42
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
|
+
|
|
43
68
|
/** An explicit block font owns every rich descendant; an empty value deliberately leaves imported inline typography untouched. */
|
|
44
69
|
function overrideRichFont(root, fontFamily) {
|
|
45
70
|
if (!fontFamily) return;
|
|
@@ -62,7 +87,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
62
87
|
case 'text': {
|
|
63
88
|
const edit = live;
|
|
64
89
|
const content = el('div', {
|
|
65
|
-
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,
|
|
66
91
|
}, { ...attr, ...editableAttrs(edit), html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
67
92
|
// Imported email HTML keeps inline font-family declarations so an
|
|
68
93
|
// untouched block retains its source typography. Once the user chooses
|
|
@@ -96,22 +121,57 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
96
121
|
}
|
|
97
122
|
return wrap;
|
|
98
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
|
+
*/
|
|
99
141
|
case 'button': {
|
|
100
142
|
const wrap = el('div', { textAlign: p.align, padding: '4px 0' }, attr);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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',
|
|
105
151
|
// Outline-style buttons (transparent fill + border) are a standard
|
|
106
152
|
// email pattern; the color falls back to the label color so a bare
|
|
107
153
|
// "outline thickness" bump looks right without a second step.
|
|
108
154
|
border: p.borderW ? p.borderW + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.borderColor || p.color) : '0',
|
|
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,
|
|
109
165
|
}, { href: linkHref(p.href), ...editableAttrs(live), text: p.label });
|
|
110
166
|
a.addEventListener('click', (e) => e.preventDefault());
|
|
111
167
|
// Button editing is deliberately plain: no focus-tracked `editing` state, no
|
|
112
168
|
// paste handling, no floating RTE toolbar -- only its label commits on blur.
|
|
113
169
|
if (live) a.addEventListener('blur', (e) => { if (e.target.textContent !== p.label) ctx.onBlur(b, 'label', e.target.textContent); });
|
|
114
|
-
|
|
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);
|
|
115
175
|
return wrap;
|
|
116
176
|
}
|
|
117
177
|
case 'divider': {
|
|
@@ -190,7 +250,13 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
190
250
|
// instead of re-rendering the editor (see the tick in editor-core.js).
|
|
191
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);
|
|
192
252
|
wrap.appendChild(el('div', { fontSize: '12px', letterSpacing: '0.14em', textTransform: 'uppercase', opacity: '0.6', marginBottom: '10px' }, { text: p.label }));
|
|
193
|
-
|
|
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' });
|
|
194
260
|
parts.forEach(([lab, v]) => {
|
|
195
261
|
const box = el('div', { border: '1px solid ' + p.color + '33', padding: '9px 12px', minWidth: '58px' });
|
|
196
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') }));
|
|
@@ -203,7 +269,14 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
203
269
|
case 'menu': {
|
|
204
270
|
const wrap = el('div', { textAlign: p.align, padding: '10px 0' }, attr);
|
|
205
271
|
parseItems(p.items).forEach((it) => {
|
|
206
|
-
|
|
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 });
|
|
207
280
|
a.addEventListener('click', (e) => e.preventDefault());
|
|
208
281
|
wrap.appendChild(a);
|
|
209
282
|
});
|
|
@@ -214,7 +287,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
214
287
|
const head = el(p.level || 'h2', {
|
|
215
288
|
margin: '0', padding: pad(p), fontSize: p.size + 'px', lineHeight: p.lh, textAlign: p.align,
|
|
216
289
|
fontWeight: p.weight, letterSpacing: p.font === 'condensed' ? '0.005em' : '-0.01em',
|
|
217
|
-
color: p.color || t.text, outline: 'none',
|
|
290
|
+
color: p.color || t.text, outline: 'none', ...FIT,
|
|
218
291
|
// An explicit per-block font beats the Condensed/Body style toggle.
|
|
219
292
|
fontFamily: p.fontFamily || (p.font === 'condensed' ? "'Arial Narrow', 'Helvetica Neue Condensed', Helvetica, Arial, sans-serif" : t.font),
|
|
220
293
|
}, { ...attr, ...editableAttrs(edit), text: m(p.text), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
@@ -224,7 +297,7 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
224
297
|
}
|
|
225
298
|
case 'list': {
|
|
226
299
|
const items = String(p.items || '').split('\n').filter((l) => l.trim());
|
|
227
|
-
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);
|
|
228
301
|
items.forEach((it) => {
|
|
229
302
|
const li = el('li', { marginBottom: p.gap + 'px' }, { html: m(it) });
|
|
230
303
|
list.appendChild(li);
|
|
@@ -247,6 +320,17 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
247
320
|
fontWeight: isHead ? '600' : '400', outline: 'none',
|
|
248
321
|
fontFamily: p.fontFamily || t.font,
|
|
249
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,
|
|
250
334
|
}, { ...editableAttrs(edit), text: m(cell), 'data-focus-key': edit ? 'block:' + b.id + ':c' + ri + '-' + ci : undefined });
|
|
251
335
|
if (edit) {
|
|
252
336
|
// stopPropagation is load-bearing (a bubbled click would select
|
|
@@ -289,9 +373,15 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
289
373
|
borderTop: borderSide(p.topBorder), borderRight: borderSide(p.rightBorder),
|
|
290
374
|
borderBottom: borderSide(p.bottomBorder), borderLeft: borderSide(p.leftBorder),
|
|
291
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',
|
|
292
382
|
minHeight: p.minH ? p.minH + 'px' : 'auto', maxWidth: p.maxW + '%', margin: p.align === 'center' ? '0 auto' : '0',
|
|
293
383
|
boxShadow: p.shadow ? '0 10px 30px rgba(29,31,32,0.12)' : 'none',
|
|
294
|
-
fontFamily: t.font, color: t.text, fontSize: '15px', lineHeight: '1.6',
|
|
384
|
+
fontFamily: t.font, color: t.text, fontSize: '15px', lineHeight: '1.6', ...FIT,
|
|
295
385
|
}, { ...attr, ...editableAttrs(edit), html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
296
386
|
if (edit) wireEditable(box, b, 'html', ctx, false);
|
|
297
387
|
if (!live) return box;
|
|
@@ -318,8 +408,15 @@ export function blockBody(b, theme, live, ctx) {
|
|
|
318
408
|
if (!p.end) band.appendChild(el('span', { fontFamily: 'ui-monospace,monospace', fontSize: '11.5px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, { text: '{{ ' + (p.expr || '…') + ' }}' }));
|
|
319
409
|
return band;
|
|
320
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.
|
|
321
418
|
case 'codeblock':
|
|
322
|
-
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) });
|
|
323
420
|
default:
|
|
324
421
|
return el('div');
|
|
325
422
|
}
|
package/src/render/canvas.js
CHANGED
|
@@ -269,14 +269,50 @@ export function renderDoc(core, live) {
|
|
|
269
269
|
rowEl.appendChild(grip);
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
|
|
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));
|
|
273
299
|
r.cols.forEach((c, ci) => {
|
|
274
300
|
const colLines = [];
|
|
275
301
|
const items = [];
|
|
276
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;
|
|
277
313
|
if (live) { const line = dropLine(); colLines.push(line); items.push(line); }
|
|
278
314
|
const bSel = live && sel && sel.id === b.id;
|
|
279
|
-
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 });
|
|
280
316
|
if (live) {
|
|
281
317
|
bWrap.addEventListener('dragstart', core.startDrag({ kind: 'move-block', id: b.id }));
|
|
282
318
|
bWrap.addEventListener('dragend', () => { core.drag = null; hideActive(rowDropTracker); hideActive(colTracker); });
|
|
@@ -317,7 +353,13 @@ export function renderDoc(core, live) {
|
|
|
317
353
|
if (live && !c.blocks.length) {
|
|
318
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' }));
|
|
319
355
|
}
|
|
320
|
-
|
|
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));
|
|
321
363
|
// Column-level styling lives on an inner wrapper, not on colEl itself:
|
|
322
364
|
// colEl's padding is the inter-column gutter (colStyle), and a painted
|
|
323
365
|
// background must stop at the column's visual edge, not bleed across
|
package/src/render/fields.js
CHANGED
|
@@ -12,6 +12,23 @@ function el(tag, style, attrs) {
|
|
|
12
12
|
return node;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The native `<input type="color">` accepts ONLY `#rrggbb` -- handed an
|
|
17
|
+
* imported `rgba(...)`, `rgb(...)`, `#abc` or a keyword it silently stays at
|
|
18
|
+
* black, so opening the picker on such a value read as "the color was not
|
|
19
|
+
* picked up". Normalizes what can be normalized (alpha is dropped -- the
|
|
20
|
+
* picker cannot represent it; the text field beside it still holds the exact
|
|
21
|
+
* value); anything else returns '' and the picker keeps its default.
|
|
22
|
+
*/
|
|
23
|
+
function pickerHex(v) {
|
|
24
|
+
const s = String(v == null ? '' : v).trim();
|
|
25
|
+
if (/^#[0-9a-f]{6}$/i.test(s)) return s;
|
|
26
|
+
if (/^#[0-9a-f]{3}$/i.test(s)) return '#' + s.slice(1).split('').map((c) => c + c).join('');
|
|
27
|
+
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(s);
|
|
28
|
+
if (m) return '#' + [m[1], m[2], m[3]].map((n) => Math.min(255, Number(n)).toString(16).padStart(2, '0')).join('');
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
/**
|
|
16
33
|
* Trailing debounce for free-typing inputs. Every commit rebuilds the whole
|
|
17
34
|
* canvas and panel (full re-render, no diffing) -- fine per click, but per
|
|
@@ -570,11 +587,20 @@ export function renderField(f) {
|
|
|
570
587
|
});
|
|
571
588
|
}
|
|
572
589
|
const picker = el('input', { position: 'absolute', inset: '0', width: '100%', height: '100%', opacity: '0', cursor: 'pointer', padding: '0', border: '0' }, { type: 'color' });
|
|
573
|
-
|
|
590
|
+
const ph = pickerHex(f.swatch);
|
|
591
|
+
if (ph) picker.value = ph;
|
|
574
592
|
// The native picker fires `input` continuously while dragging the hue
|
|
575
593
|
// wheel -- committed raw, that is a full re-render per mouse move.
|
|
576
594
|
const pickCommit = typeCommit(f.onChange);
|
|
577
|
-
picker.addEventListener('input', (e) =>
|
|
595
|
+
picker.addEventListener('input', (e) => {
|
|
596
|
+
// Self-preview: while this picker's dialog is open the inspector skips
|
|
597
|
+
// its rebuilds (renderTabBody, mailcraft-editor.js) so the dialog's
|
|
598
|
+
// host node survives -- which also means no rebuild will repaint this
|
|
599
|
+
// pill. Mutate it directly so swatch and hex track the dialog live.
|
|
600
|
+
swatch.style.background = e.target.value;
|
|
601
|
+
hex.value = e.target.value;
|
|
602
|
+
pickCommit.call(e.target.value);
|
|
603
|
+
});
|
|
578
604
|
picker.addEventListener('change', () => pickCommit.flush());
|
|
579
605
|
swatch.appendChild(picker);
|
|
580
606
|
const hex = el('input', { width: '82px', boxSizing: 'border-box', background: 'transparent', border: '0', outline: 'none', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '11px', padding: '0 9px' }, { placeholder: 'inherit', class: 'mc-stepper-input', 'data-focus-key': `f${f.key}`, dir: 'ltr' });
|