@seliseblocks/mailcraft 0.2.7 → 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 +76 -48
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -1
- package/src/core/blocks.js +15 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +88 -6
- package/src/core/export.js +195 -6
- package/src/core/i18n/index.js +83 -83
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +482 -31
- 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 +69 -1
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +41 -2
- package/src/render/block-body.js +109 -12
- package/src/render/canvas.js +45 -3
- 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/blocks.js
CHANGED
|
@@ -60,7 +60,14 @@ export const mkRow = (spans, blocks) => ({
|
|
|
60
60
|
border: 0, borderStyle: 'solid', lineColor: '#e2e2e5', bTop: true, bRight: true, bBottom: true, bLeft: true, radius: 0, shadow: '', maxW: 100,
|
|
61
61
|
mt: 0, mr: 0, mb: 0, ml: 0,
|
|
62
62
|
layout: 'columns', flexDir: 'row', justify: 'flex-start', alignItems: 'stretch', wrap: true, gridCols: 2,
|
|
63
|
-
py: 20, px: 24, padSplit: false, gap: 20, valign: 'top',
|
|
63
|
+
py: 20, px: 24, padSplit: false, gap: 20, valign: 'top',
|
|
64
|
+
// What this row does on a narrow screen. `mobileCols` is the number of
|
|
65
|
+
// columns it keeps there -- 1 stacks (the old `stackMobile: true`), 2 makes
|
|
66
|
+
// a two-up grid, 'keep' leaves the desktop layout alone (the old
|
|
67
|
+
// `stackMobile: false`). `mobileOrder: 'reverse'` flips the visual order,
|
|
68
|
+
// which is how an alternating image/text strip keeps the image on top of
|
|
69
|
+
// every band once stacked. Saved documents are mapped over in migrateDoc.
|
|
70
|
+
mobileCols: 1, mobileOrder: 'normal',
|
|
64
71
|
},
|
|
65
72
|
cols: spans.map((s, i) => ({ id: uid(), span: s, blocks: i === 0 && blocks ? blocks : [] })),
|
|
66
73
|
});
|
|
@@ -210,6 +217,13 @@ export function migrateDoc(doc) {
|
|
|
210
217
|
// 'square' choice rather than silently becoming an unrecognized value.
|
|
211
218
|
c.blocks.forEach((b) => { if (b.type === 'social' && b.props.shape === 'solid') b.props.shape = 'square'; });
|
|
212
219
|
});
|
|
220
|
+
// Mobile behaviour used to be one boolean, `stackMobile`. Mapped before
|
|
221
|
+
// the defaults are applied, or every document saved before this build
|
|
222
|
+
// would take `mobileCols: 1` from the defaults and a row that had
|
|
223
|
+
// deliberately opted out of stacking would start stacking.
|
|
224
|
+
if (r.props.mobileCols === undefined && r.props.stackMobile !== undefined) {
|
|
225
|
+
r.props.mobileCols = r.props.stackMobile === false ? 'keep' : 1;
|
|
226
|
+
}
|
|
213
227
|
Object.keys(defaults).forEach((k) => { if (r.props[k] === undefined) r.props[k] = defaults[k]; });
|
|
214
228
|
if (hadBlocks && !r.cols.some((c) => c.blocks.length)) emptied.push(r.id);
|
|
215
229
|
});
|
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');
|
|
@@ -1305,12 +1357,23 @@ export class EditorCore {
|
|
|
1305
1357
|
// line and a dead divider strip under the title.
|
|
1306
1358
|
const base = [];
|
|
1307
1359
|
const padF = [B.head('Spacing'), group(null, [B.range('Above & below', 'py', 0, 160, 2, 'px'), B.range('Left & right', 'px', 0, 120, 2, 'px')])];
|
|
1360
|
+
// Appended to every block type at once rather than repeated across
|
|
1361
|
+
// twenty switch arms. An absent `vis` means "all devices", so the
|
|
1362
|
+
// property only ever appears in a document that asked for it and no
|
|
1363
|
+
// migration is needed. Re-decorating an already-decorated list is safe:
|
|
1364
|
+
// `decorate` derives its flags from `kind`, which survives the pass.
|
|
1365
|
+
const visF = [B.head('Visibility'), B.seg('Show on', 'vis', [
|
|
1366
|
+
{ value: 'all', label: 'All' },
|
|
1367
|
+
{ value: 'desktop', label: 'Desktop' },
|
|
1368
|
+
{ value: 'mobile', label: 'Mobile' },
|
|
1369
|
+
])];
|
|
1370
|
+
const built = (() => {
|
|
1308
1371
|
switch (b.type) {
|
|
1309
1372
|
case 'text': return decorate(base.concat(
|
|
1310
1373
|
/<a(?:\s|>)/i.test(String(b.props.html || ''))
|
|
1311
1374
|
? [{ kind: 'richLinks', label: 'Links', html: b.props.html || '', onChange: (v) => this.setProp(b.id, 'html', v) }]
|
|
1312
1375
|
: [],
|
|
1313
|
-
[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));
|
|
1314
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));
|
|
1315
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')])));
|
|
1316
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')]));
|
|
@@ -1344,7 +1407,7 @@ export class EditorCore {
|
|
|
1344
1407
|
case 'html': return decorate(base.concat([B.area('Raw HTML', 'code')]));
|
|
1345
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')]));
|
|
1346
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')]));
|
|
1347
|
-
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));
|
|
1348
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));
|
|
1349
1412
|
case 'table': return decorate(base.concat([
|
|
1350
1413
|
// Custom kind (render/fields.js `renderTableGrid`): a real grid of
|
|
@@ -1368,6 +1431,11 @@ export class EditorCore {
|
|
|
1368
1431
|
case 'codeblock': return decorate(base.concat([B.area('Code', 'code'), B.color('Background', 'bg'), B.color('Text color', 'color'), B.range('Size', 'size', 8, 32, 0.5, 'px'), B.range('Padding', 'pad', 0, 80, 2, 'px')]));
|
|
1369
1432
|
default: return [];
|
|
1370
1433
|
}
|
|
1434
|
+
})();
|
|
1435
|
+
// Logic markers are editor furniture with no rendered body, so there is
|
|
1436
|
+
// nothing for a device to show or hide.
|
|
1437
|
+
if (b.type === 'condition' || b.type === 'loop') return built;
|
|
1438
|
+
return decorate(built.concat(visF));
|
|
1371
1439
|
}
|
|
1372
1440
|
if (f.row) {
|
|
1373
1441
|
const r = f.row;
|
|
@@ -1405,7 +1473,21 @@ export class EditorCore {
|
|
|
1405
1473
|
},
|
|
1406
1474
|
...(r.cols.length > 1 ? [
|
|
1407
1475
|
B.range('Space between columns', 'gap', 0, 120, 2, 'px'),
|
|
1408
|
-
B.
|
|
1476
|
+
B.head('On mobile'),
|
|
1477
|
+
// Replaces the old "Stack columns on mobile" switch, which could
|
|
1478
|
+
// only say all-or-nothing. Saved documents are mapped onto these
|
|
1479
|
+
// values in migrateDoc, so an existing toggle keeps its meaning.
|
|
1480
|
+
B.seg('Columns', 'mobileCols', [
|
|
1481
|
+
{ value: 1, label: 'One' },
|
|
1482
|
+
{ value: 2, label: 'Two' },
|
|
1483
|
+
{ value: 'keep', label: 'Keep' },
|
|
1484
|
+
]),
|
|
1485
|
+
// Only offered where it changes something: reversing a row that
|
|
1486
|
+
// keeps its desktop layout would do nothing.
|
|
1487
|
+
...(p.mobileCols !== 'keep' ? [B.seg('Order', 'mobileOrder', [
|
|
1488
|
+
{ value: 'normal', label: 'Normal' },
|
|
1489
|
+
{ value: 'reverse', label: 'Reverse' },
|
|
1490
|
+
])] : []),
|
|
1409
1491
|
] : []),
|
|
1410
1492
|
// Per-column styling, only for multi-column sections (a single
|
|
1411
1493
|
// column's background is just the section background).
|