@seliseblocks/mailcraft 0.2.8 → 0.2.9

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.
@@ -1039,6 +1039,22 @@ export class MailCraftEditor extends ElementBase {
1039
1039
  }
1040
1040
 
1041
1041
  renderTabBody() {
1042
+ // The browser's native colour dialog is bound to the identity of its
1043
+ // <input type="color"> node: Chromium closes the dialog the moment that
1044
+ // node leaves the document -- even if the same node is spliced back into
1045
+ // the rebuilt tree within the same task (unlike the range slider's mouse
1046
+ // capture, which focus-preserve.js does save that way). The picker lives
1047
+ // in this panel, and its `input` events commit (debounced) to the doc, so
1048
+ // every colour the user tried used to rebuild the panel ~120ms later and
1049
+ // slam the dialog shut. While the picker holds focus -- the only time its
1050
+ // dialog can be open -- the panel therefore skips its rebuild: the canvas
1051
+ // has already re-rendered above (that is the live preview), and the pill
1052
+ // previews itself (fields.js mutates its swatch and hex text directly on
1053
+ // every `input`). Nothing else in the panel changes on a colour commit,
1054
+ // so nothing is stale; the moment focus moves anywhere else, the next
1055
+ // render rebuilds the panel as usual.
1056
+ const active = this.shadowRoot.activeElement;
1057
+ if (active && active.tagName === 'INPUT' && active.type === 'color' && this.tabBody.contains(active)) return;
1042
1058
  this.tabBody.innerHTML = '';
1043
1059
  const tab = this.core.state.tab;
1044
1060
  if (tab === 'design') this.tabBody.appendChild(this.renderDesignTab());
@@ -1579,7 +1595,30 @@ export class MailCraftEditor extends ElementBase {
1579
1595
  this.codeStatusEl = elS('div', 'font-family: var(--ed-font); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ed-muted);', { class: 'mc-code-kicker' });
1580
1596
  headText.append(this.codeStatusEl, elS('div', 'font-family: var(--ed-font); font-weight: 600; font-size: 15px; line-height: 1.2;', { text: t('modal.rawHtmlTitle'), 'data-i18n': 'modal.rawHtmlTitle', class: 'mc-code-heading' }));
1581
1597
  this.codeWidthSeg = elS('div', '', { class: 'mc-segment' });
1582
- const reloadBtn = elS('button', 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 11px; display: flex; align-items: center; gap: 6px; font-family: var(--ed-font); font-size: 11px; font-weight: 600; transition: border-color 0.16s, background 0.16s;', { type: 'button', title: t('action.reloadHint'), 'data-i18n-title': 'action.reloadHint' });
1598
+ // Ghost-button chrome shared by the header's Copy and Reload.
1599
+ const GHOST_BTN = 'border: 1px solid var(--ed-line); background: transparent; color: var(--ed-text); cursor: pointer; height: 30px; padding: 0 11px; display: flex; align-items: center; gap: 6px; font-family: var(--ed-font); font-size: 11px; font-weight: 600; transition: border-color 0.16s, background 0.16s;';
1600
+ const copyBtn = elS('button', GHOST_BTN, { type: 'button', title: t('toast.htmlCopied'), 'data-i18n-title': 'toast.htmlCopied', class: 'mc-code-copy' });
1601
+ copyBtn.appendChild(icon('copy', 14));
1602
+ // Reuses keys every locale already carries (story.copy / export.copied)
1603
+ // rather than minting new ones across 32 translations.
1604
+ const copyLabel = elS('span', '', { text: t('story.copy'), 'data-i18n': 'story.copy' });
1605
+ copyBtn.appendChild(copyLabel);
1606
+ copyBtn.addEventListener('mouseenter', () => { copyBtn.style.borderColor = 'var(--ed-accent)'; copyBtn.style.background = 'var(--ed-soft)'; });
1607
+ copyBtn.addEventListener('mouseleave', () => { copyBtn.style.borderColor = 'var(--ed-line)'; copyBtn.style.background = 'transparent'; });
1608
+ copyBtn.addEventListener('click', () => {
1609
+ this.core.copyCode();
1610
+ // Momentary "Copied": swap the i18n key itself, not just the text --
1611
+ // refreshStrings relabels every [data-i18n] node on each render and
1612
+ // would otherwise revert the feedback mid-flash.
1613
+ copyLabel.setAttribute('data-i18n', 'export.copied');
1614
+ copyLabel.textContent = this.core.t('export.copied');
1615
+ clearTimeout(this._codeCopyTimer);
1616
+ this._codeCopyTimer = setTimeout(() => {
1617
+ copyLabel.setAttribute('data-i18n', 'story.copy');
1618
+ copyLabel.textContent = this.core.t('story.copy');
1619
+ }, 1600);
1620
+ });
1621
+ const reloadBtn = elS('button', GHOST_BTN, { type: 'button', title: t('action.reloadHint'), 'data-i18n-title': 'action.reloadHint' });
1583
1622
  reloadBtn.appendChild(icon('refresh', 14));
1584
1623
  reloadBtn.appendChild(elS('span', '', { text: t('action.reload'), 'data-i18n': 'action.reload' }));
1585
1624
  reloadBtn.addEventListener('mouseenter', () => { reloadBtn.style.borderColor = 'var(--ed-accent)'; reloadBtn.style.background = 'var(--ed-soft)'; });
@@ -1601,7 +1640,7 @@ export class MailCraftEditor extends ElementBase {
1601
1640
  closeBtn.addEventListener('mouseleave', () => { closeBtn.style.borderColor = 'var(--ed-line)'; });
1602
1641
  closeBtn.addEventListener('click', () => this.core.setState({ libraryOpen: false, exportOpen: false, aiOpen: false, codeOpen: false, assetTarget: null, libHot: false }));
1603
1642
  tip(closeBtn, t('action.closeWithoutApplyingHint'), 'down', 'end');
1604
- head.append(headIcon, headText, this.codeWidthSeg, reloadBtn, applyBtn, closeBtn);
1643
+ head.append(headIcon, headText, this.codeWidthSeg, copyBtn, reloadBtn, applyBtn, closeBtn);
1605
1644
  overlay.appendChild(head);
1606
1645
 
1607
1646
  const cols = elS('div', 'display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 12px; min-height: 0; background: var(--ed-work);', { class: 'mc-code-split' });
@@ -12,6 +12,23 @@ function el(tag, style, attrs) {
12
12
  return node;
13
13
  }
14
14
 
15
+ /**
16
+ * The native `<input type="color">` accepts ONLY `#rrggbb` -- handed an
17
+ * imported `rgba(...)`, `rgb(...)`, `#abc` or a keyword it silently stays at
18
+ * black, so opening the picker on such a value read as "the color was not
19
+ * picked up". Normalizes what can be normalized (alpha is dropped -- the
20
+ * picker cannot represent it; the text field beside it still holds the exact
21
+ * value); anything else returns '' and the picker keeps its default.
22
+ */
23
+ function pickerHex(v) {
24
+ const s = String(v == null ? '' : v).trim();
25
+ if (/^#[0-9a-f]{6}$/i.test(s)) return s;
26
+ if (/^#[0-9a-f]{3}$/i.test(s)) return '#' + s.slice(1).split('').map((c) => c + c).join('');
27
+ const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(s);
28
+ if (m) return '#' + [m[1], m[2], m[3]].map((n) => Math.min(255, Number(n)).toString(16).padStart(2, '0')).join('');
29
+ return '';
30
+ }
31
+
15
32
  /**
16
33
  * Trailing debounce for free-typing inputs. Every commit rebuilds the whole
17
34
  * canvas and panel (full re-render, no diffing) -- fine per click, but per
@@ -570,11 +587,20 @@ export function renderField(f) {
570
587
  });
571
588
  }
572
589
  const picker = el('input', { position: 'absolute', inset: '0', width: '100%', height: '100%', opacity: '0', cursor: 'pointer', padding: '0', border: '0' }, { type: 'color' });
573
- picker.value = f.swatch;
590
+ const ph = pickerHex(f.swatch);
591
+ if (ph) picker.value = ph;
574
592
  // The native picker fires `input` continuously while dragging the hue
575
593
  // wheel -- committed raw, that is a full re-render per mouse move.
576
594
  const pickCommit = typeCommit(f.onChange);
577
- picker.addEventListener('input', (e) => pickCommit.call(e.target.value));
595
+ picker.addEventListener('input', (e) => {
596
+ // Self-preview: while this picker's dialog is open the inspector skips
597
+ // its rebuilds (renderTabBody, mailcraft-editor.js) so the dialog's
598
+ // host node survives -- which also means no rebuild will repaint this
599
+ // pill. Mutate it directly so swatch and hex track the dialog live.
600
+ swatch.style.background = e.target.value;
601
+ hex.value = e.target.value;
602
+ pickCommit.call(e.target.value);
603
+ });
578
604
  picker.addEventListener('change', () => pickCommit.flush());
579
605
  swatch.appendChild(picker);
580
606
  const hex = el('input', { width: '82px', boxSizing: 'border-box', background: 'transparent', border: '0', outline: 'none', color: 'var(--ed-text)', fontFamily: 'ui-monospace, monospace', fontSize: '11px', padding: '0 9px' }, { placeholder: 'inherit', class: 'mc-stepper-input', 'data-focus-key': `f${f.key}`, dir: 'ltr' });
@@ -1,158 +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
- }
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
+ }
package/src/render/rte.js CHANGED
@@ -110,6 +110,12 @@ export function renderRte(core, b) {
110
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
111
  btn('superscript', 'Superscript', () => core.exec('superscript'), st('superscript')),
112
112
  btn('subscript', 'Subscript', () => core.exec('subscript'), st('subscript')),
113
+ );
114
+ // Only where a size is actually rendered: `box` hardcodes 15px and `html`
115
+ // sets no font-size at all, so on those two the ± pair moved nothing while
116
+ // still persisting a junk `size` prop into the saved document, with the
117
+ // readout counting up beside it.
118
+ if (b.type === 'text' || b.type === 'heading') r1.append(
113
119
  sep(),
114
120
  btn('minus', 'Smaller text', () => core.size(b, -1)),
115
121
  el('span', { fontFamily: 'var(--ed-font)', fontSize: '9.5px', color: 'var(--rte-muted)', minWidth: '32px', textAlign: 'center' }, { text: (b.props.size || 16) + 'px' }),