export function toggleInlineTag( root: HTMLElement, tag: 'strong' | 'em' | 'u' | 'span', attrs?: Record ) { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); const aliases: Record = { strong: ['strong', 'b'], em: ['em', 'i'], u: ['u'], span: ['span'], }; const candidates = aliases[tag] ?? [tag]; const isMatch = (el: HTMLElement) => candidates.includes(el.tagName.toLowerCase()); function unwrap(el: HTMLElement) { const p = el.parentNode; if (!p) return; while (el.firstChild) p.insertBefore(el.firstChild, el); p.removeChild(el); } function removeEmptyAndNested(root: HTMLElement) { root.querySelectorAll(candidates.join(',')).forEach(el => { if (!el.textContent?.trim()) { el.remove(); return; } const nested = Array.from(el.querySelectorAll(candidates.join(','))); nested.forEach(n => { if (n === el) return; while (n.firstChild) el.insertBefore(n.firstChild, n); n.remove(); }); }); } function mergeAdjacent(root: HTMLElement) { const list = Array.from(root.querySelectorAll(candidates.join(','))); list.forEach(el => { const next = el.nextSibling; if (next instanceof HTMLElement && isMatch(next)) { while (next.firstChild) el.appendChild(next.firstChild); next.remove(); } }); } if (range.collapsed) { let inside = false; let n: Node | null = range.startContainer; while (n && n !== root) { if (n instanceof HTMLElement && isMatch(n)) { inside = true; break; } n = n.parentNode; } if (inside && n instanceof HTMLElement) { unwrap(n); } else { const elm = document.createElement(tag); if (attrs) Object.entries(attrs).forEach(([k, v]) => elm.setAttribute(k, v)); elm.appendChild(document.createTextNode('\u200b')); range.insertNode(elm); range.setStart(elm.firstChild!, 1); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } removeEmptyAndNested(root); mergeAdjacent(root); return; } if (range.startContainer.nodeType === Node.TEXT_NODE) (range.startContainer as Text).splitText(range.startOffset); if (range.endContainer.nodeType === Node.TEXT_NODE) (range.endContainer as Text).splitText(range.endOffset); const walker = document.createTreeWalker( range.commonAncestorContainer, NodeFilter.SHOW_TEXT, { acceptNode: n => { if (!n.nodeValue?.trim()) return NodeFilter.FILTER_REJECT; const r = document.createRange(); r.selectNodeContents(n); const inter = range.compareBoundaryPoints(Range.END_TO_START, r) < 0 && range.compareBoundaryPoints(Range.START_TO_END, r) > 0; return inter ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; }, } ); const textNodes: Text[] = []; let tn: Node | null; while ((tn = walker.nextNode())) textNodes.push(tn as Text); const allInside = textNodes.length > 0 && textNodes.every(txt => { let n: Node | null = txt; while (n && n !== root) { if (n instanceof HTMLElement && isMatch(n)) return true; n = n.parentNode; } return false; }); if (allInside) { const first = textNodes[0]; const last = textNodes[textNodes.length - 1]; const common = first.parentElement?.closest(tag) as HTMLElement | null; if (common && common.contains(last)) { const parent = common.parentNode; if (parent) { const beforeRange = document.createRange(); beforeRange.setStartBefore(common); beforeRange.setEndBefore(first); const before = beforeRange.cloneContents(); const afterRange = document.createRange(); afterRange.setStartAfter(last); afterRange.setEndAfter(common); const after = afterRange.cloneContents(); const middleRange = document.createRange(); middleRange.setStartBefore(first); middleRange.setEndAfter(last); const middle = middleRange.extractContents(); const frag = document.createDocumentFragment(); if (before.childNodes.length) { const b = document.createElement(tag); while (before.firstChild) b.appendChild(before.firstChild); frag.appendChild(b); } while (middle.firstChild) frag.appendChild(middle.firstChild); if (after.childNodes.length) { const a = document.createElement(tag); while (after.firstChild) a.appendChild(after.firstChild); frag.appendChild(a); } parent.insertBefore(frag, common); parent.removeChild(common); } } else { textNodes.forEach(txt => { let n: Node | null = txt; while (n && n !== root) { if (n instanceof HTMLElement && isMatch(n)) { unwrap(n); break; } n = n.parentNode; } }); } } else { textNodes.forEach(txt => { const inProtected = txt.parentElement?.closest('.mention, nile-mention'); if (inProtected) return; const inAnchor = txt.parentElement?.closest('a'); if (inAnchor && tag === 'span') return; const wrapper = document.createElement(tag); if (attrs) Object.entries(attrs).forEach(([k, v]) => wrapper.setAttribute(k, v)); if (inAnchor) { inAnchor.insertBefore(wrapper, txt); wrapper.appendChild(txt); } else { txt.parentNode?.insertBefore(wrapper, txt); wrapper.appendChild(txt); } }); } removeEmptyAndNested(root); mergeAdjacent(root); sel.removeAllRanges(); sel.addRange(range); } export function surroundInline( range: Range, tag: string, attrs?: Record ) { const wrap = document.createElement(tag); if (attrs) Object.entries(attrs).forEach(([k, v]) => wrap.setAttribute(k, v)); try { range.surroundContents(wrap); } catch { const frag = range.extractContents(); wrap.appendChild(frag); range.insertNode(wrap); } } export function unwrap(node: HTMLElement) { const p = node.parentNode; if (!p) return; while (node.firstChild) p.insertBefore(node.firstChild, node); p.removeChild(node); } function unwrapAllMatching( node: Node, root: HTMLElement, matcher: (el: HTMLElement) => boolean ) { let n: Node | null = node.parentNode; while (n && n !== root) { if (n instanceof HTMLElement && matcher(n)) { unwrap(n); n = node.parentNode; continue; } n = n.parentNode; } } export function rgbToHex(rgb: string): string { const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (!m) return '#000000'; const r = Number(m[1]).toString(16).padStart(2, '0'); const g = Number(m[2]).toString(16).padStart(2, '0'); const b = Number(m[3]).toString(16).padStart(2, '0'); return `#${r}${g}${b}`; } export function setFontFamily(root: HTMLElement, family: string) { const sel = document.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); if (range.collapsed) { const span = document.createElement('span'); span.style.fontFamily = family; span.appendChild(document.createTextNode('\u200b')); range.insertNode(span); const r = document.createRange(); r.setStart(span.firstChild!, 1); r.collapse(true); sel.removeAllRanges(); sel.addRange(r); return; } surroundInline(range, 'span', { style: `font-family:${family}` }); }