@seliseblocks/mailcraft 0.2.8 → 0.2.10
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 +135 -0
- package/DOCS.md +32 -32
- package/README.md +4 -1
- package/README.md.txt +4 -1
- package/dist/mailcraft-editor.bundle.js +74 -57
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +3 -2
- package/src/core/blocks.js +30 -11
- package/src/core/css-cascade.js +4 -1
- package/src/core/editor-core.js +80 -16
- package/src/core/export.js +83 -10
- package/src/core/icons.js +0 -1
- package/src/core/import-html.js +694 -55
- package/src/core/sanitize.js +72 -1
- package/src/mailcraft-editor.js +44 -4
- package/src/render/block-body.js +17 -5
- package/src/render/canvas.js +3 -0
- package/src/render/fields.js +28 -2
- package/src/render/rte.js +6 -0
- package/src/render/style.js +7 -0
- package/types/index.d.ts +10 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seliseblocks/mailcraft",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
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": {
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
},
|
|
60
61
|
"scripts": {
|
|
61
62
|
"build": "node build.js",
|
|
62
|
-
"test": "node test/storage.test.mjs && node test/export.test.mjs && node test/templates.test.mjs && node test/system.test.mjs && node test/toolbar.test.mjs && node test/editor-dom.test.mjs && node test/css-cascade.test.mjs && node test/render-dom.test.mjs && node test/import-html.test.mjs && node test/interaction.test.mjs && node test/focus-preserve.test.mjs && node test/shortcuts.test.mjs && node test/import-structure.test.mjs",
|
|
63
|
+
"test": "node test/storage.test.mjs && node test/export.test.mjs && node test/templates.test.mjs && node test/system.test.mjs && node test/toolbar.test.mjs && node test/editor-dom.test.mjs && node test/css-cascade.test.mjs && node test/render-dom.test.mjs && node test/import-html.test.mjs && node test/interaction.test.mjs && node test/focus-preserve.test.mjs && node test/shortcuts.test.mjs && node test/import-structure.test.mjs && node test/roundtrip.test.mjs",
|
|
63
64
|
"prepublishOnly": "npm run build && npm test",
|
|
64
65
|
"coverage": "c8 --reporter=text --reporter=text-summary --src=src --all --exclude=\"test/**\" --exclude=\"examples/**\" --exclude=\"build.js\" npm test"
|
|
65
66
|
},
|
package/src/core/blocks.js
CHANGED
|
@@ -18,7 +18,6 @@ BLOCKS.push(
|
|
|
18
18
|
{ type: 'heading', code: 'HED', label: 'Heading', hint: 'Display heading', make: () => ({ text: 'What we kept', level: 'h2', size: 34, lh: 1.12, align: 'left', color: '', weight: '600', font: 'body', py: 8, px: 0 }) },
|
|
19
19
|
{ type: 'list', code: 'LST', label: 'List', hint: 'Bulleted or numbered list', make: () => ({ items: 'Import your list\nPick a template\nSend the first one', ordered: false, size: 15, lh: 1.7, color: '', gap: 4, py: 8, px: 0 }) },
|
|
20
20
|
{ type: 'table', code: 'TBL', label: 'Table', hint: 'Data table — edit cells inline', make: () => ({ data: 'Plan|Price|Seats\nStarter|$19|3\nTeam|$49|10\nStudio|$99|25', header: true, borders: true, borderWidth: 1, borderStyle: 'solid', striped: true, pad: 10, size: 14, headBg: '#f8fafc', lineColor: '#e2e8f0', align: 'left', width: 100 }) },
|
|
21
|
-
{ type: 'embed', code: 'EMB', label: 'Embed', hint: 'Iframe embed — map, form, player', make: () => ({ src: 'https://example.com/embed', height: 320, label: 'Embedded content', py: 8 }) },
|
|
22
21
|
{ type: 'css', code: 'CSS', label: 'Raw CSS', hint: 'Inject a style block', make: () => ({ code: '.mc-note { font: 13px/1.6 "Helvetica Neue", Helvetica, Arial, sans-serif; color: #0065b3; letter-spacing: 0.04em; }', note: 'Styles apply to raw HTML blocks below.' }) },
|
|
23
22
|
{ type: 'codeblock', code: 'PRE', label: 'Code', hint: 'Preformatted code sample', make: () => ({ code: 'curl -X POST https://api.example.com/v1/sends \\\n -H "Authorization: Bearer $KEY" \\\n -d campaign=mc-4471', bg: '#172033', color: '#e8e9ea', size: 12.5, pad: 14 }) },
|
|
24
23
|
);
|
|
@@ -180,7 +179,7 @@ export const PALETTE = [
|
|
|
180
179
|
{ t: 'heading' }, { t: 'text' }, { t: 'list' }, { g: 'quote' }, { t: 'image' }, { g: 'gallery' },
|
|
181
180
|
{ t: 'button' }, { t: 'table' }, { g: 'card' }, { g: 'product' }, { g: 'hero' }, { g: 'stats' },
|
|
182
181
|
{ t: 'box' }, { t: 'divider' }, { t: 'spacer' }, { t: 'social' }, { t: 'video' },
|
|
183
|
-
{ t: '
|
|
182
|
+
{ t: 'menu' }, { g: 'footer' }, { t: 'html' }, { t: 'css' }, { t: 'svg' },
|
|
184
183
|
{ t: 'codeblock' }, { t: 'countdown' }, { t: 'condition' }, { t: 'loop' },
|
|
185
184
|
];
|
|
186
185
|
|
|
@@ -208,6 +207,15 @@ export function migrateDoc(doc) {
|
|
|
208
207
|
if (r.props.mb === undefined) r.props.mb = legacyMarginY;
|
|
209
208
|
if (r.props.ml === undefined) r.props.ml = 0;
|
|
210
209
|
r.cols.forEach((c) => {
|
|
210
|
+
// The embed block (an <iframe>) was removed on 2026-09-02: mail clients
|
|
211
|
+
// strip iframes, so the block only ever rendered in browser contexts.
|
|
212
|
+
// A saved document keeps its content as the raw html it always exported.
|
|
213
|
+
c.blocks.forEach((b) => {
|
|
214
|
+
if (b.type !== 'embed') return;
|
|
215
|
+
const p = b.props || {};
|
|
216
|
+
b.type = 'html';
|
|
217
|
+
b.props = { code: '<div style="padding:' + (p.py || 0) + 'px 0"><iframe src="' + (p.src || '') + '" title="' + (p.label || '') + '" style="width:100%;height:' + (p.height || 320) + 'px;border:1px solid rgba(29,31,32,0.14);background:#fff;display:block"></iframe></div>' };
|
|
218
|
+
});
|
|
211
219
|
// A block whose type this build no longer knows is dropped rather than
|
|
212
220
|
// rendered as an unrecognized shape; a row emptied that way goes with it.
|
|
213
221
|
c.blocks = c.blocks.filter((b) => DEF(b.type));
|
|
@@ -266,15 +274,26 @@ export function normalizeDoc(input) {
|
|
|
266
274
|
return {
|
|
267
275
|
id: r.id || uid(),
|
|
268
276
|
props: Object.assign({}, rowDefaults, r.props || {}),
|
|
269
|
-
cols: cols.map((c) =>
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
277
|
+
cols: cols.map((c) => {
|
|
278
|
+
const col = {
|
|
279
|
+
id: c.id || uid(),
|
|
280
|
+
span: Number(c.span) > 0 ? Number(c.span) : evenSpan,
|
|
281
|
+
blocks: (Array.isArray(c.blocks) ? c.blocks : []).filter((b) => b && typeof b === 'object' && b.type).map((b) => (
|
|
282
|
+
DEF(b.type)
|
|
283
|
+
? { id: b.id || uid(), type: b.type, props: Object.assign(DEF(b.type).make(), b.props || {}) }
|
|
284
|
+
: { id: b.id || uid(), type: b.type, props: b.props || {} }
|
|
285
|
+
)),
|
|
286
|
+
};
|
|
287
|
+
// A column's optional styling (the canvas's `styled` wrapper and the
|
|
288
|
+
// importer's hoist both speak these keys) rides through untouched --
|
|
289
|
+
// rebuilding the column without them silently stripped a card
|
|
290
|
+
// column's paint on every loadDoc/loadTemplate, before export ever
|
|
291
|
+
// saw it.
|
|
292
|
+
['bg', 'radius', 'padY', 'padX', 'border', 'borderStyle', 'lineColor'].forEach((k) => {
|
|
293
|
+
if (c[k] !== undefined && c[k] !== '' && c[k] !== 0) col[k] = c[k];
|
|
294
|
+
});
|
|
295
|
+
return col;
|
|
296
|
+
}),
|
|
278
297
|
};
|
|
279
298
|
}),
|
|
280
299
|
};
|
package/src/core/css-cascade.js
CHANGED
|
@@ -58,7 +58,10 @@ function specificity(sel) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export function inlineStylesheets(doc) {
|
|
61
|
-
|
|
61
|
+
// A style tag carrying the css-block marker IS a block (import-html reads
|
|
62
|
+
// it back as one); folding its rules inline here stamped them onto every
|
|
63
|
+
// matched element AND kept the block, doubling the styles on each save.
|
|
64
|
+
const sheets = Array.from(doc.querySelectorAll('style')).filter((s) => !(s.getAttribute && s.getAttribute('data-mc')));
|
|
62
65
|
if (!sheets.length) return;
|
|
63
66
|
const css = stripAtRules(stripComments(sheets.map((s) => s.textContent || '').join('\n')));
|
|
64
67
|
|
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
|
|
|
@@ -1178,7 +1224,7 @@ export class EditorCore {
|
|
|
1178
1224
|
|
|
1179
1225
|
// ---- export / code view --------------------------------------------------
|
|
1180
1226
|
|
|
1181
|
-
buildHtml() { return buildHtmlFn(this.state, this.exportRoot, boxCss); }
|
|
1227
|
+
buildHtml(opts) { return buildHtmlFn(this.state, this.exportRoot, boxCss, opts); }
|
|
1182
1228
|
|
|
1183
1229
|
openExport = () => {
|
|
1184
1230
|
this.setState({ exportOpen: true, exportCode: this.buildHtml(), copied: false, libraryOpen: false, aiOpen: false, codeOpen: false });
|
|
@@ -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,9 +1373,9 @@ 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.props.fontFamily)), 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
|
-
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')])));
|
|
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.props.fontFamily)), 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')]));
|
|
1328
1380
|
case 'spacer': return decorate(base.concat([B.range('Height', 'height', 0, 400, 2, 'px')]));
|
|
1329
1381
|
// Dynamic-content markers: the expression lives on the start marker;
|
|
@@ -1348,15 +1400,15 @@ export class EditorCore {
|
|
|
1348
1400
|
B.seg('Icon palette', 'palette', [{ value: 'custom', label: 'Custom' }, { value: 'brand', label: 'Brand' }]),
|
|
1349
1401
|
B.seg('Icon shape', 'shape', [{ value: 'outline', label: 'Outline' }, { value: 'bare', label: 'Bare' }, { value: 'circle', label: 'Circle' }, { value: 'square', label: 'Square' }]),
|
|
1350
1402
|
B.tog('Show network names', 'showLabel'),
|
|
1351
|
-
B.sel('Font', 'fontFamily', this.fontOptions(true)),
|
|
1403
|
+
B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)),
|
|
1352
1404
|
B.seg('Align', 'align', ALIGN), B.range('Icon size', 'size', 10, 64, 1, 'px'), B.range('Space between icons', 'gap', 0, 80, 2, 'px'), B.color('Icon color', 'color'),
|
|
1353
1405
|
]));
|
|
1354
1406
|
case 'video': return decorate(base.concat([B.btn('Choose thumbnail', () => this.openLibrary({ id: b.id, key: 'src' })), B.text('Video URL', 'href', 'https://'), B.text('Caption', 'caption'), B.color('Badge color', 'badge')]));
|
|
1355
1407
|
case 'html': return decorate(base.concat([B.area('Raw HTML', 'code')]));
|
|
1356
|
-
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
|
-
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',
|
|
1359
|
-
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));
|
|
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.props.fontFamily)), B.color('Color', 'color')]));
|
|
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.props.fontFamily)), B.range('Text size', 'size', 8, 32, 1, 'px'), B.range('Space between items', 'gap', 0, 80, 2, 'px'), B.color('Text color', 'color')]));
|
|
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.props.fontFamily)), 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));
|
|
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.props.fontFamily)), 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
|
|
1362
1414
|
// cell inputs with add/remove row+column controls, replacing the
|
|
@@ -1364,11 +1416,10 @@ export class EditorCore {
|
|
|
1364
1416
|
// `data` string underneath.
|
|
1365
1417
|
{ kind: 'tablegrid', label: 'Cells', value: b.props.data || '', header: !!b.props.header, onChange: (v) => this.setProp(b.id, 'data', v) },
|
|
1366
1418
|
B.tog('Header row', 'header'), B.tog('Cell borders', 'borders'), ...(b.props.borders ? [B.range('Border thickness', 'borderWidth', 1, 10, 1, 'px'), B.sel('Border style', 'borderStyle', BORDER_STYLES)] : []), B.tog('Zebra stripes', 'striped'),
|
|
1367
|
-
B.sel('Font', 'fontFamily', this.fontOptions(true)),
|
|
1419
|
+
B.sel('Font', 'fontFamily', this.fontOptions(true, b.props.fontFamily)),
|
|
1368
1420
|
B.range('Space in cells', 'pad', 0, 50, 1, 'px'), B.range('Text size', 'size', 8, 32, 1, 'px'), B.range('Width', 'width', 10, 100, 5, '%'),
|
|
1369
1421
|
B.color('Header color', 'headBg'), B.color('Line color', 'lineColor'), B.seg('Align', 'align', ALIGN),
|
|
1370
1422
|
]));
|
|
1371
|
-
case 'embed': return decorate(base.concat([B.text('Embed URL', 'src', 'https://'), B.range('Height', 'height', 40, 1200, 10, 'px'), B.text('Accessible label', 'label'), B.range('Padding', 'py', 0, 120, 2, 'px')]));
|
|
1372
1423
|
case 'css': return decorate(base.concat([B.area('CSS', 'code'), B.text('Note to yourself', 'note')]));
|
|
1373
1424
|
case 'box': return decorate(base.concat([B.area('Content (HTML allowed)', 'html'), B.color('Background color', 'bg'), B.btn('Choose background image', () => this.openLibrary({ id: b.id, key: 'bgImage' })), B.text('Background image URL', 'bgImage', 'https://'), B.range('Space inside', 'pad', 0, 160, 2, 'px'), B.range('Border thickness', 'border', 0, 20, 1, 'px')].concat(b.props.border ? [
|
|
1374
1425
|
B.sel('Border style', 'borderStyle', BORDER_STYLES), B.color('Border color', 'lineColor'),
|
|
@@ -1524,7 +1575,7 @@ export class EditorCore {
|
|
|
1524
1575
|
* an empty value meaning "use the theme font" (renderers read
|
|
1525
1576
|
* `p.fontFamily || theme.font`, so old documents need no migration).
|
|
1526
1577
|
*/
|
|
1527
|
-
fontOptions(inherit) {
|
|
1578
|
+
fontOptions(inherit, current) {
|
|
1528
1579
|
const stacks = [
|
|
1529
1580
|
{ value: '"Helvetica Neue", Helvetica, Arial, sans-serif', label: 'Helvetica Neue' },
|
|
1530
1581
|
{ value: 'Helvetica, Arial, sans-serif', label: 'Helvetica / Arial' },
|
|
@@ -1537,7 +1588,20 @@ export class EditorCore {
|
|
|
1537
1588
|
{ value: '"Palatino Linotype", Palatino, Georgia, serif', label: 'Palatino' },
|
|
1538
1589
|
{ value: 'ui-monospace, "Courier New", monospace', label: 'Monospace' },
|
|
1539
1590
|
];
|
|
1540
|
-
|
|
1591
|
+
const opts = inherit ? [{ value: '', label: 'Inherit — theme font' }].concat(stacks) : stacks;
|
|
1592
|
+
// An imported email brings its own stack, and it is almost never one of
|
|
1593
|
+
// the ten above. A <select> whose value matches no option shows nothing
|
|
1594
|
+
// selected -- the control read 'Inherit — theme font' (or the first
|
|
1595
|
+
// stack) about a block that was really set in Georgia, and the only way
|
|
1596
|
+
// to answer "what font is this?" was to overwrite it. Kept as an option
|
|
1597
|
+
// of its own, matched loosely because quoting and spacing differ
|
|
1598
|
+
// between what CSSOM hands back and what the list declares ('DM Sans'
|
|
1599
|
+
// vs "DM Sans").
|
|
1600
|
+
const key = (v) => String(v || '').replace(/["']/g, '').replace(/\s*,\s*/g, ',').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
1601
|
+
if (current && !opts.some((o) => key(o.value) === key(current))) {
|
|
1602
|
+
opts.push({ value: current, label: String(current).split(',')[0].replace(/["']/g, '').trim() + ' — from the email' });
|
|
1603
|
+
}
|
|
1604
|
+
return opts;
|
|
1541
1605
|
}
|
|
1542
1606
|
|
|
1543
1607
|
themeFields() {
|
|
@@ -1569,7 +1633,7 @@ export class EditorCore {
|
|
|
1569
1633
|
B.color('Border color', 'borderColor'),
|
|
1570
1634
|
] : []).concat([
|
|
1571
1635
|
B.head('Type & color'),
|
|
1572
|
-
B.sel('Default font', 'font', this.fontOptions(false)),
|
|
1636
|
+
B.sel('Default font', 'font', this.fontOptions(false, this.state.doc.theme.font)),
|
|
1573
1637
|
B.color('Text color', 'text'),
|
|
1574
1638
|
B.color('Link color', 'link'),
|
|
1575
1639
|
]));
|
package/src/core/export.js
CHANGED
|
@@ -136,9 +136,38 @@ export function decorateLogicTags(html) {
|
|
|
136
136
|
return s;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
export function buildHtml(state, root, boxCss) {
|
|
139
|
+
export function buildHtml(state, root, boxCss, opts) {
|
|
140
140
|
const d = state.doc; const t = d.theme;
|
|
141
141
|
const logic = logicPlan(d);
|
|
142
|
+
/*
|
|
143
|
+
* Fidelity markers. A handful of blocks render into markup that cannot be
|
|
144
|
+
* read back into the block it came from: a countdown bakes its digits, a
|
|
145
|
+
* video is just a linked image, a section box and a code sample are styled
|
|
146
|
+
* divs like any other, a raw-CSS block is a bare <style>, and a flex/grid
|
|
147
|
+
* row is a div no table walker can re-shape. For exactly those, the export
|
|
148
|
+
* stamps a compact attribute layer -- `data-mc` (the type), `data-mcp`
|
|
149
|
+
* (the props the DOM itself does not carry), `data-mcr` (a CSS-layout
|
|
150
|
+
* row's settings) and the inert `mc-keep` class -- which the importer
|
|
151
|
+
* trusts when present and ignores otherwise. Mail clients ignore unknown
|
|
152
|
+
* attributes wholesale, and everything else round-trips from its rendered
|
|
153
|
+
* shape alone, so the layer stays this small. Attribute names deliberately
|
|
154
|
+
* sit outside `grab()`'s `data-mc-[a-z-]+` strip (they live on exporter
|
|
155
|
+
* scaffolding, but belt and braces). A host that wants pristine HTML can
|
|
156
|
+
* pass `exportHtml({ markers: false })` and accept the lossy reload.
|
|
157
|
+
*/
|
|
158
|
+
const markers = !(opts && opts.markers === false);
|
|
159
|
+
const attrEsc = (v) => String(v).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
160
|
+
const MARKED = { countdown: 1, video: 1, box: 1, codeblock: 1 };
|
|
161
|
+
const marker = (b) => {
|
|
162
|
+
if (!markers || !MARKED[b.type]) return '';
|
|
163
|
+
const payload = { ...b.props };
|
|
164
|
+
// Content the rendered DOM already carries is not duplicated into the
|
|
165
|
+
// payload: a box's html IS the grabbed markup, a code sample's text IS
|
|
166
|
+
// the <pre>'s own.
|
|
167
|
+
if (b.type === 'box') delete payload.html;
|
|
168
|
+
if (b.type === 'codeblock') delete payload.code;
|
|
169
|
+
return ' data-mc="' + b.type + '" data-mcp="' + attrEsc(JSON.stringify(payload)) + '"';
|
|
170
|
+
};
|
|
142
171
|
const grab = (id) => {
|
|
143
172
|
const el = root.querySelector('[data-mc-content="' + id + '"]');
|
|
144
173
|
if (!el) return '';
|
|
@@ -174,7 +203,11 @@ export function buildHtml(state, root, boxCss) {
|
|
|
174
203
|
*/
|
|
175
204
|
const mobilePlan = (rp, cols) => {
|
|
176
205
|
const mode = rp.mobileCols === undefined ? 1 : rp.mobileCols;
|
|
177
|
-
if (cols < 2
|
|
206
|
+
if (cols < 2) return { row: '', cell: '' };
|
|
207
|
+
// `keep` exports NO functional class, which made it indistinguishable
|
|
208
|
+
// from foreign HTML (where stacking is the safer default) -- so it ships
|
|
209
|
+
// as the inert `mc-keep`, a marker with no stylesheet rule behind it.
|
|
210
|
+
if (mode === 'keep') return { row: markers ? 'mc-keep' : '', cell: '' };
|
|
178
211
|
const rowCls = [];
|
|
179
212
|
// Two-up and reverse both need the row to become a flex container, which
|
|
180
213
|
// is safe precisely because it only ever runs inside the media query: a
|
|
@@ -208,11 +241,15 @@ export function buildHtml(state, root, boxCss) {
|
|
|
208
241
|
return blocksOf.map((b) => logic.emit.get(b.id) || '').filter(Boolean).map((s) => ' ' + s).join('\n');
|
|
209
242
|
}
|
|
210
243
|
const colInner = (c) => c.blocks.map((b) => {
|
|
211
|
-
if (b.type === 'css') return '<style>' + (b.props.code || '') + '</style>';
|
|
244
|
+
if (b.type === 'css') return '<style' + (markers ? ' data-mc="css"' + (b.props.note ? ' data-mcn="' + attrEsc(b.props.note) + '"' : '') : '') + '>' + (b.props.code || '') + '</style>';
|
|
212
245
|
if (b.type === 'html') return b.props.code || '';
|
|
213
|
-
|
|
246
|
+
// The same width-carrying span the canvas draws (block-body.js), so the
|
|
247
|
+
// Width slider reaches the sent mail and the importer can read it back
|
|
248
|
+
// -- exported bare, the drawing shipped at its intrinsic size and the
|
|
249
|
+
// prop reset to 100 on every reload.
|
|
250
|
+
if (b.type === 'svg') return '<div style="text-align:' + b.props.align + ';padding:' + b.props.py + 'px 0"><span style="display:inline-block;width:' + (b.props.width || 100) + '%">' + (b.props.code || '') + '</span></div>';
|
|
214
251
|
if (b.type === 'condition' || b.type === 'loop') return logic.emit.get(b.id) || '';
|
|
215
|
-
return '<div' + visClass(b.props) + ' style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
|
|
252
|
+
return '<div' + marker(b) + visClass(b.props) + ' style="' + boxCss(b.props) + '">' + grab(b.id) + '</div>';
|
|
216
253
|
}).filter(Boolean).join('\n ') || ' ';
|
|
217
254
|
const cells = r.cols.map((c) => {
|
|
218
255
|
// Column-level styling (bg/radius/inner padding) renders as a wrapper
|
|
@@ -228,9 +265,12 @@ export function buildHtml(state, root, boxCss) {
|
|
|
228
265
|
// for every wide client.
|
|
229
266
|
const wrapCls = [plan.cell ? 'mc-stack' : '', plan.row].filter(Boolean).join(' ');
|
|
230
267
|
const stackWrap = wrapCls ? ' class="' + wrapCls + '"' : '';
|
|
268
|
+
const mcr = markers && rp.layout && rp.layout !== 'columns'
|
|
269
|
+
? ' data-mcr="' + attrEsc(JSON.stringify({ layout: rp.layout, flexDir: rp.flexDir || 'row', justify: rp.justify || 'flex-start', alignItems: rp.alignItems || 'stretch', wrap: rp.wrap !== false, gridCols: rp.gridCols || 2, gap: rp.gap || 0, spans: r.cols.map((c) => c.span) })) + '"'
|
|
270
|
+
: '';
|
|
231
271
|
const cssBody = rp.layout === 'grid'
|
|
232
|
-
? '<div' + stackWrap + ' style="display:grid;grid-template-columns:repeat(' + (rp.gridCols || 2) + ',minmax(0,1fr));gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div>' + colInner(c) + '</div>').join('\n ') + '\n </div>'
|
|
233
|
-
: '<div' + stackWrap + ' style="display:flex;flex-direction:' + (rp.flexDir || 'row') + ';justify-content:' + (rp.justify || 'flex-start') + ';align-items:' + (rp.alignItems || 'stretch') + ';flex-wrap:' + (rp.wrap ? 'wrap' : 'nowrap') + ';gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div style="flex:' + c.span + ' 1 auto;min-width:0">' + colInner(c) + '</div>').join('\n ') + '\n </div>';
|
|
272
|
+
? '<div' + mcr + stackWrap + ' style="display:grid;grid-template-columns:repeat(' + (rp.gridCols || 2) + ',minmax(0,1fr));gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div>' + colInner(c) + '</div>').join('\n ') + '\n </div>'
|
|
273
|
+
: '<div' + mcr + stackWrap + ' style="display:flex;flex-direction:' + (rp.flexDir || 'row') + ';justify-content:' + (rp.justify || 'flex-start') + ';align-items:' + (rp.alignItems || 'stretch') + ';flex-wrap:' + (rp.wrap ? 'wrap' : 'nowrap') + ';gap:' + rp.gap + 'px">\n ' + r.cols.map((c) => '<div style="flex:' + c.span + ' 1 auto;min-width:0">' + colInner(c) + '</div>').join('\n ') + '\n </div>';
|
|
234
274
|
const body = rp.layout && rp.layout !== 'columns'
|
|
235
275
|
? cssBody
|
|
236
276
|
// The `<tr>` is what becomes the flex container for two-up and reverse;
|
|
@@ -244,8 +284,21 @@ export function buildHtml(state, root, boxCss) {
|
|
|
244
284
|
: 'background:' + (rp.bg || t.contentBg || 'transparent') + ';';
|
|
245
285
|
const tdBorder = rowBorderCss(rp)
|
|
246
286
|
+ (rp.radius ? 'border-radius:' + rp.radius + 'px;' : '')
|
|
247
|
-
+ (rp.shadow ? 'box-shadow:' + rp.shadow + ';' : '')
|
|
248
|
-
|
|
287
|
+
+ (rp.shadow ? 'box-shadow:' + rp.shadow + ';' : '');
|
|
288
|
+
// Outside margins and Max width need an element mail clients actually
|
|
289
|
+
// honour them on: margins on a <td> are ignored by every major client
|
|
290
|
+
// (the old shape wrote them there -- inert in sent mail AND never read
|
|
291
|
+
// back), and max-width was canvas-only. Both now ride a wrapper div
|
|
292
|
+
// INSIDE the cell, carrying the row's paint and padding with them so
|
|
293
|
+
// the margin sits outside the painted box, exactly as the canvas draws
|
|
294
|
+
// it. Rows using neither keep the old markup byte for byte.
|
|
295
|
+
const boxed = rp.mt || rp.mr || rp.mb || rp.ml || rp.my || (rp.maxW && rp.maxW < 100);
|
|
296
|
+
if (boxed) {
|
|
297
|
+
const cap = rp.maxW && rp.maxW < 100 ? 'max-width:' + rp.maxW + '%;' : '';
|
|
298
|
+
// centerEmpty only when capped: an uncapped box spans the cell anyway,
|
|
299
|
+
// and `auto` there would only read back as a lost slider value.
|
|
300
|
+
return ' <tr>\n <td style="padding:0;">\n <div style="' + cap + 'margin:' + rowMargin(rp, !!cap) + ';padding:' + rowPad(rp) + ';' + tdBg + tdBorder + '">\n ' + body + '\n </div>\n </td>\n </tr>';
|
|
301
|
+
}
|
|
249
302
|
return ' <tr>\n <td style="padding:' + rowPad(rp) + ';' + tdBg + tdBorder + '">\n ' + body + '\n </td>\n </tr>';
|
|
250
303
|
}).join('\n') + (logic.tail ? '\n ' + logic.tail : '');
|
|
251
304
|
// The page section -- the full-width area the content column sits on. Its
|
|
@@ -353,6 +406,26 @@ export function buildHtml(state, root, boxCss) {
|
|
|
353
406
|
// `text-size-adjust` at 100%, never `none`: both stop a mobile client
|
|
354
407
|
// inflating the type, but `none` also blocks legitimate scaling and leaves
|
|
355
408
|
// text unreadably small on some Android clients.
|
|
409
|
+
/*
|
|
410
|
+
* theme.link, made real: mail clients have no stylesheet to inherit a link
|
|
411
|
+
* color from (Gmail strips <style>; the sheet rule that paints the canvas
|
|
412
|
+
* never ships), so the value must ride every anchor inline. Stamped only
|
|
413
|
+
* where no inline color already stands -- a button label, a menu item, or
|
|
414
|
+
* a link the user recolored by hand keeps its own -- and skipped entirely
|
|
415
|
+
* when the theme has no link color. The canvas strips restated stamps at
|
|
416
|
+
* render time (overrideLinkColor), which is what keeps a reloaded document
|
|
417
|
+
* inheriting: export -> import -> export re-stamps the same bytes.
|
|
418
|
+
*/
|
|
419
|
+
const stampLinks = (html) => {
|
|
420
|
+
if (!t.link) return html;
|
|
421
|
+
return html.replace(/<a\b([^>]*)>/g, (m0, attrs) => {
|
|
422
|
+
if (/(?:[;"\s])color\s*:/.test(attrs)) return m0;
|
|
423
|
+
if (/style="/.test(attrs)) {
|
|
424
|
+
return '<a' + attrs.replace(/style="([^"]*)"/, (s0, css) => 'style="' + css + (!css.trim() || css.trim().endsWith(';') ? '' : ';') + 'color:' + t.link + ';"') + '>';
|
|
425
|
+
}
|
|
426
|
+
return '<a' + attrs + ' style="color:' + t.link + ';">';
|
|
427
|
+
});
|
|
428
|
+
};
|
|
356
429
|
const bodyStyle = 'margin:0;padding:0;background:' + pageBg + ';font-family:' + t.font.replace(/"/g, "'") + ';color:' + t.text + ';-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;text-size-adjust:100%;';
|
|
357
|
-
return msoHarden('<!doctype html>\n<html lang="en" xmlns:o="urn:schemas-microsoft-com:office:office">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light">\n<meta name="supported-color-schemes" content="light">\n<title>' + 'Email' + '</title>' + msoHead + stackCss + '\n</head>\n<body style="' + bodyStyle + '">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n ' + ghostOpen + '\n ' + shell + '\n ' + ghostClose + '\n </td></tr>\n</table>\n</body>\n</html>');
|
|
430
|
+
return msoHarden(stampLinks('<!doctype html>\n<html lang="en" xmlns:o="urn:schemas-microsoft-com:office:office">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<meta name="color-scheme" content="light">\n<meta name="supported-color-schemes" content="light">\n<title>' + 'Email' + '</title>' + msoHead + stackCss + '\n</head>\n<body style="' + bodyStyle + '">\n<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:' + pageBg + ';">\n <tr><td align="center" style="padding:' + pagePad + ';">\n ' + ghostOpen + '\n ' + shell + '\n ' + ghostClose + '\n </td></tr>\n</table>\n</body>\n</html>'));
|
|
358
431
|
}
|
package/src/core/icons.js
CHANGED
|
@@ -67,7 +67,6 @@ const ICONS = {
|
|
|
67
67
|
hero: [['rect', 3, 4, 18, 16], 'M6 11h12', 'M6 15h6', ['rect', 14.5, 13.5, 4, 3]],
|
|
68
68
|
stats: ['M5 19v-7', 'M12 19V5', 'M19 19v-4', 'M3 19h18'],
|
|
69
69
|
gallery: [['rect', 3, 4, 7.5, 7.5], ['rect', 13.5, 4, 7.5, 7.5], ['rect', 3, 12.5, 7.5, 7.5], ['rect', 13.5, 12.5, 7.5, 7.5]],
|
|
70
|
-
embed: [['rect', 2.5, 5, 19, 14], 'M2.5 9h19', 'M10.5 11.5l3.5 2-3.5 2z'],
|
|
71
70
|
css: ['M5 4h14l-1.5 15L12 21l-5.5-2z', 'M8.5 9h7', 'M9 13h5'],
|
|
72
71
|
codeblock: [['rect', 3, 4, 18, 16], 'M9.5 10l-2 2 2 2', 'M14.5 10l2 2-2 2'],
|
|
73
72
|
data: ['M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3z', 'M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6', 'M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3'],
|