@seliseblocks/mailcraft 0.2.14 → 0.2.16

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 (57) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/DOCS.md +5 -4
  3. package/README.md +2 -1
  4. package/README.md.txt +2 -1
  5. package/dist/mailcraft-editor.bundle.js +71 -71
  6. package/dist/mailcraft-editor.bundle.js.map +3 -3
  7. package/examples/templates/back-in-stock.html +1 -8
  8. package/examples/templates/cart-left-behind.html +1 -8
  9. package/examples/templates/meet-nova-launch.html +5 -0
  10. package/examples/templates/rate-your-headphones.html +1 -8
  11. package/examples/templates/the-sunday-brief.html +5 -0
  12. package/examples/templates/your-order-shipped.html +1 -0
  13. package/examples/vanilla.html +14 -24
  14. package/package.json +1 -1
  15. package/src/core/i18n/ar.js +2 -1
  16. package/src/core/i18n/bg.js +2 -1
  17. package/src/core/i18n/bn.js +2 -1
  18. package/src/core/i18n/ca.js +2 -1
  19. package/src/core/i18n/cs.js +2 -1
  20. package/src/core/i18n/da.js +2 -1
  21. package/src/core/i18n/de-CH.js +2 -1
  22. package/src/core/i18n/de.js +2 -1
  23. package/src/core/i18n/dz.js +2 -1
  24. package/src/core/i18n/el.js +2 -1
  25. package/src/core/i18n/en.js +2 -1
  26. package/src/core/i18n/es.js +2 -1
  27. package/src/core/i18n/et.js +2 -1
  28. package/src/core/i18n/fi.js +2 -1
  29. package/src/core/i18n/fr.js +2 -1
  30. package/src/core/i18n/hr.js +2 -1
  31. package/src/core/i18n/hu.js +2 -1
  32. package/src/core/i18n/index.js +83 -83
  33. package/src/core/i18n/it.js +2 -1
  34. package/src/core/i18n/lt.js +2 -1
  35. package/src/core/i18n/lv.js +2 -1
  36. package/src/core/i18n/nb.js +2 -1
  37. package/src/core/i18n/nl.js +2 -1
  38. package/src/core/i18n/pl.js +2 -1
  39. package/src/core/i18n/pt.js +2 -1
  40. package/src/core/i18n/ro.js +2 -1
  41. package/src/core/i18n/ru.js +2 -1
  42. package/src/core/i18n/sk.js +2 -1
  43. package/src/core/i18n/sl.js +2 -1
  44. package/src/core/i18n/sv.js +2 -1
  45. package/src/core/i18n/tr.js +2 -1
  46. package/src/core/i18n/uk.js +2 -1
  47. package/src/core/ids.js +1 -1
  48. package/src/core/layout-style.js +100 -100
  49. package/src/core/parse.js +10 -10
  50. package/src/core/placeholder.js +15 -15
  51. package/src/core/variables.js +11 -11
  52. package/src/mailcraft-editor.js +26 -10
  53. package/src/render/focus-preserve.js +158 -158
  54. package/src/render/screenshot.js +95 -7
  55. package/src/render/story.js +541 -415
  56. package/src/render/style.js +1 -1
  57. package/types/index.d.ts +22 -7
@@ -1,83 +1,83 @@
1
- import { EN as ENBase } from './en.js';
2
-
3
- /**
4
- * Builds a translator. `overrides` is whatever a host passes as `.messages`
5
- * on the element -- a host's own table, an imported locale, or both merged
6
- * via `defineMessages` below.
7
- *
8
- * Three deliberate properties:
9
- * 1. English always resolves. A locale is an overlay, never a replacement,
10
- * so a partial or missing translation shows English rather than a gap.
11
- * 2. Params interpolate `{name}`.
12
- * 3. A truly missing key (not in `overrides`, not in `EN`) renders as the
13
- * key itself, not an empty string -- a visible `toast.deleted` in the UI
14
- * is obviously wrong and names the exact key to add, where blank text
15
- * just looks like a broken build.
16
- */
17
- export function createTranslator(overrides) {
18
- const table = overrides || {};
19
- return function t(key, params) {
20
- const template = table[key] ?? ENBase[key] ?? key;
21
- if (!params) return template;
22
- return template.replace(/\{(\w+)\}/g, (whole, name) => (name in params ? String(params[name]) : whole));
23
- };
24
- }
25
-
26
- /** Merges a locale over a base, for a host assembling its own table -- the documented way to combine a shipped locale with a few product-specific overrides. */
27
- export function defineMessages(base, overrides) {
28
- return Object.assign({}, base, overrides);
29
- }
30
-
31
- /** Keys in `base` that `locale` does not translate. What a translator has left to do. */
32
- export function missingKeys(locale, base) {
33
- const source = base || ENBase;
34
- return Object.keys(source).filter((key) => locale[key] === undefined).sort();
35
- }
36
-
37
- /**
38
- * Every locale that ships, for a host building a language switcher.
39
- * Metadata only -- no message tables -- so listing the locales never pulls
40
- * every translation file into a consumer's bundle; a host deep-imports the
41
- * one it wants, e.g. `mailcraft-editor/src/core/i18n/bn.js`.
42
- */
43
- export const LOCALES = [
44
- { tag: 'en', name: 'English' },
45
- { tag: 'ar', name: 'Arabic', rtl: true },
46
- { tag: 'bn', name: 'Bangla' },
47
- { tag: 'dz', name: 'Dzongkha' },
48
- { tag: 'bg', name: 'Bulgarian' },
49
- { tag: 'ca', name: 'Catalan' },
50
- { tag: 'cs', name: 'Czech' },
51
- { tag: 'da', name: 'Danish' },
52
- { tag: 'de', name: 'German' },
53
- { tag: 'de-CH', name: 'Swiss German' },
54
- { tag: 'el', name: 'Greek' },
55
- { tag: 'es', name: 'Spanish' },
56
- { tag: 'et', name: 'Estonian' },
57
- { tag: 'fi', name: 'Finnish' },
58
- { tag: 'fr', name: 'French' },
59
- { tag: 'hr', name: 'Croatian' },
60
- { tag: 'hu', name: 'Hungarian' },
61
- { tag: 'it', name: 'Italian' },
62
- { tag: 'lt', name: 'Lithuanian' },
63
- { tag: 'lv', name: 'Latvian' },
64
- { tag: 'nb', name: 'Norwegian Bokmål' },
65
- { tag: 'nl', name: 'Dutch' },
66
- { tag: 'pl', name: 'Polish' },
67
- { tag: 'pt', name: 'Portuguese' },
68
- { tag: 'ro', name: 'Romanian' },
69
- { tag: 'ru', name: 'Russian' },
70
- { tag: 'sk', name: 'Slovak' },
71
- { tag: 'sl', name: 'Slovenian' },
72
- { tag: 'sv', name: 'Swedish' },
73
- { tag: 'tr', name: 'Turkish' },
74
- { tag: 'uk', name: 'Ukrainian' },
75
- ];
76
-
77
- /** `true` when the tag is written right to left. Metadata only -- `dir` is still what actually flips the layout. */
78
- export function isRtl(tag) {
79
- const entry = LOCALES.find((l) => l.tag === tag);
80
- return entry ? entry.rtl === true : false;
81
- }
82
-
83
- export { EN, MESSAGE_KEYS } from './en.js';
1
+ import { EN as ENBase } from './en.js';
2
+
3
+ /**
4
+ * Builds a translator. `overrides` is whatever a host passes as `.messages`
5
+ * on the element -- a host's own table, an imported locale, or both merged
6
+ * via `defineMessages` below.
7
+ *
8
+ * Three deliberate properties:
9
+ * 1. English always resolves. A locale is an overlay, never a replacement,
10
+ * so a partial or missing translation shows English rather than a gap.
11
+ * 2. Params interpolate `{name}`.
12
+ * 3. A truly missing key (not in `overrides`, not in `EN`) renders as the
13
+ * key itself, not an empty string -- a visible `toast.deleted` in the UI
14
+ * is obviously wrong and names the exact key to add, where blank text
15
+ * just looks like a broken build.
16
+ */
17
+ export function createTranslator(overrides) {
18
+ const table = overrides || {};
19
+ return function t(key, params) {
20
+ const template = table[key] ?? ENBase[key] ?? key;
21
+ if (!params) return template;
22
+ return template.replace(/\{(\w+)\}/g, (whole, name) => (name in params ? String(params[name]) : whole));
23
+ };
24
+ }
25
+
26
+ /** Merges a locale over a base, for a host assembling its own table -- the documented way to combine a shipped locale with a few product-specific overrides. */
27
+ export function defineMessages(base, overrides) {
28
+ return Object.assign({}, base, overrides);
29
+ }
30
+
31
+ /** Keys in `base` that `locale` does not translate. What a translator has left to do. */
32
+ export function missingKeys(locale, base) {
33
+ const source = base || ENBase;
34
+ return Object.keys(source).filter((key) => locale[key] === undefined).sort();
35
+ }
36
+
37
+ /**
38
+ * Every locale that ships, for a host building a language switcher.
39
+ * Metadata only -- no message tables -- so listing the locales never pulls
40
+ * every translation file into a consumer's bundle; a host deep-imports the
41
+ * one it wants, e.g. `mailcraft-editor/src/core/i18n/bn.js`.
42
+ */
43
+ export const LOCALES = [
44
+ { tag: 'en', name: 'English' },
45
+ { tag: 'ar', name: 'Arabic', rtl: true },
46
+ { tag: 'bn', name: 'Bangla' },
47
+ { tag: 'dz', name: 'Dzongkha' },
48
+ { tag: 'bg', name: 'Bulgarian' },
49
+ { tag: 'ca', name: 'Catalan' },
50
+ { tag: 'cs', name: 'Czech' },
51
+ { tag: 'da', name: 'Danish' },
52
+ { tag: 'de', name: 'German' },
53
+ { tag: 'de-CH', name: 'Swiss German' },
54
+ { tag: 'el', name: 'Greek' },
55
+ { tag: 'es', name: 'Spanish' },
56
+ { tag: 'et', name: 'Estonian' },
57
+ { tag: 'fi', name: 'Finnish' },
58
+ { tag: 'fr', name: 'French' },
59
+ { tag: 'hr', name: 'Croatian' },
60
+ { tag: 'hu', name: 'Hungarian' },
61
+ { tag: 'it', name: 'Italian' },
62
+ { tag: 'lt', name: 'Lithuanian' },
63
+ { tag: 'lv', name: 'Latvian' },
64
+ { tag: 'nb', name: 'Norwegian Bokmål' },
65
+ { tag: 'nl', name: 'Dutch' },
66
+ { tag: 'pl', name: 'Polish' },
67
+ { tag: 'pt', name: 'Portuguese' },
68
+ { tag: 'ro', name: 'Romanian' },
69
+ { tag: 'ru', name: 'Russian' },
70
+ { tag: 'sk', name: 'Slovak' },
71
+ { tag: 'sl', name: 'Slovenian' },
72
+ { tag: 'sv', name: 'Swedish' },
73
+ { tag: 'tr', name: 'Turkish' },
74
+ { tag: 'uk', name: 'Ukrainian' },
75
+ ];
76
+
77
+ /** `true` when the tag is written right to left. Metadata only -- `dir` is still what actually flips the layout. */
78
+ export function isRtl(tag) {
79
+ const entry = LOCALES.find((l) => l.tag === tag);
80
+ return entry ? entry.rtl === true : false;
81
+ }
82
+
83
+ export { EN, MESSAGE_KEYS } from './en.js';
@@ -181,7 +181,6 @@ export const IT = {
181
181
  'rte.highlightColor': 'Colore evidenziazione',
182
182
  'rte.removeHighlight': 'Rimuovi evidenziazione',
183
183
  'rte.clearFormatting': 'Cancella formattazione',
184
- 'action.downloadPng': 'Scarica PNG',
185
184
  'action.screenshot': 'Screenshot',
186
185
  'action.screenshotHint': 'Vedi l’intero modello come immagine e scaricalo',
187
186
  'toast.pngSaved': 'Screenshot scaricato',
@@ -198,6 +197,8 @@ export const IT = {
198
197
  'story.pause': 'Pausa',
199
198
  'story.replay': 'Riproduci di nuovo',
200
199
  'story.copy': 'Copia',
200
+ 'story.download': 'Scarica {fmt}',
201
+ 'story.format': 'Formato immagine — PNG è senza perdite, JPG e WebP sono file più piccoli',
201
202
  'story.meta': '{w}×{h} · schermata {i} di {n}',
202
203
  'footer.poweredBy': 'Con tecnologia SELISE Blocks © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const LT = {
181
181
  'rte.highlightColor': 'Paryškinimo spalva',
182
182
  'rte.removeHighlight': 'Pašalinti paryškinimą',
183
183
  'rte.clearFormatting': 'Išvalyti formatavimą',
184
- 'action.downloadPng': 'Atsisiųsti PNG',
185
184
  'action.screenshot': 'Ekrano kopija',
186
185
  'action.screenshotHint': 'Peržiūrėti visą šabloną kaip paveikslėlį ir jį atsisiųsti',
187
186
  'toast.pngSaved': 'Ekrano kopija atsisiųsta',
@@ -198,6 +197,8 @@ export const LT = {
198
197
  'story.pause': 'Pristabdyti',
199
198
  'story.replay': 'Paleisti iš naujo',
200
199
  'story.copy': 'Kopijuoti',
200
+ 'story.download': 'Atsisiųsti {fmt}',
201
+ 'story.format': 'Vaizdo formatas — PNG be nuostolių, JPG ir WebP failai mažesni',
201
202
  'story.meta': '{w}×{h} · ekranas {i} iš {n}',
202
203
  'footer.poweredBy': 'Veikia su SELISE Blocks © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const LV = {
181
181
  'rte.highlightColor': 'Izcēluma krāsa',
182
182
  'rte.removeHighlight': 'Noņemt izcēlumu',
183
183
  'rte.clearFormatting': 'Notīrīt formatējumu',
184
- 'action.downloadPng': 'Lejupielādēt PNG',
185
184
  'action.screenshot': 'Ekrānuzņēmums',
186
185
  'action.screenshotHint': 'Apskatīt visu veidni kā attēlu un lejupielādēt to',
187
186
  'toast.pngSaved': 'Ekrānuzņēmums lejupielādēts',
@@ -198,6 +197,8 @@ export const LV = {
198
197
  'story.pause': 'Pauzēt',
199
198
  'story.replay': 'Atskaņot vēlreiz',
200
199
  'story.copy': 'Kopēt',
200
+ 'story.download': 'Lejupielādēt {fmt}',
201
+ 'story.format': 'Attēla formāts — PNG ir bezzudumu, JPG un WebP faili ir mazāki',
201
202
  'story.meta': '{w}×{h} · ekrāns {i} no {n}',
202
203
  'footer.poweredBy': 'Darbina SELISE Blocks © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const NB = {
181
181
  'rte.highlightColor': 'Uthevingsfarge',
182
182
  'rte.removeHighlight': 'Fjern utheving',
183
183
  'rte.clearFormatting': 'Fjern formatering',
184
- 'action.downloadPng': 'Last ned PNG',
185
184
  'action.screenshot': 'Skjermbilde',
186
185
  'action.screenshotHint': 'Se hele malen som et bilde, og last det ned',
187
186
  'toast.pngSaved': 'Skjermbilde lastet ned',
@@ -198,6 +197,8 @@ export const NB = {
198
197
  'story.pause': 'Pause',
199
198
  'story.replay': 'Spill av igjen',
200
199
  'story.copy': 'Kopier',
200
+ 'story.download': 'Last ned {fmt}',
201
+ 'story.format': 'Bildeformat — PNG er tapsfritt, JPG og WebP er mindre filer',
201
202
  'story.meta': '{w}×{h} · skjerm {i} av {n}',
202
203
  'footer.poweredBy': 'Drevet av SELISE Blocks © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const NL = {
181
181
  'rte.highlightColor': 'Markeerkleur',
182
182
  'rte.removeHighlight': 'Markering verwijderen',
183
183
  'rte.clearFormatting': 'Opmaak wissen',
184
- 'action.downloadPng': 'PNG downloaden',
185
184
  'action.screenshot': 'Schermafbeelding',
186
185
  'action.screenshotHint': 'Bekijk de hele template als afbeelding en download die',
187
186
  'toast.pngSaved': 'Schermafbeelding gedownload',
@@ -198,6 +197,8 @@ export const NL = {
198
197
  'story.pause': 'Pauzeren',
199
198
  'story.replay': 'Opnieuw afspelen',
200
199
  'story.copy': 'Kopiëren',
200
+ 'story.download': '{fmt} downloaden',
201
+ 'story.format': 'Afbeeldingsformaat — PNG is verliesvrij, JPG en WebP zijn kleinere bestanden',
201
202
  'story.meta': '{w}×{h} · scherm {i} van {n}',
202
203
  'footer.poweredBy': 'Mogelijk gemaakt door SELISE Blocks © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const PL = {
181
181
  'rte.highlightColor': 'Kolor wyróżnienia',
182
182
  'rte.removeHighlight': 'Usuń wyróżnienie',
183
183
  'rte.clearFormatting': 'Wyczyść formatowanie',
184
- 'action.downloadPng': 'Pobierz PNG',
185
184
  'action.screenshot': 'Zrzut ekranu',
186
185
  'action.screenshotHint': 'Zobacz cały szablon jako obraz i pobierz go',
187
186
  'toast.pngSaved': 'Zrzut ekranu pobrany',
@@ -198,6 +197,8 @@ export const PL = {
198
197
  'story.pause': 'Wstrzymaj',
199
198
  'story.replay': 'Odtwórz ponownie',
200
199
  'story.copy': 'Kopiuj',
200
+ 'story.download': 'Pobierz {fmt}',
201
+ 'story.format': 'Format obrazu — PNG jest bezstratny, JPG i WebP to mniejsze pliki',
201
202
  'story.meta': '{w}×{h} · ekran {i} z {n}',
202
203
  'footer.poweredBy': 'Napędzane przez SELISE Blocks © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const PT = {
181
181
  'rte.highlightColor': 'Cor de destaque',
182
182
  'rte.removeHighlight': 'Remover destaque',
183
183
  'rte.clearFormatting': 'Limpar formatação',
184
- 'action.downloadPng': 'Transferir PNG',
185
184
  'action.screenshot': 'Captura de ecrã',
186
185
  'action.screenshotHint': 'Ver todo o modelo como imagem e transferi-lo',
187
186
  'toast.pngSaved': 'Captura transferida',
@@ -198,6 +197,8 @@ export const PT = {
198
197
  'story.pause': 'Pausa',
199
198
  'story.replay': 'Reproduzir de novo',
200
199
  'story.copy': 'Copiar',
200
+ 'story.download': 'Transferir {fmt}',
201
+ 'story.format': 'Formato da imagem — PNG é sem perdas, JPG e WebP são ficheiros mais pequenos',
201
202
  'story.meta': '{w}×{h} · ecrã {i} de {n}',
202
203
  'footer.poweredBy': 'Com tecnologia SELISE Blocks © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const RO = {
181
181
  'rte.highlightColor': 'Culoarea evidențierii',
182
182
  'rte.removeHighlight': 'Elimină evidențierea',
183
183
  'rte.clearFormatting': 'Șterge formatarea',
184
- 'action.downloadPng': 'Descarcă PNG',
185
184
  'action.screenshot': 'Captură de ecran',
186
185
  'action.screenshotHint': 'Vezi tot șablonul ca imagine, apoi descarc-o',
187
186
  'toast.pngSaved': 'Captura a fost descărcată',
@@ -198,6 +197,8 @@ export const RO = {
198
197
  'story.pause': 'Pauză',
199
198
  'story.replay': 'Redă din nou',
200
199
  'story.copy': 'Copiază',
200
+ 'story.download': 'Descarcă {fmt}',
201
+ 'story.format': 'Formatul imaginii — PNG este fără pierderi, JPG și WebP sunt fișiere mai mici',
201
202
  'story.meta': '{w}×{h} · ecranul {i} din {n}',
202
203
  'footer.poweredBy': 'Susținut de SELISE Blocks © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const RU = {
181
181
  'rte.highlightColor': 'Цвет выделения',
182
182
  'rte.removeHighlight': 'Убрать выделение',
183
183
  'rte.clearFormatting': 'Очистить форматирование',
184
- 'action.downloadPng': 'Скачать PNG',
185
184
  'action.screenshot': 'Снимок',
186
185
  'action.screenshotHint': 'Посмотреть весь шаблон как изображение и скачать его',
187
186
  'toast.pngSaved': 'Снимок скачан',
@@ -198,6 +197,8 @@ export const RU = {
198
197
  'story.pause': 'Пауза',
199
198
  'story.replay': 'Ещё раз',
200
199
  'story.copy': 'Копировать',
200
+ 'story.download': 'Скачать {fmt}',
201
+ 'story.format': 'Формат изображения — PNG без потерь, JPG и WebP — файлы меньше',
201
202
  'story.meta': '{w}×{h} · экран {i} из {n}',
202
203
  'footer.poweredBy': 'Работает на SELISE Blocks © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const SK = {
181
181
  'rte.highlightColor': 'Farba zvýraznenia',
182
182
  'rte.removeHighlight': 'Odstrániť zvýraznenie',
183
183
  'rte.clearFormatting': 'Zrušiť formátovanie',
184
- 'action.downloadPng': 'Stiahnuť PNG',
185
184
  'action.screenshot': 'Snímka',
186
185
  'action.screenshotHint': 'Zobraziť celú šablónu ako obrázok a stiahnuť ju',
187
186
  'toast.pngSaved': 'Snímka stiahnutá',
@@ -198,6 +197,8 @@ export const SK = {
198
197
  'story.pause': 'Pozastaviť',
199
198
  'story.replay': 'Prehrať znova',
200
199
  'story.copy': 'Kopírovať',
200
+ 'story.download': 'Stiahnuť {fmt}',
201
+ 'story.format': 'Formát obrázka — PNG je bezstratový, JPG a WebP sú menšie súbory',
201
202
  'story.meta': '{w}×{h} · obrazovka {i} z {n}',
202
203
  'footer.poweredBy': 'Beží na SELISE Blocks © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const SL = {
181
181
  'rte.highlightColor': 'Barva poudarka',
182
182
  'rte.removeHighlight': 'Odstrani poudarek',
183
183
  'rte.clearFormatting': 'Počisti oblikovanje',
184
- 'action.downloadPng': 'Prenesi PNG',
185
184
  'action.screenshot': 'Posnetek zaslona',
186
185
  'action.screenshotHint': 'Poglej celotno predlogo kot sliko in jo prenesi',
187
186
  'toast.pngSaved': 'Posnetek zaslona prenesen',
@@ -198,6 +197,8 @@ export const SL = {
198
197
  'story.pause': 'Premor',
199
198
  'story.replay': 'Predvajaj znova',
200
199
  'story.copy': 'Kopiraj',
200
+ 'story.download': 'Prenesi {fmt}',
201
+ 'story.format': 'Oblika slike — PNG je brez izgub, JPG in WebP sta manjši datoteki',
201
202
  'story.meta': '{w}×{h} · zaslon {i} od {n}',
202
203
  'footer.poweredBy': 'Poganja SELISE Blocks © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const SV = {
181
181
  'rte.highlightColor': 'Överstrykningsfärg',
182
182
  'rte.removeHighlight': 'Ta bort överstrykning',
183
183
  'rte.clearFormatting': 'Rensa formatering',
184
- 'action.downloadPng': 'Ladda ner PNG',
185
184
  'action.screenshot': 'Skärmbild',
186
185
  'action.screenshotHint': 'Se hela mallen som en bild och ladda ner den',
187
186
  'toast.pngSaved': 'Skärmbild nedladdad',
@@ -198,6 +197,8 @@ export const SV = {
198
197
  'story.pause': 'Pausa',
199
198
  'story.replay': 'Spela upp igen',
200
199
  'story.copy': 'Kopiera',
200
+ 'story.download': 'Ladda ner {fmt}',
201
+ 'story.format': 'Bildformat — PNG är förlustfritt, JPG och WebP är mindre filer',
201
202
  'story.meta': '{w}×{h} · skärm {i} av {n}',
202
203
  'footer.poweredBy': 'Drivs av SELISE Blocks © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const TR = {
181
181
  'rte.highlightColor': 'Vurgu rengi',
182
182
  'rte.removeHighlight': 'Vurguyu kaldır',
183
183
  'rte.clearFormatting': 'Biçimlendirmeyi temizle',
184
- 'action.downloadPng': 'PNG indir',
185
184
  'action.screenshot': 'Ekran görüntüsü',
186
185
  'action.screenshotHint': 'Şablonun tamamını görsel olarak görün, sonra indirin',
187
186
  'toast.pngSaved': 'Ekran görüntüsü indirildi',
@@ -198,6 +197,8 @@ export const TR = {
198
197
  'story.pause': 'Duraklat',
199
198
  'story.replay': 'Yeniden oynat',
200
199
  'story.copy': 'Kopyala',
200
+ 'story.download': '{fmt} indir',
201
+ 'story.format': 'Görsel biçimi — PNG kayıpsızdır, JPG ve WebP daha küçük dosyalardır',
201
202
  'story.meta': '{w}×{h} · ekran {i}/{n}',
202
203
  'footer.poweredBy': 'SELISE Blocks tarafından desteklenir © 2026',
203
204
  };
@@ -181,7 +181,6 @@ export const UK = {
181
181
  'rte.highlightColor': 'Колір виділення',
182
182
  'rte.removeHighlight': 'Прибрати виділення',
183
183
  'rte.clearFormatting': 'Очистити форматування',
184
- 'action.downloadPng': 'Завантажити PNG',
185
184
  'action.screenshot': 'Знімок екрана',
186
185
  'action.screenshotHint': 'Переглянути весь шаблон як зображення та завантажити його',
187
186
  'toast.pngSaved': 'Знімок завантажено',
@@ -198,6 +197,8 @@ export const UK = {
198
197
  'story.pause': 'Пауза',
199
198
  'story.replay': 'Відтворити ще раз',
200
199
  'story.copy': 'Копіювати',
200
+ 'story.download': 'Завантажити {fmt}',
201
+ 'story.format': 'Формат зображення — PNG без втрат, JPG і WebP — менші файли',
201
202
  'story.meta': '{w}×{h} · екран {i} з {n}',
202
203
  'footer.poweredBy': 'Працює на SELISE Blocks © 2026',
203
204
  };
package/src/core/ids.js CHANGED
@@ -1 +1 @@
1
- export const uid = () => Math.random().toString(36).slice(2, 9);
1
+ export const uid = () => Math.random().toString(36).slice(2, 9);
@@ -1,46 +1,46 @@
1
- /**
2
- * Pure style-computation functions shared by the live renderer and the export
3
- * builder, ported verbatim. Objects use camelCase keys so they can be applied
4
- * directly via `Object.assign(el.style, obj)`.
5
- */
6
-
7
- import { cssUrl } from './sanitize.js';
8
- export function pad(p) {
9
- return (p.py || 0) + 'px ' + (p.px || 0) + 'px';
10
- }
11
-
12
- export function boxStyle(p) {
13
- const on = (key) => p[key] !== false;
14
- const side = (key) => (p.bBorder && on(key) ? p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5') : '0');
15
- return {
16
- background: p.bBg || 'transparent',
17
- borderTop: side('bTop'), borderRight: side('bRight'), borderBottom: side('bBottom'), borderLeft: side('bLeft'),
18
- borderRadius: (p.bRadius || 0) + 'px',
19
- padding: (p.bPad || 0) + 'px',
20
- };
21
- }
22
-
23
- export function boxCss(p) {
24
- const bits = [];
25
- if (p.bBg) bits.push('background:' + p.bBg);
26
- if (p.bBorder) {
27
- const value = p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5');
28
- const sides = { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
29
- if (sides.top && sides.right && sides.bottom && sides.left) bits.push('border:' + value);
30
- else Object.keys(sides).filter((key) => sides[key]).forEach((key) => bits.push('border-' + key + ':' + value));
31
- }
32
- if (p.bRadius) bits.push('border-radius:' + p.bRadius + 'px');
33
- if (p.bPad) bits.push('padding:' + p.bPad + 'px');
34
- return bits.length ? bits.join(';') + ';' : 'margin:0';
35
- }
36
-
37
- /** A row's effective padding as a four-value CSS shorthand. `pt/pb/pl/pr` are optional per-side overrides (set by the inspector's "Per-side padding" split, or by the importer for asymmetric source padding); wherever a side is absent it follows the linked `py`/`px` pair, so documents that never split keep behaving exactly as before. */
1
+ /**
2
+ * Pure style-computation functions shared by the live renderer and the export
3
+ * builder, ported verbatim. Objects use camelCase keys so they can be applied
4
+ * directly via `Object.assign(el.style, obj)`.
5
+ */
6
+
7
+ import { cssUrl } from './sanitize.js';
8
+ export function pad(p) {
9
+ return (p.py || 0) + 'px ' + (p.px || 0) + 'px';
10
+ }
11
+
12
+ export function boxStyle(p) {
13
+ const on = (key) => p[key] !== false;
14
+ const side = (key) => (p.bBorder && on(key) ? p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5') : '0');
15
+ return {
16
+ background: p.bBg || 'transparent',
17
+ borderTop: side('bTop'), borderRight: side('bRight'), borderBottom: side('bBottom'), borderLeft: side('bLeft'),
18
+ borderRadius: (p.bRadius || 0) + 'px',
19
+ padding: (p.bPad || 0) + 'px',
20
+ };
21
+ }
22
+
23
+ export function boxCss(p) {
24
+ const bits = [];
25
+ if (p.bBg) bits.push('background:' + p.bBg);
26
+ if (p.bBorder) {
27
+ const value = p.bBorder + 'px ' + (p.bStyle || 'solid') + ' ' + (p.bLine || '#e2e2e5');
28
+ const sides = { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
29
+ if (sides.top && sides.right && sides.bottom && sides.left) bits.push('border:' + value);
30
+ else Object.keys(sides).filter((key) => sides[key]).forEach((key) => bits.push('border-' + key + ':' + value));
31
+ }
32
+ if (p.bRadius) bits.push('border-radius:' + p.bRadius + 'px');
33
+ if (p.bPad) bits.push('padding:' + p.bPad + 'px');
34
+ return bits.length ? bits.join(';') + ';' : 'margin:0';
35
+ }
36
+
37
+ /** A row's effective padding as a four-value CSS shorthand. `pt/pb/pl/pr` are optional per-side overrides (set by the inspector's "Per-side padding" split, or by the importer for asymmetric source padding); wherever a side is absent it follows the linked `py`/`px` pair, so documents that never split keep behaving exactly as before. */
38
38
  export function rowPad(p) {
39
- const t = p.pt ?? p.py ?? 0;
40
- const b = p.pb ?? p.py ?? 0;
41
- const l = p.pl ?? p.px ?? 0;
42
- const r = p.pr ?? p.px ?? 0;
43
- return t + 'px ' + r + 'px ' + b + 'px ' + l + 'px';
39
+ const t = p.pt ?? p.py ?? 0;
40
+ const b = p.pb ?? p.py ?? 0;
41
+ const l = p.pl ?? p.px ?? 0;
42
+ const r = p.pr ?? p.px ?? 0;
43
+ return t + 'px ' + r + 'px ' + b + 'px ' + l + 'px';
44
44
  }
45
45
 
46
46
  /** A row's outside spacing in CSS clockwise order, with the old vertical `my` value as a saved-document fallback. Empty horizontal margins can remain `auto` in the live canvas so the advanced max-width control stays centered. */
@@ -52,64 +52,64 @@ export function rowMargin(p, centerEmpty) {
52
52
  const emptyHorizontal = centerEmpty && !r && !l;
53
53
  return t + 'px ' + (emptyHorizontal ? 'auto' : r + 'px') + ' ' + b + 'px ' + (emptyHorizontal ? 'auto' : l + 'px');
54
54
  }
55
-
56
- /** Which sides a row's border draws on. Sides default ON (`!== false`) so documents saved before per-side toggles existed keep their full border. */
57
- export function rowBorderSides(p) {
58
- return { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
59
- }
60
-
61
- /** The row border as an inline-CSS string (export path). Empty when the width is 0 or every side is toggled off. */
62
- export function rowBorderCss(p) {
63
- if (!p.border) return '';
64
- const s = rowBorderSides(p);
65
- const value = p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5');
66
- if (s.top && s.right && s.bottom && s.left) return 'border:' + value + ';';
67
- return ['top', 'right', 'bottom', 'left'].filter((k) => s[k]).map((k) => 'border-' + k + ':' + value + ';').join('');
68
- }
69
-
70
- export function rowBg(p) {
71
- const ov = (p.overlay || 0) / 100;
72
- const layers = [];
73
- if (p.bgImage && ov) layers.push('linear-gradient(rgba(20,22,24,' + ov + '),rgba(20,22,24,' + ov + '))');
74
- if (p.bgImage) layers.push('url("' + cssUrl(p.bgImage) + '")');
75
- const s = rowBorderSides(p);
76
- const side = (on) => (p.border && on ? p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5') : '0');
77
- return {
78
- backgroundColor: p.bg || 'transparent',
79
- backgroundImage: layers.length ? layers.join(',') : 'none',
80
- backgroundSize: p.bgSize || 'cover',
81
- backgroundPosition: p.bgPos || 'center',
82
- backgroundRepeat: p.bgRepeat || 'no-repeat',
83
- borderTop: side(s.top),
84
- borderRight: side(s.right),
85
- borderBottom: side(s.bottom),
86
- borderLeft: side(s.left),
87
- borderRadius: (p.radius || 0) + 'px',
88
- // A raw CSS string, not a boolean: imports keep the source's exact
89
- // shadow; the inspector toggle writes/clears a standard one.
90
- boxShadow: p.shadow || 'none',
91
- maxWidth: (p.maxW || 100) + '%',
55
+
56
+ /** Which sides a row's border draws on. Sides default ON (`!== false`) so documents saved before per-side toggles existed keep their full border. */
57
+ export function rowBorderSides(p) {
58
+ return { top: p.bTop !== false, right: p.bRight !== false, bottom: p.bBottom !== false, left: p.bLeft !== false };
59
+ }
60
+
61
+ /** The row border as an inline-CSS string (export path). Empty when the width is 0 or every side is toggled off. */
62
+ export function rowBorderCss(p) {
63
+ if (!p.border) return '';
64
+ const s = rowBorderSides(p);
65
+ const value = p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5');
66
+ if (s.top && s.right && s.bottom && s.left) return 'border:' + value + ';';
67
+ return ['top', 'right', 'bottom', 'left'].filter((k) => s[k]).map((k) => 'border-' + k + ':' + value + ';').join('');
68
+ }
69
+
70
+ export function rowBg(p) {
71
+ const ov = (p.overlay || 0) / 100;
72
+ const layers = [];
73
+ if (p.bgImage && ov) layers.push('linear-gradient(rgba(20,22,24,' + ov + '),rgba(20,22,24,' + ov + '))');
74
+ if (p.bgImage) layers.push('url("' + cssUrl(p.bgImage) + '")');
75
+ const s = rowBorderSides(p);
76
+ const side = (on) => (p.border && on ? p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.lineColor || '#e2e2e5') : '0');
77
+ return {
78
+ backgroundColor: p.bg || 'transparent',
79
+ backgroundImage: layers.length ? layers.join(',') : 'none',
80
+ backgroundSize: p.bgSize || 'cover',
81
+ backgroundPosition: p.bgPos || 'center',
82
+ backgroundRepeat: p.bgRepeat || 'no-repeat',
83
+ borderTop: side(s.top),
84
+ borderRight: side(s.right),
85
+ borderBottom: side(s.bottom),
86
+ borderLeft: side(s.left),
87
+ borderRadius: (p.radius || 0) + 'px',
88
+ // A raw CSS string, not a boolean: imports keep the source's exact
89
+ // shadow; the inspector toggle writes/clears a standard one.
90
+ boxShadow: p.shadow || 'none',
91
+ maxWidth: (p.maxW || 100) + '%',
92
92
  margin: rowMargin(p, true),
93
93
  };
94
94
  }
95
-
96
- export function colsWrap(p) {
97
- const gap = p.gap || 0;
98
- if (p.layout === 'grid') return { display: 'grid', gridTemplateColumns: 'repeat(' + (p.gridCols || 2) + ', minmax(0, 1fr))', gap: gap + 'px' };
99
- if (p.layout === 'flex') {
100
- return {
101
- display: 'flex', flexDirection: p.flexDir || 'row', justifyContent: p.justify || 'flex-start',
102
- alignItems: p.alignItems || 'stretch', flexWrap: p.wrap ? 'wrap' : 'nowrap', gap: gap + 'px',
103
- };
104
- }
105
- return { display: 'flex', alignItems: 'stretch', margin: '0 ' + (-gap / 2) + 'px' };
106
- }
107
-
108
- export function colStyle(p, c) {
109
- if (p.layout === 'grid') return { minWidth: 0 };
110
- if (p.layout === 'flex') return { flex: (p.flexDir || 'row').indexOf('column') === 0 ? '0 0 auto' : c.span + ' 1 auto', minWidth: 0 };
111
- return {
112
- flex: c.span + ' 1 0%', minWidth: 0, padding: '0 ' + (p.gap || 0) / 2 + 'px',
113
- alignSelf: p.valign === 'middle' ? 'center' : (p.valign === 'bottom' ? 'flex-end' : 'flex-start'),
114
- };
115
- }
95
+
96
+ export function colsWrap(p) {
97
+ const gap = p.gap || 0;
98
+ if (p.layout === 'grid') return { display: 'grid', gridTemplateColumns: 'repeat(' + (p.gridCols || 2) + ', minmax(0, 1fr))', gap: gap + 'px' };
99
+ if (p.layout === 'flex') {
100
+ return {
101
+ display: 'flex', flexDirection: p.flexDir || 'row', justifyContent: p.justify || 'flex-start',
102
+ alignItems: p.alignItems || 'stretch', flexWrap: p.wrap ? 'wrap' : 'nowrap', gap: gap + 'px',
103
+ };
104
+ }
105
+ return { display: 'flex', alignItems: 'stretch', margin: '0 ' + (-gap / 2) + 'px' };
106
+ }
107
+
108
+ export function colStyle(p, c) {
109
+ if (p.layout === 'grid') return { minWidth: 0 };
110
+ if (p.layout === 'flex') return { flex: (p.flexDir || 'row').indexOf('column') === 0 ? '0 0 auto' : c.span + ' 1 auto', minWidth: 0 };
111
+ return {
112
+ flex: c.span + ' 1 0%', minWidth: 0, padding: '0 ' + (p.gap || 0) / 2 + 'px',
113
+ alignSelf: p.valign === 'middle' ? 'center' : (p.valign === 'bottom' ? 'flex-end' : 'flex-start'),
114
+ };
115
+ }