export function toggleList(root: HTMLElement, kind: 'ul' | 'ol') {
const sel = document.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
function styleList(list: HTMLElement) {
list.style.margin = '0';
}
function isEmptyBlock(el: HTMLElement) {
return !el.textContent?.trim() && !el.querySelector('img,br');
}
function setCursorInside(el: HTMLElement) {
const selection = document.getSelection();
if (!selection) return;
const r = document.createRange();
r.selectNodeContents(el);
r.collapse(true);
selection.removeAllRanges();
selection.addRange(r);
}
function closestBlock(node: Node | null): HTMLElement | null {
while (node && node !== root) {
if (node instanceof HTMLElement) {
const display = getComputedStyle(node).display;
if (
node.tagName.match(/^(P|DIV|H[1-6]|LI)$/) ||
display === 'block' ||
display === 'list-item'
)
return node;
}
node = node.parentNode;
}
return null;
}
function unwrapList(list: HTMLElement) {
const frag = document.createDocumentFragment();
(Array.from(list.querySelectorAll('li')) as HTMLElement[]).forEach(li => {
const p = document.createElement('p');
if (li.style.textAlign) p.style.textAlign = li.style.textAlign;
while (li.firstChild) p.appendChild(li.firstChild);
frag.appendChild(p);
});
list.replaceWith(frag);
}
function unwrapListItem(block: HTMLElement) {
const li = block.closest('li');
if (!li) return;
const list = li.parentElement as HTMLElement;
const p = document.createElement('p');
if (li.style.textAlign) p.style.textAlign = li.style.textAlign;
while (li.firstChild) p.appendChild(li.firstChild);
list.parentNode?.insertBefore(p, list.nextSibling);
li.remove();
if (list.childNodes.length === 0) list.remove();
setCursorInside(p);
}
function switchListType(list: HTMLElement, newType: 'ul' | 'ol') {
const newList = document.createElement(newType);
styleList(newList);
while (list.firstChild) newList.appendChild(list.firstChild);
list.replaceWith(newList);
}
if (range.collapsed) {
const block = closestBlock(range.startContainer);
const insideList = block?.closest('ul,ol') as HTMLElement | null;
if (insideList && insideList.tagName.toLowerCase() === kind) {
unwrapListItem(block as HTMLElement);
return;
}
if (insideList && insideList.tagName.toLowerCase() !== kind) {
switchListType(insideList, kind);
return;
}
if (
!block ||
block === root ||
block.childNodes.length === 0 ||
isEmptyBlock(block)
) {
const list = document.createElement(kind);
const li = document.createElement('li');
li.appendChild(document.createElement('br'));
list.appendChild(li);
styleList(list);
range.insertNode(list);
setCursorInside(li);
return;
}
const list = document.createElement(kind);
styleList(list);
const li = document.createElement('li');
if (block.style.textAlign) li.style.textAlign = block.style.textAlign;
while (block.firstChild) li.appendChild(block.firstChild);
list.appendChild(li);
block.replaceWith(list);
setCursorInside(li);
return;
}
const blocks: HTMLElement[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
acceptNode: n => {
if (!(n instanceof HTMLElement)) return NodeFilter.FILTER_SKIP;
if (!/^(P|DIV|LI|H[1-6])$/.test(n.tagName)) return NodeFilter.FILTER_SKIP;
const r = document.createRange();
r.selectNodeContents(n);
const intersects =
range.compareBoundaryPoints(Range.END_TO_START, r) < 0 &&
range.compareBoundaryPoints(Range.START_TO_END, r) > 0;
return intersects ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
let n: Node | null;
while ((n = walker.nextNode())) blocks.push(n as HTMLElement);
if (blocks.length === 0) {
const block = closestBlock(range.startContainer);
if (block) blocks.push(block);
}
const firstLi = blocks[0].closest('li');
if (firstLi) {
const currentList = firstLi.closest('ul,ol') as HTMLElement | null;
if (!currentList) return;
if (currentList.tagName.toLowerCase() === kind) unwrapList(currentList);
else switchListType(currentList, kind);
return;
}
const newList = document.createElement(kind);
styleList(newList);
for (const block of blocks) {
const li = document.createElement('li');
if (block.style.textAlign) li.style.textAlign = block.style.textAlign;
while (block.firstChild) li.appendChild(block.firstChild);
newList.appendChild(li);
}
blocks[0].replaceWith(newList);
for (let i = 1; i < blocks.length; i++) blocks[i].remove();
}