@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,158 @@
1
+ /**
2
+ * The original relies on React's reconciliation to keep a focused `<input>`'s
3
+ * DOM node alive across a per-keystroke re-render (Design-tab fields, the
4
+ * campaign title, AI brief/goal/tone, search boxes, the code textarea all
5
+ * commit on every `input` event, not just on blur). This renderer has no
6
+ * VDOM -- it rebuilds DOM from scratch -- so without this, typing a single
7
+ * character into any of those fields would lose focus immediately after.
8
+ *
9
+ * The fix: every such input (and every RTE-edited block: text, heading, box,
10
+ * html) carries a stable `data-focus-key`. Before a rebuild, capture the
11
+ * focused element's key + selection range; after, find the new element with
12
+ * the same key and restore focus and the caret position, so the net effect
13
+ * matches React's outcome even though the DOM node identity changed.
14
+ *
15
+ * Contenteditable blocks need this just as much as plain inputs: focusing
16
+ * one sets `core.state.editing`, and `EditorCore.mountKeyboard`'s
17
+ * `selectionchange` listener refreshes the toolbar's active states on every
18
+ * caret move. Without
19
+ * caret-position restoration here, that would blow away and recreate the
20
+ * focused div each time, so the very first keystroke would silently drop
21
+ * focus and the RTE toolbar would appear to do nothing.
22
+ */
23
+ export function withFocusPreserved(root, rebuild) {
24
+ const active = root.activeElement;
25
+ const key = active && active.dataset ? active.dataset.focusKey : null;
26
+ let selStart = null; let selEnd = null; let scrollTop = null; let editable = false;
27
+ // A range slider mid-drag is the one focus-preservation case where
28
+ // restoring focus on a rebuilt node isn't enough: dragging its thumb is a
29
+ // native, implicit mouse capture tied to that exact element, and replacing
30
+ // the element (as every other rebuilt input does here) silently drops that
31
+ // capture -- the thumb stops tracking the mouse and the slider feels like
32
+ // it's snapping/jumping instead of gliding. So this one case skips
33
+ // rebuilding the node entirely: the live element is pulled out before
34
+ // `rebuild()` and spliced back into the freshly-built tree afterward.
35
+ const rangeNode = key && active.tagName === 'INPUT' && active.type === 'range' ? active : null;
36
+ if (rangeNode) {
37
+ // nothing to capture -- the node itself is preserved below, after rebuild()
38
+ } else if (key && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
39
+ try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch { /* some input types don't support selection */ }
40
+ scrollTop = active.scrollTop;
41
+ } else if (key && active.isContentEditable) {
42
+ editable = true;
43
+ const sel = shadowSelection(root);
44
+ if (sel && sel.rangeCount && active.contains(sel.anchorNode)) {
45
+ const range = sel.getRangeAt(0);
46
+ selStart = textOffset(active, range.startContainer, range.startOffset);
47
+ selEnd = textOffset(active, range.endContainer, range.endOffset);
48
+ }
49
+ scrollTop = active.scrollTop;
50
+ }
51
+
52
+ rebuild();
53
+
54
+ if (!key) return;
55
+ const next = root.querySelector(`[data-focus-key="${cssEscape(key)}"]`);
56
+ if (!next) return;
57
+ if (rangeNode) {
58
+ for (const attr of ['min', 'max', 'step']) {
59
+ const v = next.getAttribute(attr);
60
+ if (v === null) rangeNode.removeAttribute(attr); else rangeNode.setAttribute(attr, v);
61
+ }
62
+ next.replaceWith(rangeNode);
63
+ return;
64
+ }
65
+ if (editable) {
66
+ // Set the Range *before* focusing: focusing a contenteditable establishes
67
+ // its own default collapsed selection as a side effect, and doing that
68
+ // after we've placed the real caret fires a second, out-of-order
69
+ // `selectionchange` notification for that now-stale default -- which
70
+ // arrives *after* the one for our real restore, so it looks like a fresh,
71
+ // later selection change and clobbers the correct caret right back to
72
+ // collapsed. Setting the range first means `.focus()` adopts the
73
+ // selection that's already there instead of replacing it.
74
+ if (selStart != null) {
75
+ try {
76
+ const range = document.createRange();
77
+ setPointAtOffset(range, next, selStart, true);
78
+ setPointAtOffset(range, next, selEnd, false);
79
+ const sel = shadowSelection(root);
80
+ sel.removeAllRanges();
81
+ sel.addRange(range);
82
+ } catch { /* ignore -- element structure changed under the caret */ }
83
+ }
84
+ // `preventScroll` matters a lot here: this `.focus()` fires on every
85
+ // re-render of a block mid-edit (every keystroke), not just once. Without
86
+ // it, the browser's default focus-scroll-into-view runs every time --
87
+ // harmless on a short template, but on a long one it yanks the canvas
88
+ // back toward the focused block on every keystroke, fighting whatever
89
+ // scroll position the user actually had.
90
+ next.focus({ preventScroll: true });
91
+ } else {
92
+ next.focus({ preventScroll: true });
93
+ if (selStart != null) {
94
+ try { next.setSelectionRange(selStart, selEnd); } catch { /* ignore */ }
95
+ }
96
+ }
97
+ if (scrollTop != null) next.scrollTop = scrollTop;
98
+ }
99
+
100
+ /**
101
+ * `document.getSelection()`/`window.getSelection()` is redacted to the host
102
+ * document when the real selection lives inside an open shadow root -- in
103
+ * Chrome its `anchorNode` reports as `<body>` rather than the actual text
104
+ * node being edited, even though the visible caret and `execCommand` both
105
+ * still operate on the real position. Reading through that redacted object
106
+ * (as this file needs to, to compute/restore a caret offset) silently gives
107
+ * back garbage -- not an error, just always "position 0" -- which is what
108
+ * made every re-render-while-typing reset the caret to the start and type
109
+ * new characters in reverse. `ShadowRoot.getSelection()` (Chromium-only; no
110
+ * standard equivalent yet) reports the real node/offset.
111
+ */
112
+ function shadowSelection(root) {
113
+ return typeof root.getSelection === 'function' ? root.getSelection() : window.getSelection();
114
+ }
115
+
116
+ /** Character offset of (node, offset) counting only text within `root`, walking in document order. */
117
+ export function textOffset(root, node, offset) {
118
+ if (node.nodeType !== Node.TEXT_NODE) {
119
+ // A range boundary can land on an element (e.g. offset counts child nodes) --
120
+ // resolve it to the text position right before its `offset`-th child.
121
+ let n = 0;
122
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
123
+ let cur; let target = node.childNodes[offset];
124
+ if (!target) { while (walker.nextNode()) n += walker.currentNode.nodeValue.length; return n; }
125
+ while ((cur = walker.nextNode())) { if (cur === target || target.contains(cur)) return n; n += cur.nodeValue.length; }
126
+ return n;
127
+ }
128
+ let n = 0;
129
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
130
+ let cur;
131
+ while ((cur = walker.nextNode())) {
132
+ if (cur === node) return n + offset;
133
+ n += cur.nodeValue.length;
134
+ }
135
+ return n;
136
+ }
137
+
138
+ /** Sets the start or end point of `range` to the text-offset position inside `root`. */
139
+ function setPointAtOffset(range, root, targetOffset, isStart) {
140
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
141
+ let n = 0; let cur; let last = null;
142
+ while ((cur = walker.nextNode())) {
143
+ last = cur;
144
+ const len = cur.nodeValue.length;
145
+ if (n + len >= targetOffset) {
146
+ const point = targetOffset - n;
147
+ if (isStart) range.setStart(cur, point); else range.setEnd(cur, point);
148
+ return;
149
+ }
150
+ n += len;
151
+ }
152
+ if (last) { if (isStart) range.setStart(last, last.nodeValue.length); else range.setEnd(last, last.nodeValue.length); }
153
+ else { if (isStart) range.setStart(root, 0); else range.setEnd(root, 0); }
154
+ }
155
+
156
+ function cssEscape(s) {
157
+ return String(s).replace(/["\\]/g, '\\$&');
158
+ }
@@ -0,0 +1,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: '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
+ }
@@ -0,0 +1,132 @@
1
+ import { renderDoc } from './canvas.js';
2
+
3
+ /**
4
+ * Full-template screenshot, zero dependencies: render the same static tree
5
+ * the preview modal uses (inline styles only), wrap it in an SVG
6
+ * `<foreignObject>`, rasterize that through an `<img>` onto a canvas, and
7
+ * hand back a PNG Blob.
8
+ *
9
+ * Two hard rules of SVG-loaded-as-image. The first is that the SVG has to
10
+ * reach the `<img>` as a `data:` URI, never a `blob:` one: Chromium treats a
11
+ * blob-loaded SVG that contains a `<foreignObject>` as cross-origin content,
12
+ * so the canvas comes out tainted and `toBlob()` throws
13
+ * ("Tainted canvases may not be exported") -- i.e. every capture fails, with
14
+ * nothing in the console to say why. A `data:` URI of the same bytes is
15
+ * origin-clean and rasterizes identically.
16
+ *
17
+ * The second is that it may not fetch ANY
18
+ * external resource -- not "CORS applies", but a total network ban -- so
19
+ * every image has to already be a data: URI when the SVG is built. Uploads
20
+ * and the seeded placeholders are data: URIs by construction
21
+ * (core/assets.js, core/placeholder.js); anything else (a hand-typed image
22
+ * URL) is inlined here via a best-effort fetch, and a fetch the remote
23
+ * server refuses (no CORS header) degrades to a blank pixel instead of
24
+ * silently producing a tainted, unexportable canvas.
25
+ *
26
+ * Known limits, accepted: `<iframe>` embeds never paint inside foreignObject
27
+ * (they render as their reserved box), and web fonts are unavailable, so
28
+ * text falls back through the theme's system font stack -- which is what an
29
+ * email client would show anyway.
30
+ */
31
+
32
+ const BLANK_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
33
+
34
+ function toDataUri(url) {
35
+ return fetch(url, { mode: 'cors' }).then((res) => {
36
+ if (!res.ok) throw new Error('fetch failed: ' + res.status);
37
+ return res.blob();
38
+ }).then((blob) => new Promise((resolve, reject) => {
39
+ const fr = new FileReader();
40
+ fr.onload = () => resolve(fr.result);
41
+ fr.onerror = reject;
42
+ fr.readAsDataURL(blob);
43
+ }));
44
+ }
45
+
46
+ async function inlineExternalImages(root) {
47
+ const jobs = [];
48
+ root.querySelectorAll('img').forEach((img) => {
49
+ const src = img.getAttribute('src') || '';
50
+ if (!src || src.startsWith('data:')) return;
51
+ jobs.push(toDataUri(src).then(
52
+ (uri) => { img.setAttribute('src', uri); },
53
+ () => { img.setAttribute('src', BLANK_PIXEL); },
54
+ ));
55
+ });
56
+ root.querySelectorAll('[style]').forEach((el) => {
57
+ const m = (el.style.backgroundImage || '').match(/url\(["']?(?!data:)([^"')]+)["']?\)/);
58
+ if (!m) return;
59
+ jobs.push(toDataUri(m[1]).then(
60
+ (uri) => { el.style.backgroundImage = 'url("' + uri + '")'; },
61
+ () => { el.style.backgroundImage = 'none'; },
62
+ ));
63
+ });
64
+ await Promise.all(jobs);
65
+ }
66
+
67
+ /**
68
+ * Renders the current document full-length (desktop width, independent of
69
+ * the device toggle and zoom) and returns a PNG Blob at `scale`x resolution.
70
+ * `mountInto` is any attached container inside the editor's shadow root --
71
+ * the tree must live in the live document briefly, off-screen, to lay out
72
+ * and be measured before serialization.
73
+ *
74
+ * (`export const` + async function expression, not `export async function`:
75
+ * build.js's transform only recognizes `export (const|function|class)`.)
76
+ */
77
+ export const captureTemplatePng = async function (core, mountInto, scale = 2) {
78
+ const theme = core.state.doc.theme;
79
+ const pad = 32;
80
+ const wrapper = document.createElement('div');
81
+ // Off-screen but attached (fixed keeps it out of any scroll container's
82
+ // scrollHeight, so capturing never moves the user's canvas position).
83
+ wrapper.style.cssText = `position: fixed; left: -100000px; top: 0; width: ${theme.width + pad * 2}px; background: ${theme.bg}; padding: ${pad}px; box-sizing: border-box; display: flex; justify-content: center;`;
84
+ wrapper.setAttribute('aria-hidden', 'true');
85
+ // renderDoc sizes the sheet from `state.device`, so with the mobile toggle
86
+ // on it would return a 375px sheet floating inside this desktop-width
87
+ // wrapper. Borrow the state for the length of one synchronous render --
88
+ // nothing observes it in between, and no re-render is triggered.
89
+ const device = core.state.device;
90
+ core.state.device = 'desktop';
91
+ try {
92
+ wrapper.appendChild(renderDoc(core, false));
93
+ } finally {
94
+ core.state.device = device;
95
+ }
96
+ mountInto.appendChild(wrapper);
97
+ try {
98
+ await inlineExternalImages(wrapper);
99
+ // Heights depend on images' intrinsic ratios (blocks set width, not
100
+ // height) -- decode everything before measuring or tall images collapse.
101
+ await Promise.all(Array.from(wrapper.querySelectorAll('img')).map(
102
+ (img) => (img.decode ? img.decode().catch(() => {}) : Promise.resolve()),
103
+ ));
104
+ const w = wrapper.offsetWidth;
105
+ const h = wrapper.offsetHeight;
106
+ // Serialize a *clone* with the off-screen positioning stripped -- the
107
+ // wrapper's own `fixed; left: -100000px` would carry into the SVG and
108
+ // place the content outside the drawable area.
109
+ const copy = wrapper.cloneNode(true);
110
+ copy.style.position = 'static';
111
+ copy.style.left = 'auto';
112
+ const xml = new XMLSerializer().serializeToString(copy);
113
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><foreignObject width="100%" height="100%">${xml}</foreignObject></svg>`;
114
+
115
+ const img = new Image();
116
+ await new Promise((resolve, reject) => {
117
+ img.onload = resolve;
118
+ img.onerror = () => reject(new Error('SVG rasterization failed'));
119
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
120
+ });
121
+ const canvas = document.createElement('canvas');
122
+ canvas.width = Math.max(1, Math.round(w * scale));
123
+ canvas.height = Math.max(1, Math.round(h * scale));
124
+ const ctx = canvas.getContext('2d');
125
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
126
+ return await new Promise((resolve, reject) => {
127
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('PNG encoding failed'))), 'image/png');
128
+ });
129
+ } finally {
130
+ wrapper.remove();
131
+ }
132
+ };