@seliseblocks/mailcraft 0.2.0 → 0.2.1

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.
Files changed (69) hide show
  1. package/DOCS.md +7 -8
  2. package/README.md +4 -3
  3. package/README.md.txt +4 -3
  4. package/dist/mailcraft-editor.bundle.js +79 -51
  5. package/dist/mailcraft-editor.bundle.js.map +3 -3
  6. package/examples/templates/activate-your-account.html +1 -1
  7. package/examples/templates/back-in-stock.html +1 -1
  8. package/examples/templates/cart-left-behind.html +1 -1
  9. package/examples/templates/community-giveaway.html +1 -1
  10. package/examples/templates/frontend-futures-invite.html +1 -1
  11. package/examples/templates/give-25-get-25.html +1 -1
  12. package/examples/templates/invoice-paid.html +1 -1
  13. package/examples/templates/meet-nova-launch.html +1 -1
  14. package/examples/templates/mega-weekend-sale.html +1 -1
  15. package/examples/templates/order-confirmed.html +1 -1
  16. package/examples/templates/rate-your-headphones.html +1 -1
  17. package/examples/templates/reset-your-password.html +1 -1
  18. package/examples/templates/thankyou-promo-code.html +1 -1
  19. package/examples/templates/the-sunday-brief.html +1 -1
  20. package/examples/templates/welcome-to-your-workspace.html +1 -1
  21. package/examples/templates/your-order-shipped.html +1 -1
  22. package/examples/templates/your-password-was-changed.html +1 -1
  23. package/examples/templates/your-signin-code.html +1 -1
  24. package/examples/vanilla.html +27 -25
  25. package/package.json +1 -1
  26. package/src/core/binder.js +21 -1
  27. package/src/core/editor-core.js +12 -2
  28. package/src/core/export.js +19 -3
  29. package/src/core/i18n/ar.js +0 -1
  30. package/src/core/i18n/bg.js +0 -1
  31. package/src/core/i18n/bn.js +0 -1
  32. package/src/core/i18n/ca.js +0 -1
  33. package/src/core/i18n/cs.js +0 -1
  34. package/src/core/i18n/da.js +0 -1
  35. package/src/core/i18n/de-CH.js +0 -1
  36. package/src/core/i18n/de.js +0 -1
  37. package/src/core/i18n/dz.js +0 -1
  38. package/src/core/i18n/el.js +0 -1
  39. package/src/core/i18n/en.js +0 -1
  40. package/src/core/i18n/es.js +0 -1
  41. package/src/core/i18n/et.js +0 -1
  42. package/src/core/i18n/fi.js +0 -1
  43. package/src/core/i18n/fr.js +0 -1
  44. package/src/core/i18n/hr.js +0 -1
  45. package/src/core/i18n/hu.js +0 -1
  46. package/src/core/i18n/it.js +0 -1
  47. package/src/core/i18n/lt.js +0 -1
  48. package/src/core/i18n/lv.js +0 -1
  49. package/src/core/i18n/nb.js +0 -1
  50. package/src/core/i18n/nl.js +0 -1
  51. package/src/core/i18n/pl.js +0 -1
  52. package/src/core/i18n/pt.js +0 -1
  53. package/src/core/i18n/ro.js +0 -1
  54. package/src/core/i18n/ru.js +0 -1
  55. package/src/core/i18n/sk.js +0 -1
  56. package/src/core/i18n/sl.js +0 -1
  57. package/src/core/i18n/sv.js +0 -1
  58. package/src/core/i18n/tr.js +0 -1
  59. package/src/core/i18n/uk.js +0 -1
  60. package/src/core/import-html.js +22 -0
  61. package/src/core/storage-limits.js +16 -11
  62. package/src/core/storage.js +1 -1
  63. package/src/core/theme.js +15 -1
  64. package/src/mailcraft-editor.js +38 -11
  65. package/src/render/canvas.js +43 -6
  66. package/src/render/fields.js +17 -0
  67. package/src/render/screenshot.js +7 -1
  68. package/src/render/style.js +36 -8
  69. package/types/index.d.ts +5 -2
@@ -102,7 +102,6 @@ export const ET = {
102
102
  'storage.uploading': 'Laadin üles {count}…',
103
103
  'storage.errLoadFailed': 'Failikogu laadimine ebaõnnestus — {reason}',
104
104
  'storage.errNoLimits': 'Üleslaadimine on välja lülitatud: see rakendus pole määranud üleslaadimispiiranguid.',
105
- 'storage.errNoAccept': 'Üleslaadimine on välja lülitatud: ühtegi failivormingut pole lubatud.',
106
105
  'storage.errNoMaxBytes': 'Üleslaadimine on välja lülitatud: faili suurimat lubatud mahtu pole määratud.',
107
106
  'storage.errTooLarge': '{name} on {size} — piir on {max}.',
108
107
  'storage.errFormat': '{name} on {type}-fail, mis pole siin lubatud.',
@@ -102,7 +102,6 @@ export const FI = {
102
102
  'storage.uploading': 'Ladataan {count}…',
103
103
  'storage.errLoadFailed': 'Tiedostokirjastoa ei voitu ladata — {reason}',
104
104
  'storage.errNoLimits': 'Lataukset on poistettu käytöstä: tämä sovellus ei ole asettanut latausrajoja.',
105
- 'storage.errNoAccept': 'Lataukset on poistettu käytöstä: yhtään tiedostomuotoa ei ole sallittu.',
106
105
  'storage.errNoMaxBytes': 'Lataukset on poistettu käytöstä: tiedoston enimmäiskokoa ei ole asetettu.',
107
106
  'storage.errTooLarge': '{name} on {size} — raja on {max}.',
108
107
  'storage.errFormat': '{name} on {type}-tiedosto, jota ei sallita tässä.',
@@ -102,7 +102,6 @@ export const FR = {
102
102
  'storage.uploading': 'Envoi de {count}…',
103
103
  'storage.errLoadFailed': 'La bibliothèque de fichiers n’a pas pu être chargée — {reason}',
104
104
  'storage.errNoLimits': 'Les envois sont désactivés : cette application n’a défini aucune limite d’envoi.',
105
- 'storage.errNoAccept': 'Les envois sont désactivés : aucun format de fichier n’a été autorisé.',
106
105
  'storage.errNoMaxBytes': 'Les envois sont désactivés : aucune taille maximale de fichier n’a été définie.',
107
106
  'storage.errTooLarge': '{name} fait {size} — la limite est {max}.',
108
107
  'storage.errFormat': '{name} est un fichier {type}, non autorisé ici.',
@@ -102,7 +102,6 @@ export const HR = {
102
102
  'storage.uploading': 'Prijenos {count}…',
103
103
  'storage.errLoadFailed': 'Knjižnicu datoteka nije moguće učitati — {reason}',
104
104
  'storage.errNoLimits': 'Prijenos je isključen: ova aplikacija nije postavila nikakva ograničenja prijenosa.',
105
- 'storage.errNoAccept': 'Prijenos je isključen: nije dopušten nijedan format datoteke.',
106
105
  'storage.errNoMaxBytes': 'Prijenos je isključen: nije postavljena najveća veličina datoteke.',
107
106
  'storage.errTooLarge': '{name} je {size} — ograničenje je {max}.',
108
107
  'storage.errFormat': '{name} je datoteka vrste {type}, koja ovdje nije dopuštena.',
@@ -102,7 +102,6 @@ export const HU = {
102
102
  'storage.uploading': '{count} feltöltése…',
103
103
  'storage.errLoadFailed': 'A fájlkönyvtár nem tölthető be — {reason}',
104
104
  'storage.errNoLimits': 'A feltöltés ki van kapcsolva: ez az alkalmazás nem állított be feltöltési korlátokat.',
105
- 'storage.errNoAccept': 'A feltöltés ki van kapcsolva: nincs engedélyezett fájlformátum.',
106
105
  'storage.errNoMaxBytes': 'A feltöltés ki van kapcsolva: nincs beállítva maximális fájlméret.',
107
106
  'storage.errTooLarge': 'A(z) {name} mérete {size} — a korlát {max}.',
108
107
  'storage.errFormat': 'A(z) {name} egy {type} fájl, ami itt nem engedélyezett.',
@@ -102,7 +102,6 @@ export const IT = {
102
102
  'storage.uploading': 'Caricamento di {count}…',
103
103
  'storage.errLoadFailed': 'Impossibile caricare la libreria file — {reason}',
104
104
  'storage.errNoLimits': 'I caricamenti sono disattivati: questa applicazione non ha impostato limiti di caricamento.',
105
- 'storage.errNoAccept': 'I caricamenti sono disattivati: nessun formato di file è stato consentito.',
106
105
  'storage.errNoMaxBytes': 'I caricamenti sono disattivati: non è stata impostata una dimensione massima del file.',
107
106
  'storage.errTooLarge': '{name} è {size} — il limite è {max}.',
108
107
  'storage.errFormat': '{name} è un file {type}, non consentito qui.',
@@ -102,7 +102,6 @@ export const LT = {
102
102
  'storage.uploading': 'Įkeliama {count}…',
103
103
  'storage.errLoadFailed': 'Nepavyko įkelti failų bibliotekos — {reason}',
104
104
  'storage.errNoLimits': 'Įkėlimai išjungti: ši programa nenustatė jokių įkėlimo ribų.',
105
- 'storage.errNoAccept': 'Įkėlimai išjungti: neleidžiamas nė vienas failo formatas.',
106
105
  'storage.errNoMaxBytes': 'Įkėlimai išjungti: nenustatytas didžiausias failo dydis.',
107
106
  'storage.errTooLarge': '{name} yra {size} — riba yra {max}.',
108
107
  'storage.errFormat': '{name} yra {type} failas, kuris čia neleidžiamas.',
@@ -102,7 +102,6 @@ export const LV = {
102
102
  'storage.uploading': 'Notiek {count} augšupielāde…',
103
103
  'storage.errLoadFailed': 'Neizdevās ielādēt failu bibliotēku — {reason}',
104
104
  'storage.errNoLimits': 'Augšupielāde ir izslēgta: šī lietotne nav noteikusi augšupielādes ierobežojumus.',
105
- 'storage.errNoAccept': 'Augšupielāde ir izslēgta: nav atļauts neviens faila formāts.',
106
105
  'storage.errNoMaxBytes': 'Augšupielāde ir izslēgta: nav noteikts maksimālais faila izmērs.',
107
106
  'storage.errTooLarge': '{name} ir {size} — ierobežojums ir {max}.',
108
107
  'storage.errFormat': '{name} ir {type} fails, kas šeit nav atļauts.',
@@ -102,7 +102,6 @@ export const NB = {
102
102
  'storage.uploading': 'Laster opp {count}…',
103
103
  'storage.errLoadFailed': 'Filbiblioteket kunne ikke lastes — {reason}',
104
104
  'storage.errNoLimits': 'Opplasting er slått av: denne appen har ikke satt noen opplastingsgrenser.',
105
- 'storage.errNoAccept': 'Opplasting er slått av: ingen filformater er tillatt.',
106
105
  'storage.errNoMaxBytes': 'Opplasting er slått av: ingen maksimal filstørrelse er satt.',
107
106
  'storage.errTooLarge': '{name} er {size} — grensen er {max}.',
108
107
  'storage.errFormat': '{name} er en {type}-fil, som ikke er tillatt her.',
@@ -102,7 +102,6 @@ export const NL = {
102
102
  'storage.uploading': '{count} uploaden…',
103
103
  'storage.errLoadFailed': 'De bestandsbibliotheek kon niet worden geladen — {reason}',
104
104
  'storage.errNoLimits': 'Uploaden staat uit: deze app heeft geen uploadlimieten ingesteld.',
105
- 'storage.errNoAccept': 'Uploaden staat uit: er zijn geen bestandsformaten toegestaan.',
106
105
  'storage.errNoMaxBytes': 'Uploaden staat uit: er is geen maximale bestandsgrootte ingesteld.',
107
106
  'storage.errTooLarge': '{name} is {size} — de limiet is {max}.',
108
107
  'storage.errFormat': '{name} is een {type}-bestand, dat hier niet is toegestaan.',
@@ -102,7 +102,6 @@ export const PL = {
102
102
  'storage.uploading': 'Przesyłanie {count}…',
103
103
  'storage.errLoadFailed': 'Nie udało się wczytać biblioteki plików — {reason}',
104
104
  'storage.errNoLimits': 'Przesyłanie jest wyłączone: ta aplikacja nie ustawiła żadnych limitów przesyłania.',
105
- 'storage.errNoAccept': 'Przesyłanie jest wyłączone: nie dopuszczono żadnego formatu pliku.',
106
105
  'storage.errNoMaxBytes': 'Przesyłanie jest wyłączone: nie ustawiono maksymalnego rozmiaru pliku.',
107
106
  'storage.errTooLarge': '{name} ma {size} — limit to {max}.',
108
107
  'storage.errFormat': '{name} to plik {type}, który nie jest tu dozwolony.',
@@ -102,7 +102,6 @@ export const PT = {
102
102
  'storage.uploading': 'A carregar {count}…',
103
103
  'storage.errLoadFailed': 'Não foi possível carregar a biblioteca de ficheiros — {reason}',
104
104
  'storage.errNoLimits': 'Os carregamentos estão desativados: esta aplicação não definiu limites de carregamento.',
105
- 'storage.errNoAccept': 'Os carregamentos estão desativados: não foi permitido nenhum formato de ficheiro.',
106
105
  'storage.errNoMaxBytes': 'Os carregamentos estão desativados: não foi definido um tamanho máximo de ficheiro.',
107
106
  'storage.errTooLarge': '{name} tem {size} — o limite é {max}.',
108
107
  'storage.errFormat': '{name} é um ficheiro {type}, que não é permitido aqui.',
@@ -102,7 +102,6 @@ export const RO = {
102
102
  'storage.uploading': 'Se încarcă {count}…',
103
103
  'storage.errLoadFailed': 'Biblioteca de fișiere nu a putut fi încărcată — {reason}',
104
104
  'storage.errNoLimits': 'Încărcările sunt oprite: această aplicație nu a stabilit nicio limită de încărcare.',
105
- 'storage.errNoAccept': 'Încărcările sunt oprite: nu a fost permis niciun format de fișier.',
106
105
  'storage.errNoMaxBytes': 'Încărcările sunt oprite: nu a fost stabilită o dimensiune maximă a fișierului.',
107
106
  'storage.errTooLarge': '{name} are {size} — limita este {max}.',
108
107
  'storage.errFormat': '{name} este un fișier {type}, care nu este permis aici.',
@@ -102,7 +102,6 @@ export const RU = {
102
102
  'storage.uploading': 'Загрузка {count}…',
103
103
  'storage.errLoadFailed': 'Не удалось загрузить библиотеку файлов — {reason}',
104
104
  'storage.errNoLimits': 'Загрузка выключена: это приложение не задало ограничения на загрузку.',
105
- 'storage.errNoAccept': 'Загрузка выключена: ни один формат файлов не разрешён.',
106
105
  'storage.errNoMaxBytes': 'Загрузка выключена: не задан максимальный размер файла.',
107
106
  'storage.errTooLarge': '{name} весит {size} — предел {max}.',
108
107
  'storage.errFormat': '{name} — файл {type}, который здесь не разрешён.',
@@ -102,7 +102,6 @@ export const SK = {
102
102
  'storage.uploading': 'Nahrávanie {count}…',
103
103
  'storage.errLoadFailed': 'Knižnicu súborov sa nepodarilo načítať — {reason}',
104
104
  'storage.errNoLimits': 'Nahrávanie je vypnuté: táto aplikácia nenastavila žiadne limity nahrávania.',
105
- 'storage.errNoAccept': 'Nahrávanie je vypnuté: nebol povolený žiadny formát súboru.',
106
105
  'storage.errNoMaxBytes': 'Nahrávanie je vypnuté: nie je nastavená maximálna veľkosť súboru.',
107
106
  'storage.errTooLarge': '{name} má {size} — limit je {max}.',
108
107
  'storage.errFormat': '{name} je súbor typu {type}, ktorý tu nie je povolený.',
@@ -102,7 +102,6 @@ export const SL = {
102
102
  'storage.uploading': 'Nalaganje {count}…',
103
103
  'storage.errLoadFailed': 'Knjižnice datotek ni bilo mogoče naložiti — {reason}',
104
104
  'storage.errNoLimits': 'Nalaganje je izklopljeno: ta aplikacija ni določila omejitev nalaganja.',
105
- 'storage.errNoAccept': 'Nalaganje je izklopljeno: dovoljena ni nobena vrsta datoteke.',
106
105
  'storage.errNoMaxBytes': 'Nalaganje je izklopljeno: največja velikost datoteke ni določena.',
107
106
  'storage.errTooLarge': '{name} je {size} — omejitev je {max}.',
108
107
  'storage.errFormat': '{name} je datoteka vrste {type}, ki tu ni dovoljena.',
@@ -102,7 +102,6 @@ export const SV = {
102
102
  'storage.uploading': 'Laddar upp {count}…',
103
103
  'storage.errLoadFailed': 'Filbiblioteket kunde inte laddas — {reason}',
104
104
  'storage.errNoLimits': 'Uppladdning är avstängd: den här appen har inte satt några uppladdningsgränser.',
105
- 'storage.errNoAccept': 'Uppladdning är avstängd: inga filformat har tillåtits.',
106
105
  'storage.errNoMaxBytes': 'Uppladdning är avstängd: ingen maximal filstorlek har angetts.',
107
106
  'storage.errTooLarge': '{name} är {size} — gränsen är {max}.',
108
107
  'storage.errFormat': '{name} är en {type}-fil, som inte tillåts här.',
@@ -102,7 +102,6 @@ export const TR = {
102
102
  'storage.uploading': '{count} yükleniyor…',
103
103
  'storage.errLoadFailed': 'Dosya kitaplığı yüklenemedi — {reason}',
104
104
  'storage.errNoLimits': 'Yüklemeler kapalı: bu uygulama hiçbir yükleme sınırı belirlemedi.',
105
- 'storage.errNoAccept': 'Yüklemeler kapalı: hiçbir dosya biçimine izin verilmedi.',
106
105
  'storage.errNoMaxBytes': 'Yüklemeler kapalı: en büyük dosya boyutu belirlenmedi.',
107
106
  'storage.errTooLarge': '{name} {size} — sınır {max}.',
108
107
  'storage.errFormat': '{name} bir {type} dosyası ve burada izin verilmiyor.',
@@ -102,7 +102,6 @@ export const UK = {
102
102
  'storage.uploading': 'Завантаження {count}…',
103
103
  'storage.errLoadFailed': 'Не вдалося завантажити бібліотеку файлів — {reason}',
104
104
  'storage.errNoLimits': 'Завантаження вимкнено: цей застосунок не задав обмежень на завантаження.',
105
- 'storage.errNoAccept': 'Завантаження вимкнено: не дозволено жодного формату файлів.',
106
105
  'storage.errNoMaxBytes': 'Завантаження вимкнено: не задано максимальний розмір файлу.',
107
106
  'storage.errTooLarge': '{name} важить {size} — обмеження {max}.',
108
107
  'storage.errFormat': '{name} — файл {type}, який тут не дозволено.',
@@ -955,6 +955,28 @@ function themeFromParsedDoc(doc) {
955
955
  });
956
956
  const bestWidth = Object.keys(widthCounts).sort((a, b) => widthCounts[b] - widthCounts[a])[0];
957
957
  if (bestWidth) theme.width = Number(bestWidth);
958
+ // Page padding and content shape, read off the same content table the
959
+ // width vote just picked: the padding on the cell that centers it is the
960
+ // band the template sits in, and the table's own radius is the content
961
+ // column's corner. Without this an import flattened both, and the next
962
+ // export silently squared the template off and closed the gap around it.
963
+ if (bestWidth) {
964
+ const content = Array.from(body.querySelectorAll('table')).find((tb) => {
965
+ const w = tb.getAttribute('width') || (tb.style && tb.style.width) || '';
966
+ return w && !String(w).endsWith('%') && PX(w) === Number(bestWidth);
967
+ });
968
+ if (content) {
969
+ const r = PX(content.style && content.style.borderRadius);
970
+ if (r > 0) theme.radius = r;
971
+ const cell = content.closest ? content.closest('td') : null;
972
+ if (cell && cell.style) {
973
+ const padY = PX(cell.style.paddingTop);
974
+ const padX = PX(cell.style.paddingLeft);
975
+ if (padY > 0) theme.padY = padY;
976
+ if (padX > 0) theme.padX = padX;
977
+ }
978
+ }
979
+ }
958
980
  // Prefer the first *real* stack (has a comma or quotes) over a lone generic
959
981
  // keyword: builders wrap everything in a `font-family:sans-serif` shim div
960
982
  // with the actual `'DM Sans', Arial, ...` declared a level deeper.
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Upload validation -- mechanism only, no policy.
3
3
  *
4
- * Deliberately ships no default ceilings. What an email template may carry
5
- * depends on the sending platform (ESP attachment caps), the audience's
6
- * clients (Outlook's Word engine won't render WebP; nothing renders AVIF) and
7
- * the host's own product rules, so the numbers are the host's to set:
8
- * `editor.storageLimits`, or `limits` on the provider. With a provider wired
9
- * and no limits declared, uploads are refused rather than waved through.
4
+ * Sizes and counts stay undefaulted: what an email template may carry depends
5
+ * on the sending platform (ESP attachment caps) and the host's own product
6
+ * rules, so those numbers are the host's to set -- `editor.storageLimits`, or
7
+ * `limits` on the provider. Formats are the one axis with a default: every
8
+ * image type the sniffer can name is allowed unless the host narrows `accept`.
9
+ * With a provider wired and no `maxBytes` declared, uploads are refused rather
10
+ * than waved through.
10
11
  *
11
12
  * Checks run *before* the provider is called, so a rejected file never reaches
12
13
  * the backend. That matters for any store where minting an upload URL also
@@ -96,10 +97,9 @@ export function sanitizeName(name) {
96
97
  return clean.slice(0, 120 - ext.length) + ext;
97
98
  }
98
99
 
99
- /** The host has to have said what's allowed. Format and size are the two that matter; the rest are optional tightenings. */
100
+ /** The host has to have said how big a file may be; the format list is optional and defaults to all of these. */
100
101
  export function limitsProblem(limits) {
101
102
  if (!limits) return 'storage.errNoLimits';
102
- if (!Array.isArray(limits.accept) || !limits.accept.length) return 'storage.errNoAccept';
103
103
  if (!(Number(limits.maxBytes) > 0)) return 'storage.errNoMaxBytes';
104
104
  return null;
105
105
  }
@@ -121,7 +121,12 @@ export async function validateFiles(list, limits) {
121
121
  const problem = limitsProblem(limits);
122
122
  if (problem) return { accepted, rejected: files.map((f) => ({ name: f.name, key: problem, params: {} })) };
123
123
 
124
- const accept = limits.accept.map((m) => String(m).toLowerCase());
124
+ // An omitted or empty `accept` means every image type the sniffer can name,
125
+ // not "nothing" -- a host that wants a narrower list has to say so, but the
126
+ // starting point is that an image is uploadable.
127
+ const accept = Array.isArray(limits.accept) && limits.accept.length
128
+ ? limits.accept.map((m) => String(m).toLowerCase())
129
+ : null;
125
130
  const max = Number(limits.maxBytes);
126
131
  const maxW = Number(limits.maxWidth) || 0;
127
132
  const maxH = Number(limits.maxHeight) || 0;
@@ -146,7 +151,7 @@ export async function validateFiles(list, limits) {
146
151
  rejected.push({ name, key: 'storage.errUnreadable', params: { name } });
147
152
  continue;
148
153
  }
149
- if (!accept.includes(type)) {
154
+ if (accept && !accept.includes(type)) {
150
155
  rejected.push({ name, key: 'storage.errFormat', params: { name, type: type.replace(/^image\//, '').toUpperCase() } });
151
156
  continue;
152
157
  }
@@ -181,4 +186,4 @@ export async function validateFiles(list, limits) {
181
186
  export function acceptAttribute(limits) {
182
187
  if (!limits || !Array.isArray(limits.accept) || !limits.accept.length) return 'image/*';
183
188
  return limits.accept.join(',');
184
- }
189
+ }
@@ -35,7 +35,7 @@
35
35
  * Provider-declared ceilings. `editor.storageLimits` wins over these.
36
36
  *
37
37
  * @typedef {Object} StorageLimits
38
- * @property {string[]} accept Allowed MIME types, e.g. `['image/jpeg','image/png','image/gif']`. Required.
38
+ * @property {string[]} [accept] Allowed MIME types. Omitted or empty means every image type the validator recognizes.
39
39
  * @property {number} maxBytes Per-file byte ceiling. Required.
40
40
  * @property {number} [maxWidth]
41
41
  * @property {number} [maxHeight]
package/src/core/theme.js CHANGED
@@ -1 +1,15 @@
1
- export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
1
+ /**
2
+ * The document's page-level model. `bg` paints the full-width area the email
3
+ * sits on (what a mail client shows around the content column), `contentBg`
4
+ * paints the content column itself, and `padY`/`padX`/`radius` shape the gap
5
+ * between the two -- the band that is otherwise an unstyleable strip around
6
+ * every template.
7
+ *
8
+ * They default to 0 rather than to the 24px/12px band the exporter used to
9
+ * hard-code: that band was in every sent template with no way to reach it,
10
+ * which is the strip around the content column that looked like a rendering
11
+ * fault. Now it is off unless a document asks for it. Both background keys
12
+ * accept the literal `transparent` (and any rgba()/#rrggbbaa value) as well
13
+ * as a hex colour.
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' });
@@ -429,10 +429,10 @@ export class MailCraftEditor extends ElementBase {
429
429
 
430
430
  /**
431
431
  * What may be uploaded: `{ accept, maxBytes, maxWidth, maxHeight, maxFilesPerDrop, allowSvg }`.
432
- * Required alongside a provider and deliberately undefaulted -- the ceilings
433
- * that suit an email template depend on the sending platform, the audience's
434
- * mail clients and the host's own rules, none of which this package can know.
435
- * See `core/storage-limits.js`.
432
+ * Only `maxBytes` is required alongside a provider -- sizes depend on the
433
+ * sending platform and the host's own rules, none of which this package can
434
+ * know. `accept` is optional and defaults to every image type the validator
435
+ * can recognize; list MIME types to narrow it. See `core/storage-limits.js`.
436
436
  */
437
437
  get storageLimits() { return this.core.storageLimits; }
438
438
  set storageLimits(limits) { this.core.setStorageLimits(limits); }
@@ -523,7 +523,7 @@ export class MailCraftEditor extends ElementBase {
523
523
 
524
524
  this.footerBar = this.footerText = this.footerLink = null;
525
525
 
526
- this.body = elS('div', 'display: grid; grid-template-columns: minmax(0, 1fr) 340px; grid-template-rows: minmax(0, 1fr) auto; min-height: 0;', { class: 'mc-layout' });
526
+ this.body = elS('div', 'display: grid; grid-template-columns: minmax(0, 1fr) 340px; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; background: var(--ed-work);', { class: 'mc-layout' });
527
527
  this.frame.appendChild(this.body);
528
528
  this.body.appendChild(this.buildMain());
529
529
  this.body.appendChild(this.buildAside());
@@ -744,7 +744,7 @@ export class MailCraftEditor extends ElementBase {
744
744
  * would strand whichever node the previous render had left in place.
745
745
  */
746
746
  buildFooter() {
747
- this.footerBar = elS('div', 'display: flex; grid-column: 1; grid-row: 2; justify-self: center; align-items: center; justify-content: center; gap: 4px; max-width: calc(100% - 32px); padding: 6px 14px; border-top: 1px solid var(--ed-line); background: var(--ed-panel-2); color: var(--ed-panel-meta); font-family: var(--ed-font); font-size: var(--ed-panel-meta-size); letter-spacing: 0.06em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;', { class: 'mc-footer' });
747
+ this.footerBar = elS('div', 'display: flex; grid-column: 1; grid-row: 2; justify-self: center; align-items: center; justify-content: center; gap: 4px; max-width: calc(100% - 32px); padding: 6px 14px; background: transparent; color: var(--ed-panel-meta); font-family: var(--ed-font); font-size: var(--ed-panel-meta-size); letter-spacing: 0.06em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;', { class: 'mc-footer' });
748
748
  this.footerText = elS('span', '', { class: 'mc-footer-text' });
749
749
  this.footerLink = elS('a', 'color: inherit; text-decoration: none;', { class: 'mc-footer-link' });
750
750
  this.footerBar.appendChild(this.footerText);
@@ -921,8 +921,11 @@ export class MailCraftEditor extends ElementBase {
921
921
  if (this.redoBtn) this.redoBtn.disabled = !s.future.length;
922
922
  // A host-supplied `theme` attribute owns light/dark (see applyTheme) --
923
923
  // hide the built-in toggle rather than let it fight the host's control.
924
+ // setProperty(..., 'important'): a plain assignment loses to the
925
+ // `!important` on `.mc-icon-label` (render/style.js) that keeps every
926
+ // other label-icon button visible, leaving the toggle rendered anyway.
924
927
  if (this.chromeBtn) {
925
- this.chromeBtn.style.display = this.hasAttribute('theme') ? 'none' : 'flex';
928
+ this.chromeBtn.style.setProperty('display', this.hasAttribute('theme') ? 'none' : 'flex', 'important');
926
929
  this.chromeBtn.labelNode.textContent = s.chrome === 'light' ? t('action.chromeToDark') : t('action.chromeToLight');
927
930
  this.chromeBtn.i18nTipKey = s.chrome === 'light' ? 'action.chromeHintToDark' : 'action.chromeHintToLight';
928
931
  this.chromeBtn.tipNode.textContent = t(this.chromeBtn.i18nTipKey);
@@ -1589,8 +1592,16 @@ export class MailCraftEditor extends ElementBase {
1589
1592
 
1590
1593
  const right = elS('div', 'display: grid; grid-template-rows: auto 1fr; min-height: 0; border: 1px solid var(--ed-line); border-radius: 12px; overflow: hidden; background: var(--ed-panel);', { class: 'mc-code-preview' });
1591
1594
  right.appendChild(elS('div', 'padding: 7px 16px; border-bottom: 1px solid var(--ed-line); background: var(--ed-panel); font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { text: t('code.livePreview'), 'data-i18n': 'code.livePreview', class: 'mc-pane-label' }));
1592
- const previewWrap = elS('div', 'overflow: auto; padding: 20px; display: flex; justify-content: center; background: var(--ed-work);', { class: 'mc-code-preview-body' });
1593
- this.codeFrame = elS('iframe', "width: 100%; height: 100%; min-height: 420px; border: 1px solid var(--ed-line-2); border-radius: 6px; background: #ffffff; box-shadow: 0 6px 18px rgba(15,23,42,0.10); 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' });
1595
+ // Flush, not a card on a mat: the document inside the frame paints its
1596
+ // own page background edge to edge, so padding the wrap and framing the
1597
+ // iframe stacked a second and third rectangle around a template that
1598
+ // already has one -- the pane, a mat, a bordered card, then the email's
1599
+ // own page, then its content column. `is-device` (renderCodeModal) puts
1600
+ // the mat and the frame back for the 390px phone width, where the card
1601
+ // *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' });
1603
+ this.codePreviewBody = previewWrap;
1604
+ 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' });
1594
1605
  this.codeFrame.addEventListener('load', () => this.syncCodePreviewScrollbar());
1595
1606
  previewWrap.appendChild(this.codeFrame);
1596
1607
  right.appendChild(previewWrap);
@@ -1670,7 +1681,9 @@ export class MailCraftEditor extends ElementBase {
1670
1681
  tip(btn, w.label, 'down');
1671
1682
  this.codeWidthSeg.appendChild(btn);
1672
1683
  });
1673
- this.codeFrame.style.width = s.codeDevice === 'mobile' ? '390px' : '100%';
1684
+ const phone = s.codeDevice === 'mobile';
1685
+ this.codeFrame.style.width = phone ? '390px' : '100%';
1686
+ this.codePreviewBody.classList.toggle('is-device', phone);
1674
1687
  }
1675
1688
 
1676
1689
  /** Repaint the syntax layer, gutter and metadata without touching the
@@ -1813,7 +1826,14 @@ export class MailCraftEditor extends ElementBase {
1813
1826
  closeBtn.addEventListener('click', () => this.core.setState({ previewOpen: false }));
1814
1827
  head.append(headText, this.previewDeviceSeg, closeBtn);
1815
1828
  overlay.appendChild(head);
1816
- const body = elS('div', 'overflow-y: auto; padding: 32px 24px;', { class: 'mc-preview-body' });
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' });
1833
+ // Kept on the instance: renderPreviewModal repaints it from the document's
1834
+ // page background on every render, and it runs long after this builder's
1835
+ // local has gone out of scope.
1836
+ this.previewBody = body;
1817
1837
  this.previewSlot = elS('div', '', { class: 'mc-sheet-wrap' });
1818
1838
  this.previewSlot.style.cssText = 'display: flex; justify-content: center;';
1819
1839
  body.appendChild(this.previewSlot);
@@ -1838,6 +1858,13 @@ export class MailCraftEditor extends ElementBase {
1838
1858
  tip(btn, d.label, 'down');
1839
1859
  this.previewDeviceSeg.appendChild(btn);
1840
1860
  });
1861
+ // The preview body doubles as the sent page: the theme's page background
1862
+ // paints it, exactly as the exported <body style="background:..."> does.
1863
+ // Falls back to the empty string -- and so to the stylesheet's dotted
1864
+ // grid -- for a transparent page, which is exactly what a client with no
1865
+ // page colour of its own shows behind the email.
1866
+ const pageBg = s.doc.theme.bg || '';
1867
+ this.previewBody.style.background = /^(transparent|none)$/i.test(pageBg.trim()) ? '' : pageBg;
1841
1868
  this.previewSlot.innerHTML = '';
1842
1869
  this.previewSlot.appendChild(renderDoc(this.core, false));
1843
1870
  }
@@ -175,21 +175,57 @@ export function renderDoc(core, live) {
175
175
  // clients render it LTR -- letting the chrome's direction cascade in here
176
176
  // flipped bidi punctuation and default alignment on the canvas and made it
177
177
  // disagree with the exported result.
178
+ const radius = Number(theme.radius) || 0;
178
179
  const root = el('div', {
179
- width: width + 'px', maxWidth: '100%', background: theme.contentBg, color: theme.text, fontFamily: theme.font,
180
- transition: 'width 0.28s cubic-bezier(0.22,0.61,0.36,1), background 0.2s',
180
+ width: width + 'px', maxWidth: '100%', background: theme.contentBg || 'transparent', color: theme.text, fontFamily: theme.font,
181
+ // Only when the document actually asks for a shape: an unconditional
182
+ // `0px` would override the editor chrome's own soft corner on the sheet
183
+ // (style.js) and square off every template that never touched the field.
184
+ borderRadius: radius ? radius + 'px' : '',
185
+ // Clipping the rows to that corner is right for the sent email (export
186
+ // emits `overflow:hidden` alongside the radius) and for the static
187
+ // preview, but not for the editable canvas: the sheet is also what the
188
+ // row grip straddles and what the floating RTE toolbar overhangs
189
+ // (top:-82px on the first block), and hiding the overflow cuts both off.
190
+ overflow: !live && radius ? 'hidden' : '',
191
+ transition: 'width 0.28s cubic-bezier(0.22,0.61,0.36,1), background 0.2s, border-radius 0.2s',
181
192
  }, { 'data-mc-sheet': '1', dir: 'ltr' });
182
193
 
194
+ /*
195
+ * The page: the full-width section the email sits on, painted from the
196
+ * document's own `bg`/`padY`/`padX` rather than left to the workspace
197
+ * chrome. It is the band a mail client shows around the content column --
198
+ * the one part of the template that used to exist only in the exported
199
+ * HTML, so changing its colour did nothing visible in the editor and its
200
+ * size could not be changed at all. With the padding at its 0 default the
201
+ * page hugs the sheet exactly and nothing about the canvas changes.
202
+ */
203
+ const padY = Number(theme.padY) || 0;
204
+ const padX = Number(theme.padX) || 0;
205
+ const page = el('div', {
206
+ background: theme.bg || 'transparent',
207
+ padding: padY + 'px ' + padX + 'px',
208
+ boxSizing: 'border-box', maxWidth: '100%', display: 'flex', justifyContent: 'center',
209
+ transition: 'background 0.2s, padding 0.22s cubic-bezier(0.22,0.61,0.36,1)',
210
+ // `is-padded` is what moves the editor's frame outward (style.js): with
211
+ // no padding the page hugs the sheet exactly, so the sheet keeps the
212
+ // frame and an untouched canvas looks exactly as it always did.
213
+ }, { 'data-mc-page': '1', class: 'mc-page' + (padY || padX ? ' is-padded' : ''), dir: 'ltr' });
214
+
183
215
  const rowLines = [];
184
216
  if (live) {
185
- root.addEventListener('dragover', (e) => {
217
+ // On the page, not the sheet: a drag held over the page padding is still
218
+ // aimed at the template, and these fire for the sheet's own events too
219
+ // (they bubble). Column drops stop propagation before they get here, as
220
+ // they did when these listeners sat on the sheet.
221
+ page.addEventListener('dragover', (e) => {
186
222
  const dr = core.drag; if (!dr) return;
187
223
  e.preventDefault();
188
224
  const index = core.indexFromPoint(root, e.clientY);
189
225
  showLine(rowDropTracker, rowLines[index]);
190
226
  });
191
- root.addEventListener('dragleave', (e) => { if (e.target === root) hideActive(rowDropTracker); });
192
- root.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e); });
227
+ page.addEventListener('dragleave', (e) => { if (e.target === root || e.target === page) hideActive(rowDropTracker); });
228
+ page.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e); });
193
229
  }
194
230
 
195
231
  d.rows.forEach((r, ri) => {
@@ -309,5 +345,6 @@ export function renderDoc(core, live) {
309
345
  root.appendChild(el('div', { border: '1px dashed var(--ed-accent-sheet-line)', padding: '90px 20px', textAlign: 'center', fontFamily: 'ui-monospace,monospace', fontSize: '10.5px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--ed-faint)' }, { text: 'drag a row or block here' }));
310
346
  }
311
347
 
312
- return root;
348
+ page.appendChild(root);
349
+ return page;
313
350
  }
@@ -545,6 +545,17 @@ export function renderField(f) {
545
545
  row.appendChild(fieldLabel(f.label, true));
546
546
  const pill = el('div', { display: 'flex', alignItems: 'center', flex: 'none', height: '32px', border: '1px solid var(--ed-line)', borderRadius: '8px', background: 'var(--ed-panel)', overflow: 'hidden', transition: 'border-color 0.16s, box-shadow 0.16s' }, { class: 'mc-field-control mc-color-control' });
547
547
  const swatch = el('label', { position: 'relative', width: '29px', alignSelf: 'stretch', flex: 'none', borderRight: '1px solid var(--ed-line)', background: f.swatch, cursor: 'pointer' }, { title: f.label });
548
+ // A transparent value has no colour to show, so the swatch shows the
549
+ // conventional checkerboard instead -- otherwise "no fill" and "white"
550
+ // are the same white square.
551
+ if (f.transparent) {
552
+ Object.assign(swatch.style, {
553
+ backgroundColor: '#ffffff',
554
+ backgroundImage: 'linear-gradient(45deg,#c3c9d2 25%,transparent 25%,transparent 75%,#c3c9d2 75%),linear-gradient(45deg,#c3c9d2 25%,transparent 25%,transparent 75%,#c3c9d2 75%)',
555
+ backgroundSize: '8px 8px',
556
+ backgroundPosition: '0 0, 4px 4px',
557
+ });
558
+ }
548
559
  const picker = el('input', { position: 'absolute', inset: '0', width: '100%', height: '100%', opacity: '0', cursor: 'pointer', padding: '0', border: '0' }, { type: 'color' });
549
560
  picker.value = f.swatch;
550
561
  // The native picker fires `input` continuously while dragging the hue
@@ -560,6 +571,12 @@ export function renderField(f) {
560
571
  hex.addEventListener('focus', () => { pill.style.borderColor = 'var(--ed-accent)'; pill.style.boxShadow = '0 0 0 3px var(--ed-soft)'; });
561
572
  hex.addEventListener('blur', () => { pill.style.borderColor = 'var(--ed-line)'; pill.style.boxShadow = 'none'; hexCommit.flush(); });
562
573
  pill.append(swatch, hex);
574
+ if (f.clearable) {
575
+ const none = el('button', { flex: 'none', alignSelf: 'stretch', width: '28px', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '0', borderLeft: '1px solid var(--ed-line)', background: f.transparent ? 'var(--ed-accent)' : 'transparent', color: f.transparent ? 'var(--ed-accent-ink)' : 'var(--ed-muted)', cursor: 'pointer', padding: '0' }, { type: 'button', title: f.transparent ? 'Transparent — click for a solid colour' : 'Make transparent (no fill)', 'aria-pressed': String(!!f.transparent) });
576
+ none.appendChild(icon(f.transparent ? 'check' : 'clear', 12));
577
+ none.addEventListener('click', () => f.onToggleTransparent());
578
+ pill.appendChild(none);
579
+ }
563
580
  row.appendChild(pill);
564
581
  wrap.appendChild(row);
565
582
  return wrap;
@@ -80,7 +80,13 @@ export const captureTemplatePng = async function (core, mountInto, scale = 2) {
80
80
  const wrapper = document.createElement('div');
81
81
  // Off-screen but attached (fixed keeps it out of any scroll container's
82
82
  // scrollHeight, so capturing never moves the user's canvas position).
83
- wrapper.style.cssText = `position: fixed; left: -100000px; top: 0; width: ${theme.width + pad * 2}px; background: ${theme.bg}; padding: ${pad}px; box-sizing: border-box; display: flex; justify-content: center;`;
83
+ // The document's own page padding sits inside renderDoc's page element, so
84
+ // the wrapper has to be wide enough for it -- sized off the content width
85
+ // alone, `max-width:100%` would shrink the sheet below `theme.width` by
86
+ // exactly the padding and the capture would come out narrower than the
87
+ // template really is.
88
+ const pageW = theme.width + (Number(theme.padX) || 0) * 2;
89
+ wrapper.style.cssText = `position: fixed; left: -100000px; top: 0; width: ${pageW + pad * 2}px; background: ${theme.bg}; padding: ${pad}px; box-sizing: border-box; display: flex; justify-content: center;`;
84
90
  wrapper.setAttribute('aria-hidden', 'true');
85
91
  // renderDoc sizes the sheet from `state.device`, so with the mobile toggle
86
92
  // on it would return a 375px sheet floating inside this desktop-width