@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.
Files changed (69) hide show
  1. package/DOCS.md +240 -12
  2. package/README.md +11 -3
  3. package/README.md.txt +134 -0
  4. package/dist/mailcraft-editor.bundle.js +51 -39
  5. package/dist/mailcraft-editor.bundle.js.map +3 -3
  6. package/examples/templates/order-confirmed.html +80 -80
  7. package/examples/vanilla.html +242 -23
  8. package/package.json +7 -2
  9. package/src/core/assets.js +10 -15
  10. package/src/core/binder.js +120 -118
  11. package/src/core/blocks.js +14 -1
  12. package/src/core/css-cascade.js +117 -117
  13. package/src/core/editor-core.js +1530 -1492
  14. package/src/core/export.js +142 -55
  15. package/src/core/i18n/ar.js +219 -177
  16. package/src/core/i18n/bg.js +196 -152
  17. package/src/core/i18n/bn.js +218 -176
  18. package/src/core/i18n/ca.js +196 -152
  19. package/src/core/i18n/cs.js +196 -152
  20. package/src/core/i18n/da.js +196 -152
  21. package/src/core/i18n/de-CH.js +196 -152
  22. package/src/core/i18n/de.js +196 -152
  23. package/src/core/i18n/dz.js +221 -179
  24. package/src/core/i18n/el.js +196 -152
  25. package/src/core/i18n/en.js +3 -10
  26. package/src/core/i18n/es.js +196 -152
  27. package/src/core/i18n/et.js +196 -152
  28. package/src/core/i18n/fi.js +196 -152
  29. package/src/core/i18n/fr.js +196 -152
  30. package/src/core/i18n/hr.js +196 -152
  31. package/src/core/i18n/hu.js +196 -152
  32. package/src/core/i18n/index.js +83 -83
  33. package/src/core/i18n/it.js +196 -152
  34. package/src/core/i18n/lt.js +196 -152
  35. package/src/core/i18n/lv.js +196 -152
  36. package/src/core/i18n/nb.js +196 -152
  37. package/src/core/i18n/nl.js +196 -152
  38. package/src/core/i18n/pl.js +196 -152
  39. package/src/core/i18n/pt.js +196 -152
  40. package/src/core/i18n/ro.js +196 -152
  41. package/src/core/i18n/ru.js +196 -152
  42. package/src/core/i18n/sk.js +196 -152
  43. package/src/core/i18n/sl.js +196 -152
  44. package/src/core/i18n/sv.js +196 -152
  45. package/src/core/i18n/tables.js +50 -50
  46. package/src/core/i18n/tr.js +196 -152
  47. package/src/core/i18n/uk.js +196 -152
  48. package/src/core/icons.js +237 -235
  49. package/src/core/ids.js +1 -1
  50. package/src/core/import-html.js +1025 -959
  51. package/src/core/layout-style.js +100 -100
  52. package/src/core/parse.js +10 -10
  53. package/src/core/placeholder.js +15 -15
  54. package/src/core/sanitize.js +141 -141
  55. package/src/core/storage-limits.js +184 -184
  56. package/src/core/storage.js +85 -85
  57. package/src/core/theme.js +1 -1
  58. package/src/core/variables.js +11 -11
  59. package/src/index.js +9 -9
  60. package/src/mailcraft-editor.js +26 -14
  61. package/src/render/block-body.js +49 -6
  62. package/src/render/canvas.js +31 -2
  63. package/src/render/fields.js +602 -588
  64. package/src/render/focus-preserve.js +158 -158
  65. package/src/render/rte.js +241 -212
  66. package/src/render/screenshot.js +132 -132
  67. package/src/render/story.js +415 -415
  68. package/src/render/style.js +8 -0
  69. package/types/index.d.ts +419 -0
package/src/render/rte.js CHANGED
@@ -1,212 +1,241 @@
1
- import { icon } from '../core/icons.js';
2
- import { TOKEN } from '../core/variables.js';
3
- import { typeCommit } from './fields.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 === 'text') node.textContent = v;
10
- else if (v !== undefined) node.setAttribute(k, v);
11
- }
12
- return node;
13
- }
14
-
15
- function st(cmd) { try { return document.queryCommandState(cmd); } catch { return false; } }
16
-
17
- /** Local equivalent of `mailcraft-editor.js`'s `tip()` -- this module doesn't import `elS`, so it builds the same `.mc-tooltip` markup directly. Every RTE control renders its tooltip above itself (`dir: 'up'`), since the toolbar already floats above the block it's editing. */
18
- function tipRte(node, label) {
19
- node.style.position = 'relative';
20
- if (!node.hasAttribute('aria-label')) node.setAttribute('aria-label', label);
21
- node.appendChild(el('span', {}, { class: 'mc-rte-tooltip', text: label }));
22
- return node;
23
- }
24
-
25
- /** Humanizes a variable name for display (`first_name` -> `First name`) while the inserted/appended value always stays the exact `{{ token }}` -- display and data are kept deliberately separate. */
26
- function humanize(v) {
27
- const s = String(v || '').replace(/[_.]+/g, ' ').trim();
28
- return s.charAt(0).toUpperCase() + s.slice(1);
29
- }
30
-
31
- /** Ported from the original `rte(b)` -- the floating formatting toolbar shown above a focused rich-text block. */
32
- export function renderRte(core, b) {
33
- const t = core.t;
34
- const btn = (name, title, fn, active) => {
35
- const node = el('button', {
36
- border: '0', borderRadius: '7px', background: active ? 'var(--ed-accent)' : 'transparent', color: active ? 'var(--ed-accent-ink)' : 'var(--rte-muted)',
37
- cursor: 'pointer', width: '28px', height: '28px', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none',
38
- }, { type: 'button', title, 'data-rte-control': '1', 'data-active': active ? 'true' : 'false' });
39
- node.appendChild(icon(name, 14));
40
- node.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
41
- node.addEventListener('click', (e) => {
42
- e.preventDefault(); e.stopPropagation(); fn();
43
- if (core.onFormatChange) core.onFormatChange();
44
- });
45
- tipRte(node, title);
46
- return node;
47
- };
48
- const sep = () => el('span', { width: '1px', height: '18px', background: 'var(--rte-border)', margin: '0 4px', flex: 'none' });
49
- /** A native `<input type="color">` behind an icon + a thin current-color swatch bar -- `mousedown`'s `preventDefault` keeps the live text selection from collapsing before the picker opens, the same guard every other control here uses; `core.exec()`'s `savedRange` fallback (editor-core.js) covers the rest, since opening the OS color panel unavoidably moves focus away from the block regardless. */
50
- const colorPicker = (iconName, cmd, initial, title) => {
51
- const label = el('label', {
52
- position: 'relative', width: '30px', height: '28px', borderRadius: '7px', background: 'var(--rte-input)',
53
- boxShadow: 'inset 0 0 0 1px var(--rte-border)', color: 'var(--rte-text)', cursor: 'pointer',
54
- display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none', overflow: 'hidden',
55
- }, { 'data-rte-picker': '1', title });
56
- label.appendChild(icon(iconName, 14));
57
- const swatch = el('span', { position: 'absolute', left: '6px', right: '6px', bottom: '4px', height: '3px', borderRadius: '99px', background: initial, pointerEvents: 'none' });
58
- label.appendChild(swatch);
59
- const input = el('input', { position: 'absolute', inset: '0', width: '100%', height: '100%', opacity: '0', margin: '0', cursor: 'pointer' }, { type: 'color', value: initial.startsWith('#') ? initial : '#1d1f20' });
60
- input.addEventListener('mousedown', (e) => e.stopPropagation());
61
- input.addEventListener('click', (e) => e.stopPropagation());
62
- input.addEventListener('input', (e) => { swatch.style.background = e.target.value; core.exec(cmd, e.target.value); });
63
- label.appendChild(input);
64
- tipRte(label, title);
65
- return label;
66
- };
67
- const rowStyle = { display: 'flex', alignItems: 'center', gap: '2px', whiteSpace: 'nowrap' };
68
- const selectStyle = {
69
- background: 'var(--rte-input)', color: 'var(--rte-text)', border: '1px solid var(--rte-border)', borderRadius: '7px',
70
- fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '500', letterSpacing: '0', padding: '5px 24px 5px 27px', height: '28px', cursor: 'pointer', maxWidth: '150px', appearance: 'none', WebkitAppearance: 'none', outline: 'none',
71
- };
72
- const selectControl = (label, value, width, iconName, options, onChange) => {
73
- const wrap = el('label', { position: 'relative', display: 'inline-flex', alignItems: 'center', color: 'var(--rte-muted)' }, { 'data-rte-select': '1', 'aria-label': label });
74
- const lead = el('span', { position: 'absolute', left: '8px', zIndex: '2', pointerEvents: 'none', display: 'flex' });
75
- lead.appendChild(icon(iconName, 13));
76
- const select = el('select', Object.assign({}, selectStyle, { width }), { title: label });
77
- options.forEach(([v, l]) => { const o = document.createElement('option'); o.value = v; o.textContent = l; select.appendChild(o); });
78
- select.value = value;
79
- select.addEventListener('mousedown', (e) => e.stopPropagation());
80
- select.addEventListener('change', onChange);
81
- const tail = el('span', { position: 'absolute', right: '7px', pointerEvents: 'none', display: 'flex' });
82
- tail.appendChild(icon('chevronDown', 12));
83
- wrap.append(lead, select, tail);
84
- return wrap;
85
- };
86
-
87
- const vars = core.vars();
88
- const root = el('div', {
89
- position: 'absolute', top: '-82px', left: '0', zIndex: '9', display: 'grid', gap: '5px',
90
- background: 'var(--rte-bg)', border: '1px solid var(--rte-border)', borderRadius: '8px', padding: '7px 8px', boxShadow: 'var(--rte-shadow)',
91
- }, { class: 'mc-rte', 'data-mc-rte': '1', 'data-rte-root': '1' });
92
- root.addEventListener('pointerdown', () => { core.rteActive = true; }, true);
93
- root.addEventListener('mousedown', (e) => e.stopPropagation());
94
- root.addEventListener('click', (e) => e.stopPropagation());
95
-
96
- const r1 = el('div', rowStyle);
97
- const fmt = selectControl(t('rte.textStyle'), core.currentTag(), '118px', 'typeColor',
98
- [['', t('rte.textStyle')], ['p', 'Paragraph'], ['h1', 'Heading 1'], ['h2', 'Heading 2'], ['h3', 'Heading 3'], ['h4', 'Heading 4'], ['h5', 'Heading 5'], ['h6', 'Small heading'], ['blockquote', 'Block quote']], (e) => {
99
- if (e.target.value) {
100
- core.exec('formatBlock', e.target.value);
101
- if (core.onFormatChange) core.onFormatChange();
102
- }
103
- });
104
- r1.append(
105
- fmt, sep(),
106
- btn('bold', 'Bold', () => core.exec('bold'), st('bold')),
107
- btn('italic', 'Italic', () => core.exec('italic'), st('italic')),
108
- btn('underline', 'Underline', () => core.exec('underline'), st('underline')),
109
- btn('strike', 'Strikethrough', () => core.exec('strikeThrough'), st('strikeThrough')),
110
- btn('inlineCode', 'Inline code', () => core.exec('insertHTML', '<code style="font-family:ui-monospace,monospace;font-size:0.92em;background:var(--ed-soft);padding:1px 4px">' + (core.getSelection().toString() || 'code') + '</code>')),
111
- btn('superscript', 'Superscript', () => core.exec('superscript'), st('superscript')),
112
- btn('subscript', 'Subscript', () => core.exec('subscript'), st('subscript')),
113
- sep(),
114
- btn('minus', 'Smaller text', () => core.size(b, -1)),
115
- el('span', { fontFamily: 'var(--ed-font)', fontSize: '9.5px', color: 'var(--rte-muted)', minWidth: '32px', textAlign: 'center' }, { text: (b.props.size || 16) + 'px' }),
116
- btn('plus', 'Larger text', () => core.size(b, 1)),
117
- );
118
-
119
- const r2 = el('div', rowStyle);
120
- r2.append(
121
- btn('alignLeft', 'Align left', () => core.exec('justifyLeft'), st('justifyLeft')),
122
- btn('alignCenter', 'Align center', () => core.exec('justifyCenter'), st('justifyCenter')),
123
- btn('alignRight', 'Align right', () => core.exec('justifyRight'), st('justifyRight')),
124
- btn('alignJustify', 'Justify', () => core.exec('justifyFull'), st('justifyFull')),
125
- sep(),
126
- btn('list', 'Bullet list', () => core.exec('insertUnorderedList'), st('insertUnorderedList')),
127
- btn('listOrdered', 'Numbered list', () => core.exec('insertOrderedList'), st('insertOrderedList')),
128
- btn('outdent', 'Outdent', () => core.exec('outdent')),
129
- btn('indent', 'Indent', () => core.exec('indent')),
130
- sep(),
131
- btn('link', 'Link — ⌘K', () => core.openLink(), !!core.state.linkDraft),
132
- btn('unlink', 'Remove link', () => core.removeLink(b)),
133
- btn('formatClear', t('rte.clearFormatting'), () => core.exec('removeFormat')),
134
- sep(),
135
- colorPicker('typeColor', 'foreColor', '#172033', t('rte.textColor')),
136
- colorPicker('highlighter', 'hiliteColor', '#fef08a', t('rte.highlightColor')),
137
- btn('eraser', t('rte.removeHighlight'), () => core.exec('hiliteColor', 'transparent')),
138
- sep(),
139
- );
140
- const varOptions = [['', vars.length ? t('rte.mergeTags') : t('rte.noMergeTags')]].concat(vars.map((v) => [TOKEN(v), humanize(v)]));
141
- r2.appendChild(selectControl(t('rte.mergeTags'), '', '132px', 'data', varOptions, (e) => {
142
- if (e.target.value) { core.exec('insertText', e.target.value); e.target.value = ''; }
143
- }));
144
-
145
- root.appendChild(r1);
146
- root.appendChild(r2);
147
- if (core.state.linkDraft) root.appendChild(linkPopover(core, b, vars));
148
- return root;
149
- }
150
-
151
- /** Ported from the original `linkPopover(b, vars)`. */
152
- function linkPopover(core, b, vars) {
153
- const t = core.t;
154
- const d = core.state.linkDraft;
155
- const set = (patch) => core.setState({ linkDraft: Object.assign({}, core.state.linkDraft, patch) });
156
- const inputStyle = {
157
- flex: '1', minWidth: '0', background: 'var(--rte-input)', color: 'var(--rte-text)', border: '1px solid var(--rte-border)', borderRadius: '5px',
158
- font: 'inherit', fontFamily: 'ui-monospace, monospace', fontSize: '11px', padding: '5px 7px',
159
- };
160
- const act = (label, primary, onClick) => {
161
- const node = el('button', {
162
- border: primary ? '0' : '1px solid var(--rte-border)', borderRadius: '5px', background: primary ? 'var(--ed-accent)' : 'transparent',
163
- color: primary ? 'var(--ed-accent-ink)' : 'var(--rte-text)', cursor: 'pointer', padding: '5px 10px',
164
- fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '600',
165
- }, { type: 'button', text: label });
166
- node.addEventListener('mousedown', (e) => e.preventDefault());
167
- node.addEventListener('click', (e) => { e.preventDefault(); onClick(); });
168
- return node;
169
- };
170
-
171
- const root = el('div', { display: 'flex', flexDirection: 'column', gap: '6px', borderTop: '1px solid var(--rte-border)', paddingTop: '6px', marginTop: '2px', minWidth: '380px' });
172
-
173
- const r = el('div', { display: 'flex', alignItems: 'center', gap: '5px' });
174
- const input = el('input', inputStyle, { placeholder: 'https://example.com' });
175
- input.value = d.href;
176
- setTimeout(() => input.focus(), 0);
177
- // Debounced (typeCommit): each draft update re-renders everything. Flushed
178
- // explicitly before applyLink -- both Enter and the Apply button (which
179
- // suppresses blur on mousedown to keep the text selection) read the draft
180
- // from state, so a pending keystroke commit must land first.
181
- const hrefCommit = typeCommit((v) => set({ href: v }));
182
- input.addEventListener('input', (e) => hrefCommit.call(e.target.value));
183
- input.addEventListener('blur', () => hrefCommit.flush());
184
- input.addEventListener('keydown', (e) => {
185
- if (e.key === 'Enter') { e.preventDefault(); hrefCommit.flush(); core.applyLink(b); }
186
- if (e.key === 'Escape') core.setState({ linkDraft: null });
187
- });
188
- r.appendChild(input);
189
- if (vars.length) {
190
- const sel = el('select', { background: 'var(--rte-input)', color: 'var(--rte-text)', border: '1px solid var(--rte-border)', borderRadius: '5px', fontFamily: 'var(--ed-font)', fontSize: '10.5px', padding: '4px', cursor: 'pointer', maxWidth: '110px' }, { title: t('rte.mergeTags') });
191
- const ph = document.createElement('option'); ph.value = ''; ph.textContent = t('rte.mergeTags'); sel.appendChild(ph);
192
- vars.forEach((v) => { const o = document.createElement('option'); o.value = TOKEN(v); o.textContent = humanize(v); sel.appendChild(o); });
193
- sel.addEventListener('change', (e) => { if (e.target.value) { set({ href: (d.href || '') + e.target.value }); e.target.value = ''; } });
194
- r.appendChild(sel);
195
- }
196
- root.appendChild(r);
197
-
198
- const r2 = el('div', { display: 'flex', alignItems: 'center', gap: '8px' });
199
- const label = el('label', { display: 'flex', alignItems: 'center', gap: '5px', color: 'var(--rte-muted)', fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '500', cursor: 'pointer' });
200
- const cb = el('input', { accentColor: 'var(--ed-accent)', cursor: 'pointer' }, { type: 'checkbox' });
201
- cb.checked = !!d.blank;
202
- cb.addEventListener('change', (e) => set({ blank: e.target.checked }));
203
- label.append(cb, 'Open in new tab');
204
- r2.appendChild(label);
205
- r2.appendChild(el('span', { flex: '1' }));
206
- if (d.editing) r2.appendChild(act('Remove', false, () => core.removeLink(b)));
207
- r2.appendChild(act('Cancel', false, () => core.setState({ linkDraft: null })));
208
- r2.appendChild(act(d.editing ? 'Update' : 'Apply', true, () => { hrefCommit.flush(); core.applyLink(b); }));
209
- root.appendChild(r2);
210
-
211
- return root;
212
- }
1
+ import { icon } from '../core/icons.js';
2
+ import { TOKEN } from '../core/variables.js';
3
+ import { typeCommit } from './fields.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 === 'text') node.textContent = v;
10
+ else if (v !== undefined) node.setAttribute(k, v);
11
+ }
12
+ return node;
13
+ }
14
+
15
+ function st(cmd) { try { return document.queryCommandState(cmd); } catch { return false; } }
16
+
17
+ /** Local equivalent of `mailcraft-editor.js`'s `tip()` -- this module doesn't import `elS`, so it builds the same `.mc-tooltip` markup directly. Every RTE control renders its tooltip above itself (`dir: 'up'`), since the toolbar already floats above the block it's editing. */
18
+ function tipRte(node, label) {
19
+ node.style.position = 'relative';
20
+ if (!node.hasAttribute('aria-label')) node.setAttribute('aria-label', label);
21
+ node.appendChild(el('span', {}, { class: 'mc-rte-tooltip', text: label }));
22
+ return node;
23
+ }
24
+
25
+ /** Humanizes a variable name for display (`first_name` -> `First name`) while the inserted/appended value always stays the exact `{{ token }}` -- display and data are kept deliberately separate. */
26
+ function humanize(v) {
27
+ const s = String(v || '').replace(/[_.]+/g, ' ').trim();
28
+ return s.charAt(0).toUpperCase() + s.slice(1);
29
+ }
30
+
31
+ /** Ported from the original `rte(b)` -- the floating formatting toolbar shown above a focused rich-text block. */
32
+ export function renderRte(core, b) {
33
+ const t = core.t;
34
+ const btn = (name, title, fn, active) => {
35
+ const node = el('button', {
36
+ border: '0', borderRadius: '7px', background: active ? 'var(--ed-accent)' : 'transparent', color: active ? 'var(--ed-accent-ink)' : 'var(--rte-muted)',
37
+ cursor: 'pointer', width: '28px', height: '28px', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none',
38
+ }, { type: 'button', title, 'data-rte-control': '1', 'data-active': active ? 'true' : 'false' });
39
+ node.appendChild(icon(name, 14));
40
+ node.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
41
+ node.addEventListener('click', (e) => {
42
+ e.preventDefault(); e.stopPropagation(); fn();
43
+ if (core.onFormatChange) core.onFormatChange();
44
+ });
45
+ tipRte(node, title);
46
+ return node;
47
+ };
48
+ const sep = () => el('span', { width: '1px', height: '18px', background: 'var(--rte-border)', margin: '0 4px', flex: 'none' });
49
+ /** A native `<input type="color">` behind an icon + a thin current-color swatch bar -- `mousedown`'s `preventDefault` keeps the live text selection from collapsing before the picker opens, the same guard every other control here uses; `core.exec()`'s `savedRange` fallback (editor-core.js) covers the rest, since opening the OS color panel unavoidably moves focus away from the block regardless. */
50
+ const colorPicker = (iconName, cmd, initial, title) => {
51
+ const label = el('label', {
52
+ position: 'relative', width: '30px', height: '28px', borderRadius: '7px', background: 'var(--rte-input)',
53
+ boxShadow: 'inset 0 0 0 1px var(--rte-border)', color: 'var(--rte-text)', cursor: 'pointer',
54
+ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none', overflow: 'hidden',
55
+ }, { 'data-rte-picker': '1', title });
56
+ label.appendChild(icon(iconName, 14));
57
+ const swatch = el('span', { position: 'absolute', left: '6px', right: '6px', bottom: '4px', height: '3px', borderRadius: '99px', background: initial, pointerEvents: 'none' });
58
+ label.appendChild(swatch);
59
+ const input = el('input', { position: 'absolute', inset: '0', width: '100%', height: '100%', opacity: '0', margin: '0', cursor: 'pointer' }, { type: 'color', value: initial.startsWith('#') ? initial : '#1d1f20' });
60
+ input.addEventListener('mousedown', (e) => e.stopPropagation());
61
+ input.addEventListener('click', (e) => e.stopPropagation());
62
+ input.addEventListener('input', (e) => { swatch.style.background = e.target.value; core.exec(cmd, e.target.value); });
63
+ label.appendChild(input);
64
+ tipRte(label, title);
65
+ return label;
66
+ };
67
+ const rowStyle = { display: 'flex', alignItems: 'center', gap: '2px', whiteSpace: 'nowrap' };
68
+ const selectStyle = {
69
+ background: 'var(--rte-input)', color: 'var(--rte-text)', border: '1px solid var(--rte-border)', borderRadius: '7px',
70
+ fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '500', letterSpacing: '0', padding: '5px 24px 5px 27px', height: '28px', cursor: 'pointer', maxWidth: '150px', appearance: 'none', WebkitAppearance: 'none', outline: 'none',
71
+ };
72
+ const selectControl = (label, value, width, iconName, options, onChange) => {
73
+ const wrap = el('label', { position: 'relative', display: 'inline-flex', alignItems: 'center', color: 'var(--rte-muted)' }, { 'data-rte-select': '1', 'aria-label': label });
74
+ const lead = el('span', { position: 'absolute', left: '8px', zIndex: '2', pointerEvents: 'none', display: 'flex' });
75
+ lead.appendChild(icon(iconName, 13));
76
+ const select = el('select', Object.assign({}, selectStyle, { width }), { title: label });
77
+ options.forEach(([v, l]) => { const o = document.createElement('option'); o.value = v; o.textContent = l; select.appendChild(o); });
78
+ select.value = value;
79
+ select.addEventListener('mousedown', (e) => e.stopPropagation());
80
+ select.addEventListener('change', onChange);
81
+ const tail = el('span', { position: 'absolute', right: '7px', pointerEvents: 'none', display: 'flex' });
82
+ tail.appendChild(icon('chevronDown', 12));
83
+ wrap.append(lead, select, tail);
84
+ return wrap;
85
+ };
86
+
87
+ const vars = core.vars();
88
+ const root = el('div', {
89
+ position: 'absolute', top: '-82px', left: '0', zIndex: '30', display: 'grid', gap: '5px',
90
+ background: 'var(--rte-bg)', border: '1px solid var(--rte-border)', borderRadius: '8px', padding: '7px 8px', boxShadow: 'var(--rte-shadow)',
91
+ }, { class: 'mc-rte', 'data-mc-rte': '1', 'data-rte-root': '1' });
92
+ root.addEventListener('pointerdown', () => { core.rteActive = true; }, true);
93
+ root.addEventListener('mousedown', (e) => e.stopPropagation());
94
+ root.addEventListener('click', (e) => e.stopPropagation());
95
+
96
+ const r1 = el('div', rowStyle);
97
+ const fmt = selectControl(t('rte.textStyle'), core.currentTag(), '118px', 'typeColor',
98
+ [['', t('rte.textStyle')], ['p', 'Paragraph'], ['h1', 'Heading 1'], ['h2', 'Heading 2'], ['h3', 'Heading 3'], ['h4', 'Heading 4'], ['h5', 'Heading 5'], ['h6', 'Small heading'], ['blockquote', 'Block quote']], (e) => {
99
+ if (e.target.value) {
100
+ core.exec('formatBlock', e.target.value);
101
+ if (core.onFormatChange) core.onFormatChange();
102
+ }
103
+ });
104
+ r1.append(
105
+ fmt, sep(),
106
+ btn('bold', 'Bold', () => core.exec('bold'), st('bold')),
107
+ btn('italic', 'Italic', () => core.exec('italic'), st('italic')),
108
+ btn('underline', 'Underline', () => core.exec('underline'), st('underline')),
109
+ btn('strike', 'Strikethrough', () => core.exec('strikeThrough'), st('strikeThrough')),
110
+ btn('inlineCode', 'Inline code', () => core.exec('insertHTML', '<code style="font-family:ui-monospace,monospace;font-size:0.92em;background:var(--ed-soft);padding:1px 4px">' + (core.getSelection().toString() || 'code') + '</code>')),
111
+ btn('superscript', 'Superscript', () => core.exec('superscript'), st('superscript')),
112
+ btn('subscript', 'Subscript', () => core.exec('subscript'), st('subscript')),
113
+ sep(),
114
+ btn('minus', 'Smaller text', () => core.size(b, -1)),
115
+ el('span', { fontFamily: 'var(--ed-font)', fontSize: '9.5px', color: 'var(--rte-muted)', minWidth: '32px', textAlign: 'center' }, { text: (b.props.size || 16) + 'px' }),
116
+ btn('plus', 'Larger text', () => core.size(b, 1)),
117
+ );
118
+
119
+ const r2 = el('div', rowStyle);
120
+ r2.append(
121
+ btn('alignLeft', 'Align left', () => core.exec('justifyLeft'), st('justifyLeft')),
122
+ btn('alignCenter', 'Align center', () => core.exec('justifyCenter'), st('justifyCenter')),
123
+ btn('alignRight', 'Align right', () => core.exec('justifyRight'), st('justifyRight')),
124
+ btn('alignJustify', 'Justify', () => core.exec('justifyFull'), st('justifyFull')),
125
+ sep(),
126
+ btn('list', 'Bullet list', () => core.exec('insertUnorderedList'), st('insertUnorderedList')),
127
+ btn('listOrdered', 'Numbered list', () => core.exec('insertOrderedList'), st('insertOrderedList')),
128
+ btn('outdent', 'Outdent', () => core.exec('outdent')),
129
+ btn('indent', 'Indent', () => core.exec('indent')),
130
+ sep(),
131
+ btn('link', 'Link — ⌘K', () => core.openLink(), !!core.state.linkDraft),
132
+ btn('unlink', 'Remove link', () => core.removeLink(b)),
133
+ btn('formatClear', t('rte.clearFormatting'), () => core.exec('removeFormat')),
134
+ sep(),
135
+ colorPicker('typeColor', 'foreColor', '#172033', t('rte.textColor')),
136
+ colorPicker('highlighter', 'hiliteColor', '#fef08a', t('rte.highlightColor')),
137
+ btn('eraser', t('rte.removeHighlight'), () => core.exec('hiliteColor', 'transparent')),
138
+ sep(),
139
+ );
140
+ const varOptions = [['', vars.length ? t('rte.mergeTags') : t('rte.noMergeTags')]].concat(vars.map((v) => [TOKEN(v), humanize(v)]));
141
+ r2.appendChild(selectControl(t('rte.mergeTags'), '', '132px', 'data', varOptions, (e) => {
142
+ if (e.target.value) { core.exec('insertText', e.target.value); e.target.value = ''; }
143
+ }));
144
+
145
+ root.appendChild(r1);
146
+ root.appendChild(r2);
147
+ if (core.state.linkDraft) root.appendChild(linkPopover(core, b, vars));
148
+ // The toolbar hangs a fixed 82px above the block, so on a block near the top
149
+ // of the email it reaches past the top of the scrolling workspace -- which
150
+ // clips it there, hiding the toolbar's first row under the header. When
151
+ // there is no headroom, flip it to hang below the block instead.
152
+ //
153
+ // Placement needs layout, which doesn't exist until the node is mounted --
154
+ // but deciding in a frame callback alone made the toolbar visibly jump from
155
+ // its clipped spot to the flipped one, on first open and again on every
156
+ // rebuild while editing (each replaceWith reset it above, then hopped).
157
+ // So the last decision is cached per block and applied synchronously on
158
+ // rebuilds, and only the first build for a block spends one frame hidden
159
+ // while it measures -- the toolbar then appears already in place.
160
+ const BELOW = 'calc(100% + 10px)';
161
+ const cached = core._rteFlip && core._rteFlip.id === b.id ? core._rteFlip.flip : null;
162
+ if (cached === true) root.style.top = BELOW;
163
+ else if (cached === null) root.style.visibility = 'hidden';
164
+ requestAnimationFrame(() => {
165
+ if (!root.isConnected) return;
166
+ const scroller = core.exportRoot && core.exportRoot.querySelector('.mc-workspace');
167
+ if (scroller && root.parentElement) {
168
+ // Judged from the block's own position, not the toolbar's current one,
169
+ // so an already-flipped toolbar can flip back once headroom returns.
170
+ const wouldBeTop = root.parentElement.getBoundingClientRect().top - 82;
171
+ const flip = wouldBeTop < scroller.getBoundingClientRect().top + 4;
172
+ core._rteFlip = { id: b.id, flip };
173
+ root.style.top = flip ? BELOW : '-82px';
174
+ }
175
+ root.style.visibility = '';
176
+ });
177
+ return root;
178
+ }
179
+
180
+ /** Ported from the original `linkPopover(b, vars)`. */
181
+ function linkPopover(core, b, vars) {
182
+ const t = core.t;
183
+ const d = core.state.linkDraft;
184
+ const set = (patch) => core.setState({ linkDraft: Object.assign({}, core.state.linkDraft, patch) });
185
+ const inputStyle = {
186
+ flex: '1', minWidth: '0', background: 'var(--rte-input)', color: 'var(--rte-text)', border: '1px solid var(--rte-border)', borderRadius: '5px',
187
+ font: 'inherit', fontFamily: 'ui-monospace, monospace', fontSize: '11px', padding: '5px 7px',
188
+ };
189
+ const act = (label, primary, onClick) => {
190
+ const node = el('button', {
191
+ border: primary ? '0' : '1px solid var(--rte-border)', borderRadius: '5px', background: primary ? 'var(--ed-accent)' : 'transparent',
192
+ color: primary ? 'var(--ed-accent-ink)' : 'var(--rte-text)', cursor: 'pointer', padding: '5px 10px',
193
+ fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '600',
194
+ }, { type: 'button', text: label });
195
+ node.addEventListener('mousedown', (e) => e.preventDefault());
196
+ node.addEventListener('click', (e) => { e.preventDefault(); onClick(); });
197
+ return node;
198
+ };
199
+
200
+ const root = el('div', { display: 'flex', flexDirection: 'column', gap: '6px', borderTop: '1px solid var(--rte-border)', paddingTop: '6px', marginTop: '2px', minWidth: '380px' });
201
+
202
+ const r = el('div', { display: 'flex', alignItems: 'center', gap: '5px' });
203
+ const input = el('input', inputStyle, { placeholder: 'https://example.com' });
204
+ input.value = d.href;
205
+ setTimeout(() => input.focus(), 0);
206
+ // Debounced (typeCommit): each draft update re-renders everything. Flushed
207
+ // explicitly before applyLink -- both Enter and the Apply button (which
208
+ // suppresses blur on mousedown to keep the text selection) read the draft
209
+ // from state, so a pending keystroke commit must land first.
210
+ const hrefCommit = typeCommit((v) => set({ href: v }));
211
+ input.addEventListener('input', (e) => hrefCommit.call(e.target.value));
212
+ input.addEventListener('blur', () => hrefCommit.flush());
213
+ input.addEventListener('keydown', (e) => {
214
+ if (e.key === 'Enter') { e.preventDefault(); hrefCommit.flush(); core.applyLink(b); }
215
+ if (e.key === 'Escape') core.setState({ linkDraft: null });
216
+ });
217
+ r.appendChild(input);
218
+ if (vars.length) {
219
+ const sel = el('select', { background: 'var(--rte-input)', color: 'var(--rte-text)', border: '1px solid var(--rte-border)', borderRadius: '5px', fontFamily: 'var(--ed-font)', fontSize: '10.5px', padding: '4px', cursor: 'pointer', maxWidth: '110px' }, { title: t('rte.mergeTags') });
220
+ const ph = document.createElement('option'); ph.value = ''; ph.textContent = t('rte.mergeTags'); sel.appendChild(ph);
221
+ vars.forEach((v) => { const o = document.createElement('option'); o.value = TOKEN(v); o.textContent = humanize(v); sel.appendChild(o); });
222
+ sel.addEventListener('change', (e) => { if (e.target.value) { set({ href: (d.href || '') + e.target.value }); e.target.value = ''; } });
223
+ r.appendChild(sel);
224
+ }
225
+ root.appendChild(r);
226
+
227
+ const r2 = el('div', { display: 'flex', alignItems: 'center', gap: '8px' });
228
+ const label = el('label', { display: 'flex', alignItems: 'center', gap: '5px', color: 'var(--rte-muted)', fontFamily: 'var(--ed-font)', fontSize: '10.5px', fontWeight: '500', cursor: 'pointer' });
229
+ const cb = el('input', { accentColor: 'var(--ed-accent)', cursor: 'pointer' }, { type: 'checkbox' });
230
+ cb.checked = !!d.blank;
231
+ cb.addEventListener('change', (e) => set({ blank: e.target.checked }));
232
+ label.append(cb, 'Open in new tab');
233
+ r2.appendChild(label);
234
+ r2.appendChild(el('span', { flex: '1' }));
235
+ if (d.editing) r2.appendChild(act('Remove', false, () => core.removeLink(b)));
236
+ r2.appendChild(act('Cancel', false, () => core.setState({ linkDraft: null })));
237
+ r2.appendChild(act(d.editing ? 'Update' : 'Apply', true, () => { hrefCommit.flush(); core.applyLink(b); }));
238
+ root.appendChild(r2);
239
+
240
+ return root;
241
+ }