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; }