@seliseblocks/mailcraft 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DOCS.md +557 -0
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/mailcraft-editor.bundle.js +519 -0
- package/dist/mailcraft-editor.bundle.js.map +7 -0
- package/examples/templates/activate-your-account.html +54 -0
- package/examples/templates/back-in-stock.html +63 -0
- package/examples/templates/cart-left-behind.html +74 -0
- package/examples/templates/community-giveaway.html +81 -0
- package/examples/templates/frontend-futures-invite.html +69 -0
- package/examples/templates/give-25-get-25.html +69 -0
- package/examples/templates/invoice-paid.html +77 -0
- package/examples/templates/meet-nova-launch.html +69 -0
- package/examples/templates/mega-weekend-sale.html +57 -0
- package/examples/templates/order-confirmed.html +80 -0
- package/examples/templates/rate-your-headphones.html +71 -0
- package/examples/templates/reset-your-password.html +54 -0
- package/examples/templates/thankyou-promo-code.html +68 -0
- package/examples/templates/the-sunday-brief.html +47 -0
- package/examples/templates/welcome-to-your-workspace.html +62 -0
- package/examples/templates/your-order-shipped.html +74 -0
- package/examples/templates/your-password-was-changed.html +58 -0
- package/examples/templates/your-signin-code.html +58 -0
- package/examples/vanilla.html +1574 -0
- package/package.json +66 -0
- package/src/core/accent.js +194 -0
- package/src/core/assets.js +15 -0
- package/src/core/binder.js +119 -0
- package/src/core/blocks.js +256 -0
- package/src/core/css-cascade.js +117 -0
- package/src/core/editor-core.js +1492 -0
- package/src/core/export.js +58 -0
- package/src/core/footer.js +73 -0
- package/src/core/i18n/ar.js +177 -0
- package/src/core/i18n/bg.js +152 -0
- package/src/core/i18n/bn.js +176 -0
- package/src/core/i18n/ca.js +152 -0
- package/src/core/i18n/cs.js +152 -0
- package/src/core/i18n/da.js +152 -0
- package/src/core/i18n/de-CH.js +152 -0
- package/src/core/i18n/de.js +152 -0
- package/src/core/i18n/dz.js +179 -0
- package/src/core/i18n/el.js +152 -0
- package/src/core/i18n/en.js +269 -0
- package/src/core/i18n/es.js +152 -0
- package/src/core/i18n/et.js +152 -0
- package/src/core/i18n/fi.js +152 -0
- package/src/core/i18n/fr.js +152 -0
- package/src/core/i18n/hr.js +152 -0
- package/src/core/i18n/hu.js +152 -0
- package/src/core/i18n/index.js +83 -0
- package/src/core/i18n/it.js +152 -0
- package/src/core/i18n/lt.js +152 -0
- package/src/core/i18n/lv.js +152 -0
- package/src/core/i18n/nb.js +152 -0
- package/src/core/i18n/nl.js +152 -0
- package/src/core/i18n/pl.js +152 -0
- package/src/core/i18n/pt.js +152 -0
- package/src/core/i18n/ro.js +152 -0
- package/src/core/i18n/ru.js +152 -0
- package/src/core/i18n/sk.js +152 -0
- package/src/core/i18n/sl.js +152 -0
- package/src/core/i18n/sv.js +152 -0
- package/src/core/i18n/tables.js +50 -0
- package/src/core/i18n/tr.js +152 -0
- package/src/core/i18n/uk.js +152 -0
- package/src/core/icons.js +235 -0
- package/src/core/ids.js +1 -0
- package/src/core/import-html.js +959 -0
- package/src/core/layout-style.js +115 -0
- package/src/core/parse.js +10 -0
- package/src/core/placeholder.js +15 -0
- package/src/core/sanitize.js +141 -0
- package/src/core/storage-limits.js +184 -0
- package/src/core/storage.js +85 -0
- package/src/core/theme.js +1 -0
- package/src/core/toolbar.js +67 -0
- package/src/core/variables.js +11 -0
- package/src/create-editor.js +109 -0
- package/src/index.js +9 -0
- package/src/mailcraft-editor.js +1862 -0
- package/src/render/block-body.js +295 -0
- package/src/render/canvas.js +284 -0
- package/src/render/fields.js +609 -0
- package/src/render/focus-preserve.js +158 -0
- package/src/render/rte.js +212 -0
- package/src/render/screenshot.js +132 -0
- package/src/render/story.js +415 -0
- package/src/render/style.js +452 -0
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
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';
|
|
44
|
+
}
|
|
45
|
+
|
|
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. */
|
|
47
|
+
export function rowMargin(p, centerEmpty) {
|
|
48
|
+
const t = p.mt ?? p.my ?? 0;
|
|
49
|
+
const r = p.mr ?? 0;
|
|
50
|
+
const b = p.mb ?? p.my ?? 0;
|
|
51
|
+
const l = p.ml ?? 0;
|
|
52
|
+
const emptyHorizontal = centerEmpty && !r && !l;
|
|
53
|
+
return t + 'px ' + (emptyHorizontal ? 'auto' : r + 'px') + ' ' + b + 'px ' + (emptyHorizontal ? 'auto' : l + 'px');
|
|
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) + '%',
|
|
92
|
+
margin: rowMargin(p, true),
|
|
93
|
+
};
|
|
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
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function parseItems(s) {
|
|
2
|
+
return String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
|
|
3
|
+
const i = l.indexOf('|');
|
|
4
|
+
return i < 0 ? { label: l, href: '#' } : { label: l.slice(0, i).trim(), href: l.slice(i + 1).trim() };
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function cellsOf(p) {
|
|
9
|
+
return String(p.data || '').split('\n').filter((l) => l.trim()).map((l) => l.split('|').map((c) => c.trim()));
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Data-URI placeholder image generator, ported verbatim from the original. */
|
|
2
|
+
function enc(s) {
|
|
3
|
+
return encodeURIComponent(s).replace(/\(/g, '%28').replace(/\)/g, '%29');
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function PH(label, w, ht) {
|
|
7
|
+
return 'data:image/svg+xml;utf8,' + enc(
|
|
8
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + ht + '">' +
|
|
9
|
+
'<defs><pattern id="s" width="9" height="9" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">' +
|
|
10
|
+
'<rect width="9" height="9" fill="#ececed"/><line x1="0" y1="0" x2="0" y2="9" stroke="#cfd3d8" stroke-width="3"/></pattern></defs>' +
|
|
11
|
+
'<rect width="100%" height="100%" fill="url(#s)"/>' +
|
|
12
|
+
'<rect x="0.5" y="0.5" width="' + (w - 1) + '" height="' + (ht - 1) + '" fill="none" stroke="#9aa2ab"/>' +
|
|
13
|
+
'<text x="50%" y="50%" dy="4" text-anchor="middle" font-family="ui-monospace,monospace" font-size="' + Math.max(11, Math.round(w / 40)) + '" fill="#5b6672">' + label + '</text></svg>',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** Scopes authored CSS to the email sheet so a raw block can never restyle the editor chrome. Ported verbatim: manual brace-matching, not the CSSOM, so arbitrary/partial CSS still scopes instead of failing to parse. */
|
|
2
|
+
export const scopeCss = (css, root) => {
|
|
3
|
+
const src = String(css || '');
|
|
4
|
+
let out = ''; let i = 0;
|
|
5
|
+
const prefix = (sel) => sel.split(',').map((s) => {
|
|
6
|
+
const t = s.trim();
|
|
7
|
+
if (!t) return '';
|
|
8
|
+
if (/^(from|to|\d+%)$/i.test(t)) return t;
|
|
9
|
+
if (t.startsWith('@')) return t;
|
|
10
|
+
if (/^(html|body|:root)\b/i.test(t)) return root + t.replace(/^(html|body|:root)/i, '');
|
|
11
|
+
return root + ' ' + t;
|
|
12
|
+
}).filter(Boolean).join(', ');
|
|
13
|
+
while (i < src.length) {
|
|
14
|
+
const brace = src.indexOf('{', i);
|
|
15
|
+
if (brace < 0) { out += src.slice(i); break; }
|
|
16
|
+
const head = src.slice(i, brace).trim();
|
|
17
|
+
let depth = 1; let j = brace + 1;
|
|
18
|
+
while (j < src.length && depth > 0) { if (src[j] === '{') depth++; else if (src[j] === '}') depth--; j++; }
|
|
19
|
+
const body = src.slice(brace + 1, j - 1);
|
|
20
|
+
if (head.startsWith('@')) {
|
|
21
|
+
out += head + '{' + (/^@(media|supports|layer|container)/i.test(head) ? scopeCss(body, root) : body) + '}';
|
|
22
|
+
} else {
|
|
23
|
+
out += prefix(head) + '{' + body + '}';
|
|
24
|
+
}
|
|
25
|
+
i = j;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const migrateTokens = (json) => String(json).replace(/\[\[\s*([\w.]+)\s*\]\]/g, '{' + '{ $1 }' + '}');
|
|
31
|
+
|
|
32
|
+
/** Paste sanitizer -- Word/Docs/Notion drop class soup, mso- properties and nested spans into the document; keep a small tag whitelist and drop attributes (href/target/rel on links survive). */
|
|
33
|
+
const PASTE_OK = { A: 1, B: 1, STRONG: 1, I: 1, EM: 1, U: 1, S: 1, STRIKE: 1, BR: 1, P: 1, UL: 1, OL: 1, LI: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1, BLOCKQUOTE: 1, CODE: 1, SUP: 1, SUB: 1 };
|
|
34
|
+
|
|
35
|
+
export const cleanHtml = (html) => {
|
|
36
|
+
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
|
|
37
|
+
doc.querySelectorAll('style,script,meta,link,title,head').forEach((n) => n.remove());
|
|
38
|
+
const walk = (node) => {
|
|
39
|
+
Array.from(node.children).forEach((el) => {
|
|
40
|
+
walk(el);
|
|
41
|
+
if (!PASTE_OK[el.tagName]) {
|
|
42
|
+
const parent = el.parentNode;
|
|
43
|
+
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
|
44
|
+
parent.removeChild(el);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
Array.from(el.attributes).forEach((at) => {
|
|
48
|
+
const keep = el.tagName === 'A' && ['href', 'target', 'rel'].indexOf(at.name) > -1;
|
|
49
|
+
if (!keep) el.removeAttribute(at.name);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
walk(doc.body);
|
|
54
|
+
return doc.body.innerHTML.replace(/<!--[\s\S]*?-->/g, '').replace(/\s{2,}/g, ' ').trim();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Import sanitizer. Pasting wants `cleanHtml`'s scorched-earth policy (Word/
|
|
59
|
+
* Docs class soup adds nothing), but an HTML *import* is the opposite case:
|
|
60
|
+
* a hand-built email's inline styles ARE the design. Running imports through
|
|
61
|
+
* the paste path collapsed every distinctly-styled paragraph -- a 26px/800
|
|
62
|
+
* headline, an italic footnote, a 12px footer -- into one uniform block in
|
|
63
|
+
* the theme's default face. This variant keeps the same structural whitelist
|
|
64
|
+
* (plus `SPAN`/`IMG`, which carry real content in emails) and preserves a
|
|
65
|
+
* whitelist of typographic/spacing style properties per element; everything
|
|
66
|
+
* else (mso-*, classes, ids, event handlers) is still stripped.
|
|
67
|
+
*/
|
|
68
|
+
const IMPORT_OK = Object.assign({ SPAN: 1, IMG: 1 }, PASTE_OK);
|
|
69
|
+
const IMPORT_STYLES = [
|
|
70
|
+
'font-size', 'font-weight', 'font-style', 'font-family', 'color', 'line-height',
|
|
71
|
+
'letter-spacing', 'text-align', 'text-decoration', 'text-transform',
|
|
72
|
+
'background-color', 'border-radius', 'padding', 'margin', 'word-break',
|
|
73
|
+
'width', 'max-width', 'height',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
export const cleanImportHtml = (html) => {
|
|
77
|
+
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
|
|
78
|
+
doc.querySelectorAll('style,script,meta,link,title,head').forEach((n) => n.remove());
|
|
79
|
+
const walk = (node) => {
|
|
80
|
+
Array.from(node.children).forEach((el) => {
|
|
81
|
+
walk(el);
|
|
82
|
+
if (!IMPORT_OK[el.tagName]) {
|
|
83
|
+
const parent = el.parentNode;
|
|
84
|
+
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
|
85
|
+
parent.removeChild(el);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const kept = [];
|
|
89
|
+
IMPORT_STYLES.forEach((p) => {
|
|
90
|
+
const v = el.style.getPropertyValue(p);
|
|
91
|
+
if (v) kept.push(p + ':' + v);
|
|
92
|
+
});
|
|
93
|
+
Array.from(el.attributes).forEach((at) => {
|
|
94
|
+
const keep = (el.tagName === 'A' && ['href', 'target', 'rel'].indexOf(at.name) > -1)
|
|
95
|
+
|| (el.tagName === 'IMG' && ['src', 'alt', 'width', 'height'].indexOf(at.name) > -1);
|
|
96
|
+
if (!keep) el.removeAttribute(at.name);
|
|
97
|
+
});
|
|
98
|
+
if (kept.length) el.setAttribute('style', kept.join(';'));
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
walk(doc.body);
|
|
102
|
+
return doc.body.innerHTML.replace(/<!--[\s\S]*?-->/g, '').replace(/\s{2,}/g, ' ').trim();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Makes an image URL safe to drop inside `url("...")`.
|
|
107
|
+
*
|
|
108
|
+
* These URLs are not ours: they come from a storage provider's backend, or from
|
|
109
|
+
* a user typing into the "Background image URL" field. They are then
|
|
110
|
+
* interpolated into CSS -- in the library tiles, in the canvas, and (worst) into
|
|
111
|
+
* a `style="..."` attribute in the exported email, where an unescaped quote ends
|
|
112
|
+
* the attribute and everything after it becomes markup in someone's campaign.
|
|
113
|
+
*
|
|
114
|
+
* Percent-encoding rather than backslash-escaping, because the same value has to
|
|
115
|
+
* survive two different contexts: `\"` is correct in CSS but is still a literal
|
|
116
|
+
* quote to an HTML attribute parser. `%22` is safe in both, and resolves
|
|
117
|
+
* identically when the browser fetches it.
|
|
118
|
+
*
|
|
119
|
+
* Schemes are allowlisted as well. A relative path has no scheme and is fine;
|
|
120
|
+
* anything exotic resolves to nothing rather than being handed to the parser.
|
|
121
|
+
*/
|
|
122
|
+
const CSS_URL_ESCAPE = { '"': '%22', "'": '%27', '(': '%28', ')': '%29', '\\': '%5C' };
|
|
123
|
+
|
|
124
|
+
export const cssUrl = (u) => {
|
|
125
|
+
const raw = String(u == null ? '' : u).trim();
|
|
126
|
+
if (!raw) return '';
|
|
127
|
+
const scheme = raw.match(/^([a-z][a-z0-9+.-]*):/i);
|
|
128
|
+
if (scheme && !/^(https?|blob)$/i.test(scheme[1]) && !/^data:image\//i.test(raw)) return '';
|
|
129
|
+
// An explicit table, not encodeURIComponent: that leaves ( ) ' untouched,
|
|
130
|
+
// and an unescaped paren closes url(...) just as surely as a quote does.
|
|
131
|
+
return raw.replace(/["'()\\]|\s/g, (c) => CSS_URL_ESCAPE[c] || '%20');
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const escHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
135
|
+
|
|
136
|
+
/** Minimal HTML syntax highlighter for the code view. */
|
|
137
|
+
export const hl = (src) => escHtml(src)
|
|
138
|
+
.replace(/([\w-]+)="([^&]*)"/g, '<span style="color:#a8763e">$1</span>=<span style="color:#4a8a6a">"$2"</span>')
|
|
139
|
+
.replace(/([\w-]+)="([^"]*)"/g, '<span style="color:#a8763e">$1</span>=<span style="color:#4a8a6a">"$2"</span>')
|
|
140
|
+
.replace(/(<\/?)([a-zA-Z][\w-]*)/g, '$1<span style="color:#5980a6;font-weight:600">$2</span>')
|
|
141
|
+
.replace(/(\{\{[^{}]*\}\})/g, '<span style="background:rgba(89,128,166,0.18);color:#2c455d;border-radius:2px">$1</span>');
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload validation -- mechanism only, no policy.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately ships no default ceilings. What an email template may carry
|
|
5
|
+
* depends on the sending platform (ESP attachment caps), the audience's
|
|
6
|
+
* clients (Outlook's Word engine won't render WebP; nothing renders AVIF) and
|
|
7
|
+
* the host's own product rules, so the numbers are the host's to set:
|
|
8
|
+
* `editor.storageLimits`, or `limits` on the provider. With a provider wired
|
|
9
|
+
* and no limits declared, uploads are refused rather than waved through.
|
|
10
|
+
*
|
|
11
|
+
* Checks run *before* the provider is called, so a rejected file never reaches
|
|
12
|
+
* the backend. That matters for any store where minting an upload URL also
|
|
13
|
+
* creates the file record: validating afterwards would leave an orphan behind
|
|
14
|
+
* for every rejection.
|
|
15
|
+
*
|
|
16
|
+
* Types are decided by sniffing the leading bytes, not by trusting `file.type`:
|
|
17
|
+
* the browser fills that in from the file extension, so renaming `payload.svg`
|
|
18
|
+
* to `photo.png` is enough to walk a script-bearing document past a MIME check
|
|
19
|
+
* and into the editor's own DOM, where the library preview renders it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { KB } from './assets.js';
|
|
23
|
+
|
|
24
|
+
const ascii = (b, at, s) => s.split('').every((c, i) => b[at + i] === c.charCodeAt(0));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The leading bytes of every raster format a mail client might plausibly be
|
|
28
|
+
* asked to show, plus the ones it can't -- knowing a file is AVIF is what lets
|
|
29
|
+
* the rejection say "AVIF" instead of "unsupported".
|
|
30
|
+
*/
|
|
31
|
+
function sniff(b) {
|
|
32
|
+
if (b.length < 12) return null;
|
|
33
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
|
|
34
|
+
if (b[0] === 0x89 && ascii(b, 1, 'PNG')) return 'image/png';
|
|
35
|
+
if (ascii(b, 0, 'GIF8')) return 'image/gif';
|
|
36
|
+
if (ascii(b, 0, 'RIFF') && ascii(b, 8, 'WEBP')) return 'image/webp';
|
|
37
|
+
if (ascii(b, 0, 'BM')) return 'image/bmp';
|
|
38
|
+
if ((ascii(b, 0, 'II') && b[2] === 0x2a && b[3] === 0) || (ascii(b, 0, 'MM') && b[2] === 0 && b[3] === 0x2a)) return 'image/tiff';
|
|
39
|
+
if (b[0] === 0 && b[1] === 0 && b[2] === 1 && b[3] === 0) return 'image/x-icon';
|
|
40
|
+
if (ascii(b, 4, 'ftyp')) {
|
|
41
|
+
const brand = String.fromCharCode(b[8], b[9], b[10], b[11]);
|
|
42
|
+
if (brand === 'avif' || brand === 'avis') return 'image/avif';
|
|
43
|
+
return 'image/heic';
|
|
44
|
+
}
|
|
45
|
+
// Markup: an SVG may open with a BOM, an XML declaration, a doctype or a
|
|
46
|
+
// comment before the root element, so the reliable tell is "text that starts
|
|
47
|
+
// with '<'", not a literal "<svg" at offset zero.
|
|
48
|
+
let i = 0;
|
|
49
|
+
if (b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf) i = 3;
|
|
50
|
+
while (i < b.length && (b[i] === 0x20 || b[i] === 0x09 || b[i] === 0x0a || b[i] === 0x0d)) i++;
|
|
51
|
+
if (b[i] === 0x3c) return 'image/svg+xml';
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** First bytes of a file. `Blob.arrayBuffer` where it exists, FileReader where it doesn't. */
|
|
56
|
+
function head(file) {
|
|
57
|
+
const slice = file.slice(0, 32);
|
|
58
|
+
if (typeof slice.arrayBuffer === 'function') {
|
|
59
|
+
return slice.arrayBuffer().then((buf) => new Uint8Array(buf)).catch(() => null);
|
|
60
|
+
}
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const fr = new FileReader();
|
|
63
|
+
fr.onload = () => resolve(new Uint8Array(fr.result));
|
|
64
|
+
fr.onerror = () => resolve(null);
|
|
65
|
+
fr.readAsArrayBuffer(slice);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Decoded pixel dimensions, or `null` when the browser can't decode it -- which is itself a reason to refuse the file. */
|
|
70
|
+
function probe(file) {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
const url = URL.createObjectURL(file);
|
|
73
|
+
const img = new Image();
|
|
74
|
+
const done = (v) => { URL.revokeObjectURL(url); resolve(v); };
|
|
75
|
+
img.onload = () => done({ w: img.naturalWidth || img.width, ht: img.naturalHeight || img.height });
|
|
76
|
+
img.onerror = () => done(null);
|
|
77
|
+
img.src = url;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Strips directory separators, control characters and anything a storage
|
|
83
|
+
* backend is likely to treat as path syntax, and caps the length. A name
|
|
84
|
+
* arrives from the user's disk and ends up in a URL.
|
|
85
|
+
*/
|
|
86
|
+
export function sanitizeName(name) {
|
|
87
|
+
const clean = String(name || 'file')
|
|
88
|
+
.replace(/[\\/]/g, '-')
|
|
89
|
+
.replace(/[<>:"|?*]/g, '')
|
|
90
|
+
.split('').filter((c) => c.charCodeAt(0) > 31).join('')
|
|
91
|
+
.trim()
|
|
92
|
+
.replace(/^\.+/, '') || 'file';
|
|
93
|
+
if (clean.length <= 120) return clean;
|
|
94
|
+
const dot = clean.lastIndexOf('.');
|
|
95
|
+
const ext = dot > 0 && clean.length - dot <= 8 ? clean.slice(dot) : '';
|
|
96
|
+
return clean.slice(0, 120 - ext.length) + ext;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The host has to have said what's allowed. Format and size are the two that matter; the rest are optional tightenings. */
|
|
100
|
+
export function limitsProblem(limits) {
|
|
101
|
+
if (!limits) return 'storage.errNoLimits';
|
|
102
|
+
if (!Array.isArray(limits.accept) || !limits.accept.length) return 'storage.errNoAccept';
|
|
103
|
+
if (!(Number(limits.maxBytes) > 0)) return 'storage.errNoMaxBytes';
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Splits a FileList into what may be uploaded and what may not.
|
|
109
|
+
*
|
|
110
|
+
* Rejections carry an i18n key and its params rather than a sentence, so the
|
|
111
|
+
* reason is translated by the same table as the rest of the chrome.
|
|
112
|
+
*
|
|
113
|
+
* @returns {Promise<{accepted: Array<{file: File, name: string, w: number, ht: number, size: number, type: string}>, rejected: Array<{name: string, key: string, params: Object}>}>}
|
|
114
|
+
*/
|
|
115
|
+
export async function validateFiles(list, limits) {
|
|
116
|
+
const files = Array.from(list || []);
|
|
117
|
+
const accepted = [];
|
|
118
|
+
const rejected = [];
|
|
119
|
+
if (!files.length) return { accepted, rejected };
|
|
120
|
+
|
|
121
|
+
const problem = limitsProblem(limits);
|
|
122
|
+
if (problem) return { accepted, rejected: files.map((f) => ({ name: f.name, key: problem, params: {} })) };
|
|
123
|
+
|
|
124
|
+
const accept = limits.accept.map((m) => String(m).toLowerCase());
|
|
125
|
+
const max = Number(limits.maxBytes);
|
|
126
|
+
const maxW = Number(limits.maxWidth) || 0;
|
|
127
|
+
const maxH = Number(limits.maxHeight) || 0;
|
|
128
|
+
const perDrop = Number(limits.maxFilesPerDrop) || 0;
|
|
129
|
+
|
|
130
|
+
const queue = perDrop && files.length > perDrop ? files.slice(0, perDrop) : files;
|
|
131
|
+
if (queue.length < files.length) {
|
|
132
|
+
files.slice(queue.length).forEach((f) => rejected.push({ name: f.name, key: 'storage.errTooMany', params: { max: perDrop } }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const file of queue) {
|
|
136
|
+
const name = sanitizeName(file.name);
|
|
137
|
+
|
|
138
|
+
if (file.size > max) {
|
|
139
|
+
rejected.push({ name, key: 'storage.errTooLarge', params: { name, size: KB(file.size), max: KB(max) } });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const bytes = await head(file);
|
|
144
|
+
const type = bytes && sniff(bytes);
|
|
145
|
+
if (!type) {
|
|
146
|
+
rejected.push({ name, key: 'storage.errUnreadable', params: { name } });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!accept.includes(type)) {
|
|
150
|
+
rejected.push({ name, key: 'storage.errFormat', params: { name, type: type.replace(/^image\//, '').toUpperCase() } });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Listing SVG in `accept` is not on its own enough. It is the one image
|
|
154
|
+
// type that is also a script host, and it renders inside the editor's own
|
|
155
|
+
// shadow root the moment it appears as a library tile -- so a host that
|
|
156
|
+
// genuinely wants it has to say so twice, and can never enable it by
|
|
157
|
+
// pasting a permissive MIME list.
|
|
158
|
+
if (type === 'image/svg+xml' && !limits.allowSvg) {
|
|
159
|
+
rejected.push({ name, key: 'storage.errSvg', params: { name } });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let dims = { w: 0, ht: 0 };
|
|
164
|
+
if (type !== 'image/svg+xml') {
|
|
165
|
+
const measured = await probe(file);
|
|
166
|
+
if (!measured) { rejected.push({ name, key: 'storage.errUnreadable', params: { name } }); continue; }
|
|
167
|
+
dims = measured;
|
|
168
|
+
if ((maxW && dims.w > maxW) || (maxH && dims.ht > maxH)) {
|
|
169
|
+
rejected.push({ name, key: 'storage.errDimensions', params: { name, w: dims.w, ht: dims.ht, maxW: maxW || dims.w, maxH: maxH || dims.ht } });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
accepted.push({ file, name, type, size: file.size, w: dims.w, ht: dims.ht });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { accepted, rejected };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** An `accept` attribute for the file picker, so the OS dialog greys out what validation would refuse anyway. */
|
|
181
|
+
export function acceptAttribute(limits) {
|
|
182
|
+
if (!limits || !Array.isArray(limits.accept) || !limits.accept.length) return 'image/*';
|
|
183
|
+
return limits.accept.join(',');
|
|
184
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage contract.
|
|
3
|
+
*
|
|
4
|
+
* This file is deliberately transport-free: no `fetch`, no auth, no origin, no
|
|
5
|
+
* backend of any kind. The editor talks to a plain object supplied by the host
|
|
6
|
+
* (`editor.storageProvider = …`) in exactly the way it talks to `aiProvider` --
|
|
7
|
+
* so a host can back the file library with S3, a DMS, a CDN or its own proxy
|
|
8
|
+
* without this package knowing which.
|
|
9
|
+
*
|
|
10
|
+
* No adapter ships here, deliberately. An adapter is a mapping onto somebody
|
|
11
|
+
* else's API surface, so vendoring one would mean republishing this package
|
|
12
|
+
* every time that surface moves. The host writes its own, next to the auth and
|
|
13
|
+
* base URL it already owns.
|
|
14
|
+
*
|
|
15
|
+
* @typedef {Object} Asset
|
|
16
|
+
* @property {string} id Stable id. With a provider this is the backend's file id -- `remove()` gets it back verbatim.
|
|
17
|
+
* @property {string} name Display/file name.
|
|
18
|
+
* @property {string} url Resolvable image URL. Must outlive the send: an email renders it long after the editor closed.
|
|
19
|
+
* @property {string} folder Folder display name.
|
|
20
|
+
* @property {string} [folderId] Backend folder id, when the provider has one.
|
|
21
|
+
* @property {number} w Pixel width (0 when unknown).
|
|
22
|
+
* @property {number} ht Pixel height (0 when unknown).
|
|
23
|
+
* @property {number} size Bytes.
|
|
24
|
+
*
|
|
25
|
+
* @typedef {Object} StorageProvider
|
|
26
|
+
* @property {() => Promise<Array<{id: string, name: string}>>} [folders]
|
|
27
|
+
* Selectable folders. Omit for a flat library.
|
|
28
|
+
* @property {(q: {folderId: string, cursor: ?string, query: string}) => Promise<{items: Asset[], cursor: ?string}>} list
|
|
29
|
+
* One page of assets. `cursor` is opaque -- whatever the provider returned last, handed back to fetch the next page.
|
|
30
|
+
* @property {(file: File, o: {folderId: string, width: number, height: number, signal: ?AbortSignal}) => Promise<Asset>} upload
|
|
31
|
+
* Stores one already-validated file and resolves to the asset that represents it.
|
|
32
|
+
* @property {(asset: Asset) => Promise<void>} [remove]
|
|
33
|
+
* Deletes. Without it the library's DEL only drops the tile from view.
|
|
34
|
+
* @property {StorageLimits} [limits]
|
|
35
|
+
* Provider-declared ceilings. `editor.storageLimits` wins over these.
|
|
36
|
+
*
|
|
37
|
+
* @typedef {Object} StorageLimits
|
|
38
|
+
* @property {string[]} accept Allowed MIME types, e.g. `['image/jpeg','image/png','image/gif']`. Required.
|
|
39
|
+
* @property {number} maxBytes Per-file byte ceiling. Required.
|
|
40
|
+
* @property {number} [maxWidth]
|
|
41
|
+
* @property {number} [maxHeight]
|
|
42
|
+
* @property {number} [maxFilesPerDrop]
|
|
43
|
+
* @property {boolean} [allowSvg] SVG is refused even when listed in `accept` unless this is also true -- see `storage-limits.js`.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** The synthetic "everything" folder. Its id is empty so a provider reading `folderId` sees "no folder filter", not a magic name. */
|
|
47
|
+
export const ALL_FOLDER_ID = '';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Coerces whatever a provider returned into the shape the renderer indexes
|
|
51
|
+
* into. A provider that forgets `w`/`ht` should degrade to a tile without
|
|
52
|
+
* dimensions, not to `undefined×undefined` printed in the UI -- and `probe`
|
|
53
|
+
* (what the limits check already measured client-side) fills those in for
|
|
54
|
+
* backends that don't store image dimensions at all.
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeAsset(raw, probe) {
|
|
57
|
+
const a = raw || {};
|
|
58
|
+
return {
|
|
59
|
+
id: String(a.id ?? a.itemId ?? ''),
|
|
60
|
+
name: String(a.name ?? (probe && probe.name) ?? 'file'),
|
|
61
|
+
url: String(a.url ?? ''),
|
|
62
|
+
folder: String(a.folder ?? ''),
|
|
63
|
+
folderId: a.folderId != null ? String(a.folderId) : undefined,
|
|
64
|
+
w: Number(a.w ?? (probe && probe.w) ?? 0) || 0,
|
|
65
|
+
ht: Number(a.ht ?? (probe && probe.ht) ?? 0) || 0,
|
|
66
|
+
size: Number(a.size ?? (probe && probe.size) ?? 0) || 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `editor.storageLimits` over `provider.limits`, per key rather than
|
|
72
|
+
* wholesale: a host that only wants to tighten `maxBytes` shouldn't have to
|
|
73
|
+
* restate the provider's `accept` list to do it.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveLimits(hostLimits, providerLimits) {
|
|
76
|
+
if (!hostLimits && !providerLimits) return null;
|
|
77
|
+
return Object.assign({}, providerLimits || {}, hostLimits || {});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Names the methods a provider is missing, for a loud failure at assignment rather than a quiet one on first upload. */
|
|
81
|
+
export function providerProblems(p) {
|
|
82
|
+
if (!p || typeof p !== 'object') return ['storageProvider must be an object'];
|
|
83
|
+
const missing = ['list', 'upload'].filter((k) => typeof p[k] !== 'function');
|
|
84
|
+
return missing.length ? [`storageProvider is missing ${missing.join(' and ')}`] : [];
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const THEME = () => ({ bg: '#eef2f7', contentBg: '#ffffff', width: 620, font: '"Helvetica Neue", Helvetica, Arial, sans-serif', text: '#172033', link: '#0065b3' });
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which parts of the editor's own top bar are shown.
|
|
3
|
+
*
|
|
4
|
+
* Hosts embed the editor inside chrome they already own -- a page header, a
|
|
5
|
+
* breadcrumb, their own Save/Send buttons -- where a second bar with a second
|
|
6
|
+
* logo reads as two applications stacked on top of each other. The bar is
|
|
7
|
+
* therefore configurable down to the individual control, and can be removed
|
|
8
|
+
* entirely.
|
|
9
|
+
*
|
|
10
|
+
* What that costs: `undo`, `redo` and `export` have element-level equivalents
|
|
11
|
+
* (`undo()`, `redo()`, `exportHtml()`), so hiding those is only declining to
|
|
12
|
+
* render the button. `preview`, `code` and `ai` open panels that nothing in
|
|
13
|
+
* the public API opens -- a host that hides them is giving the panel up, not
|
|
14
|
+
* just the button. DOCS.md carries the same table for integrators.
|
|
15
|
+
*
|
|
16
|
+
* Keyboard shortcuts are unaffected by any of this -- they are bound on the
|
|
17
|
+
* document, not on the bar -- so Ctrl/Cmd+Z, Shift+Ctrl/Cmd+Z and Ctrl/Cmd+E
|
|
18
|
+
* (the export dialog, and the Screenshot button inside it) work with no bar
|
|
19
|
+
* at all.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Every switchable part, in the order it appears in the bar. */
|
|
23
|
+
export const TOOLBAR_ITEMS = ['logo', 'status', 'device', 'undo', 'redo', 'theme', 'ai', 'code', 'preview', 'export'];
|
|
24
|
+
|
|
25
|
+
const HIDDEN = ['none', 'hidden', 'off', 'false'];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalizes whatever the host set into `{ item: boolean }`, or `null` for
|
|
29
|
+
* "no bar at all".
|
|
30
|
+
*
|
|
31
|
+
* Accepts, in the two shapes a Web Component gets configured through:
|
|
32
|
+
*
|
|
33
|
+
* - property: `false` (no bar), or an object of overrides where only the
|
|
34
|
+
* keys set to `false` are turned off -- so `{ logo: false }` keeps every
|
|
35
|
+
* control and drops just the brand, without restating the other nine.
|
|
36
|
+
* - attribute: `"none"` (no bar), or a comma list naming the items to keep
|
|
37
|
+
* (`toolbar="undo,redo,export"`). Markup has only strings to work with,
|
|
38
|
+
* and an allow-list is the readable half of the two: the alternative is
|
|
39
|
+
* spelling out the seven things you did not want.
|
|
40
|
+
*
|
|
41
|
+
* Unset means everything is shown -- the editor is fully usable standalone,
|
|
42
|
+
* and configuring the bar is opt-in.
|
|
43
|
+
*
|
|
44
|
+
* An empty result (every item switched off) collapses to `null`: an empty
|
|
45
|
+
* 54px bar is not what anyone meant by turning off the last control.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveToolbar(value) {
|
|
48
|
+
if (value === false) return null;
|
|
49
|
+
if (typeof value === 'string' && HIDDEN.indexOf(value.trim().toLowerCase()) >= 0) return null;
|
|
50
|
+
|
|
51
|
+
const on = {};
|
|
52
|
+
TOOLBAR_ITEMS.forEach((k) => { on[k] = true; });
|
|
53
|
+
|
|
54
|
+
if (value && typeof value === 'object') {
|
|
55
|
+
TOOLBAR_ITEMS.forEach((k) => { if (value[k] === false) on[k] = false; });
|
|
56
|
+
} else if (typeof value === 'string' && value.trim() && value.trim().toLowerCase() !== 'all') {
|
|
57
|
+
const keep = value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
58
|
+
TOOLBAR_ITEMS.forEach((k) => { on[k] = keep.indexOf(k) >= 0; });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return TOOLBAR_ITEMS.some((k) => on[k]) ? on : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Stable string for a resolved config, so a re-set that changes nothing does not cost a shell rebuild. */
|
|
65
|
+
export function toolbarKey(on) {
|
|
66
|
+
return on ? TOOLBAR_ITEMS.map((k) => (on[k] ? '1' : '0')).join('') : 'none';
|
|
67
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const DEFAULT_VARS = 'first_name\nlast_name\nemail\ncompany\ncity\norder_id\nplan\ndiscount\nunsubscribe_url';
|
|
2
|
+
export const TOKEN = (t) => '{' + '{ ' + t + ' }' + '}';
|
|
3
|
+
|
|
4
|
+
/** Variables are supplied by the host application -- the editor only ever shows the tokens, never a substituted value. */
|
|
5
|
+
export function vars(raw) {
|
|
6
|
+
const list = Array.isArray(raw) ? raw : String(raw == null ? DEFAULT_VARS : raw).split(/[\n,]/);
|
|
7
|
+
return list.map((v) => String(v).trim().replace(/^\{\{\s*|\s*\}\}$/g, '')).filter(Boolean);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Which prop field a merge tag lands in when inserted from the Data tab, keyed by the selected block's type. */
|
|
11
|
+
export const INSERT_KEYS = { text: 'html', heading: 'text', button: 'label', html: 'code', codeblock: 'code', quote: 'text', list: 'items', table: 'data' };
|