@seliseblocks/mailcraft 0.2.1 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seliseblocks/mailcraft",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Framework-agnostic drag-and-drop email template editor, packaged as a zero-dependency Web Component.",
5
5
  "license": "MIT",
6
6
  "author": "SELISE Digital Platforms",
@@ -1500,11 +1500,16 @@ export class EditorCore {
1500
1500
  B.head('Content area'),
1501
1501
  B.color('Content area background color', 'contentBg', { transparent: true, solid: '#ffffff' }),
1502
1502
  B.range('Corner radius', 'radius', 0, 48, 1, 'px'),
1503
+ B.range('Border thickness', 'borderW', 0, 12, 1, 'px'),
1504
+ ].concat(this.state.doc.theme.borderW ? [
1505
+ B.sel('Border style', 'borderStyle', BORDER_STYLES),
1506
+ B.color('Border color', 'borderColor'),
1507
+ ] : []).concat([
1503
1508
  B.head('Type & color'),
1504
1509
  B.sel('Default font', 'font', this.fontOptions(false)),
1505
1510
  B.color('Text color', 'text'),
1506
1511
  B.color('Link color', 'link'),
1507
- ]);
1512
+ ]));
1508
1513
  }
1509
1514
 
1510
1515
  vars() { return varsFn(this.variablesRaw); }
@@ -156,6 +156,7 @@ export function buildHtml(state, root, boxCss) {
156
156
  const pagePad = (t.padY || t.padX) ? (t.padY || 0) + 'px ' + (t.padX || 0) + 'px' : '0';
157
157
  // `overflow:hidden` is what actually clips a row's own background to the
158
158
  // rounded corner; without it the first and last rows paint square over it.
159
- const contentShape = t.radius ? 'border-radius:' + t.radius + 'px;overflow:hidden;' : '';
159
+ const contentShape = (t.radius ? 'border-radius:' + t.radius + 'px;overflow:hidden;' : '')
160
+ + (t.borderW ? 'border:' + t.borderW + 'px ' + (t.borderStyle || 'solid') + ' ' + (t.borderColor || '#e2e2e5') + ';' : '');
160
161
  return '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light dark">\n<title>' + 'Email' + '</title>\n</head>\n<body style="margin:0;padding:0;background:' + pageBg + ';font-family:' + t.font.replace(/"/g, "'") + ';color:' + t.text + ';-webkit-font-smoothing:antialiased;">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n <table role="presentation" width="' + t.width + '" cellpadding="0" cellspacing="0" border="0" style="width:' + t.width + 'px;max-width:100%;background:' + (t.contentBg || 'transparent') + ';' + contentShape + '">\n' + rows + '\n </table>\n </td></tr>\n</table>\n</body>\n</html>';
161
162
  }
@@ -0,0 +1,78 @@
1
+ import { EN } from './en.js';
2
+
3
+ /**
4
+ * Lazy per-locale loading -- the code-splitting counterpart to tables.js.
5
+ *
6
+ * The 30 non-English tables are over half of everything under src/ (about
7
+ * 82 KB gzipped once minified), and the eager `LOCALE_TABLES` map forces all
8
+ * of them into any bundle that includes the element, whatever language the
9
+ * host actually renders. Each entry here is a *literal* dynamic import, which
10
+ * is the shape every bundler code-splits on: an app that never sets `locale`
11
+ * ships no tables at all, and one that sets `locale="fr"` fetches one small
12
+ * chunk on demand. tables.js stays exported for a host that wants the whole
13
+ * map eagerly (a language switcher with instant previews, say).
14
+ *
15
+ * The single-file demo bundle is unaffected: build.js rewrites `import(...)`
16
+ * onto its synchronous module map, so attribute-driven switching keeps
17
+ * working from `file://` with no network fetch.
18
+ */
19
+ export const LOCALE_LOADERS = {
20
+ en: () => import('./en.js').then((m) => m.EN),
21
+ ar: () => import('./ar.js').then((m) => m.AR),
22
+ bg: () => import('./bg.js').then((m) => m.BG),
23
+ bn: () => import('./bn.js').then((m) => m.BN),
24
+ ca: () => import('./ca.js').then((m) => m.CA),
25
+ cs: () => import('./cs.js').then((m) => m.CS),
26
+ da: () => import('./da.js').then((m) => m.DA),
27
+ de: () => import('./de.js').then((m) => m.DE),
28
+ 'de-CH': () => import('./de-CH.js').then((m) => m.DE_CH),
29
+ dz: () => import('./dz.js').then((m) => m.DZ),
30
+ el: () => import('./el.js').then((m) => m.EL),
31
+ es: () => import('./es.js').then((m) => m.ES),
32
+ et: () => import('./et.js').then((m) => m.ET),
33
+ fi: () => import('./fi.js').then((m) => m.FI),
34
+ fr: () => import('./fr.js').then((m) => m.FR),
35
+ hr: () => import('./hr.js').then((m) => m.HR),
36
+ hu: () => import('./hu.js').then((m) => m.HU),
37
+ it: () => import('./it.js').then((m) => m.IT),
38
+ lt: () => import('./lt.js').then((m) => m.LT),
39
+ lv: () => import('./lv.js').then((m) => m.LV),
40
+ nb: () => import('./nb.js').then((m) => m.NB),
41
+ nl: () => import('./nl.js').then((m) => m.NL),
42
+ pl: () => import('./pl.js').then((m) => m.PL),
43
+ pt: () => import('./pt.js').then((m) => m.PT),
44
+ ro: () => import('./ro.js').then((m) => m.RO),
45
+ ru: () => import('./ru.js').then((m) => m.RU),
46
+ sk: () => import('./sk.js').then((m) => m.SK),
47
+ sl: () => import('./sl.js').then((m) => m.SL),
48
+ sv: () => import('./sv.js').then((m) => m.SV),
49
+ tr: () => import('./tr.js').then((m) => m.TR),
50
+ uk: () => import('./uk.js').then((m) => m.UK),
51
+ };
52
+
53
+ // Module-level (not per-element): two editors on one page asking for the same
54
+ // locale should trigger one fetch and share the parsed table. English is
55
+ // seeded -- it is the translator's built-in base, always statically present.
56
+ const cache = { en: EN };
57
+ const pending = {};
58
+
59
+ /**
60
+ * The already-loaded table for a tag. `undefined` means "shipped but not
61
+ * loaded yet" (call `loadLocale` and re-render when it lands); `null` means
62
+ * "no such locale" -- render the English base, exactly what the old eager
63
+ * `LOCALE_TABLES[tag] || null` lookup did for an unknown tag.
64
+ */
65
+ export function localeTable(tag) {
66
+ if (Object.prototype.hasOwnProperty.call(cache, tag)) return cache[tag];
67
+ return LOCALE_LOADERS[tag] ? undefined : null;
68
+ }
69
+
70
+ /** Fetches (once) and caches a locale's table; resolves null for a tag that does not ship. */
71
+ export function loadLocale(tag) {
72
+ const load = LOCALE_LOADERS[tag];
73
+ if (!load) return Promise.resolve(null);
74
+ if (!pending[tag]) {
75
+ pending[tag] = load().then((table) => { cache[tag] = table; return table; });
76
+ }
77
+ return pending[tag];
78
+ }
@@ -37,10 +37,11 @@ import { UK } from './uk.js';
37
37
  * importing and assigning a table by hand.
38
38
  *
39
39
  * Deliberately a separate module from index.js's metadata-only `LOCALES`:
40
- * importing this one pulls all translations in. The element accepts that
41
- * (host-driven language is worth it, and the demo bundle carries every module
42
- * anyway); a size-sensitive host that bypasses the attribute can still
43
- * deep-import a single `core/i18n/<tag>.js` and assign `.messages` itself.
40
+ * importing this one pulls all translations in, eagerly. The element itself
41
+ * no longer does -- it resolves the `locale` attribute through the lazy
42
+ * per-tag loaders (loaders.js), so a bundled app ships only the locales it
43
+ * actually renders. This eager map remains for a host that wants every table
44
+ * up front (an instant-preview language switcher, a server-side pass).
44
45
  */
45
46
  export const LOCALE_TABLES = {
46
47
  en: EN, ar: AR, bg: BG, bn: BN, ca: CA, cs: CS, da: DA, de: DE, 'de-CH': DE_CH,
@@ -966,8 +966,26 @@ function themeFromParsedDoc(doc) {
966
966
  return w && !String(w).endsWith('%') && PX(w) === Number(bestWidth);
967
967
  });
968
968
  if (content) {
969
+ // The content column's own background -- including the literal
970
+ // `transparent` the exporter always writes for a see-through column.
971
+ // Without this the round trip lost it: export wrote
972
+ // `background:transparent`, the import never read it back, and the
973
+ // blank-doc default repainted the content area white on every
974
+ // save/reload. Only an explicit value is taken, so a foreign email
975
+ // whose content table declares nothing keeps the white default.
976
+ const cbg = bgOf(content);
977
+ if (cbg) theme.contentBg = cbg;
969
978
  const r = PX(content.style && content.style.borderRadius);
970
979
  if (r > 0) theme.radius = r;
980
+ // The content column's full border, written by the exporter as a
981
+ // `border` shorthand on the same table -- read back so it survives the
982
+ // round trip like the radius above.
983
+ const bw = content.style ? PX(content.style.borderWidth) || PX(content.style.borderTopWidth) : 0;
984
+ if (bw > 0) {
985
+ theme.borderW = bw;
986
+ theme.borderStyle = borderStyleOf(content.style);
987
+ theme.borderColor = borderColorOf(content.style) || '';
988
+ }
971
989
  const cell = content.closest ? content.closest('td') : null;
972
990
  if (cell && cell.style) {
973
991
  const padY = PX(cell.style.paddingTop);
package/src/core/theme.js CHANGED
@@ -12,4 +12,4 @@
12
12
  * accept the literal `transparent` (and any rgba()/#rrggbbaa value) as well
13
13
  * as a hex colour.
14
14
  */
15
- export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, padY: 0, padX: 0, radius: 0, font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
15
+ export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, padY: 0, padX: 0, radius: 0, borderW: 0, borderStyle: 'solid', borderColor: '#e2e2e5', font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
package/src/index.js CHANGED
@@ -5,5 +5,6 @@ export { renderDoc } from './render/canvas.js';
5
5
  export { BLOCKS, GROUPS, LAYOUTS, PALETTE } from './core/blocks.js';
6
6
  export { createTranslator, defineMessages, missingKeys, LOCALES, isRtl, EN, MESSAGE_KEYS } from './core/i18n/index.js';
7
7
  export { LOCALE_TABLES } from './core/i18n/tables.js';
8
+ export { LOCALE_LOADERS, loadLocale, localeTable } from './core/i18n/loaders.js';
8
9
  export { ALL_FOLDER_ID, normalizeAsset, resolveLimits } from './core/storage.js';
9
10
  export { validateFiles, sanitizeName, acceptAttribute, limitsProblem } from './core/storage-limits.js';
@@ -17,7 +17,7 @@ import { createStoryViewer } from './render/story.js';
17
17
  import { STYLE } from './render/style.js';
18
18
  import { createTranslator, isRtl } from './core/i18n/index.js';
19
19
  import { ACCENT_VARS, accentTokens, parseColor } from './core/accent.js';
20
- import { LOCALE_TABLES } from './core/i18n/tables.js';
20
+ import { localeTable, loadLocale } from './core/i18n/loaders.js';
21
21
 
22
22
  /** `elS` mirrors the template's literal `style="..."` attribute strings verbatim -- copied, not re-derived, to keep the port pixel-exact. */
23
23
  function elS(tag, styleStr, attrs = {}, ...children) {
@@ -298,13 +298,26 @@ export class MailCraftEditor extends ElementBase {
298
298
 
299
299
  /**
300
300
  * Resolves the `locale` attribute to its shipped message table
301
- * (core/i18n/tables.js) and overlays any host-set `.messages` on top --
301
+ * (core/i18n/loaders.js) and overlays any host-set `.messages` on top --
302
302
  * attribute picks the language, `.messages` stays the documented way to
303
303
  * override individual strings. With no locale attribute this degrades to
304
304
  * the old behavior: `.messages` alone over built-in English.
305
+ *
306
+ * Tables load lazily (a literal dynamic import per tag, so app bundlers
307
+ * code-split them instead of shipping all 31). A not-yet-loaded locale
308
+ * renders English for the tick the chunk takes to arrive, then this method
309
+ * re-runs; the load is a no-op re-render if the attribute changed again
310
+ * meanwhile -- the guard re-reads the live attribute, never the old tag.
305
311
  */
306
312
  refreshTranslator() {
307
- const table = LOCALE_TABLES[this.getAttribute('locale')] || null;
313
+ const tag = this.getAttribute('locale') || '';
314
+ let table = tag ? localeTable(tag) : null;
315
+ if (table === undefined) {
316
+ table = null;
317
+ loadLocale(tag).then(() => {
318
+ if ((this.getAttribute('locale') || '') === tag) this.refreshTranslator();
319
+ });
320
+ }
308
321
  const overrides = this.core.messages;
309
322
  this.core.t = createTranslator(table ? Object.assign({}, table, overrides || {}) : overrides);
310
323
  this.story?.retranslate();
@@ -688,17 +701,19 @@ export class MailCraftEditor extends ElementBase {
688
701
  this.toastEl.textContent = s.toast || '';
689
702
  }
690
703
 
691
- /** Repaint just the countdown digits on the canvas -- driven by the core's
692
- * 1s tick (core.onTick), which used to re-render the whole editor. */
704
+ /** Repaint just the countdown digits on the canvas -- and in the preview,
705
+ * whose sheet is now memoized rather than rebuilt every render -- driven by
706
+ * the core's 1s tick (core.onTick), which used to re-render the whole editor. */
693
707
  refreshCountdowns() {
694
708
  const now = this.core.state.now;
695
- this.canvasSlot.querySelectorAll('[data-mc-countdown]').forEach((wrap) => {
709
+ const slots = this.core.state.previewOpen && this.previewSlot ? [this.canvasSlot, this.previewSlot] : [this.canvasSlot];
710
+ slots.forEach((slot) => slot.querySelectorAll('[data-mc-countdown]').forEach((wrap) => {
696
711
  const ms = Math.max(0, new Date(wrap.getAttribute('data-mc-countdown')).getTime() - now);
697
712
  const vals = { days: Math.floor(ms / 86400000), hrs: Math.floor(ms / 3600000) % 24, min: Math.floor(ms / 60000) % 60, sec: Math.floor(ms / 1000) % 60 };
698
713
  wrap.querySelectorAll('[data-mc-count]').forEach((n) => {
699
714
  n.textContent = String(vals[n.getAttribute('data-mc-count')] ?? 0).padStart(2, '0');
700
715
  });
701
- });
716
+ }));
702
717
  }
703
718
 
704
719
  /** The header's "Saved HH:MM" label -- autosave updates it through
@@ -964,7 +979,7 @@ export class MailCraftEditor extends ElementBase {
964
979
  this.renderExportModal();
965
980
  this.renderCodeModal();
966
981
  this.renderAiModal();
967
- this.renderPreviewModal(false);
982
+ this.renderPreviewModal();
968
983
 
969
984
  this.refreshToast();
970
985
  }
@@ -1599,7 +1614,7 @@ export class MailCraftEditor extends ElementBase {
1599
1614
  // own page, then its content column. `is-device` (renderCodeModal) puts
1600
1615
  // the mat and the frame back for the 390px phone width, where the card
1601
1616
  // *is* the point.
1602
- const previewWrap = elS('div', 'overflow: auto; padding: 0; display: flex; justify-content: center; background: var(--ed-work);', { class: 'mc-code-preview-body' });
1617
+ const previewWrap = elS('div', 'overflow: auto; display: flex; justify-content: center; background: var(--ed-work);', { class: 'mc-code-preview-body' });
1603
1618
  this.codePreviewBody = previewWrap;
1604
1619
  this.codeFrame = elS('iframe', "width: 100%; height: 100%; min-height: 420px; border: 0; background: #ffffff; transition: width 0.24s cubic-bezier(0.22,0.61,0.36,1);", { title: t('code.liveHtmlPreviewTitle'), 'data-i18n-title': 'code.liveHtmlPreviewTitle', class: 'mc-code-frame' });
1605
1620
  this.codeFrame.addEventListener('load', () => this.syncCodePreviewScrollbar());
@@ -1812,7 +1827,7 @@ export class MailCraftEditor extends ElementBase {
1812
1827
  buildPreviewModal() {
1813
1828
  const t = this.core.t;
1814
1829
  const overlay = elS('div', '', { class: 'mc-fullscreen-panel mc-preview-panel' });
1815
- overlay.style.cssText = 'position: absolute; inset: 0; z-index: 60; display: grid; grid-template-rows: 58px minmax(0, 1fr); animation: mcFade 0.16s ease; display: none;';
1830
+ overlay.style.cssText = 'position: absolute; inset: 0; z-index: 60; background: var(--ed-work); display: grid; grid-template-rows: 58px minmax(0, 1fr); animation: mcFade 0.16s ease; display: none;';
1816
1831
  const head = elS('div', 'display: flex; align-items: center; gap: 12px; padding: 0 20px; border-bottom: 1px solid var(--ed-line); background: var(--ed-panel);', { class: 'mc-preview-toolbar' });
1817
1832
  const headText = elS('div', 'flex: 1; min-width: 0;');
1818
1833
  this.previewKickerEl = 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-preview-kicker' });
@@ -1826,10 +1841,12 @@ export class MailCraftEditor extends ElementBase {
1826
1841
  closeBtn.addEventListener('click', () => this.core.setState({ previewOpen: false }));
1827
1842
  head.append(headText, this.previewDeviceSeg, closeBtn);
1828
1843
  overlay.appendChild(head);
1829
- // Flush and unpainted: this body *is* the sent email's page, so the
1830
- // sheet sits edge to edge exactly as exportHtml() renders it -- the
1831
- // theme's page background is applied on each render (renderPreviewModal).
1832
- const body = elS('div', 'overflow-y: auto; padding: 0;', { class: 'mc-preview-body' });
1844
+ // This body *is* the sent email's page: the theme's page background is
1845
+ // applied on each render (renderPreviewModal), and the stylesheet gives
1846
+ // it container padding painted in that background -- breathing room the
1847
+ // way a mail client's viewport provides it, with the sheet itself, and
1848
+ // everything exportHtml() renders, untouched.
1849
+ const body = elS('div', 'overflow-y: auto;', { class: 'mc-preview-body' });
1833
1850
  // Kept on the instance: renderPreviewModal repaints it from the document's
1834
1851
  // page background on every render, and it runs long after this builder's
1835
1852
  // local has gone out of scope.
@@ -1845,7 +1862,7 @@ export class MailCraftEditor extends ElementBase {
1845
1862
  const s = this.core.state;
1846
1863
  const t = this.core.t;
1847
1864
  this.previewModal.style.display = s.previewOpen ? 'grid' : 'none';
1848
- if (!s.previewOpen) return;
1865
+ if (!s.previewOpen) { this._previewDoc = null; return; }
1849
1866
  this.previewKickerEl.textContent = s.device === 'mobile' ? t('preview.kickerMobile') : t('preview.kickerDesktop', { width: s.doc.theme.width });
1850
1867
  this.previewDeviceSeg.innerHTML = '';
1851
1868
  [
@@ -1865,8 +1882,20 @@ export class MailCraftEditor extends ElementBase {
1865
1882
  // page colour of its own shows behind the email.
1866
1883
  const pageBg = s.doc.theme.bg || '';
1867
1884
  this.previewBody.style.background = /^(transparent|none)$/i.test(pageBg.trim()) ? '' : pageBg;
1868
- this.previewSlot.innerHTML = '';
1869
- this.previewSlot.appendChild(renderDoc(this.core, false));
1885
+ // Rebuild the sheet only when what it renders from actually changed.
1886
+ // renderPreviewModal runs on *every* render pass, and rebuilding the
1887
+ // whole email document each time made the open preview visibly rough:
1888
+ // every unrelated setState re-created every node (images re-decoded and
1889
+ // flashed, the scroll anchor jumped). The three inputs are cheap
1890
+ // identity checks -- commit/undo/redo always replace `doc` with a fresh
1891
+ // parse, refreshTranslator always replaces `t` -- so a stale hit is
1892
+ // impossible.
1893
+ if (s.doc !== this._previewDoc || s.device !== this._previewDevice || t !== this._previewT) {
1894
+ this._previewDoc = s.doc;
1895
+ this._previewDevice = s.device;
1896
+ this._previewT = t;
1897
+ this.previewSlot.replaceChildren(renderDoc(this.core, false));
1898
+ }
1870
1899
  }
1871
1900
  }
1872
1901
 
@@ -176,12 +176,16 @@ export function renderDoc(core, live) {
176
176
  // flipped bidi punctuation and default alignment on the canvas and made it
177
177
  // disagree with the exported result.
178
178
  const radius = Number(theme.radius) || 0;
179
+ const borderW = Number(theme.borderW) || 0;
179
180
  const root = el('div', {
180
181
  width: width + 'px', maxWidth: '100%', background: theme.contentBg || 'transparent', color: theme.text, fontFamily: theme.font,
181
182
  // Only when the document actually asks for a shape: an unconditional
182
183
  // `0px` would override the editor chrome's own soft corner on the sheet
183
184
  // (style.js) and square off every template that never touched the field.
184
185
  borderRadius: radius ? radius + 'px' : '',
186
+ // Same contract for the full content-area border: set only when asked,
187
+ // so the chrome's own subtle frame keeps marking the sheet otherwise.
188
+ border: borderW ? borderW + 'px ' + (theme.borderStyle || 'solid') + ' ' + (theme.borderColor || '#e2e2e5') : '',
185
189
  // Clipping the rows to that corner is right for the sent email (export
186
190
  // emits `overflow:hidden` alongside the radius) and for the static
187
191
  // preview, but not for the editable canvas: the sheet is also what the