@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.
- package/DOCS.md +7 -8
- package/README.md +4 -3
- package/README.md.txt +4 -3
- package/dist/mailcraft-editor.bundle.js +79 -51
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/examples/templates/activate-your-account.html +1 -1
- package/examples/templates/back-in-stock.html +1 -1
- package/examples/templates/cart-left-behind.html +1 -1
- package/examples/templates/community-giveaway.html +1 -1
- package/examples/templates/frontend-futures-invite.html +1 -1
- package/examples/templates/give-25-get-25.html +1 -1
- package/examples/templates/invoice-paid.html +1 -1
- package/examples/templates/meet-nova-launch.html +1 -1
- package/examples/templates/mega-weekend-sale.html +1 -1
- package/examples/templates/order-confirmed.html +1 -1
- package/examples/templates/rate-your-headphones.html +1 -1
- package/examples/templates/reset-your-password.html +1 -1
- package/examples/templates/thankyou-promo-code.html +1 -1
- package/examples/templates/the-sunday-brief.html +1 -1
- package/examples/templates/welcome-to-your-workspace.html +1 -1
- package/examples/templates/your-order-shipped.html +1 -1
- package/examples/templates/your-password-was-changed.html +1 -1
- package/examples/templates/your-signin-code.html +1 -1
- package/examples/vanilla.html +27 -25
- package/package.json +1 -1
- package/src/core/binder.js +21 -1
- package/src/core/editor-core.js +12 -2
- package/src/core/export.js +19 -3
- package/src/core/i18n/ar.js +0 -1
- package/src/core/i18n/bg.js +0 -1
- package/src/core/i18n/bn.js +0 -1
- package/src/core/i18n/ca.js +0 -1
- package/src/core/i18n/cs.js +0 -1
- package/src/core/i18n/da.js +0 -1
- package/src/core/i18n/de-CH.js +0 -1
- package/src/core/i18n/de.js +0 -1
- package/src/core/i18n/dz.js +0 -1
- package/src/core/i18n/el.js +0 -1
- package/src/core/i18n/en.js +0 -1
- package/src/core/i18n/es.js +0 -1
- package/src/core/i18n/et.js +0 -1
- package/src/core/i18n/fi.js +0 -1
- package/src/core/i18n/fr.js +0 -1
- package/src/core/i18n/hr.js +0 -1
- package/src/core/i18n/hu.js +0 -1
- package/src/core/i18n/it.js +0 -1
- package/src/core/i18n/lt.js +0 -1
- package/src/core/i18n/lv.js +0 -1
- package/src/core/i18n/nb.js +0 -1
- package/src/core/i18n/nl.js +0 -1
- package/src/core/i18n/pl.js +0 -1
- package/src/core/i18n/pt.js +0 -1
- package/src/core/i18n/ro.js +0 -1
- package/src/core/i18n/ru.js +0 -1
- package/src/core/i18n/sk.js +0 -1
- package/src/core/i18n/sl.js +0 -1
- package/src/core/i18n/sv.js +0 -1
- package/src/core/i18n/tr.js +0 -1
- package/src/core/i18n/uk.js +0 -1
- package/src/core/import-html.js +22 -0
- package/src/core/storage-limits.js +16 -11
- package/src/core/storage.js +1 -1
- package/src/core/theme.js +15 -1
- package/src/mailcraft-editor.js +38 -11
- package/src/render/canvas.js +43 -6
- package/src/render/fields.js +17 -0
- package/src/render/screenshot.js +7 -1
- package/src/render/style.js +36 -8
- package/types/index.d.ts +5 -2
package/src/core/i18n/et.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/fi.js
CHANGED
|
@@ -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ä.',
|
package/src/core/i18n/fr.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/hr.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/hu.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/it.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/lt.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/lv.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/nb.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/nl.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/pl.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/pt.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/ro.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/ru.js
CHANGED
|
@@ -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}, который здесь не разрешён.',
|
package/src/core/i18n/sk.js
CHANGED
|
@@ -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ý.',
|
package/src/core/i18n/sl.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/sv.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/tr.js
CHANGED
|
@@ -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.',
|
package/src/core/i18n/uk.js
CHANGED
|
@@ -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}, який тут не дозволено.',
|
package/src/core/import-html.js
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* and no
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/storage.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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' });
|
package/src/mailcraft-editor.js
CHANGED
|
@@ -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
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
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;
|
|
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
|
|
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
|
-
|
|
1593
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/render/canvas.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
348
|
+
page.appendChild(root);
|
|
349
|
+
return page;
|
|
313
350
|
}
|
package/src/render/fields.js
CHANGED
|
@@ -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;
|
package/src/render/screenshot.js
CHANGED
|
@@ -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
|
-
|
|
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
|