@seliseblocks/mailcraft 0.1.0 → 0.2.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 +240 -12
- package/README.md +11 -3
- package/README.md.txt +134 -0
- package/dist/mailcraft-editor.bundle.js +51 -39
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/examples/templates/order-confirmed.html +80 -80
- package/examples/vanilla.html +242 -23
- package/package.json +7 -2
- package/src/core/assets.js +10 -15
- package/src/core/binder.js +120 -118
- package/src/core/blocks.js +14 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +1530 -1492
- package/src/core/export.js +142 -55
- package/src/core/i18n/ar.js +219 -177
- package/src/core/i18n/bg.js +196 -152
- package/src/core/i18n/bn.js +218 -176
- package/src/core/i18n/ca.js +196 -152
- package/src/core/i18n/cs.js +196 -152
- package/src/core/i18n/da.js +196 -152
- package/src/core/i18n/de-CH.js +196 -152
- package/src/core/i18n/de.js +196 -152
- package/src/core/i18n/dz.js +221 -179
- package/src/core/i18n/el.js +196 -152
- package/src/core/i18n/en.js +3 -10
- package/src/core/i18n/es.js +196 -152
- package/src/core/i18n/et.js +196 -152
- package/src/core/i18n/fi.js +196 -152
- package/src/core/i18n/fr.js +196 -152
- package/src/core/i18n/hr.js +196 -152
- package/src/core/i18n/hu.js +196 -152
- package/src/core/i18n/index.js +83 -83
- package/src/core/i18n/it.js +196 -152
- package/src/core/i18n/lt.js +196 -152
- package/src/core/i18n/lv.js +196 -152
- package/src/core/i18n/nb.js +196 -152
- package/src/core/i18n/nl.js +196 -152
- package/src/core/i18n/pl.js +196 -152
- package/src/core/i18n/pt.js +196 -152
- package/src/core/i18n/ro.js +196 -152
- package/src/core/i18n/ru.js +196 -152
- package/src/core/i18n/sk.js +196 -152
- package/src/core/i18n/sl.js +196 -152
- package/src/core/i18n/sv.js +196 -152
- package/src/core/i18n/tables.js +50 -50
- package/src/core/i18n/tr.js +196 -152
- package/src/core/i18n/uk.js +196 -152
- package/src/core/icons.js +237 -235
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +1025 -959
- 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 +141 -141
- package/src/core/storage-limits.js +184 -184
- package/src/core/storage.js +85 -85
- package/src/core/theme.js +1 -1
- package/src/core/variables.js +11 -11
- package/src/index.js +9 -9
- package/src/mailcraft-editor.js +26 -14
- package/src/render/block-body.js +49 -6
- package/src/render/canvas.js +31 -2
- package/src/render/fields.js +602 -588
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +241 -212
- package/src/render/screenshot.js +132 -132
- package/src/render/story.js +415 -415
- package/src/render/style.js +8 -0
- package/types/index.d.ts +419 -0
package/src/render/fields.js
CHANGED
|
@@ -1,477 +1,482 @@
|
|
|
1
|
-
import { icon, brandIcon, socialKey, SOCIAL_BRAND, contrastInk } from '../core/icons.js';
|
|
2
|
-
import { parseItems } from '../core/parse.js';
|
|
3
|
-
|
|
4
|
-
function el(tag, style, attrs) {
|
|
5
|
-
const node = document.createElement(tag);
|
|
6
|
-
if (style) Object.assign(node.style, style);
|
|
7
|
-
if (attrs) for (const [k, v] of Object.entries(attrs)) {
|
|
8
|
-
if (k === 'text') node.textContent = v;
|
|
9
|
-
else if (v !== undefined) node.setAttribute(k, v);
|
|
10
|
-
}
|
|
11
|
-
return node;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Trailing debounce for free-typing inputs. Every commit rebuilds the whole
|
|
16
|
-
* canvas and panel (full re-render, no diffing) -- fine per click, but per
|
|
17
|
-
* keystroke it floors low-end hardware. Typing therefore commits 120ms after
|
|
18
|
-
* the last keystroke: one rebuild per pause instead of per key. `flush` runs
|
|
19
|
-
* on blur so leaving the field (or a blur-time clamp that reads committed
|
|
20
|
-
* state, e.g. B.num) never races a still-pending commit. Discrete controls
|
|
21
|
-
* (toggles, selects, steppers' -/+) stay immediate.
|
|
22
|
-
*/
|
|
23
|
-
export function typeCommit(fn) {
|
|
24
|
-
let t = null; let last;
|
|
25
|
-
const fire = () => { t = null; fn(last); };
|
|
26
|
-
return {
|
|
27
|
-
call(v) { last = v; if (t) clearTimeout(t); t = setTimeout(fire, 120); },
|
|
28
|
-
flush() { if (t) { clearTimeout(t); fire(); } },
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Label of the switch the user just clicked, consumed by the very next render.
|
|
34
|
-
*
|
|
35
|
-
* Toggling a prop re-renders the whole panel, which replaces the switch with a
|
|
36
|
-
* fresh node built already in its new state -- there is no before/after on one
|
|
37
|
-
* element for a CSS `transition` to interpolate, so the knob used to jump. The
|
|
38
|
-
* flag lets the rebuilt switch (and no other) start a one-shot keyframe from
|
|
39
|
-
* where the old one stood, so a click slides while merely opening a panel full
|
|
40
|
-
* of already-on switches still paints them in place.
|
|
41
|
-
*/
|
|
42
|
-
let pendingSwitchAnim = null;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* One label style for every field kind, so the inspector reads as one system:
|
|
46
|
-
* semibold editor UI font at 75% ink for field names (distinct from both the muted
|
|
47
|
-
* values and the uppercase group kickers), block or inline per context.
|
|
48
|
-
*/
|
|
49
|
-
function fieldLabel(text, inline) {
|
|
50
|
-
// One label treatment across every right-panel field. Placement can differ
|
|
51
|
-
// (above wide controls, beside compact controls), but size, weight and color
|
|
52
|
-
// must not change with the control type.
|
|
53
|
-
return el('label', Object.assign(
|
|
54
|
-
{ fontFamily: 'var(--ed-font)', fontSize: '12.5px', lineHeight: '1.35', fontWeight: '500', color: 'var(--ed-text)' },
|
|
55
|
-
inline
|
|
56
|
-
? { flex: '1', minWidth: '0' }
|
|
57
|
-
: { display: 'block', marginBottom: '6px' },
|
|
58
|
-
), { text, class: 'mc-field-label' });
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Which accordion sections the user has collapsed, by section label.
|
|
63
|
-
* Editor-UI state, deliberately module-level: it must survive the full
|
|
64
|
-
* re-render on every commit (the panel is rebuilt each time) but is never
|
|
65
|
-
* part of the document, and collapsing/expanding itself is pure DOM --
|
|
66
|
-
* no core state, no re-render.
|
|
67
|
-
*/
|
|
68
|
-
const sectionCollapsed = Object.create(null);
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* The flat accordion inspector layout: each `head` field becomes a
|
|
72
|
-
* full-width light section bar (uppercase label, chevron, click to
|
|
73
|
-
* collapse) and the fields under it render as roomy rows directly on the
|
|
74
|
-
* panel surface. Fields before the first head form an untitled lead group.
|
|
75
|
-
*/
|
|
76
|
-
export function renderFieldCards(container, fields) {
|
|
77
|
-
let body = null;
|
|
78
|
-
const open = (headField) => {
|
|
79
|
-
if (headField) {
|
|
80
|
-
const label = headField.label;
|
|
81
|
-
// The mc-section-* classes are theme hooks (render/style.js can restyle
|
|
82
|
-
// the section chrome without touching this builder); the inline styles
|
|
83
|
-
// stay the baseline look.
|
|
84
|
-
const bar = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '11px 14px', background: 'var(--ed-panel-2)', borderTop: '1px solid var(--ed-line)', borderBottom: '1px solid var(--ed-line)', cursor: 'pointer', userSelect: 'none' }, { role: 'button', tabindex: '0', 'aria-expanded': String(!sectionCollapsed[label]), class: 'mc-section-bar' });
|
|
85
|
-
bar.appendChild(el('span', { fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '700', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ed-muted)' }, { text: label, class: 'mc-section-label' }));
|
|
86
|
-
const chev = el('span', { display: 'flex', color: 'var(--ed-faint)', transition: 'transform 0.16s', transform: sectionCollapsed[label] ? 'rotate(-90deg)' : 'none' }, { class: 'mc-section-chevron' });
|
|
87
|
-
chev.appendChild(icon('chevronDown', 13));
|
|
88
|
-
bar.appendChild(chev);
|
|
89
|
-
const localBody = body = el('div', { padding: '12px 14px 16px', background: 'var(--ed-panel)' }, { class: 'mc-section-body mc-field-list' });
|
|
90
|
-
if (sectionCollapsed[label]) localBody.hidden = true;
|
|
91
|
-
const toggle = () => {
|
|
92
|
-
sectionCollapsed[label] = !sectionCollapsed[label];
|
|
93
|
-
localBody.hidden = !!sectionCollapsed[label];
|
|
94
|
-
chev.style.transform = sectionCollapsed[label] ? 'rotate(-90deg)' : 'none';
|
|
95
|
-
bar.setAttribute('aria-expanded', String(!sectionCollapsed[label]));
|
|
96
|
-
};
|
|
97
|
-
bar.addEventListener('click', toggle);
|
|
98
|
-
bar.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
|
|
99
|
-
container.appendChild(bar);
|
|
100
|
-
container.appendChild(localBody);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
body = el('div', { padding: '12px 14px 16px', background: 'var(--ed-panel)' }, { class: 'mc-section-body mc-field-list' });
|
|
104
|
-
container.appendChild(body);
|
|
105
|
-
};
|
|
106
|
-
fields.forEach((f) => {
|
|
107
|
-
if (f.isHead) { open(f); return; }
|
|
108
|
-
if (!body) open(null);
|
|
109
|
-
const node = renderField(f);
|
|
110
|
-
node.style.margin = '0';
|
|
111
|
-
body.appendChild(node);
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Every platform the "Add network…" dropdown offers, with a sensible starter URL. Order roughly by how often they appear in email footers. */
|
|
116
|
-
const NETWORKS = [
|
|
117
|
-
['Instagram', 'https://instagram.com/'], ['X', 'https://x.com/'], ['Facebook', 'https://facebook.com/'],
|
|
118
|
-
['LinkedIn', 'https://linkedin.com/company/'], ['YouTube', 'https://youtube.com/@'], ['TikTok', 'https://tiktok.com/@'],
|
|
119
|
-
['Pinterest', 'https://pinterest.com/'], ['WhatsApp', 'https://wa.me/'], ['Telegram', 'https://t.me/'],
|
|
120
|
-
['Threads', 'https://threads.net/@'], ['Snapchat', 'https://snapchat.com/add/'], ['Discord', 'https://discord.gg/'],
|
|
121
|
-
['Reddit', 'https://reddit.com/r/'], ['Spotify', 'https://open.spotify.com/'], ['Behance', 'https://behance.net/'],
|
|
122
|
-
['Dribbble', 'https://dribbble.com/'], ['Email', 'mailto:hello@example.com'], ['Website', 'https://example.com'],
|
|
123
|
-
];
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* The social block's network editor: one card per network (brand-colored icon
|
|
127
|
-
* chip, editable name, URL, remove) plus an "Add network…" dropdown -- what
|
|
128
|
-
* used to be a raw `Name|URL` textarea. The data stays that same string
|
|
129
|
-
* (`f.value` in, serialized back out through `f.onChange`), so the block,
|
|
130
|
-
* export and import are untouched; only the editing surface changed.
|
|
131
|
-
*/
|
|
132
|
-
function renderSocialItems(f) {
|
|
133
|
-
const box = el('div');
|
|
134
|
-
box.appendChild(fieldLabel(f.label));
|
|
135
|
-
const items = parseItems(f.value || '');
|
|
136
|
-
// No coercion here: commits land while the user is still typing (debounced,
|
|
137
|
-
// see typeCommit) and the panel re-renders from the committed string, so
|
|
138
|
-
// padding an empty field with a fallback would snap "cleared to retype"
|
|
139
|
-
// inputs back mid-edit.
|
|
140
|
-
const commit = (next) => f.onChange(next.map((it) => (it.label || '') + '|' + (it.href || '')).join('\n'));
|
|
141
|
-
const list = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
|
142
|
-
|
|
143
|
-
items.forEach((it, i) => {
|
|
144
|
-
const row = el('div', { display: 'flex', alignItems: 'center', gap: '7px', padding: '6px 7px', border: '1px solid var(--ed-line)', borderRadius: '9px', background: 'var(--ed-panel-2)' });
|
|
145
|
-
const key = socialKey(it.label);
|
|
146
|
-
const brand = SOCIAL_BRAND[key] || '';
|
|
147
|
-
const chip = el('span', { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '24px', height: '24px', flex: 'none', borderRadius: '7px', background: brand || 'var(--ed-soft)', color: brand ? contrastInk(brand) : 'var(--ed-accent)' });
|
|
148
|
-
chip.appendChild(brandIcon(key, 13));
|
|
149
|
-
const name = el('input', { width: '70px', flex: 'none', boxSizing: 'border-box', background: 'transparent', border: '1px solid transparent', borderRadius: '5px', color: 'var(--ed-text)', font: 'inherit', fontSize: '11.5px', fontWeight: '600', padding: '3px 4px' }, { 'data-focus-key': `f${f.key}-n${i}`, title: 'Network name' });
|
|
150
|
-
name.value = it.label;
|
|
151
|
-
const nameCommit = typeCommit((v) => { const next = items.slice(); next[i] = { label: v, href: it.href }; commit(next); });
|
|
152
|
-
name.addEventListener('input', (e) => nameCommit.call(e.target.value));
|
|
153
|
-
name.addEventListener('focus', () => { name.style.borderColor = 'var(--ed-accent)'; name.style.background = 'var(--ed-panel)'; name.style.outline = 'none'; });
|
|
154
|
-
name.addEventListener('blur', () => { name.style.borderColor = 'transparent'; name.style.background = 'transparent'; nameCommit.flush(); });
|
|
155
|
-
const url = el('input', { flex: '1', minWidth: '0', boxSizing: 'border-box', background: 'var(--ed-panel)', border: '1px solid var(--ed-line)', borderRadius: '6px', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '10.5px', padding: '4px 6px' }, { placeholder: 'https://', 'data-focus-key': `f${f.key}-u${i}`, title: it.href });
|
|
156
|
-
url.value = it.href;
|
|
157
|
-
const urlCommit = typeCommit((v) => { const next = items.slice(); next[i] = { label: it.label, href: v }; commit(next); });
|
|
158
|
-
url.addEventListener('input', (e) => urlCommit.call(e.target.value));
|
|
159
|
-
url.addEventListener('focus', () => { url.style.borderColor = 'var(--ed-accent)'; url.style.outline = 'none'; });
|
|
160
|
-
url.addEventListener('blur', () => { url.style.borderColor = 'var(--ed-line)'; urlCommit.flush(); });
|
|
161
|
-
const del = el('button', { width: '22px', height: '22px', flex: 'none', border: '0', borderRadius: '6px', background: 'transparent', color: 'var(--ed-faint)', cursor: 'pointer', fontSize: '13px', lineHeight: '1' }, { type: 'button', title: 'Remove ' + (it.label || 'network'), text: '✕' });
|
|
162
|
-
del.addEventListener('mouseenter', () => { del.style.background = 'var(--ed-danger-soft)'; del.style.color = 'var(--ed-danger)'; });
|
|
163
|
-
del.addEventListener('mouseleave', () => { del.style.background = 'transparent'; del.style.color = 'var(--ed-faint)'; });
|
|
164
|
-
del.addEventListener('click', () => commit(items.filter((x, xi) => xi !== i)));
|
|
165
|
-
row.append(chip, name, url, del);
|
|
166
|
-
list.appendChild(row);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
if (!items.length) {
|
|
170
|
-
list.appendChild(el('div', { fontSize: '11px', color: 'var(--ed-faint)', padding: '8px 2px' }, { text: 'No networks yet — add one below.' }));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const used = new Set(items.map((it) => socialKey(it.label)));
|
|
174
|
-
const add = el('select', { width: '100%', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px dashed var(--ed-line-2)', borderRadius: '9px', color: 'var(--ed-muted)', font: 'inherit', fontSize: '11.5px', fontWeight: '600', padding: '7px 8px', cursor: 'pointer' }, { title: 'Add network' });
|
|
175
|
-
const ph = document.createElement('option'); ph.value = ''; ph.textContent = '+ Add network…'; add.appendChild(ph);
|
|
176
|
-
NETWORKS.filter(([label]) => !used.has(socialKey(label))).forEach(([label, href]) => {
|
|
177
|
-
const o = document.createElement('option'); o.value = label + '|' + href; o.textContent = label; add.appendChild(o);
|
|
178
|
-
});
|
|
179
|
-
const custom = document.createElement('option'); custom.value = 'Link|https://'; custom.textContent = 'Custom link…'; add.appendChild(custom);
|
|
180
|
-
add.addEventListener('change', (e) => {
|
|
181
|
-
if (!e.target.value) return;
|
|
182
|
-
const [label, href] = e.target.value.split('|');
|
|
183
|
-
commit(items.concat([{ label, href }]));
|
|
184
|
-
});
|
|
185
|
-
add.addEventListener('focus', () => { add.style.borderColor = 'var(--ed-accent)'; add.style.outline = 'none'; });
|
|
186
|
-
add.addEventListener('blur', () => { add.style.borderColor = 'var(--ed-line-2)'; });
|
|
187
|
-
list.appendChild(add);
|
|
188
|
-
|
|
189
|
-
box.appendChild(list);
|
|
190
|
-
return box;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Friendly link editing for rich-text blocks. The block still stores one HTML
|
|
195
|
-
* string; each commit clones that markup, updates only the indexed anchor and
|
|
196
|
-
* serializes it back, preserving all surrounding formatting and content.
|
|
197
|
-
*/
|
|
198
|
-
function renderRichLinks(f) {
|
|
199
|
-
const box = el('div');
|
|
200
|
-
box.appendChild(fieldLabel(f.label));
|
|
201
|
-
const source = el('div');
|
|
202
|
-
source.innerHTML = f.html || '';
|
|
203
|
-
const anchors = Array.from(source.querySelectorAll('a'));
|
|
204
|
-
const list = el('div', { display: 'flex', flexDirection: 'column', gap: '7px' });
|
|
205
|
-
const change = (index, update) => {
|
|
206
|
-
const next = el('div');
|
|
207
|
-
next.innerHTML = f.html || '';
|
|
208
|
-
const anchor = next.querySelectorAll('a')[index];
|
|
209
|
-
if (!anchor) return;
|
|
210
|
-
update(anchor);
|
|
211
|
-
f.onChange(next.innerHTML);
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
anchors.forEach((anchor, i) => {
|
|
215
|
-
const card = el('div', { padding: '8px', border: '1px solid var(--ed-line)', borderRadius: '9px', background: 'var(--ed-panel-2)' });
|
|
216
|
-
const head = el('div', { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '7px' });
|
|
217
|
-
const name = el('span', { flex: '1', minWidth: '0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'var(--ed-font)', fontSize: '11.5px', fontWeight: '600', color: 'var(--ed-text)' }, { text: (anchor.textContent || '').trim() || 'Link ' + (i + 1), title: (anchor.textContent || '').trim() });
|
|
218
|
-
const remove = el('button', { flex: 'none', border: '0', borderRadius: '6px', background: 'transparent', color: 'var(--ed-faint)', cursor: 'pointer', padding: '3px 6px', fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '600' }, { type: 'button', text: 'Remove', title: 'Remove link', 'aria-label': 'Remove link from ' + ((anchor.textContent || '').trim() || 'text'), 'data-focus-key': `f${f.key}-link-remove-${i}` });
|
|
219
|
-
remove.addEventListener('mouseenter', () => { remove.style.background = 'var(--ed-danger-soft)'; remove.style.color = 'var(--ed-danger)'; });
|
|
220
|
-
remove.addEventListener('mouseleave', () => { remove.style.background = 'transparent'; remove.style.color = 'var(--ed-faint)'; });
|
|
221
|
-
remove.addEventListener('click', () => change(i, (a) => a.replaceWith(...a.childNodes)));
|
|
222
|
-
head.append(name, remove);
|
|
223
|
-
|
|
224
|
-
const url = el('input', { width: '100%', boxSizing: 'border-box', background: 'var(--ed-panel)', border: '1px solid var(--ed-line)', borderRadius: '7px', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '11px', padding: '6px 8px' }, { placeholder: 'https://', 'aria-label': 'Link destination for ' + ((anchor.textContent || '').trim() || 'link ' + (i + 1)), 'data-focus-key': `f${f.key}-link-url-${i}
|
|
225
|
-
url.value = anchor.getAttribute('href') || '';
|
|
226
|
-
const urlCommit = typeCommit((v) => change(i, (a) => a.setAttribute('href', v)));
|
|
227
|
-
url.addEventListener('input', (e) => urlCommit.call(e.target.value));
|
|
228
|
-
url.addEventListener('focus', () => { url.style.borderColor = 'var(--ed-accent)'; url.style.outline = 'none'; });
|
|
229
|
-
url.addEventListener('blur', () => { url.style.borderColor = 'var(--ed-line)'; urlCommit.flush(); });
|
|
230
|
-
|
|
231
|
-
const targetLabel = el('label', { display: 'flex', alignItems: 'center', gap: '6px', marginTop: '7px', width: 'fit-content', color: 'var(--ed-muted)', cursor: 'pointer', fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '500' });
|
|
232
|
-
const target = el('input', { accentColor: 'var(--ed-accent)', cursor: 'pointer' }, { type: 'checkbox', 'data-focus-key': `f${f.key}-link-target-${i}` });
|
|
233
|
-
target.checked = anchor.target === '_blank';
|
|
234
|
-
target.addEventListener('change', (e) => change(i, (a) => {
|
|
235
|
-
if (e.target.checked) { a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener'); }
|
|
236
|
-
else { a.removeAttribute('target'); a.removeAttribute('rel'); }
|
|
237
|
-
}));
|
|
238
|
-
targetLabel.append(target, 'Open in new tab');
|
|
239
|
-
card.append(head, url, targetLabel);
|
|
240
|
-
list.appendChild(card);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
box.appendChild(list);
|
|
244
|
-
return box;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* The table block's cell editor: an actual grid of inputs mirroring the
|
|
249
|
-
* table (bold first row when the header toggle is on), with per-row/column
|
|
250
|
-
* remove buttons and add-row/add-column actions -- replacing the raw
|
|
251
|
-
* "one line per row, split by |" textarea. Data stays that same string.
|
|
252
|
-
*/
|
|
253
|
-
function renderTableGrid(f) {
|
|
254
|
-
const box = el('div');
|
|
255
|
-
box.appendChild(fieldLabel(f.label));
|
|
256
|
-
const rows = String(f.value || '').split('\n').map((r) => r.split('|'));
|
|
257
|
-
const cols = Math.max(1, ...rows.map((r) => r.length));
|
|
258
|
-
rows.forEach((r) => { while (r.length < cols) r.push(''); });
|
|
259
|
-
// Pipes typed into a cell become '/' -- '|' is the column separator in the
|
|
260
|
-
// stored string (same rule as inline cell editing on the canvas).
|
|
261
|
-
const commit = (rs) => f.onChange(rs.map((r) => r.map((c) => String(c).replace(/\|/g, '/')).join('|')).join('\n'));
|
|
262
|
-
const grid = el('div', { display: 'flex', flexDirection: 'column', gap: '4px' });
|
|
263
|
-
const iconBtnStyle = { border: '0', borderRadius: '5px', background: 'transparent', color: 'var(--ed-faint)', cursor: 'pointer', fontSize: '11px', lineHeight: '1', padding: '2px' };
|
|
264
|
-
const dangerHover = (btn) => {
|
|
265
|
-
btn.addEventListener('mouseenter', () => { btn.style.background = 'var(--ed-danger-soft)'; btn.style.color = 'var(--ed-danger)'; });
|
|
266
|
-
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; btn.style.color = 'var(--ed-faint)'; });
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
if (cols > 1) {
|
|
270
|
-
// The right padding mirrors the per-row ✕ (18px + 4px gap) so the column
|
|
271
|
-
// ✕s line up over their columns -- but that ✕ only exists when there is
|
|
272
|
-
// more than one row, so pad only then or the strip drifts 22px left.
|
|
273
|
-
const strip = el('div', { display: 'flex', gap: '4px', paddingRight: rows.length > 1 ? '22px' : '0' });
|
|
274
|
-
for (let ci = 0; ci < cols; ci++) {
|
|
275
|
-
const cbtn = el('button', Object.assign({ flex: '1', minWidth: '0' }, iconBtnStyle), { type: 'button', title: 'Remove column ' + (ci + 1), text: '✕' });
|
|
276
|
-
dangerHover(cbtn);
|
|
277
|
-
cbtn.addEventListener('click', () => commit(rows.map((r) => r.filter((x, i) => i !== ci))));
|
|
278
|
-
strip.appendChild(cbtn);
|
|
279
|
-
}
|
|
280
|
-
grid.appendChild(strip);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
rows.forEach((r, ri) => {
|
|
284
|
-
const rowEl = el('div', { display: 'flex', gap: '4px', alignItems: 'center' });
|
|
285
|
-
r.forEach((cell, ci) => {
|
|
286
|
-
const input = el('input', {
|
|
287
|
-
flex: '1', minWidth: '0', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px solid var(--ed-line)',
|
|
288
|
-
borderRadius: '6px', color: 'var(--ed-text)', font: 'inherit', fontSize: '11px',
|
|
289
|
-
fontWeight: f.header && ri === 0 ? '700' : '400', padding: '5px 6px',
|
|
290
|
-
}, { 'data-focus-key': `f${f.key}-r${ri}c${ci}
|
|
291
|
-
input.value = cell;
|
|
292
|
-
const cellCommit = typeCommit((v) => { const next = rows.map((row) => row.slice()); next[ri][ci] = v; commit(next); });
|
|
293
|
-
input.addEventListener('input', (e) => cellCommit.call(e.target.value));
|
|
294
|
-
input.addEventListener('focus', () => { input.style.borderColor = 'var(--ed-accent)'; input.style.outline = 'none'; });
|
|
295
|
-
input.addEventListener('blur', () => { input.style.borderColor = 'var(--ed-line)'; cellCommit.flush(); });
|
|
296
|
-
rowEl.appendChild(input);
|
|
297
|
-
});
|
|
298
|
-
if (rows.length > 1) {
|
|
299
|
-
const del = el('button', Object.assign({ width: '18px', flex: 'none' }, iconBtnStyle), { type: 'button', title: 'Remove row ' + (ri + 1), text: '✕' });
|
|
300
|
-
dangerHover(del);
|
|
301
|
-
del.addEventListener('click', () => commit(rows.filter((x, i) => i !== ri)));
|
|
302
|
-
rowEl.appendChild(del);
|
|
303
|
-
}
|
|
304
|
-
grid.appendChild(rowEl);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
const actions = el('div', { display: 'flex', gap: '6px', marginTop: '2px' });
|
|
308
|
-
[['+ Row', () => commit(rows.concat([new Array(cols).fill('')]))], ['+ Column', () => commit(rows.map((r) => r.concat('')))]].forEach(([label, fn]) => {
|
|
309
|
-
const btn = el('button', { flex: '1', border: '1px dashed var(--ed-line-2)', borderRadius: '8px', background: 'transparent', color: 'var(--ed-muted)', cursor: 'pointer', fontFamily: 'var(--ed-font)', fontSize: '11px', fontWeight: '600', padding: '6px' }, { type: 'button', text: label });
|
|
310
|
-
btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--ed-accent)'; btn.style.color = 'var(--ed-accent)'; btn.style.background = 'var(--ed-soft)'; });
|
|
311
|
-
btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--ed-line-2)'; btn.style.color = 'var(--ed-muted)'; btn.style.background = 'transparent'; });
|
|
312
|
-
btn.addEventListener('click', fn);
|
|
313
|
-
actions.appendChild(btn);
|
|
314
|
-
});
|
|
315
|
-
grid.appendChild(actions);
|
|
316
|
-
box.appendChild(grid);
|
|
317
|
-
return box;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* The stepper: ONE bordered box holding bare -/+ hit areas flanking the
|
|
322
|
-
* centered value + unit. The previous design nested raised, bordered buttons
|
|
323
|
-
* inside an inset pill; the boxes-in-boxes read as visual noise, so the
|
|
324
|
-
* inner borders, shadows and track fill are gone -- a single line now
|
|
325
|
-
* carries the whole control. The focus ring lives on the box, not the inner
|
|
326
|
-
* input: the global `#mc input:focus` ring drew a stray box around the bare
|
|
327
|
-
* value in the middle of the control (`.mc-stepper-input` opts out,
|
|
328
|
-
* render/style.js).
|
|
329
|
-
*/
|
|
330
|
-
function stepper(f, width) {
|
|
331
|
-
const ctl = el('div', { display: 'flex', alignItems: 'stretch', width: width || '100%', height: '32px', flex: 'none', boxSizing: 'border-box', border: '1px solid var(--ed-line)', borderRadius: '8px', background: 'var(--ed-panel)', overflow: 'hidden', transition: 'border-color 0.16s, box-shadow 0.16s' }, { class: 'mc-field-control mc-stepper' });
|
|
332
|
-
const stepBtn = (name, title, onClick) => {
|
|
333
|
-
const b = el('button', { width: '27px', flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0', border: '0', background: 'transparent', color: 'var(--ed-muted)', cursor: 'pointer', transition: 'background 0.14s, color 0.14s' }, { type: 'button', title });
|
|
334
|
-
b.appendChild(icon(name, 11));
|
|
335
|
-
b.addEventListener('mouseenter', () => { b.style.background = 'var(--ed-soft)'; b.style.color = 'var(--ed-accent)'; });
|
|
336
|
-
b.addEventListener('mouseleave', () => { b.style.background = 'transparent'; b.style.color = 'var(--ed-muted)'; });
|
|
337
|
-
b.addEventListener('click', onClick);
|
|
338
|
-
return b;
|
|
339
|
-
};
|
|
340
|
-
const dec = stepBtn('minus', 'Decrease', () => f.onDec());
|
|
341
|
-
const mid = el('div', { flex: '1', minWidth: '0', display: 'flex', alignItems: 'baseline', alignSelf: 'center', justifyContent: 'center', gap: '3px', padding: '0 2px' });
|
|
342
|
-
// type="text" + inputmode, NOT type="number": browsers refuse the
|
|
343
|
-
// selection APIs on number inputs, so the per-keystroke re-render could
|
|
344
|
-
// never restore the caret -- typing multi-digit values scrambled them.
|
|
345
|
-
// The -/+ buttons and blur clamp cover what the number type provided.
|
|
346
|
-
const input = el('input', { width: '44px', minWidth: '0', flex: '0 1 auto', textAlign: 'center', boxSizing: 'border-box', background: 'transparent', border: '0', outline: 'none', color: 'var(--ed-text)', fontFamily: 'var(--ed-font)', fontSize: '12.5px', fontWeight: '600', padding: '0' }, { type: 'text', inputmode: 'decimal', class: 'mc-stepper-input', 'data-focus-key': `f${f.key}` });
|
|
347
|
-
input.value = f.value;
|
|
348
|
-
const rangeCommit = typeCommit(f.onInput);
|
|
349
|
-
input.addEventListener('input', (e) => rangeCommit.call(e.target.value));
|
|
350
|
-
// Flush before the clamp: onBlur reads/normalizes committed state, so a
|
|
351
|
-
// still-pending keystroke commit firing after it would undo the clamp.
|
|
352
|
-
input.addEventListener('blur', (e) => { rangeCommit.flush(); f.onBlur(e.target.value); });
|
|
353
|
-
input.addEventListener('focus', () => { ctl.style.borderColor = 'var(--ed-accent)'; ctl.style.boxShadow = '0 0 0 3px var(--ed-soft)'; });
|
|
354
|
-
input.addEventListener('blur', () => { ctl.style.borderColor = 'var(--ed-line)'; ctl.style.boxShadow = 'none'; });
|
|
355
|
-
mid.appendChild(input);
|
|
356
|
-
if (f.unit) mid.appendChild(el('span', { flex: 'none', fontSize: '12px', fontFamily: 'var(--ed-font)', fontWeight: '500', color: 'var(--ed-text)' }, { text: f.unit }));
|
|
357
|
-
const inc = stepBtn('plus', 'Increase', () => f.onInc());
|
|
358
|
-
ctl.append(dec, mid, inc);
|
|
359
|
-
return ctl;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* A grid of linked steppers for multi-side props (core/binder.js `group`):
|
|
364
|
-
* optional header row carrying the group label and a "More options" switch
|
|
365
|
-
* (which swaps the linked pair for per-side fields), then two columns of
|
|
366
|
-
* small-captioned flat steppers. One grid instead of a stack of full-width
|
|
367
|
-
* label+control rows: the sides read as one unit and the panel loses a
|
|
368
|
-
* border-to-border line per side.
|
|
369
|
-
*/
|
|
370
|
-
function renderRangeGroup(f) {
|
|
371
|
-
const box = el('div');
|
|
372
|
-
if (f.label || f.toggle) {
|
|
373
|
-
const head = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px', marginBottom: '9px' });
|
|
374
|
-
head.appendChild(fieldLabel(f.label || '', true));
|
|
375
|
-
if (f.toggle) {
|
|
376
|
-
// Namespaced anim key so this switch's slide (see pendingSwitchAnim)
|
|
377
|
-
// never collides with a plain toggle that shares the group's label.
|
|
378
|
-
const animKey = (f.label || '') + '::more';
|
|
379
|
-
const more = el('div', { display: 'flex', alignItems: 'center', gap: '7px', flex: 'none', cursor: 'pointer' }, { class: 'mc-switch-row' });
|
|
380
|
-
more.addEventListener('click', () => { pendingSwitchAnim = animKey; f.toggle.onChange(); });
|
|
381
|
-
more.appendChild(el('span', { fontFamily: 'var(--ed-font)', fontSize: '12.5px', lineHeight: '1.35', fontWeight: '500', color: 'var(--ed-text)' }, { text: 'More options', class: 'mc-field-label' }));
|
|
382
|
-
const track = el('button', null, { type: 'button', class: 'mc-switch', role: 'switch', 'aria-checked': String(!!f.toggle.on), 'aria-label': (f.label ? f.label + ' — ' : '') + 'more options' });
|
|
383
|
-
track.appendChild(el('span', null, { class: 'mc-switch-knob' }));
|
|
384
|
-
if (pendingSwitchAnim === animKey) { track.classList.add('mc-switch-anim'); pendingSwitchAnim = null; }
|
|
385
|
-
more.appendChild(track);
|
|
386
|
-
head.appendChild(more);
|
|
387
|
-
}
|
|
388
|
-
box.appendChild(head);
|
|
389
|
-
}
|
|
390
|
-
const grid = el('div', { display: 'grid', gridTemplateColumns: f.items.length === 1 ? '1fr' : '1fr 1fr', gap: '10px 12px' });
|
|
391
|
-
f.items.forEach((it) => {
|
|
392
|
-
const cell = el('div');
|
|
393
|
-
cell.appendChild(fieldLabel(it.label));
|
|
394
|
-
cell.appendChild(stepper(it));
|
|
395
|
-
grid.appendChild(cell);
|
|
396
|
-
});
|
|
397
|
-
box.appendChild(grid);
|
|
398
|
-
return box;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Builds one field row exactly as the template's `sc-if` cascade over
|
|
403
|
-
* `f.isHead/isArea/isBtn/isSeg/isRange/isToggle/isRow(isText/isNum/isColor/isSelect)`
|
|
404
|
-
* (template lines 172-236, and the theme-tab variant at 385-406 which only
|
|
405
|
-
* has isHead/isField(isNum/isColor/isSelect) -- `renderThemeField` covers that subset).
|
|
406
|
-
*/
|
|
407
|
-
export function renderField(f) {
|
|
408
|
-
const wrap = el('div', { minWidth: '0' }, { class: 'mc-field' });
|
|
409
|
-
|
|
410
|
-
if (f.isSocial) {
|
|
411
|
-
wrap.appendChild(renderSocialItems(f));
|
|
412
|
-
return wrap;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (f.isTableGrid) {
|
|
416
|
-
wrap.appendChild(renderTableGrid(f));
|
|
417
|
-
return wrap;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (f.isRichLinks) {
|
|
421
|
-
wrap.appendChild(renderRichLinks(f));
|
|
422
|
-
return wrap;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// No isHead branch: heads never reach renderField -- renderFieldCards
|
|
426
|
-
// consumes them as card kickers. A field list rendered without the card
|
|
427
|
-
// grouper would drop its headings, which is the loud failure we want.
|
|
428
|
-
|
|
429
|
-
if (f.isArea) {
|
|
430
|
-
const box = el('div');
|
|
431
|
-
box.appendChild(fieldLabel(f.label));
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
ta
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
wrap.appendChild(
|
|
472
|
-
return wrap;
|
|
473
|
-
}
|
|
474
|
-
|
|
1
|
+
import { icon, brandIcon, socialKey, SOCIAL_BRAND, contrastInk } from '../core/icons.js';
|
|
2
|
+
import { parseItems } from '../core/parse.js';
|
|
3
|
+
|
|
4
|
+
function el(tag, style, attrs) {
|
|
5
|
+
const node = document.createElement(tag);
|
|
6
|
+
if (style) Object.assign(node.style, style);
|
|
7
|
+
if (attrs) for (const [k, v] of Object.entries(attrs)) {
|
|
8
|
+
if (k === 'text') node.textContent = v;
|
|
9
|
+
else if (v !== undefined) node.setAttribute(k, v);
|
|
10
|
+
}
|
|
11
|
+
return node;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Trailing debounce for free-typing inputs. Every commit rebuilds the whole
|
|
16
|
+
* canvas and panel (full re-render, no diffing) -- fine per click, but per
|
|
17
|
+
* keystroke it floors low-end hardware. Typing therefore commits 120ms after
|
|
18
|
+
* the last keystroke: one rebuild per pause instead of per key. `flush` runs
|
|
19
|
+
* on blur so leaving the field (or a blur-time clamp that reads committed
|
|
20
|
+
* state, e.g. B.num) never races a still-pending commit. Discrete controls
|
|
21
|
+
* (toggles, selects, steppers' -/+) stay immediate.
|
|
22
|
+
*/
|
|
23
|
+
export function typeCommit(fn) {
|
|
24
|
+
let t = null; let last;
|
|
25
|
+
const fire = () => { t = null; fn(last); };
|
|
26
|
+
return {
|
|
27
|
+
call(v) { last = v; if (t) clearTimeout(t); t = setTimeout(fire, 120); },
|
|
28
|
+
flush() { if (t) { clearTimeout(t); fire(); } },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Label of the switch the user just clicked, consumed by the very next render.
|
|
34
|
+
*
|
|
35
|
+
* Toggling a prop re-renders the whole panel, which replaces the switch with a
|
|
36
|
+
* fresh node built already in its new state -- there is no before/after on one
|
|
37
|
+
* element for a CSS `transition` to interpolate, so the knob used to jump. The
|
|
38
|
+
* flag lets the rebuilt switch (and no other) start a one-shot keyframe from
|
|
39
|
+
* where the old one stood, so a click slides while merely opening a panel full
|
|
40
|
+
* of already-on switches still paints them in place.
|
|
41
|
+
*/
|
|
42
|
+
let pendingSwitchAnim = null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One label style for every field kind, so the inspector reads as one system:
|
|
46
|
+
* semibold editor UI font at 75% ink for field names (distinct from both the muted
|
|
47
|
+
* values and the uppercase group kickers), block or inline per context.
|
|
48
|
+
*/
|
|
49
|
+
function fieldLabel(text, inline) {
|
|
50
|
+
// One label treatment across every right-panel field. Placement can differ
|
|
51
|
+
// (above wide controls, beside compact controls), but size, weight and color
|
|
52
|
+
// must not change with the control type.
|
|
53
|
+
return el('label', Object.assign(
|
|
54
|
+
{ fontFamily: 'var(--ed-font)', fontSize: '12.5px', lineHeight: '1.35', fontWeight: '500', color: 'var(--ed-text)' },
|
|
55
|
+
inline
|
|
56
|
+
? { flex: '1', minWidth: '0' }
|
|
57
|
+
: { display: 'block', marginBottom: '6px' },
|
|
58
|
+
), { text, class: 'mc-field-label' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which accordion sections the user has collapsed, by section label.
|
|
63
|
+
* Editor-UI state, deliberately module-level: it must survive the full
|
|
64
|
+
* re-render on every commit (the panel is rebuilt each time) but is never
|
|
65
|
+
* part of the document, and collapsing/expanding itself is pure DOM --
|
|
66
|
+
* no core state, no re-render.
|
|
67
|
+
*/
|
|
68
|
+
const sectionCollapsed = Object.create(null);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The flat accordion inspector layout: each `head` field becomes a
|
|
72
|
+
* full-width light section bar (uppercase label, chevron, click to
|
|
73
|
+
* collapse) and the fields under it render as roomy rows directly on the
|
|
74
|
+
* panel surface. Fields before the first head form an untitled lead group.
|
|
75
|
+
*/
|
|
76
|
+
export function renderFieldCards(container, fields) {
|
|
77
|
+
let body = null;
|
|
78
|
+
const open = (headField) => {
|
|
79
|
+
if (headField) {
|
|
80
|
+
const label = headField.label;
|
|
81
|
+
// The mc-section-* classes are theme hooks (render/style.js can restyle
|
|
82
|
+
// the section chrome without touching this builder); the inline styles
|
|
83
|
+
// stay the baseline look.
|
|
84
|
+
const bar = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '11px 14px', background: 'var(--ed-panel-2)', borderTop: '1px solid var(--ed-line)', borderBottom: '1px solid var(--ed-line)', cursor: 'pointer', userSelect: 'none' }, { role: 'button', tabindex: '0', 'aria-expanded': String(!sectionCollapsed[label]), class: 'mc-section-bar' });
|
|
85
|
+
bar.appendChild(el('span', { fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '700', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ed-muted)' }, { text: label, class: 'mc-section-label' }));
|
|
86
|
+
const chev = el('span', { display: 'flex', color: 'var(--ed-faint)', transition: 'transform 0.16s', transform: sectionCollapsed[label] ? 'rotate(-90deg)' : 'none' }, { class: 'mc-section-chevron' });
|
|
87
|
+
chev.appendChild(icon('chevronDown', 13));
|
|
88
|
+
bar.appendChild(chev);
|
|
89
|
+
const localBody = body = el('div', { padding: '12px 14px 16px', background: 'var(--ed-panel)' }, { class: 'mc-section-body mc-field-list' });
|
|
90
|
+
if (sectionCollapsed[label]) localBody.hidden = true;
|
|
91
|
+
const toggle = () => {
|
|
92
|
+
sectionCollapsed[label] = !sectionCollapsed[label];
|
|
93
|
+
localBody.hidden = !!sectionCollapsed[label];
|
|
94
|
+
chev.style.transform = sectionCollapsed[label] ? 'rotate(-90deg)' : 'none';
|
|
95
|
+
bar.setAttribute('aria-expanded', String(!sectionCollapsed[label]));
|
|
96
|
+
};
|
|
97
|
+
bar.addEventListener('click', toggle);
|
|
98
|
+
bar.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
|
|
99
|
+
container.appendChild(bar);
|
|
100
|
+
container.appendChild(localBody);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
body = el('div', { padding: '12px 14px 16px', background: 'var(--ed-panel)' }, { class: 'mc-section-body mc-field-list' });
|
|
104
|
+
container.appendChild(body);
|
|
105
|
+
};
|
|
106
|
+
fields.forEach((f) => {
|
|
107
|
+
if (f.isHead) { open(f); return; }
|
|
108
|
+
if (!body) open(null);
|
|
109
|
+
const node = renderField(f);
|
|
110
|
+
node.style.margin = '0';
|
|
111
|
+
body.appendChild(node);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Every platform the "Add network…" dropdown offers, with a sensible starter URL. Order roughly by how often they appear in email footers. */
|
|
116
|
+
const NETWORKS = [
|
|
117
|
+
['Instagram', 'https://instagram.com/'], ['X', 'https://x.com/'], ['Facebook', 'https://facebook.com/'],
|
|
118
|
+
['LinkedIn', 'https://linkedin.com/company/'], ['YouTube', 'https://youtube.com/@'], ['TikTok', 'https://tiktok.com/@'],
|
|
119
|
+
['Pinterest', 'https://pinterest.com/'], ['WhatsApp', 'https://wa.me/'], ['Telegram', 'https://t.me/'],
|
|
120
|
+
['Threads', 'https://threads.net/@'], ['Snapchat', 'https://snapchat.com/add/'], ['Discord', 'https://discord.gg/'],
|
|
121
|
+
['Reddit', 'https://reddit.com/r/'], ['Spotify', 'https://open.spotify.com/'], ['Behance', 'https://behance.net/'],
|
|
122
|
+
['Dribbble', 'https://dribbble.com/'], ['Email', 'mailto:hello@example.com'], ['Website', 'https://example.com'],
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The social block's network editor: one card per network (brand-colored icon
|
|
127
|
+
* chip, editable name, URL, remove) plus an "Add network…" dropdown -- what
|
|
128
|
+
* used to be a raw `Name|URL` textarea. The data stays that same string
|
|
129
|
+
* (`f.value` in, serialized back out through `f.onChange`), so the block,
|
|
130
|
+
* export and import are untouched; only the editing surface changed.
|
|
131
|
+
*/
|
|
132
|
+
function renderSocialItems(f) {
|
|
133
|
+
const box = el('div');
|
|
134
|
+
box.appendChild(fieldLabel(f.label));
|
|
135
|
+
const items = parseItems(f.value || '');
|
|
136
|
+
// No coercion here: commits land while the user is still typing (debounced,
|
|
137
|
+
// see typeCommit) and the panel re-renders from the committed string, so
|
|
138
|
+
// padding an empty field with a fallback would snap "cleared to retype"
|
|
139
|
+
// inputs back mid-edit.
|
|
140
|
+
const commit = (next) => f.onChange(next.map((it) => (it.label || '') + '|' + (it.href || '')).join('\n'));
|
|
141
|
+
const list = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
|
|
142
|
+
|
|
143
|
+
items.forEach((it, i) => {
|
|
144
|
+
const row = el('div', { display: 'flex', alignItems: 'center', gap: '7px', padding: '6px 7px', border: '1px solid var(--ed-line)', borderRadius: '9px', background: 'var(--ed-panel-2)' });
|
|
145
|
+
const key = socialKey(it.label);
|
|
146
|
+
const brand = SOCIAL_BRAND[key] || '';
|
|
147
|
+
const chip = el('span', { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '24px', height: '24px', flex: 'none', borderRadius: '7px', background: brand || 'var(--ed-soft)', color: brand ? contrastInk(brand) : 'var(--ed-accent)' });
|
|
148
|
+
chip.appendChild(brandIcon(key, 13));
|
|
149
|
+
const name = el('input', { width: '70px', flex: 'none', boxSizing: 'border-box', background: 'transparent', border: '1px solid transparent', borderRadius: '5px', color: 'var(--ed-text)', font: 'inherit', fontSize: '11.5px', fontWeight: '600', padding: '3px 4px' }, { 'data-focus-key': `f${f.key}-n${i}`, title: 'Network name', dir: 'auto' });
|
|
150
|
+
name.value = it.label;
|
|
151
|
+
const nameCommit = typeCommit((v) => { const next = items.slice(); next[i] = { label: v, href: it.href }; commit(next); });
|
|
152
|
+
name.addEventListener('input', (e) => nameCommit.call(e.target.value));
|
|
153
|
+
name.addEventListener('focus', () => { name.style.borderColor = 'var(--ed-accent)'; name.style.background = 'var(--ed-panel)'; name.style.outline = 'none'; });
|
|
154
|
+
name.addEventListener('blur', () => { name.style.borderColor = 'transparent'; name.style.background = 'transparent'; nameCommit.flush(); });
|
|
155
|
+
const url = el('input', { flex: '1', minWidth: '0', boxSizing: 'border-box', background: 'var(--ed-panel)', border: '1px solid var(--ed-line)', borderRadius: '6px', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '10.5px', padding: '4px 6px' }, { placeholder: 'https://', 'data-focus-key': `f${f.key}-u${i}`, title: it.href, dir: 'ltr' });
|
|
156
|
+
url.value = it.href;
|
|
157
|
+
const urlCommit = typeCommit((v) => { const next = items.slice(); next[i] = { label: it.label, href: v }; commit(next); });
|
|
158
|
+
url.addEventListener('input', (e) => urlCommit.call(e.target.value));
|
|
159
|
+
url.addEventListener('focus', () => { url.style.borderColor = 'var(--ed-accent)'; url.style.outline = 'none'; });
|
|
160
|
+
url.addEventListener('blur', () => { url.style.borderColor = 'var(--ed-line)'; urlCommit.flush(); });
|
|
161
|
+
const del = el('button', { width: '22px', height: '22px', flex: 'none', border: '0', borderRadius: '6px', background: 'transparent', color: 'var(--ed-faint)', cursor: 'pointer', fontSize: '13px', lineHeight: '1' }, { type: 'button', title: 'Remove ' + (it.label || 'network'), text: '✕' });
|
|
162
|
+
del.addEventListener('mouseenter', () => { del.style.background = 'var(--ed-danger-soft)'; del.style.color = 'var(--ed-danger)'; });
|
|
163
|
+
del.addEventListener('mouseleave', () => { del.style.background = 'transparent'; del.style.color = 'var(--ed-faint)'; });
|
|
164
|
+
del.addEventListener('click', () => commit(items.filter((x, xi) => xi !== i)));
|
|
165
|
+
row.append(chip, name, url, del);
|
|
166
|
+
list.appendChild(row);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (!items.length) {
|
|
170
|
+
list.appendChild(el('div', { fontSize: '11px', color: 'var(--ed-faint)', padding: '8px 2px' }, { text: 'No networks yet — add one below.' }));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const used = new Set(items.map((it) => socialKey(it.label)));
|
|
174
|
+
const add = el('select', { width: '100%', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px dashed var(--ed-line-2)', borderRadius: '9px', color: 'var(--ed-muted)', font: 'inherit', fontSize: '11.5px', fontWeight: '600', padding: '7px 8px', cursor: 'pointer' }, { title: 'Add network' });
|
|
175
|
+
const ph = document.createElement('option'); ph.value = ''; ph.textContent = '+ Add network…'; add.appendChild(ph);
|
|
176
|
+
NETWORKS.filter(([label]) => !used.has(socialKey(label))).forEach(([label, href]) => {
|
|
177
|
+
const o = document.createElement('option'); o.value = label + '|' + href; o.textContent = label; add.appendChild(o);
|
|
178
|
+
});
|
|
179
|
+
const custom = document.createElement('option'); custom.value = 'Link|https://'; custom.textContent = 'Custom link…'; add.appendChild(custom);
|
|
180
|
+
add.addEventListener('change', (e) => {
|
|
181
|
+
if (!e.target.value) return;
|
|
182
|
+
const [label, href] = e.target.value.split('|');
|
|
183
|
+
commit(items.concat([{ label, href }]));
|
|
184
|
+
});
|
|
185
|
+
add.addEventListener('focus', () => { add.style.borderColor = 'var(--ed-accent)'; add.style.outline = 'none'; });
|
|
186
|
+
add.addEventListener('blur', () => { add.style.borderColor = 'var(--ed-line-2)'; });
|
|
187
|
+
list.appendChild(add);
|
|
188
|
+
|
|
189
|
+
box.appendChild(list);
|
|
190
|
+
return box;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Friendly link editing for rich-text blocks. The block still stores one HTML
|
|
195
|
+
* string; each commit clones that markup, updates only the indexed anchor and
|
|
196
|
+
* serializes it back, preserving all surrounding formatting and content.
|
|
197
|
+
*/
|
|
198
|
+
function renderRichLinks(f) {
|
|
199
|
+
const box = el('div');
|
|
200
|
+
box.appendChild(fieldLabel(f.label));
|
|
201
|
+
const source = el('div');
|
|
202
|
+
source.innerHTML = f.html || '';
|
|
203
|
+
const anchors = Array.from(source.querySelectorAll('a'));
|
|
204
|
+
const list = el('div', { display: 'flex', flexDirection: 'column', gap: '7px' });
|
|
205
|
+
const change = (index, update) => {
|
|
206
|
+
const next = el('div');
|
|
207
|
+
next.innerHTML = f.html || '';
|
|
208
|
+
const anchor = next.querySelectorAll('a')[index];
|
|
209
|
+
if (!anchor) return;
|
|
210
|
+
update(anchor);
|
|
211
|
+
f.onChange(next.innerHTML);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
anchors.forEach((anchor, i) => {
|
|
215
|
+
const card = el('div', { padding: '8px', border: '1px solid var(--ed-line)', borderRadius: '9px', background: 'var(--ed-panel-2)' });
|
|
216
|
+
const head = el('div', { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '7px' });
|
|
217
|
+
const name = el('span', { flex: '1', minWidth: '0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'var(--ed-font)', fontSize: '11.5px', fontWeight: '600', color: 'var(--ed-text)' }, { text: (anchor.textContent || '').trim() || 'Link ' + (i + 1), title: (anchor.textContent || '').trim(), dir: 'auto' });
|
|
218
|
+
const remove = el('button', { flex: 'none', border: '0', borderRadius: '6px', background: 'transparent', color: 'var(--ed-faint)', cursor: 'pointer', padding: '3px 6px', fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '600' }, { type: 'button', text: 'Remove', title: 'Remove link', 'aria-label': 'Remove link from ' + ((anchor.textContent || '').trim() || 'text'), 'data-focus-key': `f${f.key}-link-remove-${i}` });
|
|
219
|
+
remove.addEventListener('mouseenter', () => { remove.style.background = 'var(--ed-danger-soft)'; remove.style.color = 'var(--ed-danger)'; });
|
|
220
|
+
remove.addEventListener('mouseleave', () => { remove.style.background = 'transparent'; remove.style.color = 'var(--ed-faint)'; });
|
|
221
|
+
remove.addEventListener('click', () => change(i, (a) => a.replaceWith(...a.childNodes)));
|
|
222
|
+
head.append(name, remove);
|
|
223
|
+
|
|
224
|
+
const url = el('input', { width: '100%', boxSizing: 'border-box', background: 'var(--ed-panel)', border: '1px solid var(--ed-line)', borderRadius: '7px', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '11px', padding: '6px 8px' }, { placeholder: 'https://', 'aria-label': 'Link destination for ' + ((anchor.textContent || '').trim() || 'link ' + (i + 1)), 'data-focus-key': `f${f.key}-link-url-${i}`, dir: 'ltr' });
|
|
225
|
+
url.value = anchor.getAttribute('href') || '';
|
|
226
|
+
const urlCommit = typeCommit((v) => change(i, (a) => a.setAttribute('href', v)));
|
|
227
|
+
url.addEventListener('input', (e) => urlCommit.call(e.target.value));
|
|
228
|
+
url.addEventListener('focus', () => { url.style.borderColor = 'var(--ed-accent)'; url.style.outline = 'none'; });
|
|
229
|
+
url.addEventListener('blur', () => { url.style.borderColor = 'var(--ed-line)'; urlCommit.flush(); });
|
|
230
|
+
|
|
231
|
+
const targetLabel = el('label', { display: 'flex', alignItems: 'center', gap: '6px', marginTop: '7px', width: 'fit-content', color: 'var(--ed-muted)', cursor: 'pointer', fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '500' });
|
|
232
|
+
const target = el('input', { accentColor: 'var(--ed-accent)', cursor: 'pointer' }, { type: 'checkbox', 'data-focus-key': `f${f.key}-link-target-${i}` });
|
|
233
|
+
target.checked = anchor.target === '_blank';
|
|
234
|
+
target.addEventListener('change', (e) => change(i, (a) => {
|
|
235
|
+
if (e.target.checked) { a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener'); }
|
|
236
|
+
else { a.removeAttribute('target'); a.removeAttribute('rel'); }
|
|
237
|
+
}));
|
|
238
|
+
targetLabel.append(target, 'Open in new tab');
|
|
239
|
+
card.append(head, url, targetLabel);
|
|
240
|
+
list.appendChild(card);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
box.appendChild(list);
|
|
244
|
+
return box;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The table block's cell editor: an actual grid of inputs mirroring the
|
|
249
|
+
* table (bold first row when the header toggle is on), with per-row/column
|
|
250
|
+
* remove buttons and add-row/add-column actions -- replacing the raw
|
|
251
|
+
* "one line per row, split by |" textarea. Data stays that same string.
|
|
252
|
+
*/
|
|
253
|
+
function renderTableGrid(f) {
|
|
254
|
+
const box = el('div');
|
|
255
|
+
box.appendChild(fieldLabel(f.label));
|
|
256
|
+
const rows = String(f.value || '').split('\n').map((r) => r.split('|'));
|
|
257
|
+
const cols = Math.max(1, ...rows.map((r) => r.length));
|
|
258
|
+
rows.forEach((r) => { while (r.length < cols) r.push(''); });
|
|
259
|
+
// Pipes typed into a cell become '/' -- '|' is the column separator in the
|
|
260
|
+
// stored string (same rule as inline cell editing on the canvas).
|
|
261
|
+
const commit = (rs) => f.onChange(rs.map((r) => r.map((c) => String(c).replace(/\|/g, '/')).join('|')).join('\n'));
|
|
262
|
+
const grid = el('div', { display: 'flex', flexDirection: 'column', gap: '4px' });
|
|
263
|
+
const iconBtnStyle = { border: '0', borderRadius: '5px', background: 'transparent', color: 'var(--ed-faint)', cursor: 'pointer', fontSize: '11px', lineHeight: '1', padding: '2px' };
|
|
264
|
+
const dangerHover = (btn) => {
|
|
265
|
+
btn.addEventListener('mouseenter', () => { btn.style.background = 'var(--ed-danger-soft)'; btn.style.color = 'var(--ed-danger)'; });
|
|
266
|
+
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; btn.style.color = 'var(--ed-faint)'; });
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
if (cols > 1) {
|
|
270
|
+
// The right padding mirrors the per-row ✕ (18px + 4px gap) so the column
|
|
271
|
+
// ✕s line up over their columns -- but that ✕ only exists when there is
|
|
272
|
+
// more than one row, so pad only then or the strip drifts 22px left.
|
|
273
|
+
const strip = el('div', { display: 'flex', gap: '4px', paddingRight: rows.length > 1 ? '22px' : '0' });
|
|
274
|
+
for (let ci = 0; ci < cols; ci++) {
|
|
275
|
+
const cbtn = el('button', Object.assign({ flex: '1', minWidth: '0' }, iconBtnStyle), { type: 'button', title: 'Remove column ' + (ci + 1), text: '✕' });
|
|
276
|
+
dangerHover(cbtn);
|
|
277
|
+
cbtn.addEventListener('click', () => commit(rows.map((r) => r.filter((x, i) => i !== ci))));
|
|
278
|
+
strip.appendChild(cbtn);
|
|
279
|
+
}
|
|
280
|
+
grid.appendChild(strip);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
rows.forEach((r, ri) => {
|
|
284
|
+
const rowEl = el('div', { display: 'flex', gap: '4px', alignItems: 'center' });
|
|
285
|
+
r.forEach((cell, ci) => {
|
|
286
|
+
const input = el('input', {
|
|
287
|
+
flex: '1', minWidth: '0', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px solid var(--ed-line)',
|
|
288
|
+
borderRadius: '6px', color: 'var(--ed-text)', font: 'inherit', fontSize: '11px',
|
|
289
|
+
fontWeight: f.header && ri === 0 ? '700' : '400', padding: '5px 6px',
|
|
290
|
+
}, { 'data-focus-key': `f${f.key}-r${ri}c${ci}`, dir: 'auto' });
|
|
291
|
+
input.value = cell;
|
|
292
|
+
const cellCommit = typeCommit((v) => { const next = rows.map((row) => row.slice()); next[ri][ci] = v; commit(next); });
|
|
293
|
+
input.addEventListener('input', (e) => cellCommit.call(e.target.value));
|
|
294
|
+
input.addEventListener('focus', () => { input.style.borderColor = 'var(--ed-accent)'; input.style.outline = 'none'; });
|
|
295
|
+
input.addEventListener('blur', () => { input.style.borderColor = 'var(--ed-line)'; cellCommit.flush(); });
|
|
296
|
+
rowEl.appendChild(input);
|
|
297
|
+
});
|
|
298
|
+
if (rows.length > 1) {
|
|
299
|
+
const del = el('button', Object.assign({ width: '18px', flex: 'none' }, iconBtnStyle), { type: 'button', title: 'Remove row ' + (ri + 1), text: '✕' });
|
|
300
|
+
dangerHover(del);
|
|
301
|
+
del.addEventListener('click', () => commit(rows.filter((x, i) => i !== ri)));
|
|
302
|
+
rowEl.appendChild(del);
|
|
303
|
+
}
|
|
304
|
+
grid.appendChild(rowEl);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const actions = el('div', { display: 'flex', gap: '6px', marginTop: '2px' });
|
|
308
|
+
[['+ Row', () => commit(rows.concat([new Array(cols).fill('')]))], ['+ Column', () => commit(rows.map((r) => r.concat('')))]].forEach(([label, fn]) => {
|
|
309
|
+
const btn = el('button', { flex: '1', border: '1px dashed var(--ed-line-2)', borderRadius: '8px', background: 'transparent', color: 'var(--ed-muted)', cursor: 'pointer', fontFamily: 'var(--ed-font)', fontSize: '11px', fontWeight: '600', padding: '6px' }, { type: 'button', text: label });
|
|
310
|
+
btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--ed-accent)'; btn.style.color = 'var(--ed-accent)'; btn.style.background = 'var(--ed-soft)'; });
|
|
311
|
+
btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--ed-line-2)'; btn.style.color = 'var(--ed-muted)'; btn.style.background = 'transparent'; });
|
|
312
|
+
btn.addEventListener('click', fn);
|
|
313
|
+
actions.appendChild(btn);
|
|
314
|
+
});
|
|
315
|
+
grid.appendChild(actions);
|
|
316
|
+
box.appendChild(grid);
|
|
317
|
+
return box;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The stepper: ONE bordered box holding bare -/+ hit areas flanking the
|
|
322
|
+
* centered value + unit. The previous design nested raised, bordered buttons
|
|
323
|
+
* inside an inset pill; the boxes-in-boxes read as visual noise, so the
|
|
324
|
+
* inner borders, shadows and track fill are gone -- a single line now
|
|
325
|
+
* carries the whole control. The focus ring lives on the box, not the inner
|
|
326
|
+
* input: the global `#mc input:focus` ring drew a stray box around the bare
|
|
327
|
+
* value in the middle of the control (`.mc-stepper-input` opts out,
|
|
328
|
+
* render/style.js).
|
|
329
|
+
*/
|
|
330
|
+
function stepper(f, width) {
|
|
331
|
+
const ctl = el('div', { display: 'flex', alignItems: 'stretch', width: width || '100%', height: '32px', flex: 'none', boxSizing: 'border-box', border: '1px solid var(--ed-line)', borderRadius: '8px', background: 'var(--ed-panel)', overflow: 'hidden', transition: 'border-color 0.16s, box-shadow 0.16s' }, { class: 'mc-field-control mc-stepper' });
|
|
332
|
+
const stepBtn = (name, title, onClick) => {
|
|
333
|
+
const b = el('button', { width: '27px', flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0', border: '0', background: 'transparent', color: 'var(--ed-muted)', cursor: 'pointer', transition: 'background 0.14s, color 0.14s' }, { type: 'button', title });
|
|
334
|
+
b.appendChild(icon(name, 11));
|
|
335
|
+
b.addEventListener('mouseenter', () => { b.style.background = 'var(--ed-soft)'; b.style.color = 'var(--ed-accent)'; });
|
|
336
|
+
b.addEventListener('mouseleave', () => { b.style.background = 'transparent'; b.style.color = 'var(--ed-muted)'; });
|
|
337
|
+
b.addEventListener('click', onClick);
|
|
338
|
+
return b;
|
|
339
|
+
};
|
|
340
|
+
const dec = stepBtn('minus', 'Decrease', () => f.onDec());
|
|
341
|
+
const mid = el('div', { flex: '1', minWidth: '0', display: 'flex', alignItems: 'baseline', alignSelf: 'center', justifyContent: 'center', gap: '3px', padding: '0 2px' });
|
|
342
|
+
// type="text" + inputmode, NOT type="number": browsers refuse the
|
|
343
|
+
// selection APIs on number inputs, so the per-keystroke re-render could
|
|
344
|
+
// never restore the caret -- typing multi-digit values scrambled them.
|
|
345
|
+
// The -/+ buttons and blur clamp cover what the number type provided.
|
|
346
|
+
const input = el('input', { width: '44px', minWidth: '0', flex: '0 1 auto', textAlign: 'center', boxSizing: 'border-box', background: 'transparent', border: '0', outline: 'none', color: 'var(--ed-text)', fontFamily: 'var(--ed-font)', fontSize: '12.5px', fontWeight: '600', padding: '0' }, { type: 'text', inputmode: 'decimal', class: 'mc-stepper-input', 'data-focus-key': `f${f.key}` });
|
|
347
|
+
input.value = f.value;
|
|
348
|
+
const rangeCommit = typeCommit(f.onInput);
|
|
349
|
+
input.addEventListener('input', (e) => rangeCommit.call(e.target.value));
|
|
350
|
+
// Flush before the clamp: onBlur reads/normalizes committed state, so a
|
|
351
|
+
// still-pending keystroke commit firing after it would undo the clamp.
|
|
352
|
+
input.addEventListener('blur', (e) => { rangeCommit.flush(); f.onBlur(e.target.value); });
|
|
353
|
+
input.addEventListener('focus', () => { ctl.style.borderColor = 'var(--ed-accent)'; ctl.style.boxShadow = '0 0 0 3px var(--ed-soft)'; });
|
|
354
|
+
input.addEventListener('blur', () => { ctl.style.borderColor = 'var(--ed-line)'; ctl.style.boxShadow = 'none'; });
|
|
355
|
+
mid.appendChild(input);
|
|
356
|
+
if (f.unit) mid.appendChild(el('span', { flex: 'none', fontSize: '12px', fontFamily: 'var(--ed-font)', fontWeight: '500', color: 'var(--ed-text)' }, { text: f.unit }));
|
|
357
|
+
const inc = stepBtn('plus', 'Increase', () => f.onInc());
|
|
358
|
+
ctl.append(dec, mid, inc);
|
|
359
|
+
return ctl;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A grid of linked steppers for multi-side props (core/binder.js `group`):
|
|
364
|
+
* optional header row carrying the group label and a "More options" switch
|
|
365
|
+
* (which swaps the linked pair for per-side fields), then two columns of
|
|
366
|
+
* small-captioned flat steppers. One grid instead of a stack of full-width
|
|
367
|
+
* label+control rows: the sides read as one unit and the panel loses a
|
|
368
|
+
* border-to-border line per side.
|
|
369
|
+
*/
|
|
370
|
+
function renderRangeGroup(f) {
|
|
371
|
+
const box = el('div');
|
|
372
|
+
if (f.label || f.toggle) {
|
|
373
|
+
const head = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px', marginBottom: '9px' });
|
|
374
|
+
head.appendChild(fieldLabel(f.label || '', true));
|
|
375
|
+
if (f.toggle) {
|
|
376
|
+
// Namespaced anim key so this switch's slide (see pendingSwitchAnim)
|
|
377
|
+
// never collides with a plain toggle that shares the group's label.
|
|
378
|
+
const animKey = (f.label || '') + '::more';
|
|
379
|
+
const more = el('div', { display: 'flex', alignItems: 'center', gap: '7px', flex: 'none', cursor: 'pointer' }, { class: 'mc-switch-row' });
|
|
380
|
+
more.addEventListener('click', () => { pendingSwitchAnim = animKey; f.toggle.onChange(); });
|
|
381
|
+
more.appendChild(el('span', { fontFamily: 'var(--ed-font)', fontSize: '12.5px', lineHeight: '1.35', fontWeight: '500', color: 'var(--ed-text)' }, { text: 'More options', class: 'mc-field-label' }));
|
|
382
|
+
const track = el('button', null, { type: 'button', class: 'mc-switch', role: 'switch', 'aria-checked': String(!!f.toggle.on), 'aria-label': (f.label ? f.label + ' — ' : '') + 'more options' });
|
|
383
|
+
track.appendChild(el('span', null, { class: 'mc-switch-knob' }));
|
|
384
|
+
if (pendingSwitchAnim === animKey) { track.classList.add('mc-switch-anim'); pendingSwitchAnim = null; }
|
|
385
|
+
more.appendChild(track);
|
|
386
|
+
head.appendChild(more);
|
|
387
|
+
}
|
|
388
|
+
box.appendChild(head);
|
|
389
|
+
}
|
|
390
|
+
const grid = el('div', { display: 'grid', gridTemplateColumns: f.items.length === 1 ? '1fr' : '1fr 1fr', gap: '10px 12px' });
|
|
391
|
+
f.items.forEach((it) => {
|
|
392
|
+
const cell = el('div');
|
|
393
|
+
cell.appendChild(fieldLabel(it.label));
|
|
394
|
+
cell.appendChild(stepper(it));
|
|
395
|
+
grid.appendChild(cell);
|
|
396
|
+
});
|
|
397
|
+
box.appendChild(grid);
|
|
398
|
+
return box;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Builds one field row exactly as the template's `sc-if` cascade over
|
|
403
|
+
* `f.isHead/isArea/isBtn/isSeg/isRange/isToggle/isRow(isText/isNum/isColor/isSelect)`
|
|
404
|
+
* (template lines 172-236, and the theme-tab variant at 385-406 which only
|
|
405
|
+
* has isHead/isField(isNum/isColor/isSelect) -- `renderThemeField` covers that subset).
|
|
406
|
+
*/
|
|
407
|
+
export function renderField(f) {
|
|
408
|
+
const wrap = el('div', { minWidth: '0' }, { class: 'mc-field' });
|
|
409
|
+
|
|
410
|
+
if (f.isSocial) {
|
|
411
|
+
wrap.appendChild(renderSocialItems(f));
|
|
412
|
+
return wrap;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (f.isTableGrid) {
|
|
416
|
+
wrap.appendChild(renderTableGrid(f));
|
|
417
|
+
return wrap;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (f.isRichLinks) {
|
|
421
|
+
wrap.appendChild(renderRichLinks(f));
|
|
422
|
+
return wrap;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// No isHead branch: heads never reach renderField -- renderFieldCards
|
|
426
|
+
// consumes them as card kickers. A field list rendered without the card
|
|
427
|
+
// grouper would drop its headings, which is the loud failure we want.
|
|
428
|
+
|
|
429
|
+
if (f.isArea) {
|
|
430
|
+
const box = el('div');
|
|
431
|
+
box.appendChild(fieldLabel(f.label));
|
|
432
|
+
// dir=auto here and on the other content fields below: these hold the
|
|
433
|
+
// email's own text, whose language is independent of the editor chrome's
|
|
434
|
+
// locale -- inheriting the chrome's `dir=rtl` bidi-scrambled English copy
|
|
435
|
+
// (trailing periods jumping to the line start). Value-shaped fields
|
|
436
|
+
// (URLs, color codes) pin dir=ltr instead, since they are never RTL.
|
|
437
|
+
const ta = el('textarea', { width: '100%', minHeight: '76px', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px solid var(--ed-line)', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '11.5px', lineHeight: '1.5', padding: '7px 8px', resize: 'vertical' }, { placeholder: f.placeholder, 'data-focus-key': `f${f.key}`, dir: 'auto' });
|
|
438
|
+
ta.value = f.value;
|
|
439
|
+
const taCommit = typeCommit(f.onChange);
|
|
440
|
+
ta.addEventListener('input', (e) => taCommit.call(e.target.value));
|
|
441
|
+
ta.addEventListener('focus', () => { ta.style.borderColor = 'var(--ed-accent)'; ta.style.outline = 'none'; });
|
|
442
|
+
ta.addEventListener('blur', () => { ta.style.borderColor = 'var(--ed-line)'; taCommit.flush(); });
|
|
443
|
+
box.appendChild(ta);
|
|
444
|
+
wrap.appendChild(box);
|
|
445
|
+
return wrap;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (f.isBtn) {
|
|
449
|
+
// Soft accent action chip -- same recipe as the panel's other secondary
|
|
450
|
+
// actions, replacing the retired mono-uppercase ghost button that
|
|
451
|
+
// clashed with the card system it sits in.
|
|
452
|
+
const btn = el('button', { width: '100%', border: '0', borderRadius: '8px', background: 'var(--ed-soft)', color: 'var(--ed-accent)', cursor: 'pointer', padding: '8px 10px', fontFamily: 'var(--ed-font)', fontSize: '11.5px', fontWeight: '600', transition: 'background 0.16s, color 0.16s' }, { type: 'button', text: f.label });
|
|
453
|
+
btn.addEventListener('mouseenter', () => { btn.style.background = 'var(--ed-accent)'; btn.style.color = 'var(--ed-accent-ink)'; });
|
|
454
|
+
btn.addEventListener('mouseleave', () => { btn.style.background = 'var(--ed-soft)'; btn.style.color = 'var(--ed-accent)'; });
|
|
455
|
+
btn.addEventListener('click', f.onClick);
|
|
456
|
+
wrap.appendChild(btn);
|
|
457
|
+
return wrap;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (f.isSeg) {
|
|
461
|
+
const box = el('div');
|
|
462
|
+
box.appendChild(fieldLabel(f.label));
|
|
463
|
+
const seg = el('div', { display: 'flex', gap: '3px', padding: '3px', border: '0', borderRadius: '10px', background: 'var(--ed-panel-2)' }, { class: 'mc-segment mc-field-segment' });
|
|
464
|
+
f.options.forEach((o) => {
|
|
465
|
+
const active = o.bg === 'var(--ed-accent)';
|
|
466
|
+
const b = el('button', { flex: '1', minWidth: '0', border: '0', borderRadius: '7px', cursor: 'pointer', background: o.bg, color: o.fg, fontFamily: 'var(--ed-font)', fontSize: '10px', fontWeight: '600', padding: '0 6px', height: '28px', textTransform: 'none', letterSpacing: '0.01em', transition: 'background 0.16s, color 0.16s' }, { type: 'button', text: o.label, 'aria-pressed': String(active) });
|
|
467
|
+
b.addEventListener('click', o.onClick);
|
|
468
|
+
seg.appendChild(b);
|
|
469
|
+
});
|
|
470
|
+
box.appendChild(seg);
|
|
471
|
+
wrap.appendChild(box);
|
|
472
|
+
return wrap;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (f.isRangeGroup) {
|
|
476
|
+
wrap.appendChild(renderRangeGroup(f));
|
|
477
|
+
return wrap;
|
|
478
|
+
}
|
|
479
|
+
|
|
475
480
|
if (f.isSlider) {
|
|
476
481
|
// Drag-to-resize slider (core/binder.js `slider`): the value stays in a
|
|
477
482
|
// stable header badge instead of riding over the thumb, where it collided
|
|
@@ -491,119 +496,128 @@ export function renderField(f) {
|
|
|
491
496
|
value.textContent = input.value + f.unit;
|
|
492
497
|
input.style.background = `linear-gradient(to right, var(--ed-accent) ${pct * 100}%, var(--ed-line-2) ${pct * 100}%)`;
|
|
493
498
|
};
|
|
494
|
-
place();
|
|
495
|
-
input.addEventListener('input', place);
|
|
496
|
-
input.addEventListener('change', (e) => f.onCommit(e.target.value));
|
|
499
|
+
place();
|
|
500
|
+
input.addEventListener('input', place);
|
|
501
|
+
input.addEventListener('change', (e) => f.onCommit(e.target.value));
|
|
497
502
|
holder.appendChild(input);
|
|
498
503
|
box.append(head, holder);
|
|
499
|
-
wrap.appendChild(box);
|
|
500
|
-
return wrap;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
if (f.isRange) {
|
|
504
|
-
const box = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }, { class: 'mc-field-row' });
|
|
505
|
-
box.appendChild(fieldLabel(f.label, true));
|
|
506
|
-
box.appendChild(stepper(f, '132px'));
|
|
507
|
-
wrap.appendChild(box);
|
|
508
|
-
return wrap;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
if (f.isToggle) {
|
|
512
|
-
// The pill track and its knob live in CSS (`.mc-switch` in render/style.js)
|
|
513
|
-
// rather than inline styles: the on/off look is driven purely off
|
|
514
|
-
// `aria-checked`, which keeps the accessible state and the visual state
|
|
515
|
-
// from ever drifting apart, and lets `:hover`/`:focus-visible` style it
|
|
516
|
-
// without a JS listener per state.
|
|
517
|
-
const row = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', minHeight: '32px', cursor: 'pointer' }, { class: 'mc-switch-row mc-field-row' });
|
|
518
|
-
// Only the row listens: a click on the switch bubbles up to it, so the
|
|
519
|
-
// handler still fires exactly once (including for keyboard Enter/Space,
|
|
520
|
-
// which the browser dispatches as a click on the button).
|
|
521
|
-
row.addEventListener('click', () => { pendingSwitchAnim = f.label; f.onChange(); });
|
|
522
|
-
const togLabel = fieldLabel(f.label, true);
|
|
523
|
-
togLabel.style.cursor = 'pointer';
|
|
524
|
-
row.appendChild(togLabel);
|
|
525
|
-
const track = el('button', null, { type: 'button', class: 'mc-switch', role: 'switch', 'aria-checked': String(!!f.on), 'aria-label': f.label });
|
|
526
|
-
track.appendChild(el('span', null, { class: 'mc-switch-knob' }));
|
|
527
|
-
// See `pendingSwitchAnim`: this is the rebuilt switch for the click that
|
|
528
|
-
// just happened, so it (and only it) plays the slide.
|
|
529
|
-
if (pendingSwitchAnim === f.label) { track.classList.add('mc-switch-anim'); pendingSwitchAnim = null; }
|
|
530
|
-
row.appendChild(track);
|
|
531
|
-
wrap.appendChild(row);
|
|
532
|
-
return wrap;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
if (f.isColor) {
|
|
536
|
-
// Flat-accordion design: label at left, one bordered pill at right
|
|
537
|
-
// holding the swatch (which doubles as the native picker) and the hex
|
|
538
|
-
// field -- the swatch-and-loose-hex layout read as two stray controls.
|
|
539
|
-
const row = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px' }, { class: 'mc-field-row' });
|
|
540
|
-
row.appendChild(fieldLabel(f.label, true));
|
|
541
|
-
const pill = el('div', { display: 'flex', alignItems: 'center', flex: 'none', height: '32px', border: '1px solid var(--ed-line)', borderRadius: '8px', background: 'var(--ed-panel)', overflow: 'hidden', transition: 'border-color 0.16s, box-shadow 0.16s' }, { class: 'mc-field-control mc-color-control' });
|
|
542
|
-
const swatch = el('label', { position: 'relative', width: '29px', alignSelf: 'stretch', flex: 'none', borderRight: '1px solid var(--ed-line)', background: f.swatch, cursor: 'pointer' }, { title: f.label });
|
|
543
|
-
const picker = el('input', { position: 'absolute', inset: '0', width: '100%', height: '100%', opacity: '0', cursor: 'pointer', padding: '0', border: '0' }, { type: 'color' });
|
|
544
|
-
picker.value = f.swatch;
|
|
545
|
-
// The native picker fires `input` continuously while dragging the hue
|
|
546
|
-
// wheel -- committed raw, that is a full re-render per mouse move.
|
|
547
|
-
const pickCommit = typeCommit(f.onChange);
|
|
548
|
-
picker.addEventListener('input', (e) => pickCommit.call(e.target.value));
|
|
549
|
-
picker.addEventListener('change', () => pickCommit.flush());
|
|
550
|
-
swatch.appendChild(picker);
|
|
551
|
-
const hex = el('input', { width: '82px', boxSizing: 'border-box', background: 'transparent', border: '0', outline: 'none', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '11px', padding: '0 9px' }, { placeholder: 'inherit', class: 'mc-stepper-input', 'data-focus-key': `f${f.key}
|
|
552
|
-
hex.value = f.value;
|
|
553
|
-
const hexCommit = typeCommit(f.onChange);
|
|
554
|
-
hex.addEventListener('input', (e) => hexCommit.call(e.target.value));
|
|
555
|
-
hex.addEventListener('focus', () => { pill.style.borderColor = 'var(--ed-accent)'; pill.style.boxShadow = '0 0 0 3px var(--ed-soft)'; });
|
|
556
|
-
hex.addEventListener('blur', () => { pill.style.borderColor = 'var(--ed-line)'; pill.style.boxShadow = 'none'; hexCommit.flush(); });
|
|
557
|
-
pill.append(swatch, hex);
|
|
558
|
-
row.appendChild(pill);
|
|
559
|
-
wrap.appendChild(row);
|
|
560
|
-
return wrap;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
if (f.isRow) {
|
|
564
|
-
const row = el('div', { display: 'flex', alignItems: 'center', gap: '12px', justifyContent: 'space-between' }, { class: 'mc-field-row' });
|
|
565
|
-
row.appendChild(fieldLabel(f.label, true));
|
|
566
|
-
if (f.isText) {
|
|
567
|
-
const input = el('input', { width: '168px', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px solid var(--ed-line)', color: 'var(--ed-text)', font: 'inherit', fontSize: '12px', padding: '6px 8px' }, { placeholder: f.placeholder, class: 'mc-field-control', 'data-focus-key': `f${f.key}
|
|
568
|
-
input.value = f.value;
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
input.addEventListener('
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
504
|
+
wrap.appendChild(box);
|
|
505
|
+
return wrap;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (f.isRange) {
|
|
509
|
+
const box = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }, { class: 'mc-field-row' });
|
|
510
|
+
box.appendChild(fieldLabel(f.label, true));
|
|
511
|
+
box.appendChild(stepper(f, '132px'));
|
|
512
|
+
wrap.appendChild(box);
|
|
513
|
+
return wrap;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (f.isToggle) {
|
|
517
|
+
// The pill track and its knob live in CSS (`.mc-switch` in render/style.js)
|
|
518
|
+
// rather than inline styles: the on/off look is driven purely off
|
|
519
|
+
// `aria-checked`, which keeps the accessible state and the visual state
|
|
520
|
+
// from ever drifting apart, and lets `:hover`/`:focus-visible` style it
|
|
521
|
+
// without a JS listener per state.
|
|
522
|
+
const row = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', minHeight: '32px', cursor: 'pointer' }, { class: 'mc-switch-row mc-field-row' });
|
|
523
|
+
// Only the row listens: a click on the switch bubbles up to it, so the
|
|
524
|
+
// handler still fires exactly once (including for keyboard Enter/Space,
|
|
525
|
+
// which the browser dispatches as a click on the button).
|
|
526
|
+
row.addEventListener('click', () => { pendingSwitchAnim = f.label; f.onChange(); });
|
|
527
|
+
const togLabel = fieldLabel(f.label, true);
|
|
528
|
+
togLabel.style.cursor = 'pointer';
|
|
529
|
+
row.appendChild(togLabel);
|
|
530
|
+
const track = el('button', null, { type: 'button', class: 'mc-switch', role: 'switch', 'aria-checked': String(!!f.on), 'aria-label': f.label });
|
|
531
|
+
track.appendChild(el('span', null, { class: 'mc-switch-knob' }));
|
|
532
|
+
// See `pendingSwitchAnim`: this is the rebuilt switch for the click that
|
|
533
|
+
// just happened, so it (and only it) plays the slide.
|
|
534
|
+
if (pendingSwitchAnim === f.label) { track.classList.add('mc-switch-anim'); pendingSwitchAnim = null; }
|
|
535
|
+
row.appendChild(track);
|
|
536
|
+
wrap.appendChild(row);
|
|
537
|
+
return wrap;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (f.isColor) {
|
|
541
|
+
// Flat-accordion design: label at left, one bordered pill at right
|
|
542
|
+
// holding the swatch (which doubles as the native picker) and the hex
|
|
543
|
+
// field -- the swatch-and-loose-hex layout read as two stray controls.
|
|
544
|
+
const row = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px' }, { class: 'mc-field-row' });
|
|
545
|
+
row.appendChild(fieldLabel(f.label, true));
|
|
546
|
+
const pill = el('div', { display: 'flex', alignItems: 'center', flex: 'none', height: '32px', border: '1px solid var(--ed-line)', borderRadius: '8px', background: 'var(--ed-panel)', overflow: 'hidden', transition: 'border-color 0.16s, box-shadow 0.16s' }, { class: 'mc-field-control mc-color-control' });
|
|
547
|
+
const swatch = el('label', { position: 'relative', width: '29px', alignSelf: 'stretch', flex: 'none', borderRight: '1px solid var(--ed-line)', background: f.swatch, cursor: 'pointer' }, { title: f.label });
|
|
548
|
+
const picker = el('input', { position: 'absolute', inset: '0', width: '100%', height: '100%', opacity: '0', cursor: 'pointer', padding: '0', border: '0' }, { type: 'color' });
|
|
549
|
+
picker.value = f.swatch;
|
|
550
|
+
// The native picker fires `input` continuously while dragging the hue
|
|
551
|
+
// wheel -- committed raw, that is a full re-render per mouse move.
|
|
552
|
+
const pickCommit = typeCommit(f.onChange);
|
|
553
|
+
picker.addEventListener('input', (e) => pickCommit.call(e.target.value));
|
|
554
|
+
picker.addEventListener('change', () => pickCommit.flush());
|
|
555
|
+
swatch.appendChild(picker);
|
|
556
|
+
const hex = el('input', { width: '82px', boxSizing: 'border-box', background: 'transparent', border: '0', outline: 'none', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '11px', padding: '0 9px' }, { placeholder: 'inherit', class: 'mc-stepper-input', 'data-focus-key': `f${f.key}`, dir: 'ltr' });
|
|
557
|
+
hex.value = f.value;
|
|
558
|
+
const hexCommit = typeCommit(f.onChange);
|
|
559
|
+
hex.addEventListener('input', (e) => hexCommit.call(e.target.value));
|
|
560
|
+
hex.addEventListener('focus', () => { pill.style.borderColor = 'var(--ed-accent)'; pill.style.boxShadow = '0 0 0 3px var(--ed-soft)'; });
|
|
561
|
+
hex.addEventListener('blur', () => { pill.style.borderColor = 'var(--ed-line)'; pill.style.boxShadow = 'none'; hexCommit.flush(); });
|
|
562
|
+
pill.append(swatch, hex);
|
|
563
|
+
row.appendChild(pill);
|
|
564
|
+
wrap.appendChild(row);
|
|
565
|
+
return wrap;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (f.isRow) {
|
|
569
|
+
const row = el('div', { display: 'flex', alignItems: 'center', gap: '12px', justifyContent: 'space-between' }, { class: 'mc-field-row' });
|
|
570
|
+
row.appendChild(fieldLabel(f.label, true));
|
|
571
|
+
if (f.isText) {
|
|
572
|
+
const input = el('input', { width: '168px', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px solid var(--ed-line)', color: 'var(--ed-text)', font: 'inherit', fontSize: '12px', padding: '6px 8px' }, { placeholder: f.placeholder, class: 'mc-field-control', 'data-focus-key': `f${f.key}`, dir: 'auto' });
|
|
573
|
+
input.value = f.value;
|
|
574
|
+
// Optional suggestions (e.g. the host's merge variables on a marker's
|
|
575
|
+
// expression field): a datalist keeps the input free-text -- pick one
|
|
576
|
+
// of the host's names from the dropdown, or type any other.
|
|
577
|
+
if (f.suggestions) {
|
|
578
|
+
const dl = el('datalist', null, { id: `mc-dl-f${f.key}` });
|
|
579
|
+
f.suggestions.forEach((s) => { const opt = document.createElement('option'); opt.value = s; dl.appendChild(opt); });
|
|
580
|
+
input.setAttribute('list', dl.id);
|
|
581
|
+
row.appendChild(dl);
|
|
582
|
+
}
|
|
583
|
+
const textInputCommit = typeCommit(f.onChange);
|
|
584
|
+
input.addEventListener('input', (e) => textInputCommit.call(e.target.value));
|
|
585
|
+
input.addEventListener('focus', () => { input.style.borderColor = 'var(--ed-accent)'; input.style.outline = 'none'; });
|
|
586
|
+
input.addEventListener('blur', () => { input.style.borderColor = 'var(--ed-line)'; textInputCommit.flush(); });
|
|
587
|
+
row.appendChild(input);
|
|
588
|
+
} else if (f.isNum) {
|
|
589
|
+
// text + inputmode for the same caret-restoration reason as the range
|
|
590
|
+
// field above.
|
|
591
|
+
const input = el('input', { width: '78px', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px solid var(--ed-line)', color: 'var(--ed-text)', font: 'inherit', fontSize: '12px', padding: '6px 8px' }, { type: 'text', inputmode: 'numeric', class: 'mc-field-control', 'data-focus-key': `f${f.key}` });
|
|
592
|
+
input.value = f.value;
|
|
593
|
+
const numCommit = typeCommit(f.onChange);
|
|
594
|
+
input.addEventListener('input', (e) => numCommit.call(e.target.value));
|
|
595
|
+
// Flush before the clamp, same reason as the stepper above.
|
|
596
|
+
input.addEventListener('blur', (e) => { input.style.borderColor = 'var(--ed-line)'; numCommit.flush(); if (f.onBlur) f.onBlur(e.target.value); });
|
|
597
|
+
input.addEventListener('focus', () => { input.style.borderColor = 'var(--ed-accent)'; input.style.outline = 'none'; });
|
|
598
|
+
row.appendChild(input);
|
|
599
|
+
} else if (f.isSelect) {
|
|
600
|
+
// Focus key so changing a value (which rebuilds the panel) hands focus
|
|
601
|
+
// back to the rebuilt select -- without it every dropdown change threw
|
|
602
|
+
// keyboard users back to the top of the page.
|
|
603
|
+
const select = el('select', { width: '168px', boxSizing: 'border-box', background: 'var(--ed-panel-2)', border: '1px solid var(--ed-line)', color: 'var(--ed-text)', font: 'inherit', fontSize: '12px', padding: '6px 8px' }, { class: 'mc-field-control', 'data-focus-key': `f${f.key}` });
|
|
604
|
+
f.options.forEach((o) => { const opt = document.createElement('option'); opt.value = o.value; opt.textContent = o.label; select.appendChild(opt); });
|
|
605
|
+
// An unset prop arrives as undefined; assigning that to a <select>
|
|
606
|
+
// coerces to the string "undefined" and selects nothing -- coerce to ''
|
|
607
|
+
// so it lands on the empty-value option (e.g. Font's "Inherit").
|
|
608
|
+
select.value = f.value == null ? '' : f.value;
|
|
609
|
+
select.addEventListener('change', (e) => f.onChange(e.target.value));
|
|
610
|
+
select.addEventListener('focus', () => { select.style.borderColor = 'var(--ed-accent)'; select.style.outline = 'none'; });
|
|
611
|
+
select.addEventListener('blur', () => { select.style.borderColor = 'var(--ed-line)'; });
|
|
612
|
+
row.appendChild(select);
|
|
613
|
+
}
|
|
614
|
+
wrap.appendChild(row);
|
|
615
|
+
return wrap;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return wrap;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function renderFieldList(container, fields) {
|
|
622
|
+
fields.forEach((f) => container.appendChild(renderField(f)));
|
|
623
|
+
}
|