@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.
Files changed (89) hide show
  1. package/DOCS.md +557 -0
  2. package/LICENSE +21 -0
  3. package/README.md +126 -0
  4. package/dist/mailcraft-editor.bundle.js +519 -0
  5. package/dist/mailcraft-editor.bundle.js.map +7 -0
  6. package/examples/templates/activate-your-account.html +54 -0
  7. package/examples/templates/back-in-stock.html +63 -0
  8. package/examples/templates/cart-left-behind.html +74 -0
  9. package/examples/templates/community-giveaway.html +81 -0
  10. package/examples/templates/frontend-futures-invite.html +69 -0
  11. package/examples/templates/give-25-get-25.html +69 -0
  12. package/examples/templates/invoice-paid.html +77 -0
  13. package/examples/templates/meet-nova-launch.html +69 -0
  14. package/examples/templates/mega-weekend-sale.html +57 -0
  15. package/examples/templates/order-confirmed.html +80 -0
  16. package/examples/templates/rate-your-headphones.html +71 -0
  17. package/examples/templates/reset-your-password.html +54 -0
  18. package/examples/templates/thankyou-promo-code.html +68 -0
  19. package/examples/templates/the-sunday-brief.html +47 -0
  20. package/examples/templates/welcome-to-your-workspace.html +62 -0
  21. package/examples/templates/your-order-shipped.html +74 -0
  22. package/examples/templates/your-password-was-changed.html +58 -0
  23. package/examples/templates/your-signin-code.html +58 -0
  24. package/examples/vanilla.html +1574 -0
  25. package/package.json +66 -0
  26. package/src/core/accent.js +194 -0
  27. package/src/core/assets.js +15 -0
  28. package/src/core/binder.js +119 -0
  29. package/src/core/blocks.js +256 -0
  30. package/src/core/css-cascade.js +117 -0
  31. package/src/core/editor-core.js +1492 -0
  32. package/src/core/export.js +58 -0
  33. package/src/core/footer.js +73 -0
  34. package/src/core/i18n/ar.js +177 -0
  35. package/src/core/i18n/bg.js +152 -0
  36. package/src/core/i18n/bn.js +176 -0
  37. package/src/core/i18n/ca.js +152 -0
  38. package/src/core/i18n/cs.js +152 -0
  39. package/src/core/i18n/da.js +152 -0
  40. package/src/core/i18n/de-CH.js +152 -0
  41. package/src/core/i18n/de.js +152 -0
  42. package/src/core/i18n/dz.js +179 -0
  43. package/src/core/i18n/el.js +152 -0
  44. package/src/core/i18n/en.js +269 -0
  45. package/src/core/i18n/es.js +152 -0
  46. package/src/core/i18n/et.js +152 -0
  47. package/src/core/i18n/fi.js +152 -0
  48. package/src/core/i18n/fr.js +152 -0
  49. package/src/core/i18n/hr.js +152 -0
  50. package/src/core/i18n/hu.js +152 -0
  51. package/src/core/i18n/index.js +83 -0
  52. package/src/core/i18n/it.js +152 -0
  53. package/src/core/i18n/lt.js +152 -0
  54. package/src/core/i18n/lv.js +152 -0
  55. package/src/core/i18n/nb.js +152 -0
  56. package/src/core/i18n/nl.js +152 -0
  57. package/src/core/i18n/pl.js +152 -0
  58. package/src/core/i18n/pt.js +152 -0
  59. package/src/core/i18n/ro.js +152 -0
  60. package/src/core/i18n/ru.js +152 -0
  61. package/src/core/i18n/sk.js +152 -0
  62. package/src/core/i18n/sl.js +152 -0
  63. package/src/core/i18n/sv.js +152 -0
  64. package/src/core/i18n/tables.js +50 -0
  65. package/src/core/i18n/tr.js +152 -0
  66. package/src/core/i18n/uk.js +152 -0
  67. package/src/core/icons.js +235 -0
  68. package/src/core/ids.js +1 -0
  69. package/src/core/import-html.js +959 -0
  70. package/src/core/layout-style.js +115 -0
  71. package/src/core/parse.js +10 -0
  72. package/src/core/placeholder.js +15 -0
  73. package/src/core/sanitize.js +141 -0
  74. package/src/core/storage-limits.js +184 -0
  75. package/src/core/storage.js +85 -0
  76. package/src/core/theme.js +1 -0
  77. package/src/core/toolbar.js +67 -0
  78. package/src/core/variables.js +11 -0
  79. package/src/create-editor.js +109 -0
  80. package/src/index.js +9 -0
  81. package/src/mailcraft-editor.js +1862 -0
  82. package/src/render/block-body.js +295 -0
  83. package/src/render/canvas.js +284 -0
  84. package/src/render/fields.js +609 -0
  85. package/src/render/focus-preserve.js +158 -0
  86. package/src/render/rte.js +212 -0
  87. package/src/render/screenshot.js +132 -0
  88. package/src/render/story.js +415 -0
  89. 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
+ }