@seliseblocks/mailcraft 0.1.1 → 0.2.1

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