@seliseblocks/mailcraft 0.2.8 → 0.2.10

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.
@@ -73,7 +73,8 @@ const IMPORT_STYLES = [
73
73
  'width', 'max-width', 'height',
74
74
  ];
75
75
 
76
- export const cleanImportHtml = (html) => {
76
+ /** `dropProps` (optional): style properties to leave out of the kept whitelist -- the importer passes `['font-family']` when a run's family has been claimed as the block-level font, so the same declaration doesn't ship twice (and the renderer's overrideRichFont then has nothing to strip at render time, which keeps export -> import -> export byte-stable). */
77
+ export const cleanImportHtml = (html, dropProps, keepProps) => {
77
78
  const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
78
79
  doc.querySelectorAll('style,script,meta,link,title,head').forEach((n) => n.remove());
79
80
  const walk = (node) => {
@@ -87,6 +88,15 @@ export const cleanImportHtml = (html) => {
87
88
  }
88
89
  const kept = [];
89
90
  IMPORT_STYLES.forEach((p) => {
91
+ if (dropProps && dropProps.indexOf(p) > -1) return;
92
+ const v = el.style.getPropertyValue(p);
93
+ if (v) kept.push(p + ':' + v);
94
+ });
95
+ // `keepProps`: extra properties a specific caller vouches for beyond the
96
+ // shared whitelist -- the section-box reader passes the display/margin
97
+ // pair its own template writes (`<strong style="display:block;...">`),
98
+ // which the general list rightly refuses from arbitrary paste.
99
+ (keepProps || []).forEach((p) => {
90
100
  const v = el.style.getPropertyValue(p);
91
101
  if (v) kept.push(p + ':' + v);
92
102
  });
@@ -102,6 +112,67 @@ export const cleanImportHtml = (html) => {
102
112
  return doc.body.innerHTML.replace(/<!--[\s\S]*?-->/g, '').replace(/\s{2,}/g, ' ').trim();
103
113
  };
104
114
 
115
+ /**
116
+ * Mutation-time repairs for rich block HTML. Imported content keeps its
117
+ * per-element inline typography on purpose (`cleanImportHtml` above), but a
118
+ * descendant's own `font-size`/`color`/`line-height` always outranks the
119
+ * inherited value from the block wrapper -- so the inspector's Text size,
120
+ * Text color, Line spacing and Text weight controls were dead on any block
121
+ * whose paragraphs carried their own styles. The render-time strip that fixed
122
+ * the Font control (`overrideRichFont`, render/block-body.js) can't be reused
123
+ * here: `fontFamily` defaults to empty so "unset" means "leave the import
124
+ * alone", while `size`/`color`/`lh`/`weight` always hold a value -- stripping
125
+ * at render time would flatten every imported design on first paint. These
126
+ * run once, at the moment the user moves the control (core `setProp`), so an
127
+ * untouched document renders byte-identical and the rewrite lands in the same
128
+ * undo step as the prop change.
129
+ *
130
+ * Both return the input string untouched (same reference, no reparse churn)
131
+ * when there is nothing to rewrite.
132
+ */
133
+
134
+ /** Drops trailing float noise without inventing integers: 60.75 stays, 78.00 becomes 78. */
135
+ const trimNum = (n) => String(Number(n.toFixed(2)));
136
+
137
+ /**
138
+ * Multiplies every absolute inline font-size by `ratio`, so the block's Text
139
+ * size acts as a master scale and a 15/26/15px imported hierarchy survives a
140
+ * base change instead of being flattened. Only px/pt scale -- em/% are
141
+ * relative and already follow the wrapper. A px line-height sitting beside a
142
+ * scaled font-size scales with it, or the imported `line-height:22px` would
143
+ * strangle 45px glyphs.
144
+ */
145
+ export const scaleInlineSizes = (html, ratio) => {
146
+ const src = String(html == null ? '' : html);
147
+ if (!src || !Number.isFinite(ratio) || ratio <= 0 || ratio === 1 || !/font-size/i.test(src)) return src;
148
+ const doc = new DOMParser().parseFromString(src, 'text/html');
149
+ let changed = false;
150
+ doc.body.querySelectorAll('[style]').forEach((node) => {
151
+ const size = /^([\d.]+)(px|pt)$/.exec(node.style.fontSize || '');
152
+ if (!size) return;
153
+ node.style.fontSize = trimNum(parseFloat(size[1]) * ratio) + size[2];
154
+ const lh = /^([\d.]+)px$/.exec(node.style.lineHeight || '');
155
+ if (lh) node.style.lineHeight = trimNum(parseFloat(lh[1]) * ratio) + 'px';
156
+ changed = true;
157
+ });
158
+ return changed ? doc.body.innerHTML : src;
159
+ };
160
+
161
+ /** Removes one CSS property from every descendant's inline style -- the block-level control now owns it. The property name is exact (`color` never touches `background-color`). */
162
+ export const stripInlineStyle = (html, prop) => {
163
+ const src = String(html == null ? '' : html);
164
+ if (!src || src.indexOf(prop) === -1) return src;
165
+ const doc = new DOMParser().parseFromString(src, 'text/html');
166
+ let changed = false;
167
+ doc.body.querySelectorAll('[style]').forEach((node) => {
168
+ if (!node.style.getPropertyValue(prop)) return;
169
+ node.style.removeProperty(prop);
170
+ if (!node.getAttribute('style')) node.removeAttribute('style');
171
+ changed = true;
172
+ });
173
+ return changed ? doc.body.innerHTML : src;
174
+ };
175
+
105
176
  /**
106
177
  * Makes an image URL safe to drop inside `url("...")`.
107
178
  *
@@ -463,8 +463,9 @@ export class MailCraftEditor extends ElementBase {
463
463
  */
464
464
  loadTemplate(tpl) { this.core.loadTemplate(tpl); }
465
465
 
466
- exportHtml() {
467
- const html = this.core.buildHtml();
466
+ /** `options.markers: false` omits the fidelity-marker attributes (`data-mc*`, the inert `mc-keep` class) for hosts that want pristine HTML -- at the price of a lossy reload for the few blocks whose rendered shape cannot be read back (countdown, video, section box, code, raw CSS, flex/grid rows). */
467
+ exportHtml(options) {
468
+ const html = this.core.buildHtml(options);
468
469
  this.dispatchEvent(new CustomEvent('export', { detail: html }));
469
470
  return html;
470
471
  }
@@ -1039,6 +1040,22 @@ export class MailCraftEditor extends ElementBase {
1039
1040
  }
1040
1041
 
1041
1042
  renderTabBody() {
1043
+ // The browser's native colour dialog is bound to the identity of its
1044
+ // <input type="color"> node: Chromium closes the dialog the moment that
1045
+ // node leaves the document -- even if the same node is spliced back into
1046
+ // the rebuilt tree within the same task (unlike the range slider's mouse
1047
+ // capture, which focus-preserve.js does save that way). The picker lives
1048
+ // in this panel, and its `input` events commit (debounced) to the doc, so
1049
+ // every colour the user tried used to rebuild the panel ~120ms later and
1050
+ // slam the dialog shut. While the picker holds focus -- the only time its
1051
+ // dialog can be open -- the panel therefore skips its rebuild: the canvas
1052
+ // has already re-rendered above (that is the live preview), and the pill
1053
+ // previews itself (fields.js mutates its swatch and hex text directly on
1054
+ // every `input`). Nothing else in the panel changes on a colour commit,
1055
+ // so nothing is stale; the moment focus moves anywhere else, the next
1056
+ // render rebuilds the panel as usual.
1057
+ const active = this.shadowRoot.activeElement;
1058
+ if (active && active.tagName === 'INPUT' && active.type === 'color' && this.tabBody.contains(active)) return;
1042
1059
  this.tabBody.innerHTML = '';
1043
1060
  const tab = this.core.state.tab;
1044
1061
  if (tab === 'design') this.tabBody.appendChild(this.renderDesignTab());
@@ -1579,7 +1596,30 @@ export class MailCraftEditor extends ElementBase {
1579
1596
  this.codeStatusEl = elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { class: 'mc-code-kicker' });
1580
1597
  headText.append(this.codeStatusEl, elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 15px; line-height: 1.2;', { text: t('modal.rawHtmlTitle'), 'data-i18n': 'modal.rawHtmlTitle', class: 'mc-code-heading' }));
1581
1598
  this.codeWidthSeg = elS('div', '', { class: 'mc-segment' });
1582
- const reloadBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 11px; display: flex; align-items: center; gap: 6px; font-family: var(--ed-font); font-size: 11px; font-weight: 600; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.reloadHint'), 'data-i18n-title': 'action.reloadHint' });
1599
+ // Ghost-button chrome shared by the header's Copy and Reload.
1600
+ const GHOST_BTN = 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 11px; display: flex; align-items: center; gap: 6px; font-family: var(--ed-font); font-size: 11px; font-weight: 600; transition: border-color 0.16s, background 0.16s;';
1601
+ const copyBtn = elS('button', GHOST_BTN, { type: 'button', title: t('toast.htmlCopied'), 'data-i18n-title': 'toast.htmlCopied', class: 'mc-code-copy' });
1602
+ copyBtn.appendChild(icon('copy', 14));
1603
+ // Reuses keys every locale already carries (story.copy / export.copied)
1604
+ // rather than minting new ones across 32 translations.
1605
+ const copyLabel = elS('span', '', { text: t('story.copy'), 'data-i18n': 'story.copy' });
1606
+ copyBtn.appendChild(copyLabel);
1607
+ copyBtn.addEventListener('mouseenter', () => { copyBtn.style.borderColor = 'var(--ed-accent)'; copyBtn.style.background = 'var(--ed-soft)'; });
1608
+ copyBtn.addEventListener('mouseleave', () => { copyBtn.style.borderColor = 'var(--ed-line)'; copyBtn.style.background = 'transparent'; });
1609
+ copyBtn.addEventListener('click', () => {
1610
+ this.core.copyCode();
1611
+ // Momentary "Copied": swap the i18n key itself, not just the text --
1612
+ // refreshStrings relabels every [data-i18n] node on each render and
1613
+ // would otherwise revert the feedback mid-flash.
1614
+ copyLabel.setAttribute('data-i18n', 'export.copied');
1615
+ copyLabel.textContent = this.core.t('export.copied');
1616
+ clearTimeout(this._codeCopyTimer);
1617
+ this._codeCopyTimer = setTimeout(() => {
1618
+ copyLabel.setAttribute('data-i18n', 'story.copy');
1619
+ copyLabel.textContent = this.core.t('story.copy');
1620
+ }, 1600);
1621
+ });
1622
+ const reloadBtn = elS('button', GHOST_BTN, { type: 'button', title: t('action.reloadHint'), 'data-i18n-title': 'action.reloadHint' });
1583
1623
  reloadBtn.appendChild(icon('refresh', 14));
1584
1624
  reloadBtn.appendChild(elS('span', '', { text: t('action.reload'), 'data-i18n': 'action.reload' }));
1585
1625
  reloadBtn.addEventListener('mouseenter', () => { reloadBtn.style.borderColor = 'var(--ed-accent)'; reloadBtn.style.background = 'var(--ed-soft)'; });
@@ -1601,7 +1641,7 @@ export class MailCraftEditor extends ElementBase {
1601
1641
  closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; });
1602
1642
  closeBtn.addEventListener('click', () => this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }));
1603
1643
  tip(closeBtn, t('action.closeWithoutApplyingHint'), 'down', 'end');
1604
- head.append(headIcon, headText, this.codeWidthSeg, reloadBtn, applyBtn, closeBtn);
1644
+ head.append(headIcon, headText, this.codeWidthSeg, copyBtn, reloadBtn, applyBtn, closeBtn);
1605
1645
  overlay.appendChild(head);
1606
1646
 
1607
1647
  const cols = elS('div', 'display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 12px; min-height: 0; background: var(--ed-work);', { class: 'mc-code-split' });
@@ -71,6 +71,20 @@ function overrideRichFont(root, fontFamily) {
71
71
  root.querySelectorAll('[style]').forEach((node) => node.style.removeProperty('font-family'));
72
72
  }
73
73
 
74
+ /** An anchor merely restating the document link color drops it, so the sheet rule -- and a later Link color edit -- owns it again. The exporter stamps `theme.link` on every colorless anchor (mail clients need the value inline), and an import keeps that stamp in the block's html; left in place it froze the link color at whatever the theme said on the day of the save. A genuinely different inline color is the user's and stays. Compared through the same rgb-vs-hex fold the importer uses, since CSSOM serializes one and pickers speak the other. */
75
+ function overrideLinkColor(root, link) {
76
+ if (!link) return;
77
+ const key = (v) => {
78
+ const m = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(String(v || '').trim());
79
+ const hex = m ? '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('') : String(v || '');
80
+ return hex.toLowerCase();
81
+ };
82
+ const want = key(link);
83
+ root.querySelectorAll('a[style]').forEach((a) => {
84
+ if (a.style.color && key(a.style.color) === want) a.style.removeProperty('color');
85
+ });
86
+ }
87
+
74
88
  /**
75
89
  * Ported from the original `blockBody(b, theme, live)`. `ctx` supplies the
76
90
  * editing wiring that in the original lived on `this` (Component instance):
@@ -97,6 +111,7 @@ export function blockBody(b, theme, live, ctx) {
97
111
  // and other inline formatting remain intact, while the selected family
98
112
  // now applies consistently in the canvas and exported read-back DOM.
99
113
  overrideRichFont(content, p.fontFamily);
114
+ overrideLinkColor(content, t.link);
100
115
  if (edit) wireEditable(content, b, 'html', ctx, false);
101
116
  if (!live) return content;
102
117
  return wrapWithRte(b, ctx, edit && ctx.editingId === b.id, content);
@@ -303,6 +318,7 @@ export function blockBody(b, theme, live, ctx) {
303
318
  list.appendChild(li);
304
319
  });
305
320
  overrideRichFont(list, p.fontFamily);
321
+ overrideLinkColor(list, t.link);
306
322
  return list;
307
323
  }
308
324
  case 'table': {
@@ -351,11 +367,6 @@ export function blockBody(b, theme, live, ctx) {
351
367
  table.appendChild(tbody);
352
368
  return table;
353
369
  }
354
- case 'embed': {
355
- const wrap = el('div', { padding: p.py + 'px 0' }, attr);
356
- wrap.appendChild(el('iframe', { width: '100%', height: p.height + 'px', border: '1px solid rgba(29,31,32,0.14)', background: '#fff', display: 'block' }, { src: p.src, title: p.label }));
357
- return wrap;
358
- }
359
370
  case 'css': {
360
371
  const wrap = el('div', { fontSize: '0', lineHeight: '0' }, attr);
361
372
  wrap.appendChild(el('style', {}, { html: live ? ctx.scopeCss(p.code, '[data-mc-sheet]') : p.code }));
@@ -383,6 +394,7 @@ export function blockBody(b, theme, live, ctx) {
383
394
  boxShadow: p.shadow ? '0 10px 30px rgba(29,31,32,0.12)' : 'none',
384
395
  fontFamily: t.font, color: t.text, fontSize: '15px', lineHeight: '1.6', ...FIT,
385
396
  }, { ...attr, ...editableAttrs(edit), html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
397
+ overrideLinkColor(box, t.link);
386
398
  if (edit) wireEditable(box, b, 'html', ctx, false);
387
399
  if (!live) return box;
388
400
  return wrapWithRte(b, ctx, ctx.editingId === b.id, box);
@@ -196,6 +196,9 @@ export function renderDoc(core, live) {
196
196
  overflow: !live && radius ? 'hidden' : '',
197
197
  transition: 'width 0.28s cubic-bezier(0.22,0.61,0.36,1), background 0.2s, border-radius 0.2s',
198
198
  }, { 'data-mc-sheet': '1', dir: 'ltr' });
199
+ // A custom property cannot ride Object.assign(style, ...) -- '--mc-link'
200
+ // is not a CSSOM member -- so it is set the one way that works.
201
+ root.style.setProperty('--mc-link', theme.link || '');
199
202
 
200
203
  /*
201
204
  * The page: the full-width section the email sits on, painted from the
@@ -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' });
package/src/render/rte.js CHANGED
@@ -110,6 +110,12 @@ export function renderRte(core, b) {
110
110
  btn('inlineCode', 'Inline code', () => core.exec('insertHTML', '<code style="font-family:ui-monospace,monospace;font-size:0.92em;background:var(--ed-soft);padding:1px 4px">' + (core.getSelection().toString() || 'code') + '</code>')),
111
111
  btn('superscript', 'Superscript', () => core.exec('superscript'), st('superscript')),
112
112
  btn('subscript', 'Subscript', () => core.exec('subscript'), st('subscript')),
113
+ );
114
+ // Only where a size is actually rendered: `box` hardcodes 15px and `html`
115
+ // sets no font-size at all, so on those two the ± pair moved nothing while
116
+ // still persisting a junk `size` prop into the saved document, with the
117
+ // readout counting up beside it.
118
+ if (b.type === 'text' || b.type === 'heading') r1.append(
113
119
  sep(),
114
120
  btn('minus', 'Smaller text', () => core.size(b, -1)),
115
121
  el('span', { fontFamily: 'var(--ed-font)', fontSize: '9.5px', color: 'var(--rte-muted)', minWidth: '32px', textAlign: 'center' }, { text: (b.props.size || 16) + 'px' }),
@@ -98,6 +98,13 @@ export const STYLE = `
98
98
  #mc ::selection { background: var(--ed-select); }
99
99
  #mc a { color: var(--ed-accent); }
100
100
  #mc a:hover { color: var(--ed-accent); opacity: 0.72; }
101
+ /* Content links are the document's, not the chrome's: inside the sheet the
102
+ accent rule above painted every anchor in the HOST's brand color, which is
103
+ why the theme's own Link color setting never did anything visible. The
104
+ variable is set per render on the sheet root (render/canvas.js); anchors
105
+ with their own inline color keep it, exactly as in the sent mail. */
106
+ #mc [data-mc-sheet] a { color: var(--mc-link, var(--ed-accent)); }
107
+ #mc [data-mc-sheet] a:hover { color: var(--mc-link, var(--ed-accent)); opacity: 1; }
101
108
  @keyframes mcIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
102
109
  @keyframes mcFade { from { opacity: 0; } to { opacity: 1; } }
103
110
  @keyframes mcPulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
package/types/index.d.ts CHANGED
@@ -272,8 +272,15 @@ export class MailCraftEditor extends HTMLElement {
272
272
  /** Merged over `storageProvider.limits` per key, this side winning. */
273
273
  storageLimits: StorageLimits | null;
274
274
 
275
- /** Send-ready email HTML — valid input to the importer, so saving the export is saving the work. */
276
- exportHtml(): string;
275
+ /**
276
+ * Send-ready email HTML — valid input to the importer, so saving the export
277
+ * is saving the work. `{ markers: false }` omits the fidelity-marker
278
+ * attributes (`data-mc*`, the inert `mc-keep` class) for hosts that want
279
+ * pristine HTML, at the price of a lossy reload for the few blocks whose
280
+ * rendered shape cannot be read back (countdown, video, section box, code,
281
+ * raw CSS, flex/grid rows).
282
+ */
283
+ exportHtml(options?: { markers?: boolean }): string;
277
284
 
278
285
  /** Parses email HTML back onto the canvas. Returns the number of rows produced. */
279
286
  importHtml(html: string): number;
@@ -365,7 +372,7 @@ export interface EditorHandle {
365
372
  element: MailCraftEditor;
366
373
  /** Removes the editor and detaches the listeners this call attached. */
367
374
  destroy(): void;
368
- exportHtml(): string;
375
+ exportHtml(options?: { markers?: boolean }): string;
369
376
  importHtml(html: string): number;
370
377
  loadTemplate(tpl: Template): void;
371
378
  undo(): void;