@seliseblocks/mailcraft 0.2.6 → 0.2.7

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 (48) hide show
  1. package/DOCS.md +4 -3
  2. package/README.md +4 -1
  3. package/README.md.txt +4 -1
  4. package/dist/mailcraft-editor.bundle.js +52 -53
  5. package/dist/mailcraft-editor.bundle.js.map +3 -3
  6. package/examples/vanilla.html +19 -18
  7. package/package.json +1 -1
  8. package/src/core/brand.js +81 -0
  9. package/src/core/editor-core.js +35 -8
  10. package/src/core/i18n/ar.js +2 -2
  11. package/src/core/i18n/bg.js +2 -2
  12. package/src/core/i18n/bn.js +2 -2
  13. package/src/core/i18n/ca.js +2 -2
  14. package/src/core/i18n/cs.js +2 -2
  15. package/src/core/i18n/da.js +2 -2
  16. package/src/core/i18n/de-CH.js +2 -2
  17. package/src/core/i18n/de.js +2 -2
  18. package/src/core/i18n/dz.js +2 -2
  19. package/src/core/i18n/el.js +2 -2
  20. package/src/core/i18n/en.js +2 -2
  21. package/src/core/i18n/es.js +2 -2
  22. package/src/core/i18n/et.js +2 -2
  23. package/src/core/i18n/fi.js +2 -2
  24. package/src/core/i18n/fr.js +2 -2
  25. package/src/core/i18n/hr.js +2 -2
  26. package/src/core/i18n/hu.js +2 -2
  27. package/src/core/i18n/it.js +2 -2
  28. package/src/core/i18n/lt.js +2 -2
  29. package/src/core/i18n/lv.js +2 -2
  30. package/src/core/i18n/nb.js +2 -2
  31. package/src/core/i18n/nl.js +2 -2
  32. package/src/core/i18n/pl.js +2 -2
  33. package/src/core/i18n/pt.js +2 -2
  34. package/src/core/i18n/ro.js +2 -2
  35. package/src/core/i18n/ru.js +2 -2
  36. package/src/core/i18n/sk.js +2 -2
  37. package/src/core/i18n/sl.js +2 -2
  38. package/src/core/i18n/sv.js +2 -2
  39. package/src/core/i18n/tr.js +2 -2
  40. package/src/core/i18n/uk.js +2 -2
  41. package/src/core/icons.js +1 -0
  42. package/src/core/sanitize.js +34 -0
  43. package/src/mailcraft-editor.js +20 -10
  44. package/src/render/block-body.js +12 -6
  45. package/src/render/canvas.js +5 -1
  46. package/src/render/fields.js +9 -1
  47. package/src/render/screenshot.js +13 -2
  48. package/src/render/style.js +10 -11
@@ -66,7 +66,7 @@ export const LT = {
66
66
  'Pagrįsta sekcijomis: pirmiausia numeskite išdėstymą, tada blokus į jo stulpelius — kiekviena sekcija turi savo foną, kraštinę, flex arba tinklelio nustatymus.',
67
67
  'rows.hintStack': 'Laisvas krūvinys: be išdėstymų — kiekvienas numestas blokas tampa savo pilno pločio sekcija, sudėta iš eilės.',
68
68
  'files.libraryCount': 'Biblioteka — {count} failų',
69
- 'files.openManager': 'Atidaryti failų tvarkyklę',
69
+ 'files.openManager': 'Atidaryti medijos biblioteką',
70
70
  'files.dragToCanvas': 'Vilkite bet kurį failą tiesiai į drobę, kad jį padėtumėte kaip vaizdo bloką.',
71
71
  'layers.structure': 'Struktūra',
72
72
  'layers.sectionSingle': 'Sekcija',
@@ -82,7 +82,7 @@ export const LT = {
82
82
  'vars.noMatch': 'Nė vienas kintamasis neatitinka šio filtro.',
83
83
  'vars.noneDeclared': 'Jūsų programa dar nedeklaravo jokių kintamųjų.',
84
84
  'vars.rteHint': 'Redaguodami tekstą taip pat galite pasirinkti kintamąjį tiesiai iš įterptos įrankių juostos išskleidžiamojo meniu.',
85
- 'modal.fileManager': 'Failų tvarkyklė',
85
+ 'modal.fileManager': 'Medijos biblioteka',
86
86
  'modal.assetsTitle': 'Ištekliai',
87
87
  'modal.replaceImage': 'Pakeisti vaizdą',
88
88
  'library.searchPlaceholder': 'Ieškoti failų…',
@@ -66,7 +66,7 @@ export const LV = {
66
66
  'Balstīts uz sadaļām: vispirms nometiet izkārtojumu, tad blokus tā kolonnās — katrai sadaļai ir sava fona, apmales, flex vai režģa iestatījumi.',
67
67
  'rows.hintStack': 'Brīva kaudze: bez izkārtojumiem — katrs nomestais bloks kļūst par savu pilna platuma sadaļu, sakrautu secībā.',
68
68
  'files.libraryCount': 'Bibliotēka — {count} faili',
69
- 'files.openManager': 'Atvērt failu pārvaldnieku',
69
+ 'files.openManager': 'Atvērt multivides bibliotēku',
70
70
  'files.dragToCanvas': 'Velciet jebkuru failu tieši uz audekla, lai novietotu to kā attēla bloku.',
71
71
  'layers.structure': 'Struktūra',
72
72
  'layers.sectionSingle': 'Sadaļa',
@@ -82,7 +82,7 @@ export const LV = {
82
82
  'vars.noMatch': 'Neviens mainīgais neatbilst šim filtram.',
83
83
  'vars.noneDeclared': 'Jūsu lietotne vēl nav deklarējusi nevienu mainīgo.',
84
84
  'vars.rteHint': 'Rediģējot tekstu, jūs varat arī izvēlēties mainīgo tieši no iegultās rīkjoslas nolaižamās izvēlnes.',
85
- 'modal.fileManager': 'Failu pārvaldnieks',
85
+ 'modal.fileManager': 'Multivides bibliotēka',
86
86
  'modal.assetsTitle': 'Resursi',
87
87
  'modal.replaceImage': 'Aizstāt attēlu',
88
88
  'library.searchPlaceholder': 'Meklēt failus…',
@@ -66,7 +66,7 @@ export const NB = {
66
66
  'Seksjonsbasert: slipp en layout først, deretter blokker i kolonnene — hver seksjon har egen bakgrunn, kant, flex- eller rutenettinnstillinger.',
67
67
  'rows.hintStack': 'Fri stabel: uten layouter — hver blokk du slipper blir sin egen fullbredde-seksjon, stablet i rekkefølge.',
68
68
  'files.libraryCount': 'Bibliotek — {count} filer',
69
- 'files.openManager': 'Åpne filbehandling',
69
+ 'files.openManager': 'Åpne mediebibliotek',
70
70
  'files.dragToCanvas': 'Dra en hvilken som helst fil direkte til lerretet for å plassere den som en bildeblokk.',
71
71
  'layers.structure': 'Struktur',
72
72
  'layers.sectionSingle': 'Seksjon',
@@ -82,7 +82,7 @@ export const NB = {
82
82
  'vars.noMatch': 'Ingen variabel samsvarer med dette filteret.',
83
83
  'vars.noneDeclared': 'Applikasjonen din har ennå ikke erklært noen variabler.',
84
84
  'vars.rteHint': 'Når du redigerer tekst kan du også velge en variabel direkte fra rullegardinmenyen i den innebygde verktøylinjen.',
85
- 'modal.fileManager': 'Filbehandling',
85
+ 'modal.fileManager': 'Mediebibliotek',
86
86
  'modal.assetsTitle': 'Ressurser',
87
87
  'modal.replaceImage': 'Erstatt bilde',
88
88
  'library.searchPlaceholder': 'Søk i filer…',
@@ -66,7 +66,7 @@ export const NL = {
66
66
  'Op secties gebaseerd: plaats eerst een lay-out, sleep dan blokken in de kolommen — elke sectie heeft zijn eigen achtergrond, rand, flex- of gridinstellingen.',
67
67
  'rows.hintStack': 'Vrije stapel: zonder lay-outs — elk blok dat je plaatst wordt een eigen sectie over de volle breedte, op volgorde gestapeld.',
68
68
  'files.libraryCount': 'Bibliotheek — {count} bestanden',
69
- 'files.openManager': 'Bestandsbeheer openen',
69
+ 'files.openManager': 'Mediabibliotheek openen',
70
70
  'files.dragToCanvas': 'Sleep elk bestand direct naar het canvas om het als afbeeldingsblok te plaatsen.',
71
71
  'layers.structure': 'Structuur',
72
72
  'layers.sectionSingle': 'Sectie',
@@ -82,7 +82,7 @@ export const NL = {
82
82
  'vars.noMatch': 'Geen variabele komt overeen met dit filter.',
83
83
  'vars.noneDeclared': 'Jouw applicatie heeft nog geen variabelen gedeclareerd.',
84
84
  'vars.rteHint': 'Tijdens het bewerken van tekst kun je ook een variabele kiezen via het dropdownmenu van de inline werkbalk.',
85
- 'modal.fileManager': 'Bestandsbeheer',
85
+ 'modal.fileManager': 'Mediabibliotheek',
86
86
  'modal.assetsTitle': 'Bestanden',
87
87
  'modal.replaceImage': 'Afbeelding vervangen',
88
88
  'library.searchPlaceholder': 'Bestanden zoeken…',
@@ -66,7 +66,7 @@ export const PL = {
66
66
  'Oparty na sekcjach: najpierw upuść układ, potem bloki w jego kolumnach — każda sekcja ma własne tło, obramowanie, ustawienia flex lub siatki.',
67
67
  'rows.hintStack': 'Wolny stos: bez układów — każdy upuszczony blok staje się osobną, pełnowymiarową sekcją, ułożoną w kolejności.',
68
68
  'files.libraryCount': 'Biblioteka — {count} plików',
69
- 'files.openManager': 'Otwórz menedżera plików',
69
+ 'files.openManager': 'Otwórz bibliotekę mediów',
70
70
  'files.dragToCanvas': 'Przeciągnij dowolny plik bezpośrednio na płótno, aby umieścić go jako blok obrazu.',
71
71
  'layers.structure': 'Struktura',
72
72
  'layers.sectionSingle': 'Sekcja',
@@ -82,7 +82,7 @@ export const PL = {
82
82
  'vars.noMatch': 'Żadna zmienna nie pasuje do tego filtra.',
83
83
  'vars.noneDeclared': 'Twoja aplikacja nie zadeklarowała jeszcze żadnych zmiennych.',
84
84
  'vars.rteHint': 'Podczas edycji tekstu możesz też wybrać zmienną bezpośrednio z rozwijanego menu paska narzędzi.',
85
- 'modal.fileManager': 'Menedżer plików',
85
+ 'modal.fileManager': 'Biblioteka mediów',
86
86
  'modal.assetsTitle': 'Zasoby',
87
87
  'modal.replaceImage': 'Zastąp obraz',
88
88
  'library.searchPlaceholder': 'Szukaj plików…',
@@ -66,7 +66,7 @@ export const PT = {
66
66
  'Baseado em seções: solte um layout primeiro, depois blocos em suas colunas — cada seção tem seu próprio fundo, borda, configurações flex ou grade.',
67
67
  'rows.hintStack': 'Pilha livre: sem layouts — cada bloco que você solta se torna sua própria seção de largura total, empilhada em ordem.',
68
68
  'files.libraryCount': 'Biblioteca — {count} arquivos',
69
- 'files.openManager': 'Abrir gerenciador de arquivos',
69
+ 'files.openManager': 'Abrir biblioteca de mídia',
70
70
  'files.dragToCanvas': 'Arraste qualquer arquivo diretamente para a tela para colocá-lo como bloco de imagem.',
71
71
  'layers.structure': 'Estrutura',
72
72
  'layers.sectionSingle': 'Seção',
@@ -82,7 +82,7 @@ export const PT = {
82
82
  'vars.noMatch': 'Nenhuma variável corresponde a esse filtro.',
83
83
  'vars.noneDeclared': 'Sua aplicação ainda não declarou nenhuma variável.',
84
84
  'vars.rteHint': 'Ao editar texto, você também pode escolher uma variável diretamente no menu da barra de ferramentas em linha.',
85
- 'modal.fileManager': 'Gerenciador de arquivos',
85
+ 'modal.fileManager': 'Biblioteca de mídia',
86
86
  'modal.assetsTitle': 'Recursos',
87
87
  'modal.replaceImage': 'Substituir imagem',
88
88
  'library.searchPlaceholder': 'Pesquisar arquivos…',
@@ -66,7 +66,7 @@ export const RO = {
66
66
  'Bazat pe secțiuni: plasați mai întâi un aspect, apoi blocuri în coloanele sale — fiecare secțiune are propriul fundal, bordură, setări flex sau grilă.',
67
67
  'rows.hintStack': 'Stivă liberă: fără aspecte — fiecare bloc plasat devine propria secțiune pe toată lățimea, stivuită în ordine.',
68
68
  'files.libraryCount': 'Bibliotecă — {count} fișiere',
69
- 'files.openManager': 'Deschide managerul de fișiere',
69
+ 'files.openManager': 'Deschide biblioteca media',
70
70
  'files.dragToCanvas': 'Trageți orice fișier direct pe canvas pentru a-l plasa ca bloc de imagine.',
71
71
  'layers.structure': 'Structură',
72
72
  'layers.sectionSingle': 'Secțiune',
@@ -82,7 +82,7 @@ export const RO = {
82
82
  'vars.noMatch': 'Nicio variabilă nu corespunde acestui filtru.',
83
83
  'vars.noneDeclared': 'Aplicația dvs. nu a declarat încă nicio variabilă.',
84
84
  'vars.rteHint': 'În timp ce editați text, puteți alege o variabilă și direct din meniul derulant al barei de instrumente în linie.',
85
- 'modal.fileManager': 'Manager de fișiere',
85
+ 'modal.fileManager': 'Biblioteca media',
86
86
  'modal.assetsTitle': 'Resurse',
87
87
  'modal.replaceImage': 'Înlocuiește imaginea',
88
88
  'library.searchPlaceholder': 'Căutare fișiere…',
@@ -66,7 +66,7 @@ export const RU = {
66
66
  'На основе секций: сначала поместите макет, затем блоки в его колонки — у каждой секции свой фон, граница, настройки flex или сетки.',
67
67
  'rows.hintStack': 'Свободный стек: без макетов — каждый добавленный блок становится собственной секцией на всю ширину, уложенной по порядку.',
68
68
  'files.libraryCount': 'Библиотека — {count} файлов',
69
- 'files.openManager': 'Открыть файловый менеджер',
69
+ 'files.openManager': 'Открыть медиатеку',
70
70
  'files.dragToCanvas': 'Перетащите любой файл прямо на холст, чтобы разместить его как блок изображения.',
71
71
  'layers.structure': 'Структура',
72
72
  'layers.sectionSingle': 'Секция',
@@ -82,7 +82,7 @@ export const RU = {
82
82
  'vars.noMatch': 'Нет переменных, соответствующих этому фильтру.',
83
83
  'vars.noneDeclared': 'Ваше приложение ещё не объявило переменные.',
84
84
  'vars.rteHint': 'При редактировании текста вы также можете выбрать переменную прямо из выпадающего списка встроенной панели инструментов.',
85
- 'modal.fileManager': 'Файловый менеджер',
85
+ 'modal.fileManager': 'Медиатека',
86
86
  'modal.assetsTitle': 'Файлы',
87
87
  'modal.replaceImage': 'Заменить изображение',
88
88
  'library.searchPlaceholder': 'Поиск файлов…',
@@ -66,7 +66,7 @@ export const SK = {
66
66
  'Založené na sekciách: najprv umiestnite rozloženie, potom bloky do jeho stĺpcov — každá sekcia má vlastné pozadie, ohraničenie, nastavenia flex alebo mriežky.',
67
67
  'rows.hintStack': 'Voľný zásobník: bez rozložení — každý umiestnený blok sa stane vlastnou sekciou na celú šírku, zoradenou postupne.',
68
68
  'files.libraryCount': 'Knižnica — {count} súborov',
69
- 'files.openManager': 'Otvoriť správcu súborov',
69
+ 'files.openManager': 'Otvoriť knižnicu médií',
70
70
  'files.dragToCanvas': 'Presuňte ľubovoľný súbor priamo na plátno a umiestnite ho ako obrazový blok.',
71
71
  'layers.structure': 'Štruktúra',
72
72
  'layers.sectionSingle': 'Sekcia',
@@ -82,7 +82,7 @@ export const SK = {
82
82
  'vars.noMatch': 'Žiadna premenná nezodpovedá tomuto filtru.',
83
83
  'vars.noneDeclared': 'Vaša aplikácia zatiaľ nedeklarovala žiadne premenné.',
84
84
  'vars.rteHint': 'Pri úprave textu môžete premennú vybrať aj priamo z rozbaľovacej ponuky vloženého panela nástrojov.',
85
- 'modal.fileManager': 'Správca súborov',
85
+ 'modal.fileManager': 'Knižnica médií',
86
86
  'modal.assetsTitle': 'Zdroje',
87
87
  'modal.replaceImage': 'Nahradiť obrázok',
88
88
  'library.searchPlaceholder': 'Hľadať súbory…',
@@ -66,7 +66,7 @@ export const SL = {
66
66
  'Temelji na odsekih: najprej spustite postavitev, nato bloke v njene stolpce — vsak odsek ima lastno ozadje, rob, nastavitve flex ali mreže.',
67
67
  'rows.hintStack': 'Prosti sklad: brez postavitev — vsak spuščen blok postane lasten odsek čez celo širino, zložen po vrsti.',
68
68
  'files.libraryCount': 'Knjižnica — {count} datotek',
69
- 'files.openManager': 'Odpri upravitelja datotek',
69
+ 'files.openManager': 'Odpri predstavnostno knjižnico',
70
70
  'files.dragToCanvas': 'Povlecite katero koli datoteko neposredno na platno, da jo postavite kot slikovni blok.',
71
71
  'layers.structure': 'Struktura',
72
72
  'layers.sectionSingle': 'Odsek',
@@ -82,7 +82,7 @@ export const SL = {
82
82
  'vars.noMatch': 'Nobena spremenljivka ne ustreza temu filtru.',
83
83
  'vars.noneDeclared': 'Vaša aplikacija še ni deklarirala nobenih spremenljivk.',
84
84
  'vars.rteHint': 'Med urejanjem besedila lahko spremenljivko izberete tudi neposredno iz spustnega menija vgrajene orodne vrstice.',
85
- 'modal.fileManager': 'Upravitelj datotek',
85
+ 'modal.fileManager': 'Predstavnostna knjižnica',
86
86
  'modal.assetsTitle': 'Viri',
87
87
  'modal.replaceImage': 'Zamenjaj sliko',
88
88
  'library.searchPlaceholder': 'Iskanje datotek…',
@@ -66,7 +66,7 @@ export const SV = {
66
66
  'Sektionsbaserad: släpp en layout först, sedan block i dess kolumner — varje sektion har egen bakgrund, kant, flex- eller rutnätsinställningar.',
67
67
  'rows.hintStack': 'Fri stapel: utan layouter — varje block du släpper blir sin egen sektion i full bredd, staplad i ordning.',
68
68
  'files.libraryCount': 'Bibliotek — {count} filer',
69
- 'files.openManager': 'Öppna filhanteraren',
69
+ 'files.openManager': 'Öppna mediebiblioteket',
70
70
  'files.dragToCanvas': 'Dra valfri fil direkt till canvasen för att placera den som ett bildblock.',
71
71
  'layers.structure': 'Struktur',
72
72
  'layers.sectionSingle': 'Sektion',
@@ -82,7 +82,7 @@ export const SV = {
82
82
  'vars.noMatch': 'Ingen variabel matchar det filtret.',
83
83
  'vars.noneDeclared': 'Din applikation har ännu inte deklarerat några variabler.',
84
84
  'vars.rteHint': 'När du redigerar text kan du också välja en variabel direkt från rullgardinsmenyn i den inbäddade verktygsfältet.',
85
- 'modal.fileManager': 'Filhanterare',
85
+ 'modal.fileManager': 'Mediebibliotek',
86
86
  'modal.assetsTitle': 'Resurser',
87
87
  'modal.replaceImage': 'Ersätt bild',
88
88
  'library.searchPlaceholder': 'Sök filer…',
@@ -66,7 +66,7 @@ export const TR = {
66
66
  'Bölüm tabanlı: önce bir düzen bırakın, ardından sütunlarına bloklar bırakın — her bölümün kendi arka planı, kenarlığı, flex veya ızgara ayarları vardır.',
67
67
  'rows.hintStack': 'Serbest yığın: düzen olmadan — bıraktığınız her blok, sırayla istiflenen kendi tam genişlikteki bölümü haline gelir.',
68
68
  'files.libraryCount': 'Kitaplık — {count} dosya',
69
- 'files.openManager': 'Dosya yöneticisini aç',
69
+ 'files.openManager': 'Medya kitaplığını aç',
70
70
  'files.dragToCanvas': 'Görsel blok olarak yerleştirmek için herhangi bir dosyayı doğrudan tuvale sürükleyin.',
71
71
  'layers.structure': 'Yapı',
72
72
  'layers.sectionSingle': 'Bölüm',
@@ -82,7 +82,7 @@ export const TR = {
82
82
  'vars.noMatch': 'Bu filtreyle eşleşen değişken yok.',
83
83
  'vars.noneDeclared': 'Uygulamanız henüz herhangi bir değişken tanımlamadı.',
84
84
  'vars.rteHint': 'Metni düzenlerken, satır içi araç çubuğunun açılır menüsünden doğrudan bir değişken de seçebilirsiniz.',
85
- 'modal.fileManager': 'Dosya yöneticisi',
85
+ 'modal.fileManager': 'Medya kitaplığı',
86
86
  'modal.assetsTitle': 'Varlıklar',
87
87
  'modal.replaceImage': 'Görseli değiştir',
88
88
  'library.searchPlaceholder': 'Dosyaları ara…',
@@ -66,7 +66,7 @@ export const UK = {
66
66
  'На основі секцій: спочатку розмістіть макет, потім блоки в його колонках — кожна секція має власний фон, межу, налаштування flex або сітки.',
67
67
  'rows.hintStack': 'Вільний стек: без макетів — кожен доданий блок стає власною секцією на всю ширину, укладеною по порядку.',
68
68
  'files.libraryCount': 'Бібліотека — {count} файлів',
69
- 'files.openManager': 'Відкрити файловий менеджер',
69
+ 'files.openManager': 'Відкрити медіатеку',
70
70
  'files.dragToCanvas': 'Перетягніть будь-який файл прямо на полотно, щоб розмістити його як блок зображення.',
71
71
  'layers.structure': 'Структура',
72
72
  'layers.sectionSingle': 'Секція',
@@ -82,7 +82,7 @@ export const UK = {
82
82
  'vars.noMatch': 'Немає змінних, що відповідають цьому фільтру.',
83
83
  'vars.noneDeclared': 'Ваш застосунок ще не оголосив жодних змінних.',
84
84
  'vars.rteHint': 'Під час редагування тексту ви також можете вибрати змінну прямо з випадного списку вбудованої панелі інструментів.',
85
- 'modal.fileManager': 'Файловий менеджер',
85
+ 'modal.fileManager': 'Медіатека',
86
86
  'modal.assetsTitle': 'Файли',
87
87
  'modal.replaceImage': 'Замінити зображення',
88
88
  'library.searchPlaceholder': 'Пошук файлів…',
package/src/core/icons.js CHANGED
@@ -235,3 +235,4 @@ export function socialKey(label) {
235
235
  if (l.includes('mail') || l.includes('email')) return 'mail';
236
236
  return 'globe';
237
237
  }
238
+
@@ -131,6 +131,40 @@ export const cssUrl = (u) => {
131
131
  return raw.replace(/["'()\\]|\s/g, (c) => CSS_URL_ESCAPE[c] || '%20');
132
132
  };
133
133
 
134
+ /**
135
+ * A link target on its way into an anchor. Two jobs, and both of them showed
136
+ * up as "the link does not work" rather than as anything that errors:
137
+ *
138
+ * - A bare host (`selise.ch`, `www.selise.ch/pricing`) gets `https://`.
139
+ * Typed with no scheme it is a *relative* URL, so the mail client
140
+ * resolves it against its own origin and the click lands nowhere -- the
141
+ * single most common way an authored link ships broken, and invisible in
142
+ * the editor because the canvas never follows it.
143
+ * - Schemes are allowlisted the way `cssUrl` allowlists image sources, so
144
+ * `javascript:` resolves to nothing instead of becoming a click target.
145
+ *
146
+ * Left exactly as typed: anything already carrying an allowed scheme, an
147
+ * in-page `#anchor`, a rooted `/path`, and anything opening with a merge tag
148
+ * -- `{{ResetUrl}}` is a whole URL the host substitutes later, so prefixing
149
+ * it would corrupt what reaches the template engine. A bare `name@host.tld`
150
+ * becomes `mailto:`, which is the only thing it could have meant.
151
+ */
152
+ export const linkHref = (u) => {
153
+ const raw = String(u == null ? '' : u).trim();
154
+ if (!raw) return '';
155
+ const scheme = raw.match(/^([a-z][a-z0-9+.-]*):/i);
156
+ if (scheme) {
157
+ if (/^(https?|mailto|tel)$/i.test(scheme[1])) return raw;
158
+ // `selise.ch:8080/x` matches the scheme shape but is a host and a port;
159
+ // it falls through to be prefixed rather than dropped.
160
+ if (!/^[a-z][a-z0-9+.-]*:\d/i.test(raw)) return '';
161
+ }
162
+ if (raw.startsWith('//')) return 'https:' + raw;
163
+ if (/^[#/]/.test(raw) || raw.startsWith('{{')) return raw;
164
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) return 'mailto:' + raw;
165
+ return 'https://' + raw;
166
+ };
167
+
134
168
  export const escHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
135
169
 
136
170
  /** Minimal HTML syntax highlighter for the code view. */
@@ -6,6 +6,7 @@ import { renderDoc } from './render/canvas.js';
6
6
  import { renderRte } from './render/rte.js';
7
7
  import { renderField, renderFieldCards, typeCommit } from './render/fields.js';
8
8
  import { icon } from './core/icons.js';
9
+ import { brandLockup } from './core/brand.js';
9
10
  import { TOKEN } from './core/variables.js';
10
11
  import { hl, cssUrl } from './core/sanitize.js';
11
12
  import { decorateLogicTags } from './core/export.js';
@@ -494,7 +495,15 @@ export class MailCraftEditor extends ElementBase {
494
495
  async copyScreenshot(blob) {
495
496
  const t = this.core.t;
496
497
  try {
497
- const png = blob || await this.screenshotPng();
498
+ // Safari only honours `clipboard.write()` while the user gesture is
499
+ // still live, and awaiting the capture first (per-image fetch +
500
+ // inline, decode, rasterize, PNG encode) always outlives it --
501
+ // NotAllowedError on every attempt, where Chromium is lenient and
502
+ // lets it through. Handing ClipboardItem the *promise* is the
503
+ // supported shape for exactly this: the write is issued
504
+ // synchronously with the gesture and the capture resolves inside
505
+ // it. Chromium accepts the same form, so there is no branch here.
506
+ const png = blob ? Promise.resolve(blob) : this.screenshotPng();
498
507
  await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
499
508
  this.core.flash(t('toast.pngCopied'));
500
509
  } catch {
@@ -569,15 +578,11 @@ export class MailCraftEditor extends ElementBase {
569
578
  const t = this.core.t;
570
579
  const header = elS('header', "display: flex; align-items: center; gap: 14px; padding: 0 16px; border-bottom: 1px solid var(--ed-line); background: linear-gradient(to bottom, var(--ed-panel), var(--ed-panel-2)); position: relative; z-index: 30;", { class: 'mc-header' });
571
580
 
572
- const brandMark = elS('span', '', { class: 'mc-brand-mark' });
573
- const markSvg = icon('mailSpark', 20);
574
- markSvg.setAttribute('stroke-width', '1.8');
575
- brandMark.appendChild(markSvg);
576
- const brand = elS('div', 'display: flex; align-items: center; gap: 9px;');
577
- brand.append(
578
- brandMark,
579
- elS('span', 'font-family: var(--ed-font); font-weight: 600; font-size: 15px; letter-spacing: -0.01em; line-height: 1;', { text: 'MailCraft', class: 'mc-brand-name' }),
580
- );
581
+ // The logo itself, not a mark beside a typeset word: the wordmark is part
582
+ // of the artwork, so it keeps the brand's own typeface whatever font the
583
+ // host pushes into --ed-font.
584
+ const brand = elS('div', 'display: flex; align-items: center;', { class: 'mc-brand' });
585
+ brand.appendChild(brandLockup(30));
581
586
  if (on.logo) header.append(brand, elS('div', 'width: 1px; height: 22px; background: var(--ed-line);'));
582
587
 
583
588
  this.savedLabel = elS('span', 'display: flex; align-items: center; gap: 6px; font-family: ui-monospace, monospace; font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ed-faint); white-space: nowrap;');
@@ -1224,6 +1229,11 @@ export class MailCraftEditor extends ElementBase {
1224
1229
  tile.addEventListener('mouseleave', () => { tile.style.borderColor = 'var(--ed-line)'; });
1225
1230
  tile.addEventListener('dragstart', this.core.startDrag({ kind: 'asset', assetId: a.id }));
1226
1231
  tile.addEventListener('dragend', () => { this.core.drag = null; this.core.setState({ drop: null, rowDrop: null }); });
1232
+ // Touch Safari never fires HTML5 drag events, so drag alone left
1233
+ // these tiles unusable on iOS. Click is the same fallback the
1234
+ // library modal's tiles already carry; a completed drag ends in
1235
+ // `dragend`, not `click`, so the two do not both fire.
1236
+ tile.addEventListener('click', () => this.core.useAsset(a));
1227
1237
  tile.appendChild(elS('div', `width: 100%; height: 64px; background-image: url("${a.url}"); background-size: cover; background-position: center; background-color: var(--ed-work);`, { role: 'img', 'aria-label': a.name }));
1228
1238
  tile.appendChild(elS('div', 'font-family: ui-monospace, monospace; font-size: 9px; color: var(--ed-muted); padding: 4px 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;', { text: a.name }));
1229
1239
  grid.appendChild(tile);
@@ -1,6 +1,7 @@
1
1
  import { icon, brandIcon, socialKey, SOCIAL_BRAND, contrastInk } from '../core/icons.js';
2
2
  import { pad } from '../core/layout-style.js';
3
3
  import { parseItems, cellsOf } from '../core/parse.js';
4
+ import { linkHref } from '../core/sanitize.js';
4
5
 
5
6
  function el(tag, style, attrs) {
6
7
  const node = document.createElement(tag);
@@ -77,12 +78,17 @@ export function blockBody(b, theme, live, ctx) {
77
78
  }
78
79
  case 'image': {
79
80
  const wrap = el('div', { padding: pad(p), textAlign: p.align, fontSize: '0' }, attr);
80
- if (p.href) {
81
+ const imgHref = linkHref(p.href);
82
+ if (imgHref) {
81
83
  // The % width must live on the anchor, not the img: a percentage on a
82
84
  // child of a shrink-to-fit inline-block resolves against the image's
83
85
  // own intrinsic size (i.e. not at all), which rendered every linked
84
86
  // logo/icon at full intrinsic width no matter what `width` said.
85
- const a = el('a', { display: 'inline-block', width: p.width + '%' }, { href: p.href });
87
+ const a = el('a', { display: 'inline-block', width: p.width + '%' }, { href: imgHref });
88
+ // Same guard as every other anchor the canvas draws: without it a
89
+ // click on a linked logo navigates the host application away from the
90
+ // editor, taking the uncommitted document with it.
91
+ a.addEventListener('click', (e) => e.preventDefault());
86
92
  a.appendChild(el('img', { width: '100%', borderRadius: p.radius + 'px', display: 'block', border: '0' }, { src: p.src, alt: p.alt }));
87
93
  wrap.appendChild(a);
88
94
  } else {
@@ -100,7 +106,7 @@ export function blockBody(b, theme, live, ctx) {
100
106
  // email pattern; the color falls back to the label color so a bare
101
107
  // "outline thickness" bump looks right without a second step.
102
108
  border: p.borderW ? p.borderW + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.borderColor || p.color) : '0',
103
- }, { href: p.href, ...editableAttrs(live), text: p.label });
109
+ }, { href: linkHref(p.href), ...editableAttrs(live), text: p.label });
104
110
  a.addEventListener('click', (e) => e.preventDefault());
105
111
  // Button editing is deliberately plain: no focus-tracked `editing` state, no
106
112
  // paste handling, no floating RTE toolbar -- only its label commits on blur.
@@ -139,7 +145,7 @@ export function blockBody(b, theme, live, ctx) {
139
145
  borderRadius: shape === 'circle' ? '50%' : shape === 'square' ? Math.round(box * 0.28) + 'px' : '0',
140
146
  color: iconColor, background: badge ? source : 'transparent',
141
147
  border: shape === 'outline' ? '1px solid ' + source : '0',
142
- }, { href: it.href, title: it.label });
148
+ }, { href: linkHref(it.href), title: it.label });
143
149
  a.addEventListener('click', (e) => e.preventDefault());
144
150
  let provided = null;
145
151
  if (ctx && ctx.iconProvider) { try { provided = ctx.iconProvider(key, { label: it.label, size: p.size, color: iconColor }); } catch { provided = null; } }
@@ -154,7 +160,7 @@ export function blockBody(b, theme, live, ctx) {
154
160
  }
155
161
  case 'video': {
156
162
  const wrap = el('div', { padding: '4px 0', textAlign: 'center' }, attr);
157
- const a = el('a', { display: 'block', position: 'relative' }, { href: p.href });
163
+ const a = el('a', { display: 'block', position: 'relative' }, { href: linkHref(p.href) });
158
164
  a.addEventListener('click', (e) => e.preventDefault());
159
165
  a.appendChild(el('img', { width: '100%', display: 'block', border: '0' }, { src: p.src, alt: p.caption }));
160
166
  const badge = el('span', { position: 'absolute', inset: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' });
@@ -197,7 +203,7 @@ export function blockBody(b, theme, live, ctx) {
197
203
  case 'menu': {
198
204
  const wrap = el('div', { textAlign: p.align, padding: '10px 0' }, attr);
199
205
  parseItems(p.items).forEach((it) => {
200
- const a = el('a', { color: p.color, fontSize: p.size + 'px', fontFamily: p.fontFamily || t.font, textDecoration: 'none', margin: '0 ' + p.gap / 2 + 'px', letterSpacing: '0.12em', textTransform: 'uppercase' }, { href: it.href, text: it.label });
206
+ const a = el('a', { color: p.color, fontSize: p.size + 'px', fontFamily: p.fontFamily || t.font, textDecoration: 'none', margin: '0 ' + p.gap / 2 + 'px', letterSpacing: '0.12em', textTransform: 'uppercase' }, { href: linkHref(it.href), text: it.label });
201
207
  a.addEventListener('click', (e) => e.preventDefault());
202
208
  wrap.appendChild(a);
203
209
  });
@@ -231,7 +231,11 @@ export function renderDoc(core, live) {
231
231
  showLine(rowDropTracker, rowLines[index]);
232
232
  });
233
233
  page.addEventListener('dragleave', (e) => { if (e.target === root || e.target === page) hideActive(rowDropTracker); });
234
- page.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e); });
234
+ // Measured against `root`, exactly as the line above it was: the slots are
235
+ // the sheet's children, not the page's, and deriving the index a second
236
+ // time from the event's own target is what used to send every dropped
237
+ // section to index 0 while the line sat where the pointer actually was.
238
+ page.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e, core.indexFromPoint(root, e.clientY)); });
235
239
  }
236
240
 
237
241
  d.rows.forEach((r, ri) => {
@@ -1,5 +1,6 @@
1
1
  import { icon, brandIcon, socialKey, SOCIAL_BRAND, contrastInk } from '../core/icons.js';
2
2
  import { parseItems } from '../core/parse.js';
3
+ import { linkHref } from '../core/sanitize.js';
3
4
 
4
5
  function el(tag, style, attrs) {
5
6
  const node = document.createElement(tag);
@@ -226,7 +227,14 @@ function renderRichLinks(f) {
226
227
  const urlCommit = typeCommit((v) => change(i, (a) => a.setAttribute('href', v)));
227
228
  url.addEventListener('input', (e) => urlCommit.call(e.target.value));
228
229
  url.addEventListener('focus', () => { url.style.borderColor = 'var(--ed-accent)'; url.style.outline = 'none'; });
229
- url.addEventListener('blur', () => { url.style.borderColor = 'var(--ed-line)'; urlCommit.flush(); });
230
+ url.addEventListener('blur', () => {
231
+ url.style.borderColor = 'var(--ed-line)';
232
+ // Scheme normalization happens on the way out, never per keystroke: a
233
+ // half-typed "h" rewritten to "https://h" under the caret is unusable.
234
+ url.value = linkHref(url.value);
235
+ urlCommit.call(url.value);
236
+ urlCommit.flush();
237
+ });
230
238
 
231
239
  const targetLabel = el('label', { display: 'flex', alignItems: 'center', gap: '6px', marginTop: '7px', width: 'fit-content', color: 'var(--ed-muted)', cursor: 'pointer', fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '500' });
232
240
  const target = el('input', { accentColor: 'var(--ed-accent)', cursor: 'pointer' }, { type: 'checkbox', 'data-focus-key': `f${f.key}-link-target-${i}` });
@@ -124,9 +124,20 @@ export const captureTemplatePng = async function (core, mountInto, scale = 2) {
124
124
  img.onerror = () => reject(new Error('SVG rasterization failed'));
125
125
  img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
126
126
  });
127
+ // WebKit fires `load` on an SVG-as-image before it is reliably
128
+ // drawable, and drawing on that tick yields a blank or half-laid-out
129
+ // frame. Same `decode()` guard the inner images get above.
130
+ if (img.decode) await img.decode().catch(() => {});
131
+ // Safari caps total canvas area, and past the cap it hands back a
132
+ // *blank* canvas that still encodes successfully -- a silently empty
133
+ // PNG with nothing thrown to catch. Long newsletters reach it easily
134
+ // at 2x (a 680x4000 template is 10.9M device pixels). Scale the
135
+ // capture down to fit instead of returning an empty image.
136
+ const MAX_CANVAS_AREA = 16e6;
137
+ const fit = Math.min(scale, Math.sqrt(MAX_CANVAS_AREA / Math.max(1, w * h)));
127
138
  const canvas = document.createElement('canvas');
128
- canvas.width = Math.max(1, Math.round(w * scale));
129
- canvas.height = Math.max(1, Math.round(h * scale));
139
+ canvas.width = Math.max(1, Math.round(w * fit));
140
+ canvas.height = Math.max(1, Math.round(h * fit));
130
141
  const ctx = canvas.getContext('2d');
131
142
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
132
143
  return await new Promise((resolve, reject) => {
@@ -23,6 +23,10 @@ export const STYLE = `
23
23
  /* The accent as it must read on the email sheet, which is a white page in
24
24
  both themes -- so this family does NOT flip with the chrome (see the dark
25
25
  palette below, where it is deliberately not overridden). */
26
+ /* Logo ink. Deliberately literal rather than accent-derived: the host can
27
+ repaint the chrome through the brand attribute, and a logo that follows
28
+ it stops being the logo. Values are the official light artwork. */
29
+ --ed-logo-mark: #2D86C7; --ed-logo-text: #3C3939;
26
30
  --ed-accent-sheet: #0065b3; --ed-accent-sheet-strong: #004f8c; --ed-accent-sheet-ink: #ffffff;
27
31
  --ed-accent-sheet-line: rgba(0,101,179,0.5);
28
32
  --ed-grid: rgba(71,85,105,0.10);
@@ -43,6 +47,8 @@ export const STYLE = `
43
47
  --ed-panel-label: #e2e8f0; --ed-panel-value: #c0cad8; --ed-panel-meta: #8d9aae;
44
48
  --ed-accent: #58a8e3; --ed-accent-strong: #8fc4ec; --ed-accent-ink: #08111f; --ed-soft: rgba(88,168,227,0.13);
45
49
  --ed-accent-tint: #8fcdf2; --ed-glow: rgba(88,168,227,0.28); --ed-select: rgba(88,168,227,0.3);
50
+ /* The official dark-mode logo artwork: mark and wordmark both go light. */
51
+ --ed-logo-mark: #F2F2F2; --ed-logo-text: #EEEEEE;
46
52
  --ed-grid: rgba(148,163,184,0.08);
47
53
  --ed-danger: #f2777c; --ed-danger-soft: rgba(242,119,124,0.14);
48
54
  --ed-success: #4ade95; --ed-success-soft: rgba(74,222,149,0.14);
@@ -151,14 +157,9 @@ export const STYLE = `
151
157
  .mc-footer-link { cursor: pointer; transition: color 0.14s ease; }
152
158
  .mc-footer-link:hover { color: var(--ed-accent); text-decoration: underline; }
153
159
 
154
- /* Brand mark -- the small gradient envelope glyph in the header. */
155
- .mc-brand-mark {
156
- display: flex; align-items: center; justify-content: center; flex: none;
157
- width: 26px; height: 26px; border-radius: 8px;
158
- background: linear-gradient(145deg, var(--ed-accent-tint), var(--ed-accent) 55%, var(--ed-accent-strong));
159
- box-shadow: 0 5px 14px var(--ed-glow);
160
- color: #ffffff;
161
- }
160
+ /* The header logo. It is the official lockup artwork, so it gets no chip, no
161
+ gradient and no recolouring -- only a height. */
162
+ .mc-brand { display: flex; align-items: center; flex: none; }
162
163
 
163
164
  /*
164
165
  * Shared segmented-control shell -- one bordered/backed track that owns the
@@ -269,9 +270,7 @@ export const STYLE = `
269
270
  /* Opaque, no backdrop-filter: the header sits over the scrolling canvas, so a
270
271
  backdrop blur re-composites every scroll frame -- visible scroll jank. */
271
272
  #mc .mc-header { padding: 0 18px !important; background: var(--ed-panel) !important; }
272
- #mc .mc-brand-mark { width: 30px !important; height: 30px !important; border-radius: 9px; background: linear-gradient(145deg, var(--ed-accent-tint), var(--ed-accent) 56%, var(--ed-accent-strong)) !important; box-shadow: 0 7px 18px var(--ed-glow); }
273
- #mc .mc-brand-mark svg { width: 20px; height: 20px; display: block; color: #fff; }
274
- #mc .mc-brand-name { font-family: var(--ed-font) !important; font-size: 17px !important; letter-spacing: -.03em !important; }
273
+ #mc .mc-brand svg { height: 34px; width: auto; display: block; }
275
274
  #mc .mc-header button { font-family: var(--ed-font) !important; font-size: 10.5px !important; font-weight: 600; letter-spacing: .015em !important; text-transform: none !important; }
276
275
  #mc .mc-header > div { border-radius: 10px; }
277
276
  #mc .mc-segment { gap: 3px !important; padding: 3px !important; border: 0 !important; border-radius: 10px; background: var(--ed-panel-2) !important; }