@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.
@@ -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
- const a = el('a', {
102
- display: p.full ? 'block' : 'inline-block', background: p.bg, color: p.color, textDecoration: 'none',
103
- padding: p.py + 'px ' + p.px + 'px', borderRadius: p.radius + 'px', fontFamily: p.fontFamily || t.font, fontSize: p.size + 'px',
104
- fontWeight: '600', letterSpacing: '0.02em', outline: 'none',
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
- wrap.appendChild(a);
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
- const row = el('div', { display: 'flex', justifyContent: 'center', gap: '10px' });
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
- const a = el('a', { 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 });
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
  }
@@ -269,14 +269,50 @@ export function renderDoc(core, live) {
269
269
  rowEl.appendChild(grip);
270
270
  }
271
271
 
272
- const colsEl = el('div', colsWrap(r.props));
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
- const colEl = el('div', colStyle(r.props, c));
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
@@ -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
- picker.value = f.swatch;
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) => pickCommit.call(e.target.value));
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' });