@seliseblocks/mailcraft 0.2.8 → 0.2.9
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/CHANGELOG.md +99 -0
- package/DOCS.md +27 -27
- package/README.md +1 -1
- package/README.md.txt +1 -1
- package/dist/mailcraft-editor.bundle.js +58 -56
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +57 -5
- package/src/core/i18n/index.js +83 -83
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +152 -11
- package/src/core/layout-style.js +100 -100
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/sanitize.js +61 -0
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +41 -2
- package/src/render/fields.js +28 -2
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +6 -0
- package/src/render/story.js +415 -415
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seliseblocks/mailcraft",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "Framework-agnostic drag-and-drop email template editor, packaged as a zero-dependency Web Component.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "SELISE Digital Platforms",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"examples",
|
|
50
50
|
"README.md",
|
|
51
51
|
"DOCS.md",
|
|
52
|
+
"CHANGELOG.md",
|
|
52
53
|
"LICENSE"
|
|
53
54
|
],
|
|
54
55
|
"engines": {
|
package/src/core/css-cascade.js
CHANGED
|
@@ -1,117 +1,117 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Import-time CSS cascade: folds a parsed document's `<style>` rules into the
|
|
3
|
-
* elements' inline styles, so the importer's classifiers (which read inline
|
|
4
|
-
* styles only) see class-styled templates -- Mailchimp exports, hand-written
|
|
5
|
-
* emails, framework output that was never inlined -- the same way a mail
|
|
6
|
-
* client would.
|
|
7
|
-
*
|
|
8
|
-
* Deliberately a subset of a real cascade, matched to what email CSS uses:
|
|
9
|
-
* - `@media` blocks (and every other at-rule) are dropped whole: responsive
|
|
10
|
-
* overrides can't be represented in the imported model, desktop values win.
|
|
11
|
-
* A side benefit: base-rule `.desktop_hide { display:none }` still applies,
|
|
12
|
-
* so mobile-only duplicate content is correctly dropped by the importer's
|
|
13
|
-
* hidden-element skip.
|
|
14
|
-
* - Selectors containing `:` are skipped -- pseudo-classes/-elements are
|
|
15
|
-
* interactive or generated state with no place in a static import.
|
|
16
|
-
* - Specificity is the classic ids/classes/tags count; equal specificity
|
|
17
|
-
* resolves by source order; `!important` wins over everything including
|
|
18
|
-
* inline styles, which otherwise always win (matching browser behavior for
|
|
19
|
-
* the combinations that matter here).
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
function stripComments(css) {
|
|
23
|
-
return String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Removes every at-rule: block-less ones (`@import ...;`) to the semicolon, block ones (`@media { ... }`) across balanced braces. */
|
|
27
|
-
function stripAtRules(css) {
|
|
28
|
-
let out = '';
|
|
29
|
-
let i = 0;
|
|
30
|
-
while (i < css.length) {
|
|
31
|
-
if (css[i] === '@') {
|
|
32
|
-
const semi = css.indexOf(';', i);
|
|
33
|
-
const brace = css.indexOf('{', i);
|
|
34
|
-
if (brace === -1 || (semi !== -1 && semi < brace)) {
|
|
35
|
-
i = semi === -1 ? css.length : semi + 1;
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
let depth = 0;
|
|
39
|
-
let j = brace;
|
|
40
|
-
for (; j < css.length; j++) {
|
|
41
|
-
if (css[j] === '{') depth++;
|
|
42
|
-
else if (css[j] === '}') { depth--; if (!depth) break; }
|
|
43
|
-
}
|
|
44
|
-
i = j + 1;
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
out += css[i];
|
|
48
|
-
i++;
|
|
49
|
-
}
|
|
50
|
-
return out;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function specificity(sel) {
|
|
54
|
-
const ids = (sel.match(/#[\w-]+/g) || []).length;
|
|
55
|
-
const classes = (sel.match(/\.[\w-]+|\[[^\]]*\]/g) || []).length;
|
|
56
|
-
const tags = (sel.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
|
|
57
|
-
return ids * 100 + classes * 10 + tags;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function inlineStylesheets(doc) {
|
|
61
|
-
const sheets = Array.from(doc.querySelectorAll('style'));
|
|
62
|
-
if (!sheets.length) return;
|
|
63
|
-
const css = stripAtRules(stripComments(sheets.map((s) => s.textContent || '').join('\n')));
|
|
64
|
-
|
|
65
|
-
const rules = [];
|
|
66
|
-
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
|
|
67
|
-
let m;
|
|
68
|
-
let order = 0;
|
|
69
|
-
while ((m = ruleRe.exec(css))) {
|
|
70
|
-
const decls = [];
|
|
71
|
-
m[2].split(';').forEach((d) => {
|
|
72
|
-
const at = d.indexOf(':');
|
|
73
|
-
if (at < 0) return;
|
|
74
|
-
const prop = d.slice(0, at).trim().toLowerCase();
|
|
75
|
-
let value = d.slice(at + 1).trim();
|
|
76
|
-
if (!prop || !value || prop.indexOf('--') === 0 || prop.indexOf('mso-') === 0) return;
|
|
77
|
-
const important = /!important\s*$/i.test(value);
|
|
78
|
-
if (important) value = value.replace(/!important\s*$/i, '').trim();
|
|
79
|
-
if (value) decls.push({ prop, value, important });
|
|
80
|
-
});
|
|
81
|
-
if (!decls.length) continue;
|
|
82
|
-
m[1].split(',').forEach((raw) => {
|
|
83
|
-
const sel = raw.trim();
|
|
84
|
-
if (!sel || sel === '*' || sel.indexOf(':') > -1) return;
|
|
85
|
-
rules.push({ sel, decls, spec: specificity(sel), order: order++ });
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
if (!rules.length) return;
|
|
89
|
-
|
|
90
|
-
// Ascending: later (more specific / later-in-source) rules overwrite
|
|
91
|
-
// earlier winners per property below.
|
|
92
|
-
rules.sort((a, b) => a.spec - b.spec || a.order - b.order);
|
|
93
|
-
|
|
94
|
-
const winners = new Map(); // element -> Map(prop -> {value, important})
|
|
95
|
-
rules.forEach((rule) => {
|
|
96
|
-
let matched;
|
|
97
|
-
try { matched = doc.querySelectorAll(rule.sel); } catch { return; }
|
|
98
|
-
matched.forEach((el) => {
|
|
99
|
-
if (el !== doc.body && !doc.body.contains(el)) return;
|
|
100
|
-
let bucket = winners.get(el);
|
|
101
|
-
if (!bucket) { bucket = new Map(); winners.set(el, bucket); }
|
|
102
|
-
rule.decls.forEach((d) => {
|
|
103
|
-
const cur = bucket.get(d.prop);
|
|
104
|
-
if (cur && cur.important && !d.important) return;
|
|
105
|
-
bucket.set(d.prop, d);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
winners.forEach((bucket, el) => {
|
|
111
|
-
if (!el.style) return;
|
|
112
|
-
bucket.forEach((d, prop) => {
|
|
113
|
-
if (!d.important && el.style.getPropertyValue(prop)) return; // inline wins
|
|
114
|
-
try { el.style.setProperty(prop, d.value); } catch { /* unparseable value -- skip */ }
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Import-time CSS cascade: folds a parsed document's `<style>` rules into the
|
|
3
|
+
* elements' inline styles, so the importer's classifiers (which read inline
|
|
4
|
+
* styles only) see class-styled templates -- Mailchimp exports, hand-written
|
|
5
|
+
* emails, framework output that was never inlined -- the same way a mail
|
|
6
|
+
* client would.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately a subset of a real cascade, matched to what email CSS uses:
|
|
9
|
+
* - `@media` blocks (and every other at-rule) are dropped whole: responsive
|
|
10
|
+
* overrides can't be represented in the imported model, desktop values win.
|
|
11
|
+
* A side benefit: base-rule `.desktop_hide { display:none }` still applies,
|
|
12
|
+
* so mobile-only duplicate content is correctly dropped by the importer's
|
|
13
|
+
* hidden-element skip.
|
|
14
|
+
* - Selectors containing `:` are skipped -- pseudo-classes/-elements are
|
|
15
|
+
* interactive or generated state with no place in a static import.
|
|
16
|
+
* - Specificity is the classic ids/classes/tags count; equal specificity
|
|
17
|
+
* resolves by source order; `!important` wins over everything including
|
|
18
|
+
* inline styles, which otherwise always win (matching browser behavior for
|
|
19
|
+
* the combinations that matter here).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function stripComments(css) {
|
|
23
|
+
return String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Removes every at-rule: block-less ones (`@import ...;`) to the semicolon, block ones (`@media { ... }`) across balanced braces. */
|
|
27
|
+
function stripAtRules(css) {
|
|
28
|
+
let out = '';
|
|
29
|
+
let i = 0;
|
|
30
|
+
while (i < css.length) {
|
|
31
|
+
if (css[i] === '@') {
|
|
32
|
+
const semi = css.indexOf(';', i);
|
|
33
|
+
const brace = css.indexOf('{', i);
|
|
34
|
+
if (brace === -1 || (semi !== -1 && semi < brace)) {
|
|
35
|
+
i = semi === -1 ? css.length : semi + 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
let depth = 0;
|
|
39
|
+
let j = brace;
|
|
40
|
+
for (; j < css.length; j++) {
|
|
41
|
+
if (css[j] === '{') depth++;
|
|
42
|
+
else if (css[j] === '}') { depth--; if (!depth) break; }
|
|
43
|
+
}
|
|
44
|
+
i = j + 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
out += css[i];
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function specificity(sel) {
|
|
54
|
+
const ids = (sel.match(/#[\w-]+/g) || []).length;
|
|
55
|
+
const classes = (sel.match(/\.[\w-]+|\[[^\]]*\]/g) || []).length;
|
|
56
|
+
const tags = (sel.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
|
|
57
|
+
return ids * 100 + classes * 10 + tags;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function inlineStylesheets(doc) {
|
|
61
|
+
const sheets = Array.from(doc.querySelectorAll('style'));
|
|
62
|
+
if (!sheets.length) return;
|
|
63
|
+
const css = stripAtRules(stripComments(sheets.map((s) => s.textContent || '').join('\n')));
|
|
64
|
+
|
|
65
|
+
const rules = [];
|
|
66
|
+
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
|
|
67
|
+
let m;
|
|
68
|
+
let order = 0;
|
|
69
|
+
while ((m = ruleRe.exec(css))) {
|
|
70
|
+
const decls = [];
|
|
71
|
+
m[2].split(';').forEach((d) => {
|
|
72
|
+
const at = d.indexOf(':');
|
|
73
|
+
if (at < 0) return;
|
|
74
|
+
const prop = d.slice(0, at).trim().toLowerCase();
|
|
75
|
+
let value = d.slice(at + 1).trim();
|
|
76
|
+
if (!prop || !value || prop.indexOf('--') === 0 || prop.indexOf('mso-') === 0) return;
|
|
77
|
+
const important = /!important\s*$/i.test(value);
|
|
78
|
+
if (important) value = value.replace(/!important\s*$/i, '').trim();
|
|
79
|
+
if (value) decls.push({ prop, value, important });
|
|
80
|
+
});
|
|
81
|
+
if (!decls.length) continue;
|
|
82
|
+
m[1].split(',').forEach((raw) => {
|
|
83
|
+
const sel = raw.trim();
|
|
84
|
+
if (!sel || sel === '*' || sel.indexOf(':') > -1) return;
|
|
85
|
+
rules.push({ sel, decls, spec: specificity(sel), order: order++ });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (!rules.length) return;
|
|
89
|
+
|
|
90
|
+
// Ascending: later (more specific / later-in-source) rules overwrite
|
|
91
|
+
// earlier winners per property below.
|
|
92
|
+
rules.sort((a, b) => a.spec - b.spec || a.order - b.order);
|
|
93
|
+
|
|
94
|
+
const winners = new Map(); // element -> Map(prop -> {value, important})
|
|
95
|
+
rules.forEach((rule) => {
|
|
96
|
+
let matched;
|
|
97
|
+
try { matched = doc.querySelectorAll(rule.sel); } catch { return; }
|
|
98
|
+
matched.forEach((el) => {
|
|
99
|
+
if (el !== doc.body && !doc.body.contains(el)) return;
|
|
100
|
+
let bucket = winners.get(el);
|
|
101
|
+
if (!bucket) { bucket = new Map(); winners.set(el, bucket); }
|
|
102
|
+
rule.decls.forEach((d) => {
|
|
103
|
+
const cur = bucket.get(d.prop);
|
|
104
|
+
if (cur && cur.important && !d.important) return;
|
|
105
|
+
bucket.set(d.prop, d);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
winners.forEach((bucket, el) => {
|
|
111
|
+
if (!el.style) return;
|
|
112
|
+
bucket.forEach((d, prop) => {
|
|
113
|
+
if (!d.important && el.style.getPropertyValue(prop)) return; // inline wins
|
|
114
|
+
try { el.style.setProperty(prop, d.value); } catch { /* unparseable value -- skip */ }
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
package/src/core/editor-core.js
CHANGED
|
@@ -3,7 +3,7 @@ import { mk, blk, mkRow, GROUPS, LAYOUTS, migrateDoc, normalizeDoc, blankDoc } f
|
|
|
3
3
|
import { binder, decorate, group } from './binder.js';
|
|
4
4
|
import { ALL_FOLDER_ID, normalizeAsset, resolveLimits, providerProblems } from './storage.js';
|
|
5
5
|
import { validateFiles, limitsProblem } from './storage-limits.js';
|
|
6
|
-
import { migrateTokens, cleanHtml, escHtml, linkHref } from './sanitize.js';
|
|
6
|
+
import { migrateTokens, cleanHtml, escHtml, linkHref, scaleInlineSizes, stripInlineStyle } from './sanitize.js';
|
|
7
7
|
import { buildHtml as buildHtmlFn } from './export.js';
|
|
8
8
|
import { htmlToDoc } from './import-html.js';
|
|
9
9
|
import { boxCss } from './layout-style.js';
|
|
@@ -35,6 +35,48 @@ const UPLOAD_CONCURRENCY = 3;
|
|
|
35
35
|
*/
|
|
36
36
|
const RETIRED_SEED = /^data:image\/svg\+xml;utf8,.*%3Cpattern%20id%3D%22s%22/;
|
|
37
37
|
const withoutRetiredSeeds = (assets) => (Array.isArray(assets) ? assets.filter((a) => !RETIRED_SEED.test(String((a && a.url) || ''))) : []);
|
|
38
|
+
/**
|
|
39
|
+
* The one span Text size moves in, per block type -- shared by the inspector
|
|
40
|
+
* range and the RTE's ± buttons. They used to disagree: the panel allowed
|
|
41
|
+
* 8–96 (text) and 12–120 (heading) while `size()` clamped to a private
|
|
42
|
+
* 10–64, so a single "Larger text" click on a 96px block *shrank* it to 64.
|
|
43
|
+
*/
|
|
44
|
+
const SIZE_SPAN = { text: [8, 96], heading: [12, 120] };
|
|
45
|
+
/** The blocks whose content is rich HTML in props, and the prop it lives in. Only these need the descendant rewrites below. */
|
|
46
|
+
const RICH_HTML_PROP = { text: 'html', list: 'items' };
|
|
47
|
+
/** Inspector key -> the inline CSS property whose descendant copies mask it (see `stripInlineStyle`). */
|
|
48
|
+
const RICH_OWNED_STYLE = { color: 'color', lh: 'line-height', weight: 'font-weight', align: 'text-align' };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Keeps a rich block's descendants in agreement with the block-level control
|
|
52
|
+
* being moved. Imported HTML deliberately keeps per-element inline typography
|
|
53
|
+
* (sanitize.js `cleanImportHtml`), and a descendant's own declaration beats
|
|
54
|
+
* inheritance from the wrapper -- so without this, Text size / color / Line
|
|
55
|
+
* spacing / weight / Align silently did nothing on any imported (or
|
|
56
|
+
* AI-drafted) block. Size *scales* descendants so a 15/26/15px hierarchy
|
|
57
|
+
* survives as 45/78/45 instead of flattening; the others strip the descendant
|
|
58
|
+
* property, exactly the semantics the Font control already shipped with.
|
|
59
|
+
* Runs inside the same `commit` as the prop write: one undo step, and a
|
|
60
|
+
* document nobody touches is never rewritten.
|
|
61
|
+
*/
|
|
62
|
+
function syncRichContent(block, key, val) {
|
|
63
|
+
const prop = RICH_HTML_PROP[block.type];
|
|
64
|
+
if (!prop) return;
|
|
65
|
+
// list items are one fragment per line; the rewrite must not run across the joins.
|
|
66
|
+
const perLine = (src, fn) => (prop === 'items' ? String(src).split('\n').map(fn).join('\n') : fn(String(src)));
|
|
67
|
+
const src = block.props[prop];
|
|
68
|
+
if (src == null || src === '') return;
|
|
69
|
+
if (key === 'size') {
|
|
70
|
+
const cur = Number(block.props.size);
|
|
71
|
+
const next = Number(val);
|
|
72
|
+
// An unknown base (imports that carried no readable size) can't be scaled
|
|
73
|
+
// against -- the first explicit size just establishes the base.
|
|
74
|
+
if (cur > 0 && next > 0 && next !== cur) block.props[prop] = perLine(src, (s) => scaleInlineSizes(s, next / cur));
|
|
75
|
+
} else if (RICH_OWNED_STYLE[key]) {
|
|
76
|
+
block.props[prop] = perLine(src, (s) => stripInlineStyle(s, RICH_OWNED_STYLE[key]));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
38
80
|
const BORDER_STYLES = [
|
|
39
81
|
{ value: 'solid', label: 'Solid' },
|
|
40
82
|
{ value: 'dashed', label: 'Dashed' },
|
|
@@ -524,7 +566,8 @@ export class EditorCore {
|
|
|
524
566
|
if (b.props[key] !== val) this.setProp(b.id, key, val);
|
|
525
567
|
}
|
|
526
568
|
const cur = Number(b.props.size) || 16;
|
|
527
|
-
|
|
569
|
+
const [lo, hi] = SIZE_SPAN[b.type] || [10, 64];
|
|
570
|
+
this.setProp(b.id, 'size', Math.max(lo, Math.min(hi, cur + delta)));
|
|
528
571
|
}
|
|
529
572
|
|
|
530
573
|
hasCountdown() { return this.state.doc.rows.some((r) => r.cols.some((c) => c.blocks.some((b) => b.type === 'countdown'))); }
|
|
@@ -620,7 +663,10 @@ export class EditorCore {
|
|
|
620
663
|
this.commit((doc) => {
|
|
621
664
|
const f = this.find(doc, id);
|
|
622
665
|
const target = f.block ? f.block.props : (f.row ? f.row.props : null);
|
|
623
|
-
if (target)
|
|
666
|
+
if (!target) return;
|
|
667
|
+
// Before the write: the size rewrite needs the outgoing value as its base.
|
|
668
|
+
if (f.block) syncRichContent(f.block, key, val);
|
|
669
|
+
target[key] = val;
|
|
624
670
|
});
|
|
625
671
|
}
|
|
626
672
|
|
|
@@ -1190,6 +1236,12 @@ export class EditorCore {
|
|
|
1190
1236
|
this.flash(this.t('toast.htmlCopied'));
|
|
1191
1237
|
};
|
|
1192
1238
|
|
|
1239
|
+
/** Code view's source pane onto the clipboard, unsaved edits included: `codeSrc` is what the pane shows, which may be ahead of what Apply has pushed to the canvas. */
|
|
1240
|
+
copyCode = () => {
|
|
1241
|
+
if (navigator.clipboard) navigator.clipboard.writeText(this.state.codeSrc).catch(() => {});
|
|
1242
|
+
this.flash(this.t('toast.htmlCopied'));
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1193
1245
|
downloadExport = () => {
|
|
1194
1246
|
const blob = new Blob([this.state.exportCode], { type: 'text/html' });
|
|
1195
1247
|
const a = document.createElement('a');
|
|
@@ -1321,7 +1373,7 @@ export class EditorCore {
|
|
|
1321
1373
|
/<a(?:\s|>)/i.test(String(b.props.html || ''))
|
|
1322
1374
|
? [{ kind: 'richLinks', label: 'Links', html: b.props.html || '', onChange: (v) => this.setProp(b.id, 'html', v) }]
|
|
1323
1375
|
: [],
|
|
1324
|
-
[B.area('Text', 'html'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size',
|
|
1376
|
+
[B.area('Text', 'html'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', ...SIZE_SPAN.text, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '500', label: 'Medium' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
|
|
1325
1377
|
case 'image': return decorate(base.concat([B.btn('Choose from library', () => this.openLibrary({ id: b.id, key: 'src' })), B.text('Alt text', 'alt', 'Describe the image'), B.text('Link URL', 'href', 'https://'), B.range('Width', 'width', 5, 100, 1, '%'), B.seg('Align', 'align', ALIGN), B.range('Rounded corners', 'radius', 0, 200, 1, 'px')], padF));
|
|
1326
1378
|
case 'button': return decorate(base.concat([B.text('Label', 'label'), B.text('Link URL', 'href', 'https://'), B.color('Button color', 'bg'), B.color('Text color', 'color'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Rounded corners', 'radius', 0, 60, 1, 'px'), B.range('Outline thickness', 'borderW', 0, 6, 1, 'px')].concat(b.props.borderW ? [B.sel('Outline style', 'borderStyle', BORDER_STYLES), B.color('Outline color', 'borderColor')] : []).concat([B.range('Button height', 'py', 0, 60, 1, 'px'), B.range('Button width', 'px', 0, 120, 2, 'px'), B.seg('Align', 'align', ALIGN), B.tog('Full width', 'full')])));
|
|
1327
1379
|
case 'divider': return decorate(base.concat([B.range('Thickness', 'thickness', 1, 20, 1, 'px'), B.sel('Line style', 'lineStyle', BORDER_STYLES), B.range('Width', 'width', 5, 100, 5, '%'), B.color('Color', 'color'), B.range('Space above & below', 'py', 0, 160, 2, 'px')]));
|
|
@@ -1355,7 +1407,7 @@ export class EditorCore {
|
|
|
1355
1407
|
case 'html': return decorate(base.concat([B.area('Raw HTML', 'code')]));
|
|
1356
1408
|
case 'countdown': return decorate(base.concat([B.text('Ends at (YYYY-MM-DDTHH:MM)', 'target'), B.text('Label', 'label'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.color('Color', 'color')]));
|
|
1357
1409
|
case 'menu': return decorate(base.concat([B.area('Items — one per line as Label|URL', 'items'), B.seg('Align', 'align', ALIGN), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 32, 1, 'px'), B.range('Space between items', 'gap', 0, 80, 2, 'px'), B.color('Text color', 'color')]));
|
|
1358
|
-
case 'heading': return decorate(base.concat([B.area('Text', 'text'), B.sel('Heading level', 'level', [{ value: 'h1', label: 'H1 — largest' }, { value: 'h2', label: 'H2' }, { value: 'h3', label: 'H3' }, { value: 'h4', label: 'H4 — smallest' }]), B.seg('Font style', 'font', [{ value: 'condensed', label: 'Condensed' }, { value: 'body', label: 'Body' }]), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size',
|
|
1410
|
+
case 'heading': return decorate(base.concat([B.area('Text', 'text'), B.sel('Heading level', 'level', [{ value: 'h1', label: 'H1 — largest' }, { value: 'h2', label: 'H2' }, { value: 'h3', label: 'H3' }, { value: 'h4', label: 'H4 — smallest' }]), B.seg('Font style', 'font', [{ value: 'condensed', label: 'Condensed' }, { value: 'body', label: 'Body' }]), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', ...SIZE_SPAN.heading, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 2.2, 0.02, ''), B.seg('Align', 'align', ALIGN), B.sel('Text weight', 'weight', [{ value: '400', label: 'Regular' }, { value: '600', label: 'Semibold' }, { value: '700', label: 'Bold' }]), B.color('Text color', 'color')], padF));
|
|
1359
1411
|
case 'list': return decorate(base.concat([B.area('Items — one per line', 'items'), B.tog('Numbered', 'ordered'), B.sel('Font', 'fontFamily', this.fontOptions(true)), B.range('Text size', 'size', 8, 48, 1, 'px'), B.range('Line spacing', 'lh', 0.8, 3, 0.05, ''), B.range('Space between items', 'gap', 0, 60, 1, 'px'), B.color('Text color', 'color')], padF));
|
|
1360
1412
|
case 'table': return decorate(base.concat([
|
|
1361
1413
|
// Custom kind (render/fields.js `renderTableGrid`): a real grid of
|
package/src/core/i18n/index.js
CHANGED
|
@@ -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';
|
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);
|