@seliseblocks/mailcraft 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DOCS.md +557 -0
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/mailcraft-editor.bundle.js +519 -0
- package/dist/mailcraft-editor.bundle.js.map +7 -0
- package/examples/templates/activate-your-account.html +54 -0
- package/examples/templates/back-in-stock.html +63 -0
- package/examples/templates/cart-left-behind.html +74 -0
- package/examples/templates/community-giveaway.html +81 -0
- package/examples/templates/frontend-futures-invite.html +69 -0
- package/examples/templates/give-25-get-25.html +69 -0
- package/examples/templates/invoice-paid.html +77 -0
- package/examples/templates/meet-nova-launch.html +69 -0
- package/examples/templates/mega-weekend-sale.html +57 -0
- package/examples/templates/order-confirmed.html +80 -0
- package/examples/templates/rate-your-headphones.html +71 -0
- package/examples/templates/reset-your-password.html +54 -0
- package/examples/templates/thankyou-promo-code.html +68 -0
- package/examples/templates/the-sunday-brief.html +47 -0
- package/examples/templates/welcome-to-your-workspace.html +62 -0
- package/examples/templates/your-order-shipped.html +74 -0
- package/examples/templates/your-password-was-changed.html +58 -0
- package/examples/templates/your-signin-code.html +58 -0
- package/examples/vanilla.html +1574 -0
- package/package.json +66 -0
- package/src/core/accent.js +194 -0
- package/src/core/assets.js +15 -0
- package/src/core/binder.js +119 -0
- package/src/core/blocks.js +256 -0
- package/src/core/css-cascade.js +117 -0
- package/src/core/editor-core.js +1492 -0
- package/src/core/export.js +58 -0
- package/src/core/footer.js +73 -0
- package/src/core/i18n/ar.js +177 -0
- package/src/core/i18n/bg.js +152 -0
- package/src/core/i18n/bn.js +176 -0
- package/src/core/i18n/ca.js +152 -0
- package/src/core/i18n/cs.js +152 -0
- package/src/core/i18n/da.js +152 -0
- package/src/core/i18n/de-CH.js +152 -0
- package/src/core/i18n/de.js +152 -0
- package/src/core/i18n/dz.js +179 -0
- package/src/core/i18n/el.js +152 -0
- package/src/core/i18n/en.js +269 -0
- package/src/core/i18n/es.js +152 -0
- package/src/core/i18n/et.js +152 -0
- package/src/core/i18n/fi.js +152 -0
- package/src/core/i18n/fr.js +152 -0
- package/src/core/i18n/hr.js +152 -0
- package/src/core/i18n/hu.js +152 -0
- package/src/core/i18n/index.js +83 -0
- package/src/core/i18n/it.js +152 -0
- package/src/core/i18n/lt.js +152 -0
- package/src/core/i18n/lv.js +152 -0
- package/src/core/i18n/nb.js +152 -0
- package/src/core/i18n/nl.js +152 -0
- package/src/core/i18n/pl.js +152 -0
- package/src/core/i18n/pt.js +152 -0
- package/src/core/i18n/ro.js +152 -0
- package/src/core/i18n/ru.js +152 -0
- package/src/core/i18n/sk.js +152 -0
- package/src/core/i18n/sl.js +152 -0
- package/src/core/i18n/sv.js +152 -0
- package/src/core/i18n/tables.js +50 -0
- package/src/core/i18n/tr.js +152 -0
- package/src/core/i18n/uk.js +152 -0
- package/src/core/icons.js +235 -0
- package/src/core/ids.js +1 -0
- package/src/core/import-html.js +959 -0
- package/src/core/layout-style.js +115 -0
- package/src/core/parse.js +10 -0
- package/src/core/placeholder.js +15 -0
- package/src/core/sanitize.js +141 -0
- package/src/core/storage-limits.js +184 -0
- package/src/core/storage.js +85 -0
- package/src/core/theme.js +1 -0
- package/src/core/toolbar.js +67 -0
- package/src/core/variables.js +11 -0
- package/src/create-editor.js +109 -0
- package/src/index.js +9 -0
- package/src/mailcraft-editor.js +1862 -0
- package/src/render/block-body.js +295 -0
- package/src/render/canvas.js +284 -0
- package/src/render/fields.js +609 -0
- package/src/render/focus-preserve.js +158 -0
- package/src/render/rte.js +212 -0
- package/src/render/screenshot.js +132 -0
- package/src/render/story.js +415 -0
- package/src/render/style.js +452 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { icon, brandIcon, socialKey, SOCIAL_BRAND, contrastInk } from '../core/icons.js';
|
|
2
|
+
import { pad } from '../core/layout-style.js';
|
|
3
|
+
import { parseItems, cellsOf } from '../core/parse.js';
|
|
4
|
+
|
|
5
|
+
function el(tag, style, attrs) {
|
|
6
|
+
const node = document.createElement(tag);
|
|
7
|
+
if (style) Object.assign(node.style, style);
|
|
8
|
+
if (attrs) for (const [k, v] of Object.entries(attrs)) {
|
|
9
|
+
if (k === 'html') node.innerHTML = v;
|
|
10
|
+
else if (k === 'text') node.textContent = v;
|
|
11
|
+
else if (v !== undefined) node.setAttribute(k, v);
|
|
12
|
+
}
|
|
13
|
+
return node;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function m(v) { return String(v == null ? '' : v); }
|
|
17
|
+
|
|
18
|
+
/** An explicit block font owns every rich descendant; an empty value deliberately leaves imported inline typography untouched. */
|
|
19
|
+
function overrideRichFont(root, fontFamily) {
|
|
20
|
+
if (!fontFamily) return;
|
|
21
|
+
root.querySelectorAll('[style]').forEach((node) => node.style.removeProperty('font-family'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ported from the original `blockBody(b, theme, live)`. `ctx` supplies the
|
|
26
|
+
* editing wiring that in the original lived on `this` (Component instance):
|
|
27
|
+
* `ctx.editingId`, `ctx.onFocus(block, el, key)`, `ctx.onBlur(block, key, value)`,
|
|
28
|
+
* `ctx.onPaste(block, plainOnly)`, `ctx.renderRte(block)` (returns the floating
|
|
29
|
+
* toolbar node or null), `ctx.onTableCellBlur(block, ri, ci, value)`, `ctx.now`
|
|
30
|
+
* (for the countdown), `ctx.vars` and `ctx.onInsertVariable` for the code-view escape hatch.
|
|
31
|
+
*/
|
|
32
|
+
export function blockBody(b, theme, live, ctx) {
|
|
33
|
+
const p = b.props; const t = theme;
|
|
34
|
+
const attr = live ? { 'data-mc-content': b.id } : {};
|
|
35
|
+
|
|
36
|
+
switch (b.type) {
|
|
37
|
+
case 'text': {
|
|
38
|
+
const edit = live;
|
|
39
|
+
const content = el('div', {
|
|
40
|
+
padding: pad(p), fontSize: p.size + 'px', lineHeight: p.lh, textAlign: p.align, fontWeight: p.weight, color: p.color || t.text, outline: 'none', fontFamily: p.fontFamily || t.font,
|
|
41
|
+
}, { ...attr, contenteditable: edit ? 'true' : undefined, spellcheck: 'false', html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
42
|
+
// Imported email HTML keeps inline font-family declarations so an
|
|
43
|
+
// untouched block retains its source typography. Once the user chooses
|
|
44
|
+
// a block font, however, those nested declarations outrank inheritance
|
|
45
|
+
// from this wrapper and made the Font control appear broken. Remove only
|
|
46
|
+
// that imported property from descendants; their size, color, spacing
|
|
47
|
+
// and other inline formatting remain intact, while the selected family
|
|
48
|
+
// now applies consistently in the canvas and exported read-back DOM.
|
|
49
|
+
overrideRichFont(content, p.fontFamily);
|
|
50
|
+
if (edit) wireEditable(content, b, 'html', ctx, false);
|
|
51
|
+
if (!live) return content;
|
|
52
|
+
return wrapWithRte(b, ctx, edit && ctx.editingId === b.id, content);
|
|
53
|
+
}
|
|
54
|
+
case 'image': {
|
|
55
|
+
const wrap = el('div', { padding: pad(p), textAlign: p.align, fontSize: '0' }, attr);
|
|
56
|
+
if (p.href) {
|
|
57
|
+
// The % width must live on the anchor, not the img: a percentage on a
|
|
58
|
+
// child of a shrink-to-fit inline-block resolves against the image's
|
|
59
|
+
// own intrinsic size (i.e. not at all), which rendered every linked
|
|
60
|
+
// logo/icon at full intrinsic width no matter what `width` said.
|
|
61
|
+
const a = el('a', { display: 'inline-block', width: p.width + '%' }, { href: p.href });
|
|
62
|
+
a.appendChild(el('img', { width: '100%', borderRadius: p.radius + 'px', display: 'block', border: '0' }, { src: p.src, alt: p.alt }));
|
|
63
|
+
wrap.appendChild(a);
|
|
64
|
+
} else {
|
|
65
|
+
wrap.appendChild(el('img', { width: p.width + '%', maxWidth: '100%', borderRadius: p.radius + 'px', display: 'inline-block', border: '0' }, { src: p.src, alt: p.alt }));
|
|
66
|
+
}
|
|
67
|
+
return wrap;
|
|
68
|
+
}
|
|
69
|
+
case 'button': {
|
|
70
|
+
const wrap = el('div', { textAlign: p.align, padding: '4px 0' }, attr);
|
|
71
|
+
const a = el('a', {
|
|
72
|
+
display: p.full ? 'block' : 'inline-block', background: p.bg, color: p.color, textDecoration: 'none',
|
|
73
|
+
padding: p.py + 'px ' + p.px + 'px', borderRadius: p.radius + 'px', fontFamily: p.fontFamily || t.font, fontSize: p.size + 'px',
|
|
74
|
+
fontWeight: '600', letterSpacing: '0.02em', outline: 'none',
|
|
75
|
+
// Outline-style buttons (transparent fill + border) are a standard
|
|
76
|
+
// email pattern; the color falls back to the label color so a bare
|
|
77
|
+
// "outline thickness" bump looks right without a second step.
|
|
78
|
+
border: p.borderW ? p.borderW + 'px ' + (p.borderStyle || 'solid') + ' ' + (p.borderColor || p.color) : '0',
|
|
79
|
+
}, { href: p.href, contenteditable: live ? 'true' : undefined, spellcheck: 'false', text: p.label });
|
|
80
|
+
a.addEventListener('click', (e) => e.preventDefault());
|
|
81
|
+
// Button editing is deliberately plain: no focus-tracked `editing` state, no
|
|
82
|
+
// paste handling, no floating RTE toolbar -- only its label commits on blur.
|
|
83
|
+
if (live) a.addEventListener('blur', (e) => { if (e.target.textContent !== p.label) ctx.onBlur(b, 'label', e.target.textContent); });
|
|
84
|
+
wrap.appendChild(a);
|
|
85
|
+
return wrap;
|
|
86
|
+
}
|
|
87
|
+
case 'divider': {
|
|
88
|
+
const wrap = el('div', { padding: p.py + 'px 0' }, attr);
|
|
89
|
+
wrap.appendChild(el('div', { height: '0', width: p.width + '%', margin: '0 auto', borderTop: p.thickness + 'px ' + (p.lineStyle || 'solid') + ' ' + p.color }));
|
|
90
|
+
return wrap;
|
|
91
|
+
}
|
|
92
|
+
case 'spacer':
|
|
93
|
+
return el('div', { height: p.height + 'px' }, attr);
|
|
94
|
+
case 'social': {
|
|
95
|
+
// Two independent axes cover every style the block offers without a
|
|
96
|
+
// second hand-drawn icon set per platform: `palette` picks the source
|
|
97
|
+
// color (the block's one `color` prop, or each platform's own brand
|
|
98
|
+
// hex), `shape` picks the container -- bordered tap-target, bare icon,
|
|
99
|
+
// or a filled circle/square badge (the badge's icon color auto-flips
|
|
100
|
+
// black/white via `contrastInk` so light brand colors like Snapchat
|
|
101
|
+
// yellow stay legible).
|
|
102
|
+
const shape = p.shape || 'outline';
|
|
103
|
+
const palette = p.palette || 'custom';
|
|
104
|
+
const badge = shape === 'circle' || shape === 'square';
|
|
105
|
+
const box = Math.round((p.size || 20) * 1.9);
|
|
106
|
+
const wrap = el('div', { textAlign: p.align, padding: '8px 0' }, attr);
|
|
107
|
+
parseItems(p.items).forEach((it) => {
|
|
108
|
+
const key = socialKey(it.label);
|
|
109
|
+
const source = palette === 'brand' ? (SOCIAL_BRAND[key] || p.color) : p.color;
|
|
110
|
+
const iconColor = badge ? contrastInk(source) : source;
|
|
111
|
+
const a = el('a', {
|
|
112
|
+
display: 'inline-flex', flexDirection: p.showLabel ? 'column' : 'row', alignItems: 'center', justifyContent: 'center', gap: '5px',
|
|
113
|
+
width: p.showLabel ? 'auto' : box + 'px', height: p.showLabel ? 'auto' : box + 'px', padding: p.showLabel ? '7px 9px' : '0',
|
|
114
|
+
margin: '0 ' + (p.gap || 12) / 2 + 'px', verticalAlign: 'middle', textDecoration: 'none',
|
|
115
|
+
borderRadius: shape === 'circle' ? '50%' : shape === 'square' ? Math.round(box * 0.28) + 'px' : '0',
|
|
116
|
+
color: iconColor, background: badge ? source : 'transparent',
|
|
117
|
+
border: shape === 'outline' ? '1px solid ' + source : '0',
|
|
118
|
+
}, { href: it.href, title: it.label });
|
|
119
|
+
a.addEventListener('click', (e) => e.preventDefault());
|
|
120
|
+
let provided = null;
|
|
121
|
+
if (ctx && ctx.iconProvider) { try { provided = ctx.iconProvider(key, { label: it.label, size: p.size, color: iconColor }); } catch { provided = null; } }
|
|
122
|
+
a.appendChild(provided instanceof Node ? provided : brandIcon(key, p.size));
|
|
123
|
+
if (p.showLabel) {
|
|
124
|
+
const span = el('span', { fontFamily: p.fontFamily || t.font, fontSize: '10px', letterSpacing: '0.12em', textTransform: 'uppercase' }, { text: it.label });
|
|
125
|
+
a.appendChild(span);
|
|
126
|
+
}
|
|
127
|
+
wrap.appendChild(a);
|
|
128
|
+
});
|
|
129
|
+
return wrap;
|
|
130
|
+
}
|
|
131
|
+
case 'video': {
|
|
132
|
+
const wrap = el('div', { padding: '4px 0', textAlign: 'center' }, attr);
|
|
133
|
+
const a = el('a', { display: 'block', position: 'relative' }, { href: p.href });
|
|
134
|
+
a.addEventListener('click', (e) => e.preventDefault());
|
|
135
|
+
a.appendChild(el('img', { width: '100%', display: 'block', border: '0' }, { src: p.src, alt: p.caption }));
|
|
136
|
+
const badge = el('span', { position: 'absolute', inset: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' });
|
|
137
|
+
const circle = el('span', { width: '54px', height: '54px', borderRadius: '50%', background: p.badge, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', paddingLeft: '4px' }, { text: '▶' });
|
|
138
|
+
badge.appendChild(circle);
|
|
139
|
+
a.appendChild(badge);
|
|
140
|
+
wrap.appendChild(a);
|
|
141
|
+
wrap.appendChild(el('div', { fontFamily: t.font, fontSize: '12.5px', color: p.badge, opacity: '0.7', marginTop: '8px' }, { text: p.caption }));
|
|
142
|
+
return wrap;
|
|
143
|
+
}
|
|
144
|
+
case 'html': {
|
|
145
|
+
const raw = String(p.code || '');
|
|
146
|
+
const edit = live && !/<style|<scr/i.test(raw);
|
|
147
|
+
const safe = live
|
|
148
|
+
? raw.replace(/<style([^>]*)>([\s\S]*?)<\/style>/gi, (mm, at, css) => '<style' + at + '>' + ctx.scopeCss(css, '[data-mc-sheet]') + '</style>')
|
|
149
|
+
: raw;
|
|
150
|
+
const content = el('div', { padding: '6px 0', fontFamily: t.font, color: t.text, outline: 'none' }, { ...attr, contenteditable: edit ? 'true' : undefined, spellcheck: 'false', html: m(safe), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
151
|
+
if (edit) wireEditable(content, b, 'code', ctx, false);
|
|
152
|
+
if (!live) return content;
|
|
153
|
+
return wrapWithRte(b, ctx, edit && ctx.editingId === b.id, content);
|
|
154
|
+
}
|
|
155
|
+
case 'countdown': {
|
|
156
|
+
const ms = Math.max(0, new Date(p.target).getTime() - ctx.now);
|
|
157
|
+
const parts = [['days', Math.floor(ms / 86400000)], ['hrs', Math.floor(ms / 3600000) % 24], ['min', Math.floor(ms / 60000) % 60], ['sec', Math.floor(ms / 1000) % 60]];
|
|
158
|
+
// The data-mc-countdown/data-mc-count hooks (live canvas only -- export
|
|
159
|
+
// reads this DOM back) let the 1s tick repaint just these digits
|
|
160
|
+
// instead of re-rendering the editor (see the tick in editor-core.js).
|
|
161
|
+
const wrap = el('div', { padding: '10px 0', textAlign: 'center', fontFamily: p.fontFamily || t.font, color: p.color }, live ? Object.assign({ 'data-mc-countdown': p.target }, attr) : attr);
|
|
162
|
+
wrap.appendChild(el('div', { fontSize: '12px', letterSpacing: '0.14em', textTransform: 'uppercase', opacity: '0.6', marginBottom: '10px' }, { text: p.label }));
|
|
163
|
+
const row = el('div', { display: 'flex', justifyContent: 'center', gap: '10px' });
|
|
164
|
+
parts.forEach(([lab, v]) => {
|
|
165
|
+
const box = el('div', { border: '1px solid ' + p.color + '33', padding: '9px 12px', minWidth: '58px' });
|
|
166
|
+
box.appendChild(el('div', { fontSize: '25px', fontFamily: p.fontFamily || t.font, fontWeight: '700', lineHeight: '1' }, live ? { text: String(v).padStart(2, '0'), 'data-mc-count': lab } : { text: String(v).padStart(2, '0') }));
|
|
167
|
+
box.appendChild(el('div', { fontSize: '9.5px', letterSpacing: '0.16em', textTransform: 'uppercase', opacity: '0.55', marginTop: '4px' }, { text: lab }));
|
|
168
|
+
row.appendChild(box);
|
|
169
|
+
});
|
|
170
|
+
wrap.appendChild(row);
|
|
171
|
+
return wrap;
|
|
172
|
+
}
|
|
173
|
+
case 'menu': {
|
|
174
|
+
const wrap = el('div', { textAlign: p.align, padding: '10px 0' }, attr);
|
|
175
|
+
parseItems(p.items).forEach((it) => {
|
|
176
|
+
const a = el('a', { color: p.color, fontSize: p.size + 'px', fontFamily: p.fontFamily || t.font, textDecoration: 'none', margin: '0 ' + p.gap / 2 + 'px', letterSpacing: '0.12em', textTransform: 'uppercase' }, { href: it.href, text: it.label });
|
|
177
|
+
a.addEventListener('click', (e) => e.preventDefault());
|
|
178
|
+
wrap.appendChild(a);
|
|
179
|
+
});
|
|
180
|
+
return wrap;
|
|
181
|
+
}
|
|
182
|
+
case 'heading': {
|
|
183
|
+
const edit = live;
|
|
184
|
+
const head = el(p.level || 'h2', {
|
|
185
|
+
margin: '0', padding: pad(p), fontSize: p.size + 'px', lineHeight: p.lh, textAlign: p.align,
|
|
186
|
+
fontWeight: p.weight, letterSpacing: p.font === 'condensed' ? '0.005em' : '-0.01em',
|
|
187
|
+
color: p.color || t.text, outline: 'none',
|
|
188
|
+
// An explicit per-block font beats the Condensed/Body style toggle.
|
|
189
|
+
fontFamily: p.fontFamily || (p.font === 'condensed' ? "'Arial Narrow', 'Helvetica Neue Condensed', Helvetica, Arial, sans-serif" : t.font),
|
|
190
|
+
}, { ...attr, contenteditable: edit ? 'true' : undefined, spellcheck: 'false', text: m(p.text), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
191
|
+
if (edit) wireEditable(head, b, 'text', ctx, true);
|
|
192
|
+
if (!live) return head;
|
|
193
|
+
return wrapWithRte(b, ctx, ctx.editingId === b.id, head);
|
|
194
|
+
}
|
|
195
|
+
case 'list': {
|
|
196
|
+
const items = String(p.items || '').split('\n').filter((l) => l.trim());
|
|
197
|
+
const list = el(p.ordered ? 'ol' : 'ul', { margin: '0', padding: (p.py || 0) + 'px 0 ' + (p.py || 0) + 'px 22px', fontFamily: p.fontFamily || t.font, fontSize: p.size + 'px', lineHeight: p.lh, color: p.color || t.text }, attr);
|
|
198
|
+
items.forEach((it) => {
|
|
199
|
+
const li = el('li', { marginBottom: p.gap + 'px' }, { html: m(it) });
|
|
200
|
+
list.appendChild(li);
|
|
201
|
+
});
|
|
202
|
+
overrideRichFont(list, p.fontFamily);
|
|
203
|
+
return list;
|
|
204
|
+
}
|
|
205
|
+
case 'table': {
|
|
206
|
+
const rows = cellsOf(p);
|
|
207
|
+
const edit = live;
|
|
208
|
+
const line = p.borders ? (p.borderWidth || 1) + 'px ' + (p.borderStyle || 'solid') + ' ' + p.lineColor : '0';
|
|
209
|
+
const table = el('table', { width: p.width + '%', borderCollapse: 'collapse', fontFamily: p.fontFamily || t.font, fontSize: p.size + 'px', color: t.text, border: line }, { ...attr, cellpadding: '0', cellspacing: '0' });
|
|
210
|
+
const tbody = el('tbody');
|
|
211
|
+
rows.forEach((row, ri) => {
|
|
212
|
+
const isHead = p.header && ri === 0;
|
|
213
|
+
const tr = el('tr', { background: isHead ? p.headBg : (p.striped && ri % 2 === 0 ? 'rgba(29,31,32,0.028)' : 'transparent') });
|
|
214
|
+
row.forEach((cell, ci) => {
|
|
215
|
+
const td = el(isHead ? 'th' : 'td', {
|
|
216
|
+
border: line, padding: p.pad + 'px ' + Math.round(p.pad * 1.2) + 'px', textAlign: ci === 0 ? p.align : (isHead ? p.align : 'left'),
|
|
217
|
+
fontWeight: isHead ? '600' : '400', outline: 'none',
|
|
218
|
+
fontFamily: p.fontFamily || t.font,
|
|
219
|
+
letterSpacing: isHead ? '0.06em' : 'normal', textTransform: isHead ? 'uppercase' : 'none', fontSize: isHead ? p.size + 1 + 'px' : p.size + 'px',
|
|
220
|
+
}, { contenteditable: edit ? 'true' : undefined, spellcheck: 'false', text: m(cell), 'data-focus-key': edit ? 'block:' + b.id + ':c' + ri + '-' + ci : undefined });
|
|
221
|
+
if (edit) {
|
|
222
|
+
// stopPropagation is load-bearing (a bubbled click would select
|
|
223
|
+
// via the block wrapper AND deselect via the workspace), but it
|
|
224
|
+
// also meant clicking a cell never selected the table at all --
|
|
225
|
+
// the cells cover the whole block. Selecting on *focus* instead,
|
|
226
|
+
// with the focus key above letting focus-preserve restore the
|
|
227
|
+
// caret across the selection re-render, gives the table the same
|
|
228
|
+
// click-to-select behavior as a text block.
|
|
229
|
+
td.addEventListener('click', (e) => e.stopPropagation());
|
|
230
|
+
td.addEventListener('focus', () => { if (ctx.selectBlock) ctx.selectBlock(b); });
|
|
231
|
+
td.addEventListener('blur', () => ctx.onTableCellBlur(b, ri, ci, td.textContent));
|
|
232
|
+
}
|
|
233
|
+
tr.appendChild(td);
|
|
234
|
+
});
|
|
235
|
+
tbody.appendChild(tr);
|
|
236
|
+
});
|
|
237
|
+
table.appendChild(tbody);
|
|
238
|
+
return table;
|
|
239
|
+
}
|
|
240
|
+
case 'embed': {
|
|
241
|
+
const wrap = el('div', { padding: p.py + 'px 0' }, attr);
|
|
242
|
+
wrap.appendChild(el('iframe', { width: '100%', height: p.height + 'px', border: '1px solid rgba(29,31,32,0.14)', background: '#fff', display: 'block' }, { src: p.src, title: p.label }));
|
|
243
|
+
return wrap;
|
|
244
|
+
}
|
|
245
|
+
case 'css': {
|
|
246
|
+
const wrap = el('div', { fontSize: '0', lineHeight: '0' }, attr);
|
|
247
|
+
wrap.appendChild(el('style', {}, { html: live ? ctx.scopeCss(p.code, '[data-mc-sheet]') : p.code }));
|
|
248
|
+
if (live) {
|
|
249
|
+
wrap.appendChild(el('div', { fontFamily: 'ui-monospace, monospace', fontSize: '10px', lineHeight: '1.5', letterSpacing: '0.06em', textTransform: 'uppercase', color: '#7d8791', border: '1px dashed rgba(89,128,166,0.5)', padding: '8px 10px' }, { text: 'style block — ' + (p.code || '').length + ' chars, hidden when sent' }));
|
|
250
|
+
}
|
|
251
|
+
return wrap;
|
|
252
|
+
}
|
|
253
|
+
case 'box': {
|
|
254
|
+
const edit = live;
|
|
255
|
+
const borderSide = (on) => (p.border && on !== false ? p.border + 'px ' + (p.borderStyle || 'solid') + ' ' + p.lineColor : '0');
|
|
256
|
+
const box = el('div', {
|
|
257
|
+
background: p.bgImage ? 'linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0)), url("' + p.bgImage + '")' : p.bg,
|
|
258
|
+
backgroundSize: 'cover', backgroundPosition: 'center',
|
|
259
|
+
borderTop: borderSide(p.topBorder), borderRight: borderSide(p.rightBorder),
|
|
260
|
+
borderBottom: borderSide(p.bottomBorder), borderLeft: borderSide(p.leftBorder),
|
|
261
|
+
borderRadius: p.radius + 'px', padding: p.pad + 'px', textAlign: p.align,
|
|
262
|
+
minHeight: p.minH ? p.minH + 'px' : 'auto', maxWidth: p.maxW + '%', margin: p.align === 'center' ? '0 auto' : '0',
|
|
263
|
+
boxShadow: p.shadow ? '0 10px 30px rgba(29,31,32,0.12)' : 'none',
|
|
264
|
+
fontFamily: t.font, color: t.text, fontSize: '15px', lineHeight: '1.6',
|
|
265
|
+
}, { ...attr, contenteditable: edit ? 'true' : undefined, spellcheck: 'false', html: m(p.html), 'data-focus-key': edit ? 'block:' + b.id : undefined });
|
|
266
|
+
if (edit) wireEditable(box, b, 'html', ctx, false);
|
|
267
|
+
if (!live) return box;
|
|
268
|
+
return wrapWithRte(b, ctx, ctx.editingId === b.id, box);
|
|
269
|
+
}
|
|
270
|
+
case 'svg':
|
|
271
|
+
return el('div', { padding: p.py + 'px 0', textAlign: p.align }, { ...attr, html: '<span style="display:inline-block;width:' + p.width + '%">' + p.code + '</span>' });
|
|
272
|
+
case 'codeblock':
|
|
273
|
+
return el('pre', { margin: '0', padding: p.pad + 'px', background: p.bg, color: p.color, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: p.size + 'px', lineHeight: '1.6', overflowX: 'auto', whiteSpace: 'pre' }, { ...attr, text: m(p.code) });
|
|
274
|
+
default:
|
|
275
|
+
return el('div');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function wireEditable(node, block, key, ctx, isPlainText) {
|
|
280
|
+
node.addEventListener('paste', (e) => ctx.onPaste(e, isPlainText));
|
|
281
|
+
node.addEventListener('focus', (e) => ctx.onFocus(block, e.currentTarget, key, isPlainText));
|
|
282
|
+
node.addEventListener('blur', (e) => {
|
|
283
|
+
if (ctx.rteActiveRef && ctx.rteActiveRef.current) return;
|
|
284
|
+
const val = isPlainText ? e.target.textContent : e.target.innerHTML;
|
|
285
|
+
ctx.onBlur(block, key, val);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** `h('div',{style:{position:'relative'}}, editing===b.id ? this.rte(b) : null, content)` */
|
|
290
|
+
function wrapWithRte(block, ctx, showRte, content) {
|
|
291
|
+
const wrap = el('div', { position: 'relative' });
|
|
292
|
+
if (showRte) wrap.appendChild(ctx.renderRte(block));
|
|
293
|
+
wrap.appendChild(content);
|
|
294
|
+
return wrap;
|
|
295
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { DEF } from '../core/blocks.js';
|
|
2
|
+
import { boxStyle, rowBg, rowPad, colsWrap, colStyle } from '../core/layout-style.js';
|
|
3
|
+
import { scopeCss } from '../core/sanitize.js';
|
|
4
|
+
import { cellsOf } from '../core/parse.js';
|
|
5
|
+
import { icon } from '../core/icons.js';
|
|
6
|
+
import { blockBody } from './block-body.js';
|
|
7
|
+
import { renderRte } from './rte.js';
|
|
8
|
+
|
|
9
|
+
function el(tag, style, attrs) {
|
|
10
|
+
const node = document.createElement(tag);
|
|
11
|
+
if (style) Object.assign(node.style, style);
|
|
12
|
+
if (attrs) for (const [k, v] of Object.entries(attrs)) {
|
|
13
|
+
if (k === 'text') node.textContent = v;
|
|
14
|
+
else if (v !== undefined) node.setAttribute(k, v);
|
|
15
|
+
}
|
|
16
|
+
return node;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Ported from `dropLine(active, key)`, but decoupled from React/state: the
|
|
21
|
+
* original re-rendered the whole tree on every `dragover` (cheap for React,
|
|
22
|
+
* since its reconciler patches only the one changed node); this renderer has
|
|
23
|
+
* no diffing, so doing the same would rebuild the entire canvas on every
|
|
24
|
+
* pixel of mouse movement while dragging -- exactly what made drag-and-drop
|
|
25
|
+
* (and hover, below) feel un-smooth. Instead, drop lines are built once per
|
|
26
|
+
* render and toggled directly via `showLine`/`hideActive`, with no state
|
|
27
|
+
* change and no re-render involved.
|
|
28
|
+
*/
|
|
29
|
+
function dropLine() {
|
|
30
|
+
return el('div', { height: '0', background: 'var(--ed-accent-sheet)', margin: '0', transition: 'height 0.1s', boxShadow: 'none' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function showLine(tracker, line) {
|
|
34
|
+
if (tracker.active && tracker.active !== line) hideLine(tracker.active);
|
|
35
|
+
line.style.height = '3px'; line.style.margin = '3px 0'; line.style.boxShadow = '0 0 0 1px var(--ed-accent-sheet)';
|
|
36
|
+
tracker.active = line;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hideLine(line) {
|
|
40
|
+
line.style.height = '0'; line.style.margin = '0'; line.style.boxShadow = 'none';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hideActive(tracker) {
|
|
44
|
+
if (tracker.active) { hideLine(tracker.active); tracker.active = null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Ported from `toolbar(id, kind, code)` -- the small floating control shown on the selected row/block. */
|
|
48
|
+
function toolbar(core, id, kind, code) {
|
|
49
|
+
const btn = (name, title, fn, danger) => {
|
|
50
|
+
const node = el('button', { position: 'relative', border: '0', background: 'transparent', color: danger ? '#ffdad8' : '#fff', cursor: 'pointer', width: '23px', height: '22px', display: 'flex', alignItems: 'center', justifyContent: 'center' }, { type: 'button', title, 'aria-label': title, 'data-tip': '1' });
|
|
51
|
+
node.appendChild(icon(name, 13));
|
|
52
|
+
node.appendChild(el('span', {}, { class: 'mc-tooltip mc-tooltip-up', text: title }));
|
|
53
|
+
node.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
54
|
+
node.addEventListener('click', (e) => { e.stopPropagation(); fn(); });
|
|
55
|
+
return node;
|
|
56
|
+
};
|
|
57
|
+
// --ed-accent-sheet, not --ed-accent, on purpose: this pill sits on the
|
|
58
|
+
// canvas/email sheet, which stays the document's own light page regardless
|
|
59
|
+
// of editor chrome dark mode. The sheet family is the brand accent fitted
|
|
60
|
+
// against white (core/accent.js), so it keeps its contrast in both chrome
|
|
61
|
+
// themes -- while still following a host's `accent`.
|
|
62
|
+
const root = el('div', { position: 'absolute', top: '-12px', right: '6px', display: 'flex', alignItems: 'center', gap: '1px', background: kind === 'row' ? '#172033' : 'var(--ed-accent-sheet)', borderRadius: '6px', padding: '2px 3px', zIndex: '6', boxShadow: '0 3px 10px rgba(15,23,42,0.28)' });
|
|
63
|
+
root.appendChild(el('span', { fontFamily: 'ui-monospace,monospace', fontSize: '8.5px', letterSpacing: '0.14em', color: kind === 'row' ? '#fff' : 'var(--ed-accent-sheet-ink)', opacity: '0.7', padding: '0 5px' }, { text: code }));
|
|
64
|
+
root.appendChild(btn('up', 'Move up', () => core.nudge(id, -1)));
|
|
65
|
+
root.appendChild(btn('down', 'Move down', () => core.nudge(id, 1)));
|
|
66
|
+
root.appendChild(btn('copy', 'Duplicate', () => { core.setState({ sel: { type: kind, id } }, () => core.dupSel()); }));
|
|
67
|
+
root.appendChild(btn('trash', 'Delete', () => { core.setState({ sel: { type: kind, id } }, () => core.delSel()); }, true));
|
|
68
|
+
return root;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Selected-row controls: square accent buttons pinned inside the row's
|
|
73
|
+
* top-right corner (delete + duplicate; reordering is the left drag badge).
|
|
74
|
+
* On --ed-accent-sheet like `toolbar` above and for the same reason: the
|
|
75
|
+
* email sheet stays a light page in both chrome themes, and a dark-chrome
|
|
76
|
+
* `--ed-accent` -- light enough to read on the dark panels -- would wash out
|
|
77
|
+
* against it.
|
|
78
|
+
*/
|
|
79
|
+
function rowActions(core, id) {
|
|
80
|
+
const btn = (name, title, fn) => {
|
|
81
|
+
const node = el('button', { position: 'relative', border: '0', background: 'var(--ed-accent-sheet)', color: 'var(--ed-accent-sheet-ink)', cursor: 'pointer', width: '26px', height: '26px', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '4px', boxShadow: '0 2px 6px rgba(15,23,42,0.3)' }, { type: 'button', title, 'aria-label': title, 'data-tip': '1' });
|
|
82
|
+
node.appendChild(icon(name, 14));
|
|
83
|
+
node.appendChild(el('span', {}, { class: 'mc-tooltip mc-tooltip-up', text: title }));
|
|
84
|
+
node.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
85
|
+
node.addEventListener('click', (e) => { e.stopPropagation(); fn(); });
|
|
86
|
+
return node;
|
|
87
|
+
};
|
|
88
|
+
const root = el('div', { position: 'absolute', top: '6px', right: '6px', display: 'flex', gap: '4px', zIndex: '6' });
|
|
89
|
+
root.appendChild(btn('trash', 'Delete', () => { core.setState({ sel: { type: 'row', id } }, () => core.delSel()); }));
|
|
90
|
+
root.appendChild(btn('copy', 'Duplicate', () => { core.setState({ sel: { type: 'row', id } }, () => core.dupSel()); }));
|
|
91
|
+
return root;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function blockCtx(core, editingBlockId) {
|
|
95
|
+
return {
|
|
96
|
+
editingId: editingBlockId,
|
|
97
|
+
now: core.state.now,
|
|
98
|
+
scopeCss,
|
|
99
|
+
iconProvider: core.iconProvider,
|
|
100
|
+
rteActiveRef: { get current() { return core.rteActive; } },
|
|
101
|
+
// Also sets `sel` (not just `editing`): a real click both focuses the
|
|
102
|
+
// contenteditable and fires a `click` on the wrapping block that would
|
|
103
|
+
// normally call `core.select`. But focus's own setState already forces a
|
|
104
|
+
// full canvas re-render (see focus-preserve.js), which replaces every
|
|
105
|
+
// block's DOM node -- including the one mousedown/mouseup/click are
|
|
106
|
+
// mid-gesture on -- so the browser drops that click (its target vanished
|
|
107
|
+
// between mousedown and mouseup) and `core.select` never runs. Setting
|
|
108
|
+
// `sel` here directly is what used to happen implicitly once the click
|
|
109
|
+
// landed; without it, the block never shows selected (no border, no
|
|
110
|
+
// duplicate/delete/move corner toolbar) even though the RTE toolbar,
|
|
111
|
+
// which only depends on `editing`, still appears.
|
|
112
|
+
// Guarded to a no-op once state already matches: restoring focus after a
|
|
113
|
+
// rebuild (focus-preserve.js) calls `.focus()` on the freshly-created
|
|
114
|
+
// node, which fires this same `focus` listener again. Without the guard
|
|
115
|
+
// that re-fires `setState` -> re-render -> `.focus()` -> `setState` -> ...
|
|
116
|
+
// forever (an infinite, stack-overflowing loop).
|
|
117
|
+
onFocus: (block, node, key, isPlainText) => {
|
|
118
|
+
core.editEl = node;
|
|
119
|
+
core.editKey = key;
|
|
120
|
+
core.editPlain = !!isPlainText;
|
|
121
|
+
if (core.state.editing === block.id && core.state.sel && core.state.sel.type === 'block' && core.state.sel.id === block.id) return;
|
|
122
|
+
// Snapshotted only on a genuine new focus (not the guarded no-op above,
|
|
123
|
+
// which also covers every re-render-triggered refocus while typing --
|
|
124
|
+
// see `syncLiveEdit`, mailcraft-editor.js). That sync keeps
|
|
125
|
+
// `block.props[key]` continuously equal to the live DOM so re-renders
|
|
126
|
+
// stop reverting what's mid-typing, which means by the time a real
|
|
127
|
+
// blur fires, `value !== block.props[key]` is always false and the
|
|
128
|
+
// edit would never commit (no undo entry, no autosave). Comparing
|
|
129
|
+
// against the true pre-edit value captured here instead of the
|
|
130
|
+
// continuously-synced prop is what makes onBlur's change check correct.
|
|
131
|
+
core.editOriginal = block.props[key];
|
|
132
|
+
core.setState({ editing: block.id, sel: { type: 'block', id: block.id }, tab: 'design' });
|
|
133
|
+
},
|
|
134
|
+
onBlur: (block, key, value) => {
|
|
135
|
+
// A render tears down and rebuilds the whole canvas subtree (no diffing --
|
|
136
|
+
// see the `dropLine` comment above); removing the still-focused node as
|
|
137
|
+
// part of that forces a synchronous, spurious `blur` before the rebuilt
|
|
138
|
+
// replacement can be focused. Committing on that blur (and worse, its
|
|
139
|
+
// `editing: null` racing the render that's still in progress) is what
|
|
140
|
+
// produced the onFocus <-> render infinite loop above -- `core.rendering`
|
|
141
|
+
// (set for the render()'s duration, see mailcraft-editor.js) tells a real
|
|
142
|
+
// blur apart from that artifact.
|
|
143
|
+
if (core.rendering) return;
|
|
144
|
+
if (core.rteActive) return;
|
|
145
|
+
if (value !== core.editOriginal) core.setProp(block.id, key, value);
|
|
146
|
+
if (core.state.editing === block.id) core.setState({ editing: null, linkDraft: null });
|
|
147
|
+
},
|
|
148
|
+
onPaste: (e, plainOnly) => core.pasteClean(plainOnly)(e),
|
|
149
|
+
// Guarded to a no-op when already selected -- the selection re-render
|
|
150
|
+
// refocuses the cell (focus-preserve), which fires `focus` again.
|
|
151
|
+
selectBlock: (block) => {
|
|
152
|
+
if (core.state.sel && core.state.sel.type === 'block' && core.state.sel.id === block.id) return;
|
|
153
|
+
core.select('block', block.id);
|
|
154
|
+
},
|
|
155
|
+
onTableCellBlur: (block, ri, ci, val) => {
|
|
156
|
+
const next = cellsOf(core.find(core.state.doc, block.id).block.props);
|
|
157
|
+
if (next[ri] && next[ri][ci] !== val) { next[ri][ci] = val; core.setProp(block.id, 'data', next.map((r) => r.join('|')).join('\n')); }
|
|
158
|
+
},
|
|
159
|
+
renderRte: (block) => renderRte(core, block),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Ported from `renderDoc(live)`. `live=true` is the editable canvas, `live=false` is the static preview/export tree. */
|
|
164
|
+
export function renderDoc(core, live) {
|
|
165
|
+
const d = core.state.doc; const theme = d.theme;
|
|
166
|
+
const width = core.state.device === 'mobile' ? 375 : theme.width;
|
|
167
|
+
const sel = core.state.sel;
|
|
168
|
+
const ctx = blockCtx(core, core.state.editing);
|
|
169
|
+
// Lives only for this render's drag gesture(s) -- no core state, no re-render.
|
|
170
|
+
const rowDropTracker = { active: null };
|
|
171
|
+
|
|
172
|
+
const root = el('div', {
|
|
173
|
+
width: width + 'px', maxWidth: '100%', background: theme.contentBg, color: theme.text, fontFamily: theme.font,
|
|
174
|
+
transition: 'width 0.28s cubic-bezier(0.22,0.61,0.36,1), background 0.2s',
|
|
175
|
+
}, { 'data-mc-sheet': '1' });
|
|
176
|
+
|
|
177
|
+
const rowLines = [];
|
|
178
|
+
if (live) {
|
|
179
|
+
root.addEventListener('dragover', (e) => {
|
|
180
|
+
const dr = core.drag; if (!dr) return;
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
const index = core.indexFromPoint(root, e.clientY);
|
|
183
|
+
showLine(rowDropTracker, rowLines[index]);
|
|
184
|
+
});
|
|
185
|
+
root.addEventListener('dragleave', (e) => { if (e.target === root) hideActive(rowDropTracker); });
|
|
186
|
+
root.addEventListener('drop', (e) => { hideActive(rowDropTracker); core.canvasDrop(e); });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
d.rows.forEach((r, ri) => {
|
|
190
|
+
if (live) { const line = dropLine(); rowLines.push(line); root.appendChild(line); }
|
|
191
|
+
const isSel = live && sel && sel.id === r.id;
|
|
192
|
+
|
|
193
|
+
const rowEl = el('div', Object.assign({ position: 'relative', padding: rowPad(r.props) }, rowBg(r.props)), { 'data-mc-slot': '1', class: live ? `mc-row-el${isSel ? ' is-selected' : ''}` : undefined });
|
|
194
|
+
if (live) {
|
|
195
|
+
rowEl.addEventListener('click', (e) => { e.stopPropagation(); core.select('row', r.id); });
|
|
196
|
+
}
|
|
197
|
+
if (isSel) rowEl.appendChild(rowActions(core, r.id));
|
|
198
|
+
if (live) {
|
|
199
|
+
// The grab target is the row's whole left edge, not just the visible
|
|
200
|
+
// badge. Two reasons, both about `:hover`: the strip overlaps the row's
|
|
201
|
+
// own left edge (it straddles the border so the round badge can sit
|
|
202
|
+
// centered on it), because a gap there would be dead space between the
|
|
203
|
+
// row's hover box and the grip's -- crossing it drops `:hover` on
|
|
204
|
+
// `.mc-row-el` and hides the grip (display:none, see style.js) right as
|
|
205
|
+
// the cursor arrives. And it spans the row's full height, because a
|
|
206
|
+
// short handle pinned to the top has the same failure one axis over: on
|
|
207
|
+
// a tall row, moving left from anywhere below the handle exits the row
|
|
208
|
+
// before reaching it, so the grip blinks out and the section simply
|
|
209
|
+
// cannot be picked up. The badge stays small; only the hit area grew.
|
|
210
|
+
const grip = el('div', { position: 'absolute', left: '-14px', top: '0', bottom: '0', width: '28px', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'grab', zIndex: '6' }, { draggable: 'true', title: 'Drag to reorder section', 'aria-label': 'Drag to reorder section', class: 'mc-row-grip' });
|
|
211
|
+
const gripHandle = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none', width: '28px', height: '28px', borderRadius: '50%' }, { class: 'mc-row-grip-handle' });
|
|
212
|
+
gripHandle.appendChild(icon('move', 15));
|
|
213
|
+
grip.appendChild(gripHandle);
|
|
214
|
+
grip.addEventListener('dragstart', core.startDrag({ kind: 'move-row', id: r.id }));
|
|
215
|
+
grip.addEventListener('dragend', () => { core.drag = null; hideActive(rowDropTracker); });
|
|
216
|
+
grip.addEventListener('click', (e) => { e.stopPropagation(); core.select('row', r.id); });
|
|
217
|
+
rowEl.appendChild(grip);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const colsEl = el('div', colsWrap(r.props));
|
|
221
|
+
r.cols.forEach((c, ci) => {
|
|
222
|
+
const colLines = [];
|
|
223
|
+
const items = [];
|
|
224
|
+
c.blocks.forEach((b, bi) => {
|
|
225
|
+
if (live) { const line = dropLine(); colLines.push(line); items.push(line); }
|
|
226
|
+
const bSel = live && sel && sel.id === b.id;
|
|
227
|
+
const bWrap = el('div', Object.assign({ position: 'relative' }, boxStyle(b.props)), { 'data-mc-slot': '1', draggable: live ? 'true' : undefined, class: live ? `mc-block-el${bSel ? ' is-selected' : ''}` : undefined });
|
|
228
|
+
if (live) {
|
|
229
|
+
bWrap.addEventListener('dragstart', core.startDrag({ kind: 'move-block', id: b.id }));
|
|
230
|
+
bWrap.addEventListener('dragend', () => { core.drag = null; hideActive(rowDropTracker); hideActive(colTracker); });
|
|
231
|
+
bWrap.addEventListener('click', (e) => { e.stopPropagation(); core.select('block', b.id); });
|
|
232
|
+
}
|
|
233
|
+
if (bSel) bWrap.appendChild(toolbar(core, b.id, 'block', DEF(b.type).code));
|
|
234
|
+
bWrap.appendChild(blockBody(b, theme, live, ctx));
|
|
235
|
+
items.push(bWrap);
|
|
236
|
+
});
|
|
237
|
+
let colTracker;
|
|
238
|
+
if (live) {
|
|
239
|
+
const endLine = dropLine(); colLines.push(endLine); items.push(endLine);
|
|
240
|
+
colTracker = { active: null };
|
|
241
|
+
}
|
|
242
|
+
if (live && !c.blocks.length) {
|
|
243
|
+
items.push(el('div', { border: '1px dashed var(--ed-accent-sheet-line)', borderRadius: 'var(--ed-radius-sm)', color: 'var(--ed-faint)', fontFamily: 'ui-monospace,monospace', fontSize: '9.5px', letterSpacing: '0.14em', textTransform: 'uppercase', padding: '24px 8px', textAlign: 'center' }, { text: 'drop block' }));
|
|
244
|
+
}
|
|
245
|
+
const colEl = el('div', colStyle(r.props, c));
|
|
246
|
+
// Column-level styling lives on an inner wrapper, not on colEl itself:
|
|
247
|
+
// colEl's padding is the inter-column gutter (colStyle), and a painted
|
|
248
|
+
// background must stop at the column's visual edge, not bleed across
|
|
249
|
+
// the gutter. `host` is also what the drag listeners and
|
|
250
|
+
// `indexFromPoint` must use -- the block slots are its children.
|
|
251
|
+
const styled = c.bg || c.border || c.radius || c.padY || c.padX;
|
|
252
|
+
const host = styled
|
|
253
|
+
? el('div', { background: c.bg || 'transparent', border: c.border ? c.border + 'px ' + (c.borderStyle || 'solid') + ' ' + (c.lineColor || '#e2e2e5') : '0', borderRadius: (c.radius || 0) + 'px', padding: (c.padY || 0) + 'px ' + (c.padX || 0) + 'px', height: '100%', boxSizing: 'border-box' })
|
|
254
|
+
: colEl;
|
|
255
|
+
if (live) {
|
|
256
|
+
host.addEventListener('dragover', (e) => {
|
|
257
|
+
const dr = core.drag; if (!dr) return;
|
|
258
|
+
e.preventDefault(); e.stopPropagation();
|
|
259
|
+
if (dr.kind === 'row' || dr.kind === 'move-row') return;
|
|
260
|
+
const index = core.indexFromPoint(host, e.clientY);
|
|
261
|
+
showLine(colTracker, colLines[index]);
|
|
262
|
+
});
|
|
263
|
+
host.addEventListener('dragleave', (e) => { if (e.target === host) hideActive(colTracker); });
|
|
264
|
+
host.addEventListener('drop', (e) => {
|
|
265
|
+
e.stopPropagation();
|
|
266
|
+
hideActive(colTracker);
|
|
267
|
+
core.colDrop(r.id, ci)(e);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
items.forEach((it) => host.appendChild(it));
|
|
271
|
+
if (host !== colEl) colEl.appendChild(host);
|
|
272
|
+
colsEl.appendChild(colEl);
|
|
273
|
+
});
|
|
274
|
+
rowEl.appendChild(colsEl);
|
|
275
|
+
root.appendChild(rowEl);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (live) { const line = dropLine(); rowLines.push(line); root.appendChild(line); }
|
|
279
|
+
if (live && !d.rows.length) {
|
|
280
|
+
root.appendChild(el('div', { border: '1px dashed var(--ed-accent-sheet-line)', padding: '90px 20px', textAlign: 'center', fontFamily: 'ui-monospace,monospace', fontSize: '10.5px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--ed-faint)' }, { text: 'drag a row or block here' }));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return root;
|
|
284
|
+
}
|