export function setForeColor(editor: HTMLElement, hex: string) {
applyInlineStyle(editor, 'color', hex, 'data-rte-color');
}
export function setBackColor(editor: HTMLElement, hex: string) {
applyInlineStyle(editor, 'backgroundColor', hex, 'data-rte-bg');
}
function enclosingStyledSpan(
editor: HTMLElement,
node: Node | null,
dataAttr: 'data-rte-color' | 'data-rte-bg'
): HTMLSpanElement | null {
while (node && node !== editor) {
if (node instanceof HTMLSpanElement && node.hasAttribute(dataAttr)) {
return node;
}
node = node.parentNode;
}
return null;
}
function applyInlineStyle(
editor: HTMLElement,
cssProp: 'color' | 'backgroundColor',
value: string,
dataAttr: 'data-rte-color' | 'data-rte-bg'
) {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const r0 = sel.getRangeAt(0);
if (!editor.contains(r0.commonAncestorContainer)) return;
const range = r0.cloneRange();
if (range.collapsed) {
const enclosing = enclosingStyledSpan(
editor,
range.startContainer,
dataAttr
);
if (enclosing) {
(enclosing.style as any)[cssProp] = value;
mergeAdjacentStyledSpans(editor, dataAttr, cssProp);
return;
}
const s = document.createElement('span');
s.setAttribute(dataAttr, '1');
(s.style as any)[cssProp] = value;
s.appendChild(document.createTextNode('\u200B'));
range.insertNode(s);
const caret = document.createRange();
caret.setStart(s.firstChild!, 1);
caret.collapse(true);
sel.removeAllRanges();
sel.addRange(caret);
mergeAdjacentStyledSpans(editor, dataAttr, cssProp);
return;
}
const leftEdge = enclosingStyledSpan(editor, range.startContainer, dataAttr);
const rightEdge = enclosingStyledSpan(editor, range.endContainer, dataAttr);
if (leftEdge && leftEdge === rightEdge) {
if (rangeCoversWholeNode(range, leftEdge)) {
(leftEdge.style as any)[cssProp] = value;
} else {
const mid = splitAndRecolorWithinSpan(
leftEdge,
range,
dataAttr,
cssProp,
value
);
const sel = window.getSelection();
const r = document.createRange();
r.selectNodeContents(mid);
sel?.removeAllRanges();
sel?.addRange(r);
}
mergeAdjacentStyledSpans(editor, dataAttr, cssProp);
return;
}
const commonEl = (() => {
let n: Node | null = range.commonAncestorContainer;
while (n && !(n instanceof HTMLElement)) n = n.parentNode;
return n as HTMLElement | null;
})();
const walker = document.createTreeWalker(
commonEl || editor,
NodeFilter.SHOW_TEXT,
{
acceptNode: n => {
if (!n.nodeValue || !n.nodeValue.trim())
return NodeFilter.FILTER_REJECT;
const nodeRange = document.createRange();
nodeRange.selectNodeContents(n);
const intersects =
range.compareBoundaryPoints(Range.END_TO_START, nodeRange) < 0 &&
range.compareBoundaryPoints(Range.START_TO_END, nodeRange) > 0;
return intersects ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
}
);
const toProcess: Text[] = [];
let t: Node | null;
while ((t = walker.nextNode())) toProcess.push(t as Text);
toProcess.forEach(text => {
let start = 0,
end = text.length;
if (text === range.startContainer) start = range.startOffset;
if (text === range.endContainer) end = range.endOffset;
if (start > 0) text = text.splitText(start);
if (end < text.length) text.splitText(end);
const existing = enclosingStyledSpan(editor, text, dataAttr);
if (existing) {
(existing.style as any)[cssProp] = value;
return;
}
const span = document.createElement('span');
span.setAttribute(dataAttr, '1');
(span.style as any)[cssProp] = value;
const parent = text.parentElement!;
parent.replaceChild(span, text);
span.appendChild(text);
});
mergeAdjacentStyledSpans(editor, dataAttr, cssProp);
// restore selection
sel.removeAllRanges();
sel.addRange(range);
}
// Is the range covering the entire node's contents?
function rangeCoversWholeNode(range: Range, node: Node): boolean {
const all = document.createRange();
all.selectNodeContents(node);
return (
range.compareBoundaryPoints(Range.START_TO_START, all) <= 0 &&
range.compareBoundaryPoints(Range.END_TO_END, all) >= 0
);
}
// Split one styled span into [left][middle][right]; recolor only middle
function splitAndRecolorWithinSpan(
span: HTMLSpanElement,
range: Range,
dataAttr: 'data-rte-color' | 'data-rte-bg',
cssProp: 'color' | 'backgroundColor',
newValue: string
): HTMLSpanElement {
const oldValue = (span.style as any)[cssProp];
const left = document.createRange();
left.setStart(span, 0);
left.setEnd(range.startContainer, range.startOffset);
const right = document.createRange();
right.setStart(range.endContainer, range.endOffset);
right.setEnd(span, span.childNodes.length);
// Build replacement fragment
const frag = document.createDocumentFragment();
// helper to make a styled clone shell
const makeShell = (val: string) => {
const s = document.createElement('span');
s.setAttribute(dataAttr, '1');
(s.style as any)[cssProp] = val;
return s;
};
if (hasRangeContent(left)) {
const sLeft = makeShell(oldValue);
sLeft.appendChild(left.cloneContents());
frag.appendChild(sLeft);
}
const mid = makeShell(newValue);
mid.appendChild(range.cloneContents());
frag.appendChild(mid);
if (hasRangeContent(right)) {
const sRight = makeShell(oldValue);
sRight.appendChild(right.cloneContents());
frag.appendChild(sRight);
}
span.replaceWith(frag);
return mid;
}
function mergeAdjacentStyledSpans(
root: HTMLElement,
dataAttr: 'data-rte-color' | 'data-rte-bg',
cssProp: 'color' | 'backgroundColor'
) {
const spans = Array.from(
root.querySelectorAll(`span[${dataAttr}]`)
);
const valOf = (el: HTMLElement) => (el.style as any)[cssProp];
spans.forEach(s => {
const nested = Array.from(
s.querySelectorAll(`span[${dataAttr}]`)
);
nested.forEach(child => {
if (valOf(child) === valOf(s)) {
while (child.firstChild) s.insertBefore(child.firstChild, child);
child.remove();
}
});
const prev = s.previousSibling;
if (
prev instanceof HTMLSpanElement &&
prev.hasAttribute(dataAttr) &&
valOf(prev) === valOf(s)
) {
while (s.firstChild) prev.appendChild(s.firstChild);
s.remove();
return;
}
const next = s.nextSibling;
if (
next instanceof HTMLSpanElement &&
next.hasAttribute(dataAttr) &&
valOf(next) === valOf(s)
) {
while (next.firstChild) s.appendChild(next.firstChild);
next.remove();
}
});
}
function hasRangeContent(r: Range): boolean {
if (r.collapsed) return false;
const text = r.cloneContents().textContent || '';
return text.length > 0;
}