@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
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@seliseblocks/mailcraft",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic drag-and-drop email template editor, packaged as a zero-dependency Web Component.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "SELISE Digital Platforms",
|
|
7
|
+
"homepage": "https://mailcraft.seliseblocks.com",
|
|
8
|
+
"bugs": "https://github.com/SELISEdigitalplatforms/blocks-mailcraft/issues",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/SELISEdigitalplatforms/blocks-mailcraft.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"email",
|
|
15
|
+
"email-template",
|
|
16
|
+
"email-builder",
|
|
17
|
+
"editor",
|
|
18
|
+
"drag-and-drop",
|
|
19
|
+
"web-component",
|
|
20
|
+
"custom-element",
|
|
21
|
+
"html-email",
|
|
22
|
+
"wysiwyg",
|
|
23
|
+
"no-dependencies"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": [
|
|
27
|
+
"./src/index.js",
|
|
28
|
+
"./src/mailcraft-editor.js",
|
|
29
|
+
"./dist/*"
|
|
30
|
+
],
|
|
31
|
+
"main": "./src/index.js",
|
|
32
|
+
"module": "./src/index.js",
|
|
33
|
+
"unpkg": "./dist/mailcraft-editor.bundle.js",
|
|
34
|
+
"jsdelivr": "./dist/mailcraft-editor.bundle.js",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": "./src/index.js",
|
|
37
|
+
"./dist/*": "./dist/*",
|
|
38
|
+
"./src/*": "./src/*",
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"src",
|
|
43
|
+
"dist",
|
|
44
|
+
"examples",
|
|
45
|
+
"README.md",
|
|
46
|
+
"DOCS.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "node build.js",
|
|
57
|
+
"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",
|
|
58
|
+
"prepublishOnly": "npm run build && npm test",
|
|
59
|
+
"coverage": "c8 --reporter=text --reporter=text-summary --src=src --all --exclude=\"test/**\" --exclude=\"examples/**\" --exclude=\"build.js\" npm test"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"c8": "^11.0.0",
|
|
63
|
+
"esbuild": "^0.28.2",
|
|
64
|
+
"jsdom": "^25.0.1"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host brand color -> the editor's four accent tokens.
|
|
3
|
+
*
|
|
4
|
+
* A host that passes `accent="#e11d48"` means "make the chrome my brand",
|
|
5
|
+
* not "set one variable": `--ed-accent` alone would leave hover states on the
|
|
6
|
+
* old blue, white text sitting on a pale brand color, and the selection wash
|
|
7
|
+
* tinted the wrong hue. So one input color derives the whole set --
|
|
8
|
+
* `--ed-accent`, `--ed-accent-strong` (hover/active), `--ed-accent-ink` (what
|
|
9
|
+
* is legible *on* the accent) and `--ed-soft` (the tinted wash behind
|
|
10
|
+
* selected rows and active tabs).
|
|
11
|
+
*
|
|
12
|
+
* Two rules do the work, both contrast-driven rather than taste-driven,
|
|
13
|
+
* because the brand color arrives at runtime and nobody gets to eyeball it:
|
|
14
|
+
*
|
|
15
|
+
* 1. The accent has to stay readable against the chrome it sits on -- it is
|
|
16
|
+
* used for text and 1px icon strokes, not just fills. A brand yellow on
|
|
17
|
+
* the light panels, or a brand navy on the dark ones, is darkened or
|
|
18
|
+
* lightened just far enough to clear WCAG AA (4.5:1) and no further, so a
|
|
19
|
+
* brand that already passes is used exactly as given.
|
|
20
|
+
* 2. `--ed-accent-ink` picks white or near-black by whichever wins contrast
|
|
21
|
+
* against the resolved accent, so a label on a filled accent button is
|
|
22
|
+
* never the wrong one for a mid-tone brand.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately DOM-free and pure: the element resolves the attribute down to
|
|
25
|
+
* a color string, this module does the color math -- which keeps the math
|
|
26
|
+
* testable without a document.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The chrome the accent has to survive against: `--ed-panel` in each palette (render/style.js). */
|
|
30
|
+
const LIGHT_BG = { r: 255, g: 255, b: 255 };
|
|
31
|
+
const DARK_BG = { r: 17, g: 24, b: 39 };
|
|
32
|
+
const WHITE = { r: 255, g: 255, b: 255 };
|
|
33
|
+
/** Not pure black: near `--ed-text`, so accent buttons don't read harsher than the rest of the UI. */
|
|
34
|
+
const NEAR_BLACK = { r: 11, g: 17, b: 32 };
|
|
35
|
+
|
|
36
|
+
/** WCAG AA for normal text. The accent renders as 11-12px labels and hairline icon strokes, so AA is the floor, not a nicety. */
|
|
37
|
+
const MIN_CONTRAST = 4.5;
|
|
38
|
+
|
|
39
|
+
const clamp255 = (n) => Math.max(0, Math.min(255, Math.round(n)));
|
|
40
|
+
|
|
41
|
+
/** sRGB channel -> linear light, per WCAG's relative-luminance definition. */
|
|
42
|
+
function toLinear(channel) {
|
|
43
|
+
const c = channel / 255;
|
|
44
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function luminance(c) {
|
|
48
|
+
return 0.2126 * toLinear(c.r) + 0.7152 * toLinear(c.g) + 0.0722 * toLinear(c.b);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** WCAG contrast ratio, 1 (identical) to 21 (black on white). */
|
|
52
|
+
export function contrast(a, b) {
|
|
53
|
+
const l1 = luminance(a);
|
|
54
|
+
const l2 = luminance(b);
|
|
55
|
+
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Linear blend, `t` = how far from `a` toward `b`. */
|
|
59
|
+
function mix(a, b, t) {
|
|
60
|
+
return {
|
|
61
|
+
r: clamp255(a.r + (b.r - a.r) * t),
|
|
62
|
+
g: clamp255(a.g + (b.g - a.g) * t),
|
|
63
|
+
b: clamp255(a.b + (b.b - a.b) * t),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const toHex = (c) => '#' + [c.r, c.g, c.b].map((n) => clamp255(n).toString(16).padStart(2, '0')).join('');
|
|
68
|
+
|
|
69
|
+
const rgba = (c, alpha) => `rgba(${clamp255(c.r)},${clamp255(c.g)},${clamp255(c.b)},${alpha})`;
|
|
70
|
+
|
|
71
|
+
/** `hue` in degrees, `s`/`l` as 0..1. Mirrors the CSS hsl() definition so `hsl(340 82% 52%)` lands where a browser would put it. */
|
|
72
|
+
function hslToRgb(hue, s, l) {
|
|
73
|
+
const h = (((hue % 360) + 360) % 360) / 60;
|
|
74
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
75
|
+
const x = c * (1 - Math.abs((h % 2) - 1));
|
|
76
|
+
const m = l - c / 2;
|
|
77
|
+
const [r, g, b] = h < 1 ? [c, x, 0] : h < 2 ? [x, c, 0] : h < 3 ? [0, c, x]
|
|
78
|
+
: h < 4 ? [0, x, c] : h < 5 ? [x, 0, c] : [c, 0, x];
|
|
79
|
+
return { r: clamp255((r + m) * 255), g: clamp255((g + m) * 255), b: clamp255((b + m) * 255) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parses the color notations a host actually types into an attribute --
|
|
84
|
+
* `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()/rgba()` and `hsl()/hsla()`,
|
|
85
|
+
* comma- or space-separated, with or without `%`. Alpha parses but is
|
|
86
|
+
* dropped: the accent is a solid color, and `--ed-soft` owns the translucent
|
|
87
|
+
* tint. Named CSS colors are not resolved here (that needs a browser); the
|
|
88
|
+
* element falls back to a DOM probe for those.
|
|
89
|
+
*
|
|
90
|
+
* @returns {{r:number,g:number,b:number}|null} null when it isn't a color.
|
|
91
|
+
*/
|
|
92
|
+
export function parseColor(value) {
|
|
93
|
+
const raw = String(value == null ? '' : value).trim().toLowerCase();
|
|
94
|
+
if (!raw) return null;
|
|
95
|
+
|
|
96
|
+
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
|
|
97
|
+
if (hex) {
|
|
98
|
+
const h = hex[1];
|
|
99
|
+
if (h.length === 3 || h.length === 4) {
|
|
100
|
+
return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16) };
|
|
101
|
+
}
|
|
102
|
+
if (h.length === 6 || h.length === 8) {
|
|
103
|
+
return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const fn = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(raw);
|
|
109
|
+
if (!fn) return null;
|
|
110
|
+
const parts = fn[2].replace(/\//g, ' ').split(/[\s,]+/).filter(Boolean);
|
|
111
|
+
if (parts.length < 3) return null;
|
|
112
|
+
const num = (s) => parseFloat(s);
|
|
113
|
+
let out;
|
|
114
|
+
if (fn[1].startsWith('rgb')) {
|
|
115
|
+
const chan = (s) => (s.endsWith('%') ? (num(s) / 100) * 255 : num(s));
|
|
116
|
+
out = { r: clamp255(chan(parts[0])), g: clamp255(chan(parts[1])), b: clamp255(chan(parts[2])) };
|
|
117
|
+
} else {
|
|
118
|
+
const pct = (v) => (v.endsWith('%') ? num(v) / 100 : num(v));
|
|
119
|
+
const unit = (v) => Math.max(0, Math.min(1, pct(v)));
|
|
120
|
+
out = hslToRgb(num(parts[0]), unit(parts[1]), unit(parts[2]));
|
|
121
|
+
}
|
|
122
|
+
return Number.isNaN(out.r + out.g + out.b) ? null : out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Walks the color toward black (light chrome) or white (dark chrome) until it
|
|
127
|
+
* clears `MIN_CONTRAST` against the panel behind it. Steps are small and it
|
|
128
|
+
* stops at the first passing one, so a brand color that already has enough
|
|
129
|
+
* contrast comes back untouched -- the goal is legibility, not repainting
|
|
130
|
+
* someone's brand.
|
|
131
|
+
*/
|
|
132
|
+
function fitContrast(color, bg) {
|
|
133
|
+
const toward = luminance(bg) > 0.5 ? NEAR_BLACK : WHITE;
|
|
134
|
+
let out = color;
|
|
135
|
+
for (let step = 1; step <= 20 && contrast(out, bg) < MIN_CONTRAST; step += 1) {
|
|
136
|
+
out = mix(color, toward, step / 20);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** White or near-black, whichever is legible on `bg`. */
|
|
142
|
+
const inkFor = (bg) => (contrast(bg, WHITE) >= contrast(bg, NEAR_BLACK) ? WHITE : NEAR_BLACK);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Every accent token, for one brand color in one chrome.
|
|
146
|
+
*
|
|
147
|
+
* Two families, because the editor paints on two surfaces:
|
|
148
|
+
*
|
|
149
|
+
* --ed-accent* panel chrome (header, inspector, tabs, sliders), which
|
|
150
|
+
* follows the light/dark theme.
|
|
151
|
+
* --ed-accent-sheet* everything drawn *on the email sheet* -- the row grip,
|
|
152
|
+
* the block toolbars, the drop outlines. The sheet is a white
|
|
153
|
+
* page in both themes, so these are always fitted against
|
|
154
|
+
* white; a dark-theme accent light enough to read on the dark
|
|
155
|
+
* panels would disappear on the page.
|
|
156
|
+
*
|
|
157
|
+
* `strong` moves *away* from the surface (darker on light, lighter on dark) so
|
|
158
|
+
* hover always reads as "more", the way both built-in palettes behave.
|
|
159
|
+
*
|
|
160
|
+
* @param {{r:number,g:number,b:number}} color parsed brand color
|
|
161
|
+
* @param {string} chrome 'light' | 'dark'
|
|
162
|
+
* @returns {Object} token name -> CSS value
|
|
163
|
+
*/
|
|
164
|
+
export function accentTokens(color, chrome) {
|
|
165
|
+
const dark = chrome === 'dark';
|
|
166
|
+
const base = fitContrast(color, dark ? DARK_BG : LIGHT_BG);
|
|
167
|
+
const strong = dark ? mix(base, WHITE, 0.22) : mix(base, NEAR_BLACK, 0.2);
|
|
168
|
+
// In light chrome the panel *is* white, so the two families coincide and
|
|
169
|
+
// this costs nothing.
|
|
170
|
+
const sheet = dark ? fitContrast(color, LIGHT_BG) : base;
|
|
171
|
+
return {
|
|
172
|
+
'--ed-accent': toHex(base),
|
|
173
|
+
'--ed-accent-strong': toHex(strong),
|
|
174
|
+
'--ed-accent-ink': toHex(inkFor(base)),
|
|
175
|
+
// The lighter stop of the brand mark's gradient -- a highlight, so it
|
|
176
|
+
// moves toward white regardless of chrome.
|
|
177
|
+
'--ed-accent-tint': toHex(mix(base, WHITE, 0.34)),
|
|
178
|
+
// The same alphas the built-in palettes use -- a wash has to stay a hint
|
|
179
|
+
// of color over its surface, not a second surface.
|
|
180
|
+
'--ed-soft': rgba(base, dark ? 0.13 : 0.09),
|
|
181
|
+
'--ed-glow': rgba(base, 0.28),
|
|
182
|
+
// Text selection: heavier than a wash (it has to be visible under type)
|
|
183
|
+
// and heavier still on dark, where the same alpha reads fainter.
|
|
184
|
+
'--ed-select': rgba(base, dark ? 0.3 : 0.22),
|
|
185
|
+
'--ed-accent-sheet': toHex(sheet),
|
|
186
|
+
'--ed-accent-sheet-strong': toHex(mix(sheet, NEAR_BLACK, 0.22)),
|
|
187
|
+
'--ed-accent-sheet-ink': toHex(inkFor(sheet)),
|
|
188
|
+
// Drop-target dashes and hairlines on the page.
|
|
189
|
+
'--ed-accent-sheet-line': rgba(sheet, 0.5),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The tokens this module owns, so the element can clear exactly what it set when `accent` goes away. */
|
|
194
|
+
export const ACCENT_VARS = Object.keys(accentTokens({ r: 0, g: 0, b: 0 }, 'light'));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { uid } from './ids.js';
|
|
2
|
+
import { PH } from './placeholder.js';
|
|
3
|
+
|
|
4
|
+
export const FOLDERS = ['All files', 'Brand', 'Product', 'Photography', 'Uploads'];
|
|
5
|
+
|
|
6
|
+
export const seedAssets = () => [
|
|
7
|
+
{ id: uid(), name: 'logo-lockup.png', url: PH('logo lockup', 480, 180), folder: 'Brand', w: 480, ht: 180, size: 18400 },
|
|
8
|
+
{ id: uid(), name: 'hero-workshop.jpg', url: PH('workshop hero', 600, 340), folder: 'Photography', w: 600, ht: 340, size: 142000 },
|
|
9
|
+
{ id: uid(), name: 'jacket-03-front.jpg', url: PH('jacket 03 front', 520, 360), folder: 'Product', w: 520, ht: 360, size: 98000 },
|
|
10
|
+
{ id: uid(), name: 'jacket-03-detail.jpg', url: PH('jacket 03 detail', 520, 360), folder: 'Product', w: 520, ht: 360, size: 91000 },
|
|
11
|
+
{ id: uid(), name: 'boots-pair.jpg', url: PH('boots pair', 520, 360), folder: 'Product', w: 520, ht: 360, size: 88000 },
|
|
12
|
+
{ id: uid(), name: 'texture-waxed.jpg', url: PH('waxed texture', 600, 240), folder: 'Photography', w: 600, ht: 240, size: 64000 },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const KB = (n) => (n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB');
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** Ported verbatim from the original `binder(getProps, set)` and `decorate(list)`. Pure field-descriptor logic, no DOM. */
|
|
2
|
+
export function binder(getProps, set, core) {
|
|
3
|
+
return {
|
|
4
|
+
head: (label) => ({ kind: 'head', label }),
|
|
5
|
+
text: (label, key, ph) => ({ kind: 'text', label, value: getProps()[key] ?? '', placeholder: ph || '', onChange: (v) => set(key, v) }),
|
|
6
|
+
area: (label, key, ph) => ({ kind: 'area', label, value: getProps()[key] ?? '', placeholder: ph || '', onChange: (v) => set(key, v) }),
|
|
7
|
+
// Live typing only commits parseable numbers -- `Number('')` is 0, and
|
|
8
|
+
// committing that the instant a field was cleared to retype (e.g. the
|
|
9
|
+
// theme's content width collapsing to 0px mid-edit) was one of the
|
|
10
|
+
// "inputs behave weird" symptoms. Blur settles the final value, clamped.
|
|
11
|
+
num: (label, key, min, max) => ({
|
|
12
|
+
kind: 'num', label, value: getProps()[key] ?? 0, min, max,
|
|
13
|
+
onChange: (v) => { const n = parseFloat(v); if (Number.isFinite(n)) set(key, n); },
|
|
14
|
+
onBlur: (v) => {
|
|
15
|
+
if (core && core.rendering) return;
|
|
16
|
+
const n = parseFloat(v);
|
|
17
|
+
set(key, Math.min(max, Math.max(min, Number.isFinite(n) ? n : (getProps()[key] ?? min))));
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
color: (label, key) => ({ kind: 'color', label, value: getProps()[key] ?? '', swatch: /^#/.test(getProps()[key] || '') ? getProps()[key] : '#ffffff', onChange: (v) => set(key, v) }),
|
|
21
|
+
// A native range slider commits on every drag tick -- dozens a second --
|
|
22
|
+
// and each one used to trigger a full doc clone + re-render (see
|
|
23
|
+
// `commit`), which is what made dragging feel like stutter instead of a
|
|
24
|
+
// smooth slide. A stepper sidesteps that class of bug entirely: +/- is a
|
|
25
|
+
// single discrete commit per click, and typing a value only commits once
|
|
26
|
+
// you're done (on blur), not per keystroke.
|
|
27
|
+
range: (label, key, min, max, step, unit) => {
|
|
28
|
+
const cur = getProps()[key] ?? min;
|
|
29
|
+
const s = step || 1;
|
|
30
|
+
const decimals = (String(s).split('.')[1] || '').length;
|
|
31
|
+
const clamp = (v) => Math.min(max, Math.max(min, v));
|
|
32
|
+
const round = (v) => Number(v.toFixed(decimals));
|
|
33
|
+
const commit = (v) => set(key, round(clamp(v)));
|
|
34
|
+
return {
|
|
35
|
+
kind: 'range', label, value: cur, min, max, step: s, unit: unit || '', display: cur + (unit || ''),
|
|
36
|
+
onDec: () => commit(cur - s),
|
|
37
|
+
onInc: () => commit(cur + s),
|
|
38
|
+
// Live-typed digits aren't clamped (typing "1" toward "12" with a min
|
|
39
|
+
// of 10 would otherwise get snapped back to 10 mid-keystroke, making
|
|
40
|
+
// the second digit impossible to enter) -- only guarded against a
|
|
41
|
+
// non-numeric intermediate state (e.g. a bare "-").
|
|
42
|
+
onInput: (v) => { const n = parseFloat(v); if (!Number.isNaN(n)) set(key, n); },
|
|
43
|
+
// A render tears down and rebuilds the whole panel subtree (no
|
|
44
|
+
// diffing); removing this still-focused input as part of that forces
|
|
45
|
+
// a synchronous, spurious `blur` before the rebuilt replacement can
|
|
46
|
+
// be refocused. Committing on that blur re-renders again, which
|
|
47
|
+
// tears down and blurs again -- an infinite loop. `core.rendering`
|
|
48
|
+
// (set for the render()'s duration) tells a real blur apart from
|
|
49
|
+
// that artifact -- same guard as canvas.js's RTE `onBlur`.
|
|
50
|
+
onBlur: (v) => { if (core && core.rendering) return; const n = parseFloat(v); commit(Number.isNaN(n) ? cur : n); },
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
// A real drag slider, for coarse visual sizing (content width). The
|
|
54
|
+
// per-tick commit problem that ruled sliders out for ordinary fields
|
|
55
|
+
// (see `range` above) is sidestepped by contract: the renderer moves the
|
|
56
|
+
// value bubble live during the drag but only calls `onCommit` on release
|
|
57
|
+
// ('change'), so a whole drag costs one re-render.
|
|
58
|
+
slider: (label, key, min, max, step, unit) => ({
|
|
59
|
+
kind: 'slider', label, value: getProps()[key] ?? min, min, max, step: step || 1, unit: unit || '',
|
|
60
|
+
onCommit: (v) => { const n = parseFloat(v); if (Number.isFinite(n)) set(key, Math.min(max, Math.max(min, n))); },
|
|
61
|
+
}),
|
|
62
|
+
sel: (label, key, opts) => {
|
|
63
|
+
const options = opts.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
|
64
|
+
const current = getProps()[key];
|
|
65
|
+
return {
|
|
66
|
+
kind: 'select', label,
|
|
67
|
+
// New controls must not render as an unexplained blank on documents
|
|
68
|
+
// saved before that property existed. The renderer already uses the
|
|
69
|
+
// first option as its semantic fallback; show that same choice here.
|
|
70
|
+
value: current == null ? (options[0]?.value ?? '') : current,
|
|
71
|
+
options,
|
|
72
|
+
onChange: (v) => set(key, v),
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
seg: (label, key, opts) => ({
|
|
76
|
+
kind: 'seg', label, options: opts.map((o) => {
|
|
77
|
+
const v = typeof o === 'string' ? o : o.value; const l = typeof o === 'string' ? o : o.label;
|
|
78
|
+
const on = getProps()[key] === v;
|
|
79
|
+
return { label: l, bg: on ? 'var(--ed-accent)' : 'transparent', fg: on ? 'var(--ed-accent-ink)' : 'var(--ed-muted)', onClick: () => set(key, v) };
|
|
80
|
+
}),
|
|
81
|
+
}),
|
|
82
|
+
tog: (label, key, defaultOn) => {
|
|
83
|
+
const raw = getProps()[key];
|
|
84
|
+
const on = raw === undefined ? !!defaultOn : !!raw;
|
|
85
|
+
return { kind: 'toggle', label, on, onChange: () => set(key, !on) };
|
|
86
|
+
},
|
|
87
|
+
btn: (label, onClick) => ({ kind: 'btn', label, onClick }),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A grid of linked steppers for props that come in sides/corners ("Space
|
|
93
|
+
* inside" top/bottom/left/right). `toggle` ({on, onChange}), when present,
|
|
94
|
+
* renders as the header's "More options" switch that swaps the linked pair
|
|
95
|
+
* for per-side fields; `label: null` drops the header row entirely (the
|
|
96
|
+
* section head above already names the group).
|
|
97
|
+
*/
|
|
98
|
+
export function group(label, items, toggle) {
|
|
99
|
+
return { kind: 'rangeGroup', label, items, toggle: toggle || null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function decorate(list) {
|
|
103
|
+
return list.filter(Boolean).map((f, i) => {
|
|
104
|
+
const d = Object.assign({}, f, {
|
|
105
|
+
key: i,
|
|
106
|
+
isHead: f.kind === 'head', isArea: f.kind === 'area', isBtn: f.kind === 'btn', isSeg: f.kind === 'seg',
|
|
107
|
+
isRange: f.kind === 'range', isToggle: f.kind === 'toggle', isSocial: f.kind === 'social', isTableGrid: f.kind === 'tablegrid', isRichLinks: f.kind === 'richLinks',
|
|
108
|
+
isRangeGroup: f.kind === 'rangeGroup', isSlider: f.kind === 'slider',
|
|
109
|
+
isRow: ['text', 'num', 'color', 'select'].indexOf(f.kind) > -1,
|
|
110
|
+
isField: ['text', 'num', 'color', 'select'].indexOf(f.kind) > -1,
|
|
111
|
+
isText: f.kind === 'text', isNum: f.kind === 'num', isColor: f.kind === 'color', isSelect: f.kind === 'select',
|
|
112
|
+
});
|
|
113
|
+
// Group items are ranges rendered as grid cells: they need decorated
|
|
114
|
+
// flags and focus keys of their own, namespaced under the group's index
|
|
115
|
+
// so they stay unique against the flat list.
|
|
116
|
+
if (d.isRangeGroup) d.items = f.items.map((it, j) => Object.assign({}, it, { key: i + 'g' + j, isRange: true }));
|
|
117
|
+
return d;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { uid } from './ids.js';
|
|
2
|
+
import { PH } from './placeholder.js';
|
|
3
|
+
import { THEME } from './theme.js';
|
|
4
|
+
|
|
5
|
+
export const BLOCKS = [
|
|
6
|
+
{ type: 'text', code: 'TXT', label: 'Text', hint: 'Rich text — edit inline', make: () => ({ html: 'Hi {{ first_name }} — we cut three things from the fall line and kept the two that mattered. Here they are.', size: 16, lh: 1.6, color: '', align: 'left', weight: '400', py: 10, px: 0 }) },
|
|
7
|
+
{ type: 'image', code: 'IMG', label: 'Image', hint: 'Image from the library', make: () => ({ src: PH('hero image 600 × 320', 600, 320), alt: 'Hero image', width: 100, align: 'center', href: '', radius: 10, py: 0, px: 0 }) },
|
|
8
|
+
{ type: 'button', code: 'BTN', label: 'Button', hint: 'Call to action', make: () => ({ label: 'Shop the drop', href: 'https://example.com', bg: '#0065b3', color: '#ffffff', radius: 8, py: 13, px: 26, align: 'left', size: 15, full: false, borderW: 0, borderStyle: 'solid', borderColor: '' }) },
|
|
9
|
+
{ type: 'divider', code: 'DIV', label: 'Divider', hint: 'Horizontal rule', make: () => ({ thickness: 1, lineStyle: 'solid', color: '#e2e8f0', width: 100, py: 14 }) },
|
|
10
|
+
{ type: 'spacer', code: 'SPC', label: 'Spacer', hint: 'Vertical space', make: () => ({ height: 32 }) },
|
|
11
|
+
{ type: 'social', code: 'SOC', label: 'Social', hint: 'Social icon row', make: () => ({ items: 'Instagram|https://instagram.com\nX|https://x.com\nLinkedIn|https://linkedin.com\nYouTube|https://youtube.com', align: 'center', size: 20, gap: 12, color: '#0065b3', palette: 'custom', shape: 'outline', showLabel: false }) },
|
|
12
|
+
{ type: 'video', code: 'VID', label: 'Video', hint: 'Thumbnail + play badge', make: () => ({ src: PH('video thumbnail 600 × 330', 600, 330), href: 'https://example.com/watch', caption: 'Two minutes inside the workshop', badge: '#172033' }) },
|
|
13
|
+
{ type: 'html', code: 'HTM', label: 'HTML', hint: 'Raw HTML block', make: () => ({ code: '<p style="font:13px/1.6 ui-monospace,monospace;margin:0"><!-- raw HTML passes through untouched --></p>' }) },
|
|
14
|
+
{ type: 'countdown', code: 'CDN', label: 'Countdown', hint: 'Live countdown', make: () => ({ target: new Date(Date.now() + 2.4 * 86400000).toISOString().slice(0, 16), label: 'Subscriber pricing ends in', color: '#172033' }) },
|
|
15
|
+
{ type: 'menu', code: 'NAV', label: 'Menu', hint: 'Navigation row', make: () => ({ items: 'New in|https://example.com/new\nOuterwear|https://example.com/outerwear\nArchive|https://example.com/archive', align: 'center', size: 12, gap: 20, color: '#172033' }) },
|
|
16
|
+
];
|
|
17
|
+
BLOCKS.push(
|
|
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
|
+
{ 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
|
+
{ 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
|
+
{ 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
|
+
{ 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
|
+
);
|
|
25
|
+
BLOCKS.push(
|
|
26
|
+
{ type: 'box', code: 'BOX', label: 'Section box', hint: 'Styled container with free content', make: () => ({ html: '<strong style="font-size:19px;display:block;margin-bottom:6px">Section title</strong>Drop copy here, or paste markup. The box takes background, padding, border and radius.', bg: '#f8fafc', bgImage: '', border: 1, borderStyle: 'solid', lineColor: '#e2e8f0', topBorder: true, rightBorder: true, bottomBorder: true, leftBorder: true, radius: 12, pad: 22, align: 'left', minH: 0, maxW: 100, shadow: false }) },
|
|
27
|
+
{ type: 'svg', code: 'SVG', label: 'Inline SVG', hint: 'Paste SVG markup', make: () => ({ code: '<svg viewBox="0 0 120 40" width="120" height="40" fill="none" stroke="#0065b3" stroke-width="1.5"><rect x="0.75" y="0.75" width="118.5" height="38.5"/><path d="M12 28l14-16 12 10 10-8 18 14"/></svg>', align: 'left', width: 100, py: 10 }) },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export const DEF = (t) => BLOCKS.find((b) => b.type === t);
|
|
31
|
+
export const mk = (t) => ({ id: uid(), type: t, props: DEF(t).make() });
|
|
32
|
+
export const blk = (type, over) => { const b = mk(type); Object.assign(b.props, over || {}); return b; };
|
|
33
|
+
|
|
34
|
+
export const LAYOUTS = [
|
|
35
|
+
{ spans: [100], label: '1 col' },
|
|
36
|
+
{ spans: [50, 50], label: '50 / 50' },
|
|
37
|
+
{ spans: [33, 67], label: '33 / 67' },
|
|
38
|
+
{ spans: [67, 33], label: '67 / 33' },
|
|
39
|
+
{ spans: [33, 34, 33], label: '3 col' },
|
|
40
|
+
{ spans: [25, 25, 25, 25], label: '4 col' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const mkRow = (spans, blocks) => ({
|
|
44
|
+
id: uid(),
|
|
45
|
+
props: {
|
|
46
|
+
bg: '', bgImage: '', bgSize: 'cover', bgPos: 'center', bgRepeat: 'no-repeat', overlay: 0,
|
|
47
|
+
border: 0, borderStyle: 'solid', lineColor: '#e2e2e5', bTop: true, bRight: true, bBottom: true, bLeft: true, radius: 0, shadow: '', maxW: 100,
|
|
48
|
+
mt: 0, mr: 0, mb: 0, ml: 0,
|
|
49
|
+
layout: 'columns', flexDir: 'row', justify: 'flex-start', alignItems: 'stretch', wrap: true, gridCols: 2,
|
|
50
|
+
py: 20, px: 24, padSplit: false, gap: 20, valign: 'top', stackMobile: true,
|
|
51
|
+
},
|
|
52
|
+
cols: spans.map((s, i) => ({ id: uid(), span: s, blocks: i === 0 && blocks ? blocks : [] })),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const groupRows = (id) => (GROUPS[id] ? GROUPS[id].build() : []);
|
|
56
|
+
|
|
57
|
+
/** Compound presets: `build()` returns full row(s) built from ordinary blocks -- once inserted, a group is indistinguishable from any other row/blocks (no groupId persists), so every part stays individually selectable and editable. */
|
|
58
|
+
export const GROUPS = {
|
|
59
|
+
hero: {
|
|
60
|
+
label: 'Hero', icon: 'hero',
|
|
61
|
+
build: () => {
|
|
62
|
+
const row = mkRow([100]);
|
|
63
|
+
Object.assign(row.props, { py: 56, px: 36, bgImage: PH('hero background 1200 × 560', 1200, 560), overlay: 46, gap: 0 });
|
|
64
|
+
row.cols[0].blocks = [
|
|
65
|
+
blk('text', { html: 'AUTUMN 2026', size: 11, align: 'center', color: '#ffffff', py: 0 }),
|
|
66
|
+
blk('heading', { text: 'Built for the long haul', size: 44, align: 'center', color: '#ffffff', py: 10 }),
|
|
67
|
+
blk('text', { html: 'Waxed cotton, storm cuffs, and a repair promise that outlives the season.', size: 15, align: 'center', color: '#ffffff', py: 4 }),
|
|
68
|
+
blk('button', { label: 'Shop the drop', align: 'center', bg: '#ffffff', color: '#172033', radius: 8 }),
|
|
69
|
+
];
|
|
70
|
+
return [row];
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
card: {
|
|
74
|
+
label: 'Card', icon: 'card',
|
|
75
|
+
build: () => {
|
|
76
|
+
const row = mkRow([100]);
|
|
77
|
+
Object.assign(row.props, { bg: '#f8fafc', border: 1, lineColor: '#e2e8f0', radius: 12, py: 0, px: 0, gap: 0 });
|
|
78
|
+
row.cols[0].blocks = [
|
|
79
|
+
blk('image', { src: PH('card image 520 × 300', 520, 300), alt: 'Inside the workshop', py: 0, px: 0 }),
|
|
80
|
+
blk('heading', { text: 'Inside the workshop', size: 22, py: 16, px: 20 }),
|
|
81
|
+
blk('text', { html: 'Three benches, two machines, and a rule about finishing what you start.', size: 13.5, py: 0, px: 20 }),
|
|
82
|
+
blk('button', { label: 'Read the story', bg: '#0065b3', color: '#ffffff', size: 13, py: 10, px: 18, align: 'left' }),
|
|
83
|
+
];
|
|
84
|
+
return [row];
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
product: {
|
|
88
|
+
label: 'Product', icon: 'product',
|
|
89
|
+
build: () => {
|
|
90
|
+
const row = mkRow([45, 55]);
|
|
91
|
+
Object.assign(row.props, { valign: 'middle', py: 16 });
|
|
92
|
+
row.cols[0].blocks = [blk('image', { src: PH('product shot 520 × 520', 520, 520), alt: 'Field Jacket 03', py: 0, px: 0 })];
|
|
93
|
+
row.cols[1].blocks = [
|
|
94
|
+
blk('heading', { text: 'Field Jacket 03', size: 26, py: 0 }),
|
|
95
|
+
blk('text', { html: '<strong>$248</strong> — waxed cotton, storm cuffs, cut and sewn in Portugal.', size: 14, py: 8 }),
|
|
96
|
+
blk('button', { label: 'View product' }),
|
|
97
|
+
];
|
|
98
|
+
return [row];
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
stats: {
|
|
102
|
+
label: 'Stats', icon: 'stats',
|
|
103
|
+
build: () => {
|
|
104
|
+
const row = mkRow([33, 34, 33]);
|
|
105
|
+
Object.assign(row.props, { py: 22, gap: 8 });
|
|
106
|
+
[['12k', 'Subscribers'], ['41%', 'Open rate'], ['8.2%', 'Click rate']].forEach((pair, i) => {
|
|
107
|
+
row.cols[i].blocks = [
|
|
108
|
+
blk('heading', { text: pair[0], size: 32, align: 'center', color: '#0065b3', py: 0 }),
|
|
109
|
+
blk('text', { html: pair[1].toUpperCase(), size: 11, align: 'center', py: 4 }),
|
|
110
|
+
];
|
|
111
|
+
});
|
|
112
|
+
return [row];
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
quote: {
|
|
116
|
+
label: 'Quote', icon: 'quote',
|
|
117
|
+
build: () => {
|
|
118
|
+
const row = mkRow([100]);
|
|
119
|
+
Object.assign(row.props, { py: 18, gap: 0 });
|
|
120
|
+
row.cols[0].blocks = [
|
|
121
|
+
blk('text', { html: '<em>“It shipped in a week and nobody asked me for a spec.”</em>', size: 19, lh: 1.45, py: 0 }),
|
|
122
|
+
blk('text', { html: 'RAE OKONKWO, FOUNDRY SUPPLY', size: 11, py: 6 }),
|
|
123
|
+
];
|
|
124
|
+
return [row];
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
gallery: {
|
|
128
|
+
label: 'Gallery', icon: 'gallery',
|
|
129
|
+
build: () => {
|
|
130
|
+
const row = mkRow([50, 50]);
|
|
131
|
+
Object.assign(row.props, { py: 10, gap: 10 });
|
|
132
|
+
row.cols[0].blocks = [
|
|
133
|
+
blk('image', { src: PH('image 01', 400, 300), alt: '', py: 0, px: 0 }),
|
|
134
|
+
blk('image', { src: PH('image 03', 400, 300), alt: '', py: 10, px: 0 }),
|
|
135
|
+
];
|
|
136
|
+
row.cols[1].blocks = [
|
|
137
|
+
blk('image', { src: PH('image 02', 400, 300), alt: '', py: 0, px: 0 }),
|
|
138
|
+
blk('image', { src: PH('image 04', 400, 300), alt: '', py: 10, px: 0 }),
|
|
139
|
+
];
|
|
140
|
+
return [row];
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
footer: {
|
|
144
|
+
label: 'Footer', icon: 'footer',
|
|
145
|
+
build: () => {
|
|
146
|
+
const row = mkRow([100]);
|
|
147
|
+
Object.assign(row.props, { py: 22, gap: 0 });
|
|
148
|
+
row.cols[0].blocks = [
|
|
149
|
+
blk('divider', { py: 0 }),
|
|
150
|
+
blk('social', { align: 'center', size: 18, shape: 'bare' }),
|
|
151
|
+
blk('text', { html: '<strong>MailCraft, Inc.</strong><br />220 Foundry Street, Suite 4, Portland OR 97209<br />You are receiving this because you signed up at mailcraft.co.', size: 11.5, align: 'center', py: 6 }),
|
|
152
|
+
blk('text', { html: '<a href="{{ unsubscribe_url }}">Unsubscribe</a> · <a href="#">Update preferences</a>', size: 11.5, align: 'center', py: 0 }),
|
|
153
|
+
];
|
|
154
|
+
return [row];
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const PALETTE = [
|
|
160
|
+
{ t: 'heading' }, { t: 'text' }, { t: 'list' }, { g: 'quote' }, { t: 'image' }, { g: 'gallery' },
|
|
161
|
+
{ t: 'button' }, { t: 'table' }, { g: 'card' }, { g: 'product' }, { g: 'hero' }, { g: 'stats' },
|
|
162
|
+
{ t: 'box' }, { t: 'divider' }, { t: 'spacer' }, { t: 'social' }, { t: 'video' },
|
|
163
|
+
{ t: 'embed' }, { t: 'menu' }, { g: 'footer' }, { t: 'html' }, { t: 'css' }, { t: 'svg' },
|
|
164
|
+
{ t: 'codeblock' }, { t: 'countdown' },
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The document a fresh editor opens on: one empty row, default theme.
|
|
169
|
+
*
|
|
170
|
+
* Deliberately not a sample template. Template galleries belong to the host's
|
|
171
|
+
* own UI (pushed in via `editor.loadTemplate`), so booting into a built-in
|
|
172
|
+
* marketing layout would both pull a template catalogue into every bundle and
|
|
173
|
+
* put content in a host's editor that they never chose.
|
|
174
|
+
*/
|
|
175
|
+
export const blankDoc = () => ({ theme: THEME(), rows: [mkRow([100])] });
|
|
176
|
+
|
|
177
|
+
export function migrateDoc(doc) {
|
|
178
|
+
const defaults = mkRow([100]).props;
|
|
179
|
+
const emptied = [];
|
|
180
|
+
(doc.rows || []).forEach((r) => {
|
|
181
|
+
const hadBlocks = r.cols.some((c) => c.blocks.length);
|
|
182
|
+
// Outside spacing used to be one vertical `my` value. Seed the complete
|
|
183
|
+
// four-side model before defaults are applied so old documents keep their
|
|
184
|
+
// top/bottom spacing and gain neutral left/right values.
|
|
185
|
+
const legacyMarginY = r.props.my ?? 0;
|
|
186
|
+
if (r.props.mt === undefined) r.props.mt = legacyMarginY;
|
|
187
|
+
if (r.props.mr === undefined) r.props.mr = 0;
|
|
188
|
+
if (r.props.mb === undefined) r.props.mb = legacyMarginY;
|
|
189
|
+
if (r.props.ml === undefined) r.props.ml = 0;
|
|
190
|
+
r.cols.forEach((c) => {
|
|
191
|
+
// A block whose type this build no longer knows is dropped rather than
|
|
192
|
+
// rendered as an unrecognized shape; a row emptied that way goes with it.
|
|
193
|
+
c.blocks = c.blocks.filter((b) => DEF(b.type));
|
|
194
|
+
// `social.shape` used to have a 'solid' value meaning "filled badge, no
|
|
195
|
+
// particular shape" -- now that shape is split into 'circle'/'square'
|
|
196
|
+
// badges, an old 'solid' document reads as the (arbitrary but stable)
|
|
197
|
+
// 'square' choice rather than silently becoming an unrecognized value.
|
|
198
|
+
c.blocks.forEach((b) => { if (b.type === 'social' && b.props.shape === 'solid') b.props.shape = 'square'; });
|
|
199
|
+
});
|
|
200
|
+
Object.keys(defaults).forEach((k) => { if (r.props[k] === undefined) r.props[k] = defaults[k]; });
|
|
201
|
+
if (hadBlocks && !r.cols.some((c) => c.blocks.length)) emptied.push(r.id);
|
|
202
|
+
});
|
|
203
|
+
doc.rows = (doc.rows || []).filter((r) => emptied.indexOf(r.id) < 0);
|
|
204
|
+
return doc;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Any plausible document -> a complete, renderable one.
|
|
209
|
+
*
|
|
210
|
+
* The HTML path already works this way: `parseHtmlSource` classifies what it
|
|
211
|
+
* can and wraps the rest in a raw-html row rather than dropping it, so no
|
|
212
|
+
* source is ever rejected outright. JSON now gets the same contract. A partial
|
|
213
|
+
* document -- hand-written, generated by a backend, or saved by an older
|
|
214
|
+
* version -- is filled in rather than handed to the renderer with a hole in it.
|
|
215
|
+
*
|
|
216
|
+
* Everything the renderer indexes into is seeded: the six theme keys (an empty
|
|
217
|
+
* `theme` used to throw on `theme.font.replace` at export time), every row
|
|
218
|
+
* prop, at least one column per row, per-type block props from
|
|
219
|
+
* `DEF(type).make()`, and ids -- a document written by hand has none, and rows
|
|
220
|
+
* and blocks are addressed by id everywhere in the editor.
|
|
221
|
+
*
|
|
222
|
+
* Blocks of a type this build no longer knows are kept here and left to
|
|
223
|
+
* `migrateDoc`, which converts the legacy ones and drops the rest; filtering
|
|
224
|
+
* them out first would lose the conversions.
|
|
225
|
+
*
|
|
226
|
+
* Returns null when there is nothing usable, so a caller can no-op instead of
|
|
227
|
+
* replacing the user's document with an empty one.
|
|
228
|
+
*/
|
|
229
|
+
export function normalizeDoc(input) {
|
|
230
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
231
|
+
const rowDefaults = mkRow([100]).props;
|
|
232
|
+
const rows = (Array.isArray(input.rows) ? input.rows : []).filter((r) => r && typeof r === 'object');
|
|
233
|
+
const doc = {
|
|
234
|
+
theme: Object.assign(THEME(), input.theme || {}),
|
|
235
|
+
rows: rows.map((r) => {
|
|
236
|
+
const raw = Array.isArray(r.cols) && r.cols.length ? r.cols.filter((c) => c && typeof c === 'object') : [{}];
|
|
237
|
+
const cols = raw.length ? raw : [{}];
|
|
238
|
+
const evenSpan = Math.round(100 / cols.length);
|
|
239
|
+
return {
|
|
240
|
+
id: r.id || uid(),
|
|
241
|
+
props: Object.assign({}, rowDefaults, r.props || {}),
|
|
242
|
+
cols: cols.map((c) => ({
|
|
243
|
+
id: c.id || uid(),
|
|
244
|
+
span: Number(c.span) > 0 ? Number(c.span) : evenSpan,
|
|
245
|
+
blocks: (Array.isArray(c.blocks) ? c.blocks : []).filter((b) => b && typeof b === 'object' && b.type).map((b) => (
|
|
246
|
+
DEF(b.type)
|
|
247
|
+
? { id: b.id || uid(), type: b.type, props: Object.assign(DEF(b.type).make(), b.props || {}) }
|
|
248
|
+
: { id: b.id || uid(), type: b.type, props: b.props || {} }
|
|
249
|
+
)),
|
|
250
|
+
})),
|
|
251
|
+
};
|
|
252
|
+
}),
|
|
253
|
+
};
|
|
254
|
+
if (!doc.rows.length) return null;
|
|
255
|
+
return migrateDoc(doc);
|
|
256
|
+
}
|