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