UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

602 lines (529 loc) 20.3 kB
import { dom } from '../../../helper'; /** * @description Classes related to editor formats such as `list` (ol, ul, li) * - `list` is a special `line`, `block` format. */ class ListFormat { #$; #store; /** * @constructor * @param {SunEditor.Kernel} kernel */ constructor(kernel) { this.#$ = kernel.$; this.#store = kernel.store; } /** * @description Append all selected `line` element to the list and insert. * @param {string} type List type. (ol | ul):[listStyleType] * @param {Array<Node>} selectedCells `line` elements or list cells. * @param {boolean} nested If `true`, indenting existing list cells. * @example * // Create ordered list from selected lines * const lines = editor.format.getLines(); * editor.listFormat.apply('ol', lines, false); * * // Create unordered list with custom style * editor.listFormat.apply('ul:circle', selectedElements, false); * * // Indent existing list items * const listItems = [li1, li2, li3]; * editor.listFormat.apply('ul', listItems, true); */ apply(type, selectedCells, nested) { const listTag = (type.split(':')[0] || 'ol').toUpperCase(); const listStyle = type.split(':')[1] || ''; let range = this.#$.selection.getRange(); let selectedFormats = /** @type {Array<HTMLElement>} */ (!selectedCells ? this.#$.format.getLinesAndComponents(false) : selectedCells); if (selectedFormats.length === 0) { if (selectedCells) return; range = this.#$.selection.getRangeAndAddLine(range, null); selectedFormats = this.#$.format.getLinesAndComponents(false); if (selectedFormats.length === 0) return; } dom.query.sortNodeByDepth(selectedFormats, true); // merge const firstSel = selectedFormats[0]; const lastSel = selectedFormats.at(-1); let topEl = (dom.check.isListCell(firstSel) || this.#$.component.is(firstSel)) && !firstSel.previousElementSibling ? firstSel.parentElement.previousElementSibling : firstSel.previousElementSibling; let bottomEl = (dom.check.isListCell(lastSel) || this.#$.component.is(lastSel)) && !lastSel.nextElementSibling ? lastSel.parentElement.nextElementSibling : lastSel.nextElementSibling; const isCollapsed = range.collapsed; const originRange = { sc: range.startContainer, so: range.startContainer === range.endContainer && dom.check.isZeroWidth(range.startContainer) && range.startOffset === 0 && range.endOffset === 1 ? range.endOffset : range.startOffset, ec: range.endContainer, eo: range.endOffset, }; let afterRange = null; let isRemove = true; for (let i = 0, len = selectedFormats.length; i < len; i++) { if (!dom.check.isList(this.#$.format.getBlock(selectedFormats[i], (current) => this.#$.format.getBlock(current) && current !== selectedFormats[i]))) { isRemove = false; break; } } if (isRemove && (!topEl || firstSel.tagName !== topEl.tagName || listTag !== topEl.tagName.toUpperCase()) && (!bottomEl || lastSel.tagName !== bottomEl.tagName || listTag !== bottomEl.tagName.toUpperCase())) { if (nested) { for (let i = 0, len = selectedFormats.length; i < len; i++) { for (let j = i - 1; j >= 0; j--) { if (selectedFormats[j].contains(selectedFormats[i])) { selectedFormats.splice(i, 1); i--; len--; break; } } } } const currentFormat = this.#$.format.getBlock(firstSel); const cancel = currentFormat?.tagName === listTag; let rangeArr, tempList; const passComponent = (current) => { return !dom.check.isComponentContainer(current); }; if (!cancel) { tempList = dom.utils.createElement(listTag, { style: 'list-style-type: ' + listStyle }); } for (let i = 0, len = selectedFormats.length, r, o; i < len; i++) { o = this.#$.format.getBlock(selectedFormats[i], passComponent); if (!o || !dom.check.isList(o)) continue; if (!r) { r = o; rangeArr = { r: r, f: [dom.query.getParentElement(selectedFormats[i], 'LI')], }; } else { if (r !== o) { if (nested && dom.check.isListCell(o.parentNode)) { this.#detachNested(rangeArr.f); } else { afterRange = this.#$.format.removeBlock(rangeArr.f[0].parentElement, { selectedFormats: rangeArr.f, newBlockElement: tempList, shouldDelete: false, skipHistory: true }); } o = selectedFormats[i].parentNode; if (!cancel) { tempList = dom.utils.createElement(listTag, { style: 'list-style-type: ' + listStyle }); } r = o; rangeArr = { r: r, f: [dom.query.getParentElement(selectedFormats[i], 'LI')], }; } else { rangeArr.f.push(dom.query.getParentElement(selectedFormats[i], 'LI')); } } if (i === len - 1) { if (nested && dom.check.isListCell(o.parentNode)) { this.#detachNested(rangeArr.f); } else { afterRange = this.#$.format.removeBlock(rangeArr.f[0].parentElement, { selectedFormats: rangeArr.f, newBlockElement: tempList, shouldDelete: false, skipHistory: true }); } } } } else { const topElParent = topEl ? topEl.parentNode : topEl; const bottomElParent = bottomEl ? bottomEl.parentNode : bottomEl; topEl = /** @type {HTMLElement} */ (topElParent && !dom.check.isWysiwygFrame(topElParent) && topElParent.nodeName === listTag ? topElParent : topEl); bottomEl = /** @type {HTMLElement} */ (bottomElParent && !dom.check.isWysiwygFrame(bottomElParent) && bottomElParent.nodeName === listTag ? bottomElParent : bottomEl); const mergeTop = topEl?.tagName === listTag; const mergeBottom = bottomEl?.tagName === listTag; let list = mergeTop ? topEl : dom.utils.createElement(listTag, { style: 'list-style-type: ' + listStyle }); let firstList = null; let topNumber = null; // let lastList = null; // let bottomNumber = null; const passComponent = (current) => { return !dom.check.isComponentContainer(current) && !dom.check.isList(current); }; for (let i = 0, len = selectedFormats.length, newCell, fTag, isCell, next, originParent, nextParent, parentTag, siblingTag, rangeTag; i < len; i++) { fTag = selectedFormats[i]; if (fTag.childNodes.length === 0 && !this.#$.inline._isIgnoreNodeChange(fTag)) { dom.utils.removeItem(fTag); continue; } next = selectedFormats[i + 1]; originParent = fTag.parentNode; nextParent = next ? next.parentNode : null; isCell = dom.check.isListCell(fTag); rangeTag = this.#$.format.isBlock(originParent) ? originParent : null; parentTag = isCell && !dom.check.isWysiwygFrame(originParent) ? originParent.parentNode : originParent; siblingTag = isCell && !dom.check.isWysiwygFrame(originParent) ? (!next || dom.check.isListCell(parentTag) ? originParent : originParent.nextSibling) : fTag.nextSibling; newCell = dom.utils.createElement('LI'); if (this.#$.component.is(fTag)) { const isHR = /^HR$/i.test(fTag.nodeName); if (!isHR) newCell.innerHTML = '<br>'; newCell.innerHTML += fTag.outerHTML; if (isHR) newCell.innerHTML += '<br>'; } else { dom.utils.copyFormatAttributes(newCell, fTag); const fChildren = fTag.childNodes; while (fChildren[0]) { newCell.appendChild(fChildren[0]); } } list.appendChild(newCell); // if (!next) lastList = list; if (!next || parentTag !== nextParent || this.#$.format.isBlock(siblingTag)) { firstList ||= list; if ((!mergeTop || !next || parentTag !== nextParent) && !(next && dom.check.isList(nextParent) && nextParent === originParent)) { if (list.parentNode !== parentTag) parentTag.insertBefore(list, siblingTag); } } dom.utils.removeItem(fTag); if (mergeTop && topNumber === null) topNumber = list.children.length - 1; if ( next && (this.#$.format.getBlock(nextParent, passComponent) !== this.#$.format.getBlock(originParent, passComponent) || (dom.check.isList(nextParent) && dom.check.isList(originParent) && dom.query.getNodeDepth(nextParent) !== dom.query.getNodeDepth(originParent))) ) { list = dom.utils.createElement(listTag, { style: 'list-style-type: ' + listStyle }); } if (rangeTag?.children.length === 0) dom.utils.removeItem(rangeTag); } if (topNumber) { firstList = firstList.children[topNumber]; } if (mergeBottom) { // bottomNumber = list.children.length - 1; list.innerHTML += bottomEl.innerHTML; // lastList = list.children[bottomNumber] || lastList; dom.utils.removeItem(bottomEl); } } this.#store.set('_lastSelectionNode', null); return !isRemove || !isCollapsed ? originRange : afterRange || originRange; } /** * @description `selectedCells` array are detached from the list element. * - The return value is applied when the first and last lines of `selectedFormats` are `LI` respectively. * @param {Array<Node>} selectedCells Array of [`line`, `li`] elements(LI, P...) to remove. * @param {boolean} shouldDelete If `true`, It does not just remove the list, it deletes the content. * @returns {{sc: Node, ec: Node}} Node information after deletion * - sc: Start container node * - ec: End container node */ remove(selectedCells, shouldDelete) { let rangeArr = {}; let listFirst = false; let listLast = false; let first = null; let last = null; const passComponent = (current) => { return !dom.check.isComponentContainer(current); }; for (let i = 0, len = selectedCells.length, r, o, lastIndex, isList; i < len; i++) { lastIndex = i === len - 1; o = this.#$.format.getBlock(selectedCells[i], passComponent); isList = dom.check.isList(o); if (!r && isList) { r = o; rangeArr = { r: r, f: [dom.query.getParentElement(selectedCells[i], 'LI')], }; if (i === 0) listFirst = true; } else if (r && isList) { if (r !== o) { const edge = this.#$.format.removeBlock(rangeArr.f[0].parentNode, { selectedFormats: rangeArr.f, newBlockElement: null, shouldDelete, skipHistory: true }); o = selectedCells[i].parentNode; if (listFirst) { first = edge.sc; listFirst = false; } if (lastIndex) last = edge.ec; if (isList) { r = o; rangeArr = { r: r, f: [dom.query.getParentElement(selectedCells[i], 'LI')], }; if (lastIndex) listLast = true; } else { r = null; } } else { rangeArr.f.push(dom.query.getParentElement(selectedCells[i], 'LI')); if (lastIndex) listLast = true; } } if (lastIndex && dom.check.isList(r)) { const edge = this.#$.format.removeBlock(rangeArr.f[0].parentNode, { selectedFormats: rangeArr.f, newBlockElement: null, shouldDelete, skipHistory: true }); if (listLast || len === 1) last = edge.ec; if (listFirst) first = edge.sc || last; } } return { sc: first, ec: last, }; } /** * @description Nest list cells or cancel nested cells. * @param {Array<HTMLElement>} selectedCells List cells. * @param {boolean} nested Nested or cancel nested. * @example * // Indent list items (increase nesting) * const selectedItems = [liElement1, liElement2]; * editor.listFormat.applyNested(selectedItems, true); * * // Outdent list items (decrease nesting) * editor.listFormat.applyNested(selectedItems, false); * * // Get current list cells and nest them * const cells = editor.format.getLines().filter(el => el.tagName === 'LI'); * editor.listFormat.applyNested(cells, true); */ applyNested(selectedCells, nested) { selectedCells = !selectedCells ? this.#$.format.getLines().filter(function (el) { return dom.check.isListCell(el); }) : selectedCells; const cellsLen = selectedCells.length; if (cellsLen === 0 || (!nested && !dom.check.isListCell(selectedCells[0].previousElementSibling) && !dom.check.isListCell(selectedCells.at(-1).nextElementSibling))) { return { sc: selectedCells[0], so: 0, ec: selectedCells.at(-1), eo: 1, }; } let originList = selectedCells[0].parentElement; let lastCell = selectedCells.at(-1); let range = null; if (nested) { if (originList !== lastCell.parentElement && dom.check.isList(lastCell.parentElement?.parentElement) && lastCell.nextElementSibling) { lastCell = /** @type {HTMLElement} */ (lastCell.nextElementSibling); while (lastCell) { selectedCells.push(lastCell); lastCell = /** @type {HTMLElement} */ (lastCell.nextElementSibling); } } range = this.apply(originList.nodeName + ':' + originList.style.listStyleType, selectedCells, true); } else { let innerList = dom.utils.createElement(originList.nodeName); let prev = selectedCells[0].previousElementSibling; let next = lastCell.nextElementSibling; const nodePath = { s: null, e: null, sl: originList, el: originList, }; const { startContainer, startOffset, endContainer, endOffset } = this.#$.selection.getRange(); for (let i = 0, len = cellsLen, c; i < len; i++) { c = selectedCells[i]; if (c.parentElement !== originList) { this.#attachNested(originList, innerList, prev, next, nodePath); originList = c.parentElement; innerList = dom.utils.createElement(originList.nodeName); } prev = c.previousElementSibling; next = c.nextElementSibling; innerList.appendChild(c); } this.#attachNested(originList, innerList, prev, next, nodePath); if (cellsLen > 1) { const sc = dom.query.getNodeFromPath(nodePath.s, nodePath.sl); const ec = dom.query.getNodeFromPath(nodePath.e, nodePath.el); range = { sc: sc, so: 0, ec: ec, eo: ec.textContent.length, }; } else { range = { sc: startContainer, so: startOffset, ec: endContainer, eo: endOffset, }; } } return range; } /** * @description Detach Nested all nested lists under the `baseNode`. * - Returns a list with nested removed. * @param {HTMLElement} baseNode Element on which to base. * @param {boolean} all If `true`, it also detach all nested lists of a returned list. * @returns {Node} Result element * @example * // Remove first level of nesting * const listItem = document.querySelector('li'); * editor.listFormat.removeNested(listItem, false); * * // Flatten all nested lists completely * editor.listFormat.removeNested(listItem, true); * * // Remove nesting and get result * const result = editor.listFormat.removeNested(nestedLi, false); * console.log(result); // parent list element */ removeNested(baseNode, all) { const rNode = DeleteNestedList(baseNode); let rangeElement, cNodes; if (rNode) { rangeElement = rNode.cloneNode(false); cNodes = rNode.childNodes; const index = dom.query.getPositionIndex(baseNode); while (cNodes[index]) { rangeElement.appendChild(cNodes[index]); } } else { rangeElement = baseNode; } let rChildren; if (!all) { const depth = dom.query.getNodeDepth(baseNode) + 2; rChildren = dom.query.getListChildren( baseNode, (current) => { return dom.check.isListCell(current) && !current.previousElementSibling && dom.query.getNodeDepth(current) === depth; }, null, ); } else { rChildren = dom.query.getListChildren( rangeElement, (current) => { return dom.check.isListCell(current) && !current.previousElementSibling; }, null, ); } for (let i = 0, len = rChildren.length; i < len; i++) { DeleteNestedList(rChildren[i]); } if (rNode) { rNode.parentNode.insertBefore(rangeElement, rNode.nextSibling); if (cNodes?.length === 0) dom.utils.removeItem(rNode); } return rangeElement === baseNode ? rangeElement.parentNode : rangeElement; } /** * @description Attaches a nested list structure by merging adjacent lists if applicable. * - Ensures that the nested list is placed correctly in the document structure. * @param {Element} originList The original list element where the nested list is inserted. * @param {Element} innerList The nested list element. * @param {Element} prev The previous sibling element. * @param {Element} next The next sibling element. * @param {{s: Array<number> | null, e: Array<number> | null, sl: Node | null, el: Node | null}} nodePath Object storing the start and end node paths. * - s : Start node path. * - e : End node path. * - sl : Start node's parent element. * - el : End node's parent element. * @returns {Node} The attached inner list. */ #attachNested(originList, innerList, prev, next, nodePath) { let insertPrev = false; if (innerList.tagName === prev?.tagName) { const children = innerList.children; while (children[0]) { prev.appendChild(children[0]); } innerList = prev; insertPrev = true; } if (innerList.tagName === next?.tagName) { const children = next.children; while (children[0]) { innerList.appendChild(children[0]); } const temp = next.nextElementSibling; next.parentNode.removeChild(next); next = temp; } if (!insertPrev) { if (dom.check.isListCell(prev)) { originList = prev; next = null; } originList.insertBefore(innerList, next); if (!nodePath.s) { nodePath.s = dom.query.getNodePath(innerList.firstElementChild.firstChild, originList, null); nodePath.sl = originList; } const slPath = originList.contains(nodePath.sl) ? dom.query.getNodePath(nodePath.sl, originList) : null; nodePath.e = dom.query.getNodePath(innerList.lastElementChild.firstChild, originList, null); nodePath.el = originList; this.#$.nodeTransform.mergeSameTags(originList, [nodePath.s, nodePath.e, slPath], false); this.#$.nodeTransform.mergeNestedTags(originList); if (slPath) nodePath.sl = dom.query.getNodeFromPath(slPath, originList); } return innerList; } /** * @description Detaches a nested list structure by extracting list items from their parent list. * - Ensures proper restructuring of the list elements. * @param {Array<HTMLElement>} cells The list items to be detached. * @returns {{cc: Node, sc: Node, ec: Node}} An object containing reference nodes for repositioning. * - cc : The parent node of the first list item. * - sc : The first list item. * - ec : The last list item. */ #detachNested(cells) { const first = cells[0]; const last = cells.at(-1); const next = last.nextElementSibling; const originList = first.parentElement; const sibling = originList.parentElement.nextElementSibling; const parentNode = originList.parentElement.parentElement; for (let c = 0, cLen = cells.length; c < cLen; c++) { parentNode.insertBefore(cells[c], sibling); } if (next && originList.children.length > 0) { const newList = originList.cloneNode(false); const children = originList.childNodes; const index = dom.query.getPositionIndex(next); while (children[index]) { newList.appendChild(children[index]); } last.appendChild(newList); } if (originList.children.length === 0) dom.utils.removeItem(originList); this.#$.nodeTransform.mergeSameTags(parentNode); const edge = dom.query.getEdgeChildNodes(first, last); return { cc: first.parentNode, sc: edge.sc, ec: edge.ec, }; } } /** * @description Removes nested list structure by unwrapping child list elements and promoting their items to the parent level. * @param {Node} baseNode Node */ function DeleteNestedList(baseNode) { const baseParent = baseNode.parentNode; let parent = baseParent.parentNode; let siblingNode = /** @type {*} */ (baseParent); let liSibling, liParent, child, index, c; while (dom.check.isListCell(parent)) { index = dom.query.getPositionIndex(baseNode); liSibling = parent.nextElementSibling; liParent = parent.parentNode; child = siblingNode; while (child) { siblingNode = siblingNode.nextSibling; if (dom.check.isList(child)) { c = child.childNodes; while (c[index]) { liParent.insertBefore(c[index], liSibling); } if (c.length === 0) dom.utils.removeItem(child); } else { liParent.appendChild(child); } child = siblingNode; } parent = liParent.parentNode; } if (baseParent.children.length === 0) dom.utils.removeItem(baseParent); return liParent; } export default ListFormat;