@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.
- package/CHANGELOG.md +99 -0
- package/DOCS.md +27 -27
- package/README.md +1 -1
- package/README.md.txt +1 -1
- package/dist/mailcraft-editor.bundle.js +58 -56
- package/dist/mailcraft-editor.bundle.js.map +3 -3
- package/package.json +2 -1
- package/src/core/css-cascade.js +117 -117
- package/src/core/editor-core.js +57 -5
- package/src/core/i18n/index.js +83 -83
- package/src/core/ids.js +1 -1
- package/src/core/import-html.js +152 -11
- package/src/core/layout-style.js +100 -100
- package/src/core/parse.js +10 -10
- package/src/core/placeholder.js +15 -15
- package/src/core/sanitize.js +61 -0
- package/src/core/variables.js +11 -11
- package/src/mailcraft-editor.js +41 -2
- package/src/render/fields.js +28 -2
- package/src/render/focus-preserve.js +158 -158
- package/src/render/rte.js +6 -0
- package/src/render/story.js +415 -415
package/src/mailcraft-editor.js
CHANGED
|
@@ -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
|
-
|
|
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' });
|
package/src/render/fields.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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' }),
|