UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

1,485 lines (1,287 loc) 60.9 kB
import { dom, unicode, converter } from '../../../helper'; /** * @typedef {Object} NodeStyleContainerType * @property {?Node} [ancestor] * @property {?number} [offset] * @property {?Node} [container] * @property {?Node} [endContainer] */ /** * @description Classes related to editor inline formats such as style node like strong, span, etc. */ class Inline { #$; #options; #listCamel; #listKebab; /** * @constructor * @param {SunEditor.Kernel} kernel */ constructor(kernel) { this.#$ = kernel.$; this.#options = this.#$.options; // members this.#listCamel = this.#options.get('__listCommonStyle'); this.#listKebab = converter.camelToKebabCase(this.#options.get('__listCommonStyle')); } /** * @description Adds, updates, or deletes style nodes from selected text (a, span, strong, etc.). * - 1. If `styleNode` is provided, a node with the same tags and attributes is added to the selection. * - 2. If the same tag already exists, only its attributes are updated. * - 3. If `styleNode` is `null`, existing nodes are updated or removed without adding new ones. * - 4. Styles matching those in `stylesToModify` are removed. * - (Use CSS attribute names, e.g., `background-color`) * - 5. Classes matching those in `stylesToModify` (prefixed with `"."`) are removed. * - 6. `stylesToModify` is used to avoid duplicate property values from `styleNode`. * - 7. Nodes with all styles/classes removed are deleted. * - Applies when they match `styleNode`, are in `nodesToRemove`, or `styleNode` is `null`. * - 8. Tags matching names in `nodesToRemove` are deleted regardless of their style and class. * - 9. If `strictRemove` is `true`, nodes in `nodesToRemove` are only removed * - if all their styles and classes are removed. * - 10. The function won't modify nodes if the parent has the same class and style values. * - However, if `nodesToRemove` has values, it will work and separate text nodes * - even if there's no node to replace. * @param {?Node} styleNode The element to be added to the selection. If `null`, only existing nodes are modified or removed. * @param {Object} [options] Options * @param {Array<string>} [options.stylesToModify=null] Array of style or class names to check and modify. * (e.g., ['font-size'], ['.className'], ['font-family', 'color', '.className']) * @param {Array<string>} [options.nodesToRemove=null] Array of node names to remove. * If empty array or `null` when `styleNode` is `null`, all formats are removed. * (e.g., ['span'], ['strong', 'em']) * @param {boolean} [options.strictRemove=false] If `true`, only removes nodes from `nodesToRemove` if all styles and classes are removed. * @returns {HTMLElement} The element that was added to or modified in the selection. * @example * // Apply bold formatting * const bold = dom.utils.createElement('STRONG'); * editor.inline.apply(bold); * * // Remove specific styles * editor.inline.apply(null, { stylesToModify: ['font-size'] }); * * // Remove specific tags * editor.inline.apply(null, { nodesToRemove: ['span'] }); */ apply(styleNode, { stylesToModify, nodesToRemove, strictRemove } = {}) { if (dom.query.getParentElement(this.#$.selection.getNode(), dom.check.isNonEditable)) return; this.#$.selection.resetRangeToTextNode(); let range = this.#$.selection.getRangeAndAddLine(this.#$.selection.getRange(), null); stylesToModify = stylesToModify?.length > 0 ? stylesToModify : null; nodesToRemove = nodesToRemove?.length > 0 ? nodesToRemove : null; const isRemoveNode = !styleNode; const isRemoveFormat = isRemoveNode && !nodesToRemove && !stylesToModify; let startCon = range.startContainer; let startOff = range.startOffset; let endCon = range.endContainer; let endOff = range.endOffset; if ((isRemoveFormat && range.collapsed && this.#$.format.isLine(startCon.parentNode) && this.#$.format.isLine(endCon.parentNode)) || (startCon === endCon && startCon.nodeType === 1 && dom.check.isNonEditable(startCon))) { const format = startCon.parentNode; if (!dom.check.isListCell(format) || !converter.getValues(format.style).some((k) => this.#listKebab.includes(k))) { return; } } if (range.collapsed && !isRemoveFormat) { if (startCon.nodeType === 1 && !dom.check.isBreak(startCon) && !this.#$.component.is(startCon)) { let afterNode = null; const focusNode = startCon.childNodes[startOff]; if (focusNode) { if (!focusNode.nextSibling) { afterNode = null; } else { afterNode = dom.check.isBreak(focusNode) ? focusNode : focusNode.nextSibling; } } const zeroWidth = dom.utils.createTextNode(unicode.zeroWidthSpace); startCon.insertBefore(zeroWidth, afterNode); this.#$.selection.setRange(zeroWidth, 1, zeroWidth, 1); range = this.#$.selection.getRange(); startCon = range.startContainer; startOff = range.startOffset; endCon = range.endContainer; endOff = range.endOffset; } } if (this.#$.format.isLine(startCon)) { startCon = startCon.childNodes[startOff] || startCon.firstChild; startOff = 0; } if (this.#$.format.isLine(endCon)) { endCon = endCon.childNodes[endOff] || endCon.lastChild; endOff = endCon.textContent.length; } if (isRemoveNode) { styleNode = dom.utils.createElement('DIV'); } const wRegExp = RegExp; const newNodeName = styleNode.nodeName; /* checked same style property */ if (!isRemoveFormat && startCon === endCon && !nodesToRemove && styleNode) { let sNode = startCon; let checkCnt = 0; const checkAttrs = []; const checkStyles = /** @type {HTMLElement} */ (styleNode).style; for (let i = 0, len = checkStyles.length; i < len; i++) { checkAttrs.push(checkStyles[i]); } const checkClassName = /** @type {HTMLElement} */ (styleNode).className; const ckeckClasses = /** @type {HTMLElement} */ (styleNode).classList; for (let i = 0, len = ckeckClasses.length; i < len; i++) { checkAttrs.push('.' + ckeckClasses[i]); } if (checkAttrs.length > 0) { while (!this.#$.format.isLine(sNode) && !dom.check.isWysiwygFrame(sNode)) { for (let i = 0; i < checkAttrs.length; i++) { if (sNode.nodeType === 1) { const s = checkAttrs[i]; const classReg = /^\./.test(s) ? new wRegExp('\\s*' + s.replace(/^\./, '') + '(\\s+|$)', 'ig') : false; const sNodeStyle = /** @type {HTMLElement} */ (sNode).style; const sNodeClassName = /** @type {HTMLElement} */ (sNode).className; const styleCheck = isRemoveNode ? !!sNodeStyle[s] : !!sNodeStyle[s] && !!checkStyles[s] && sNodeStyle[s] === checkStyles[s]; const classCheck = classReg === false ? false : isRemoveNode ? !!sNodeClassName.match(classReg) : !!sNodeClassName.match(classReg) && !!checkClassName.match(classReg); if (styleCheck || classCheck) { checkCnt++; } } } sNode = sNode.parentNode; } if (checkCnt >= checkAttrs.length) return; } } let newNode; /** @type {NodeStyleContainerType} */ let start = {}; /** @type {NodeStyleContainerType} */ let end = {}; /** @type {string|RegExp} */ let styleRegExp = ''; /** @type {string|RegExp} */ let classRegExp = ''; /** @type {string|RegExp} */ let removeNodeRegExp; if (stylesToModify) { for (let i = 0, len = stylesToModify.length, s; i < len; i++) { s = stylesToModify[i]; if (/^\./.test(s)) { classRegExp += (classRegExp ? '|' : '\\s*(?:') + s.replace(/^\./, ''); } else { styleRegExp += (styleRegExp ? '|' : '(?:;|^|\\s)(?:') + s; } } if (styleRegExp) { styleRegExp += ')\\s*:[^;]*\\s*(?:;|$)'; styleRegExp = new wRegExp(styleRegExp, 'ig'); } if (classRegExp) { classRegExp += ')(?=\\s+|$)'; classRegExp = new wRegExp(classRegExp, 'ig'); } } if (nodesToRemove) { removeNodeRegExp = '^(?:' + nodesToRemove[0]; for (let i = 1; i < nodesToRemove.length; i++) { removeNodeRegExp += '|' + nodesToRemove[i]; } removeNodeRegExp += ')$'; removeNodeRegExp = new wRegExp(removeNodeRegExp, 'i'); } /** validation check function*/ const _removeCheck = { v: false, }; const validation = function (checkNode) { const vNode = checkNode.cloneNode(false); // all path if (vNode.nodeType === 3 || dom.check.isBreak(vNode)) return vNode; // all remove if (isRemoveFormat) return null; // remove node check const tagRemove = (!removeNodeRegExp && isRemoveNode) || /** @type {RegExp} */ (removeNodeRegExp)?.test(vNode.nodeName); // tag remove if (tagRemove && !strictRemove) { _removeCheck.v = true; return null; } // style regexp const originStyle = vNode.style.cssText; let style = ''; if (styleRegExp && originStyle.length > 0) { style = originStyle.replace(styleRegExp, '').trim(); if (style !== originStyle) _removeCheck.v = true; } // class check const originClasses = vNode.className; let classes = ''; if (classRegExp && originClasses.length > 0) { classes = originClasses.replace(classRegExp, '').trim(); if (classes !== originClasses) _removeCheck.v = true; } // remove only if (isRemoveNode) { if ((classRegExp || !originClasses) && (styleRegExp || !originStyle) && !style && !classes && tagRemove) { _removeCheck.v = true; return null; } } // change if (style || classes || vNode.nodeName !== newNodeName || Boolean(styleRegExp) !== Boolean(originStyle) || Boolean(classRegExp) !== Boolean(originClasses)) { if (styleRegExp && originStyle.length > 0) vNode.style.cssText = style; if (!vNode.style.cssText) { vNode.removeAttribute('style'); } if (classRegExp && originClasses.length > 0) vNode.className = classes.trim(); if (!vNode.className.trim()) { vNode.removeAttribute('class'); } if (!vNode.style.cssText && !vNode.className && (vNode.nodeName === newNodeName || tagRemove)) { _removeCheck.v = true; return null; } return vNode; } _removeCheck.v = true; return null; }; // get line nodes const lineNodes = this.#$.format.getLines(null); if (lineNodes.length === 0) { console.warn('[SUNEDITOR.inline.apply.warn] There is no line to apply.'); return; } range = this.#$.selection.getRange(); startCon = range.startContainer; startOff = range.startOffset; endCon = range.endContainer; endOff = range.endOffset; if (!this.#$.format.getLine(startCon, null)) { startCon = dom.query.getEdgeChild( lineNodes[0], function (current) { return current.nodeType === 3; }, false, ); startOff = 0; } if (!this.#$.format.getLine(endCon, null)) { endCon = dom.query.getEdgeChild( lineNodes.at(-1), function (current) { return current.nodeType === 3; }, false, ); endOff = endCon.textContent.length; } const oneLine = this.#$.format.getLine(startCon, null) === this.#$.format.getLine(endCon, null); const endLength = lineNodes.length - (oneLine ? 0 : 1); // node Changes newNode = styleNode.cloneNode(false); const isRemoveAnchor = isRemoveFormat || (isRemoveNode && ((arr) => { for (let n = 0, len = arr?.length; n < len; n++) { if (this._isNonSplitNode(arr[n])) return true; } return false; })(nodesToRemove)); const isSizeNode = isRemoveNode || this.#sn_isSizeNode(newNode); const _getMaintainedNode = this.#sn_getMaintainedNode.bind(this, isRemoveAnchor, isSizeNode); const _isMaintainedNode = this.#sn_isMaintainedNode.bind(this, isRemoveAnchor, isSizeNode); // one line if (oneLine) { if (this.#sn_resetCommonListCell(lineNodes[0], stylesToModify)) range = this.#$.selection.setRange(startCon, startOff, endCon, endOff); const newRange = this.#setNode_oneLine(lineNodes[0], newNode, validation, startCon, startOff, endCon, endOff, isRemoveFormat, isRemoveNode, range.collapsed, _removeCheck, _getMaintainedNode, _isMaintainedNode); start.container = newRange.startContainer; start.offset = newRange.startOffset; end.container = newRange.endContainer; end.offset = newRange.endOffset; if (start.container === end.container && dom.check.isZeroWidth(start.container)) { start.offset = end.offset = 1; } this.#sn_setCommonListStyle(newRange.ancestor, null); } else { // multi line let appliedCommonList = false; if (endLength > 0 && this.#sn_resetCommonListCell(lineNodes[endLength], stylesToModify)) appliedCommonList = true; if (this.#sn_resetCommonListCell(lineNodes[0], stylesToModify)) appliedCommonList = true; if (appliedCommonList) this.#$.selection.setRange(startCon, startOff, endCon, endOff); // end if (endLength > 0) { newNode = styleNode.cloneNode(false); end = this.#setNode_endLine(lineNodes[endLength], newNode, validation, endCon, endOff, isRemoveFormat, isRemoveNode, _removeCheck, _getMaintainedNode, _isMaintainedNode); } // mid for (let i = endLength - 1, newRange; i > 0; i--) { this.#sn_resetCommonListCell(lineNodes[i], stylesToModify); newNode = styleNode.cloneNode(false); newRange = this.#setNode_middleLine(lineNodes[i], newNode, validation, isRemoveFormat, isRemoveNode, _removeCheck, end.container); if (newRange.endContainer && newRange.ancestor.contains(newRange.endContainer)) { end.ancestor = null; end.container = newRange.endContainer; } this.#sn_setCommonListStyle(newRange.ancestor, null); } // start newNode = styleNode.cloneNode(false); start = this.#setNode_startLine(lineNodes[0], newNode, validation, startCon, startOff, isRemoveFormat, isRemoveNode, _removeCheck, _getMaintainedNode, _isMaintainedNode, end.container); if (start.endContainer) { end.ancestor = null; end.container = start.endContainer; } if (endLength <= 0) { end = start; } else if (!end.container) { end.ancestor = null; end.container = start.container; end.offset = start.container.textContent.length; } this.#sn_setCommonListStyle(start.ancestor, null); this.#sn_setCommonListStyle(end.ancestor || this.#$.format.getLine(end.container), null); } // set range this.#$.ui.offCurrentController(); this.#$.selection.setRange(start.container, start.offset, end.container, end.offset); this.#$.history.push(false); return /** @type {HTMLElement} */ (newNode); } /** * @description Remove all inline formats (styles and tags) from the currently selected text. * - This is a convenience method that calls `apply()` with `null` parameters to strip all formatting. * - Removes all inline style nodes (span, strong, em, a, etc.) * - Preserves only the plain text content * - Works on the current selection or collapsed cursor position */ remove() { this.apply(null, { stylesToModify: null, nodesToRemove: null, strictRemove: null }); } /** * @internal * @description Nodes that must remain undetached when changing text nodes (A, Label, Code, Span:font-size) * @param {Node|string} element Element to check * @returns {boolean} */ _isNonSplitNode(element) { if (!element) return false; const checkRegExp = /^(a|label|code|summary)$/i; if (typeof element === 'string') return checkRegExp.test(element); return element.nodeType === 1 && checkRegExp.test(element.nodeName); } /** * @internal * @description Nodes that need to be added without modification when changing text nodes * @param {Node} element Element to check * @returns {boolean} */ _isIgnoreNodeChange(element) { return element && element.nodeType === 1 && (dom.check.isNonEditable(element) || !this.#$.format.isTextStyleNode(element) || this.#$.component.is(element)); } /** * @description wraps text nodes of line selected text. * @param {Node} element The node of the line that contains the selected text node. * @param {Node} newInnerNode The dom that will wrap the selected text area * @param {(current: Node) => Node|null} validation Check if the node should be stripped. * @param {Node} startCon The `startContainer` property of the selection object. * @param {number} startOff The `startOffset` property of the selection object. * @param {Node} endCon The `endContainer` property of the selection object. * @param {number} endOff The `endOffset` property of the selection object. * @param {boolean} isRemoveFormat Is the remove all formats command? * @param {boolean} isRemoveNode `newInnerNode` is remove node? * @param {boolean} collapsed `range.collapsed` * @param {Object} _removeCheck Object with `v` property tracking removal state. * @param {(element: Node) => Node|null} _getMaintainedNode Function to get maintained parent node. * @param {(element: Node) => boolean} _isMaintainedNode Function to check if node should be maintained. * @returns {{ancestor: *, startContainer: *, startOffset: *, endContainer: *, endOffset: *}} */ #setNode_oneLine(element, newInnerNode, validation, startCon, startOff, endCon, endOff, isRemoveFormat, isRemoveNode, collapsed, _removeCheck, _getMaintainedNode, _isMaintainedNode) { // not add tag let parentCon = startCon.parentNode; while (!parentCon.nextSibling && !parentCon.previousSibling && !this.#$.format.isLine(parentCon.parentNode) && !dom.check.isWysiwygFrame(parentCon.parentNode)) { if (parentCon.nodeName === newInnerNode.nodeName) break; parentCon = parentCon.parentNode; } if (!isRemoveNode && !isRemoveFormat && parentCon === endCon.parentNode && parentCon.nodeName === newInnerNode.nodeName) { if (dom.check.isZeroWidth(startCon.textContent.slice(0, startOff)) && dom.check.isZeroWidth(endCon.textContent.slice(endOff))) { const children = parentCon.childNodes; let sameTag = true; for (let i = 0, len = children.length, c, s, e, z; i < len; i++) { c = children[i]; z = !dom.check.isZeroWidth(c); if (c === startCon || c === endCon) { if (c === startCon) s = true; if (c === endCon) e = true; continue; } if ((!s && z) || (s && e && z)) { sameTag = false; break; } } if (sameTag) { dom.utils.copyTagAttributes(parentCon, newInnerNode); return { ancestor: element, startContainer: startCon, startOffset: startOff, endContainer: endCon, endOffset: endOff, }; } } } // add tag _removeCheck.v = false; const inst = this; const el = element; const nNodeArray = [newInnerNode]; const pNode = element.cloneNode(false); const isSameNode = startCon === endCon; let startContainer = startCon; let startOffset = startOff; let endContainer = endCon; let endOffset = endOff; let startPass = false; let endPass = false; let pCurrent, newNode, appendNode, cssText, anchorNode; const wRegExp = RegExp; function checkCss(vNode) { const regExp = new wRegExp('(?:;|^|\\s)(?:' + cssText + 'null)\\s*:[^;]*\\s*(?:;|$)', 'ig'); let style = false; if (regExp && vNode.style.cssText.length > 0) { style = regExp.test(vNode.style.cssText); } return !style; } (function recursionFunc(current, ancestor) { const childNodes = current.childNodes; for (let i = 0, len = childNodes.length, vNode; i < len; i++) { const child = childNodes[i]; if (!child) continue; let coverNode = ancestor; let cloneNode; // startContainer if (!startPass && child === startContainer) { let line = pNode; anchorNode = _getMaintainedNode(child); let _prevText = ''; let _nextText = ''; if (startContainer.nodeType === 3) { const sText = /** @type {Text} */ (startContainer); _prevText = sText.substringData(0, startOffset); _nextText = sText.substringData(startOffset, isSameNode ? (endOffset >= startOffset ? endOffset - startOffset : sText.data.length - startOffset) : sText.data.length - startOffset); } const prevNode = dom.utils.createTextNode(_prevText); const textNode = dom.utils.createTextNode(_nextText); if (anchorNode) { const a = _getMaintainedNode(ancestor); if (a.parentNode !== line) { let m = a; let p = null; while (m.parentNode !== line) { ancestor = p = m.parentNode.cloneNode(false); while (m.childNodes[0]) { p.appendChild(m.childNodes[0]); } m.appendChild(p); m = m.parentNode; } m.parentNode.appendChild(a); } anchorNode = anchorNode.cloneNode(false); } if (!dom.check.isZeroWidth(prevNode)) { ancestor.appendChild(prevNode); } const prevAnchorNode = _getMaintainedNode(ancestor); if (prevAnchorNode) anchorNode = prevAnchorNode; if (anchorNode) line = anchorNode; newNode = /** @type {HTMLElement} */ (child); pCurrent = []; cssText = ''; while (newNode !== line && newNode !== el && newNode !== null) { vNode = _isMaintainedNode(newNode) ? null : validation(newNode); if (vNode && newNode.nodeType === 1 && checkCss(newNode)) { pCurrent.push(vNode); cssText += newNode.style.cssText.substring(0, newNode.style.cssText.indexOf(':')) + '|'; } newNode = newNode.parentElement; } const childNode = pCurrent.pop() || textNode; appendNode = newNode = childNode; while (pCurrent.length > 0) { newNode = pCurrent.pop(); appendNode.appendChild(newNode); appendNode = newNode; } newInnerNode.appendChild(childNode); line.appendChild(newInnerNode); if (anchorNode && !_getMaintainedNode(endContainer)) { newInnerNode = newInnerNode.cloneNode(false); pNode.appendChild(newInnerNode); nNodeArray.push(newInnerNode); } startContainer = textNode; startOffset = 0; startPass = true; if (newNode !== textNode) newNode.appendChild(startContainer); if (!isSameNode) continue; } // endContainer if (!endPass && child === endContainer) { anchorNode = _getMaintainedNode(child); let _prevText = ''; let _nextText = ''; if (endContainer.nodeType === 3) { const eText = /** @type {Text} */ (endContainer); _prevText = eText.substringData(endOffset, eText.length - endOffset); _nextText = isSameNode ? '' : eText.substringData(0, endOffset); } const afterNode = dom.utils.createTextNode(_prevText); const textNode = dom.utils.createTextNode(_nextText); if (anchorNode) { anchorNode = anchorNode.cloneNode(false); } else if (_isMaintainedNode(newInnerNode.parentNode) && !anchorNode) { newInnerNode = newInnerNode.cloneNode(false); pNode.appendChild(newInnerNode); nNodeArray.push(newInnerNode); } if (!dom.check.isZeroWidth(afterNode)) { newNode = /** @type {HTMLElement} */ (child); cssText = ''; pCurrent = []; const anchors = []; while (newNode !== pNode && newNode !== el && newNode !== null) { if (newNode.nodeType === 1 && checkCss(newNode)) { if (_isMaintainedNode(newNode)) anchors.push(newNode.cloneNode(false)); else pCurrent.push(newNode.cloneNode(false)); cssText += newNode.style.cssText.substring(0, newNode.style.cssText.indexOf(':')) + '|'; } newNode = newNode.parentElement; } pCurrent = pCurrent.concat(anchors); cloneNode = appendNode = newNode = pCurrent.pop() || afterNode; while (pCurrent.length > 0) { newNode = pCurrent.pop(); appendNode.appendChild(newNode); appendNode = newNode; } pNode.appendChild(cloneNode); newNode.textContent = afterNode.data; } if (anchorNode && cloneNode) { const afterAnchorNode = _getMaintainedNode(cloneNode); if (afterAnchorNode) { anchorNode = afterAnchorNode; } } newNode = /** @type {HTMLElement} */ (child); pCurrent = []; cssText = ''; while (newNode !== pNode && newNode !== el && newNode !== null) { vNode = _isMaintainedNode(newNode) ? null : validation(newNode); if (vNode && newNode.nodeType === 1 && checkCss(newNode)) { pCurrent.push(vNode); cssText += newNode.style.cssText.substring(0, newNode.style.cssText.indexOf(':')) + '|'; } newNode = newNode.parentElement; } const childNode = pCurrent.pop() || textNode; appendNode = newNode = childNode; while (pCurrent.length > 0) { newNode = pCurrent.pop(); appendNode.appendChild(newNode); appendNode = newNode; } if (anchorNode) { newInnerNode = newInnerNode.cloneNode(false); newInnerNode.appendChild(childNode); anchorNode.insertBefore(newInnerNode, anchorNode.firstChild); pNode.appendChild(anchorNode); nNodeArray.push(newInnerNode); anchorNode = null; } else { newInnerNode.appendChild(childNode); } endContainer = textNode; endOffset = textNode.data.length; endPass = true; if (!isRemoveFormat && collapsed) { newInnerNode = textNode; textNode.textContent = unicode.zeroWidthSpace; } if (newNode !== textNode) newNode.appendChild(endContainer); continue; } // other if (startPass) { if (child.nodeType === 1 && !dom.check.isBreak(child)) { if (inst._isIgnoreNodeChange(child)) { pNode.appendChild(child.cloneNode(true)); if (!collapsed) { newInnerNode = newInnerNode.cloneNode(false); pNode.appendChild(newInnerNode); nNodeArray.push(newInnerNode); } } else { recursionFunc(child, child); } continue; } newNode = /** @type {HTMLElement} */ (child); pCurrent = []; cssText = ''; const anchors = []; while (newNode.parentNode !== null && newNode !== el && newNode !== newInnerNode) { vNode = endPass ? newNode.cloneNode(false) : validation(newNode); if (newNode.nodeType === 1 && !dom.check.isBreak(child) && vNode && checkCss(newNode)) { if (_isMaintainedNode(newNode)) { if (!anchorNode) anchors.push(vNode); } else { pCurrent.push(vNode); } cssText += newNode.style.cssText.substring(0, newNode.style.cssText.indexOf(':')) + '|'; } newNode = newNode.parentElement; } pCurrent = pCurrent.concat(anchors); const childNode = pCurrent.pop() || child; appendNode = newNode = childNode; while (pCurrent.length > 0) { newNode = pCurrent.pop(); appendNode.appendChild(newNode); appendNode = newNode; } if (_isMaintainedNode(newInnerNode.parentNode) && !_isMaintainedNode(childNode) && !dom.check.isZeroWidth(newInnerNode)) { newInnerNode = newInnerNode.cloneNode(false); pNode.appendChild(newInnerNode); nNodeArray.push(newInnerNode); } if (!endPass && !anchorNode && _isMaintainedNode(childNode)) { newInnerNode = newInnerNode.cloneNode(false); const aChildren = childNode.childNodes; for (let a = 0, aLen = aChildren.length; a < aLen; a++) { newInnerNode.appendChild(aChildren[a]); } childNode.appendChild(newInnerNode); pNode.appendChild(childNode); nNodeArray.push(newInnerNode); if (/** @type {HTMLElement} */ (newInnerNode).children.length > 0) ancestor = newNode; else ancestor = newInnerNode; } else if (childNode === child) { if (!endPass) ancestor = newInnerNode; else ancestor = pNode; } else if (endPass) { pNode.appendChild(childNode); ancestor = newNode; } else { newInnerNode.appendChild(childNode); ancestor = newNode; } if (anchorNode && child.nodeType === 3) { if (_getMaintainedNode(child)) { const ancestorAnchorNode = dom.query.getParentElement(ancestor, (c) => { return inst._isNonSplitNode(c.parentNode) || c.parentNode === pNode; }); anchorNode.appendChild(ancestorAnchorNode); newInnerNode = ancestorAnchorNode.cloneNode(false); nNodeArray.push(newInnerNode); pNode.appendChild(newInnerNode); } else { anchorNode = null; } } } cloneNode = child.cloneNode(false); ancestor.appendChild(cloneNode); if (child.nodeType === 1 && !dom.check.isBreak(child)) coverNode = cloneNode; recursionFunc(child, coverNode); } })(element, pNode); // not remove tag if (isRemoveNode && !isRemoveFormat && !_removeCheck.v) { return { ancestor: element, startContainer: startCon, startOffset: startOff, endContainer: endCon, endOffset: endOff, }; } isRemoveFormat &&= isRemoveNode; if (isRemoveFormat) { for (let i = 0; i < nNodeArray.length; i++) { const removeNode = nNodeArray[i]; let textNode, textNode_s, textNode_e; if (collapsed) { textNode = dom.utils.createTextNode(unicode.zeroWidthSpace); pNode.replaceChild(textNode, removeNode); } else { const rChildren = removeNode.childNodes; textNode_s = rChildren[0]; while (rChildren[0]) { textNode_e = rChildren[0]; pNode.insertBefore(textNode_e, removeNode); } dom.utils.removeItem(removeNode); } if (i === 0) { if (collapsed) { startContainer = endContainer = textNode; } else { startContainer = textNode_s; endContainer = textNode_e; } } } } else { if (isRemoveNode) { for (let i = 0; i < nNodeArray.length; i++) { SN_StripRemoveNode(nNodeArray[i]); } } if (collapsed) { startContainer = endContainer = newInnerNode; } } this.#$.nodeTransform.removeEmptyNode(pNode, newInnerNode, false); if (collapsed) { startOffset = startContainer.textContent.length; endOffset = endContainer.textContent.length; } // endContainer reset const endConReset = isRemoveFormat || endContainer.textContent.length === 0; if (!dom.check.isBreak(endContainer) && endContainer.textContent.length === 0) { dom.utils.removeItem(endContainer); endContainer = startContainer; } endOffset = endConReset ? endContainer.textContent.length : endOffset; // node change const newStartOffset = { s: 0, e: 0, }; const startPath = dom.query.getNodePath(startContainer, pNode, newStartOffset); const mergeEndCon = !endContainer.parentNode; if (mergeEndCon) endContainer = startContainer; const newEndOffset = { s: 0, e: 0, }; const endPath = dom.query.getNodePath(endContainer, pNode, !mergeEndCon && !endConReset ? newEndOffset : null); startOffset += newStartOffset.s; endOffset = collapsed ? startOffset : mergeEndCon ? startContainer.textContent.length : endConReset ? endOffset + newStartOffset.s : endOffset + newEndOffset.s; // tag merge const newOffsets = this.#$.nodeTransform.mergeSameTags(pNode, [startPath, endPath], true); element.parentNode.replaceChild(pNode, element); startContainer = dom.query.getNodeFromPath(startPath, pNode); endContainer = dom.query.getNodeFromPath(endPath, pNode); return { ancestor: pNode, startContainer: startContainer, startOffset: startOffset + newOffsets[0], endContainer: endContainer, endOffset: endOffset + newOffsets[1], }; } /** * @description wraps first line selected text. * @param {Node} element The node of the line that contains the selected text node. * @param {Node} newInnerNode The dom that will wrap the selected text area * @param {(current: Node) => Node|null} validation Check if the node should be stripped. * @param {Node} startCon The `startContainer` property of the selection object. * @param {number} startOff The `startOffset` property of the selection object. * @param {boolean} isRemoveFormat Is the remove all formats command? * @param {boolean} isRemoveNode `newInnerNode` is remove node? * @param {Object} _removeCheck Object tracking removal state. * @param {(element: Node) => Node|null} _getMaintainedNode Function to get maintained parent node. * @param {(element: Node) => boolean} _isMaintainedNode Function to check if node should be maintained. * @param {Node} _endContainer End container node. * @returns {NodeStyleContainerType} { ancestor, container, offset, endContainer } */ #setNode_startLine(element, newInnerNode, validation, startCon, startOff, isRemoveFormat, isRemoveNode, _removeCheck, _getMaintainedNode, _isMaintainedNode, _endContainer) { // not add tag let parentCon = startCon.parentNode; while (!parentCon.nextSibling && !parentCon.previousSibling && !this.#$.format.isLine(parentCon.parentNode) && !dom.check.isWysiwygFrame(parentCon.parentNode)) { if (parentCon.nodeName === newInnerNode.nodeName) break; parentCon = parentCon.parentNode; } if (!isRemoveNode && !isRemoveFormat && parentCon.nodeName === newInnerNode.nodeName && !this.#$.format.isLine(parentCon) && !parentCon.nextSibling && dom.check.isZeroWidth(startCon.textContent.slice(0, startOff))) { let sameTag = true; let s = startCon.previousSibling; while (s) { if (!dom.check.isZeroWidth(s)) { sameTag = false; break; } s = s.previousSibling; } if (sameTag) { dom.utils.copyTagAttributes(parentCon, newInnerNode); return { ancestor: element, container: startCon, offset: startOff, }; } } // add tag _removeCheck.v = false; const inst = this; const el = element; const nNodeArray = [newInnerNode]; const pNode = element.cloneNode(false); let container = startCon; let offset = startOff; let passNode = false; let pCurrent, newNode, appendNode, anchorNode; (function recursionFunc(current, ancestor) { const childNodes = current.childNodes; for (let i = 0, len = childNodes.length, vNode, cloneChild; i < len; i++) { const child = /** @type {HTMLElement} */ (childNodes[i]); if (!child) continue; let coverNode = ancestor; if (passNode && !dom.check.isBreak(child)) { if (child.nodeType === 1) { if (inst._isIgnoreNodeChange(child)) { newInnerNode = newInnerNode.cloneNode(false); cloneChild = child.cloneNode(true); pNode.appendChild(cloneChild); pNode.appendChild(newInnerNode); nNodeArray.push(newInnerNode); // end container if (_endContainer && child.contains(_endContainer)) { const endPath = dom.query.getNodePath(_endContainer, child); _endContainer = dom.query.getNodeFromPath(endPath, cloneChild); } } else { recursionFunc(child, child); } continue; } newNode = child; pCurrent = []; const anchors = []; while (newNode.parentNode !== null && newNode !== el && newNode !== newInnerNode) { vNode = validation(newNode); if (newNode.nodeType === 1 && vNode) { if (_isMaintainedNode(newNode)) { if (!anchorNode) anchors.push(vNode); } else { pCurrent.push(vNode); } } newNode = newNode.parentNode; } pCurrent = pCurrent.concat(anchors); const isTopNode = pCurrent.length > 0; const childNode = pCurrent.pop() || child; appendNode = newNode = childNode; while (pCurrent.length > 0) { newNode = pCurrent.pop(); appendNode.appendChild(newNode); appendNode = newNode; } if (_isMaintainedNode(newInnerNode.parentNode) && !_isMaintainedNode(childNode)) { newInnerNode = newInnerNode.cloneNode(false); pNode.appendChild(newInnerNode); nNodeArray.push(newInnerNode); } if (!anchorNode && _isMaintainedNode(childNode)) { newInnerNode = newInnerNode.cloneNode(false); const aChildren = childNode.childNodes; for (let a = 0, aLen = aChildren.length; a < aLen; a++) { newInnerNode.appendChild(aChildren[a]); } childNode.appendChild(newInnerNode); pNode.appendChild(childNode); ancestor = !_isMaintainedNode(newNode) ? newNode : newInnerNode; nNodeArray.push(newInnerNode); } else if (isTopNode) { newInnerNode.appendChild(childNode); ancestor = newNode; } else { ancestor = newInnerNode; } if (anchorNode && child.nodeType === 3) { if (_getMaintainedNode(child)) { const ancestorAnchorNode = dom.query.getParentElement(ancestor, (c) => { return inst._isNonSplitNode(c.parentNode) || c.parentNode === pNode; }); anchorNode.appendChild(ancestorAnchorNode); newInnerNode = ancestorAnchorNode.cloneNode(false); nNodeArray.push(newInnerNode); pNode.appendChild(newInnerNode); } else { anchorNode = null; } } } // startContainer if (!passNode && child === container) { let line = pNode; anchorNode = _getMaintainedNode(child); let _prevText = ''; let _nextText = ''; if (container.nodeType === 3) { const cText = /** @type {Text} */ (container); _prevText = cText.substringData(0, offset); _nextText = cText.substringData(offset, cText.length - offset); } const prevNode = dom.utils.createTextNode(_prevText); const textNode = dom.utils.createTextNode(_nextText); if (anchorNode) { const a = _getMaintainedNode(ancestor); if (a && a.parentNode !== line) { let m = a; let p = null; while (m.parentNode !== line) { ancestor = p = m.parentNode.cloneNode(false); while (m.childNodes[0]) { p.appendChild(m.childNodes[0]); } m.appendChild(p); m = m.parentNode; } m.parentNode.appendChild(a); } anchorNode = anchorNode.cloneNode(false); } if (!dom.check.isZeroWidth(prevNode)) { ancestor.appendChild(prevNode); } const prevAnchorNode = _getMaintainedNode(ancestor); if (prevAnchorNode) anchorNode = prevAnchorNode; if (anchorNode) line = anchorNode; newNode = ancestor; pCurrent = []; while (newNode !== line && newNode !== null) { vNode = validation(newNode); if (newNode.nodeType === 1 && vNode) { pCurrent.push(vNode); } newNode = newNode.parentNode; } const childNode = pCurrent.pop() || ancestor; appendNode = newNode = childNode; while (pCurrent.length > 0) { newNode = pCurrent.pop(); appendNode.appendChild(newNode); appendNode = newNode; } if (childNode !== ancestor) { newInnerNode.appendChild(childNode); ancestor = newNode; } else { ancestor = newInnerNode; } if (dom.check.isBreak(child)) newInnerNode.appendChild(child.cloneNode(false)); line.appendChild(newInnerNode); container = textNode; offset = 0; passNode = true; ancestor.appendChild(container); continue; } vNode = !passNode ? child.cloneNode(false) : validation(child); if (vNode) { ancestor.appendChild(vNode); if (child.nodeType === 1 && !dom.check.isBreak(child)) coverNode = vNode; } recursionFunc(child, coverNode); } })(element, pNode); // not remove tag if (isRemoveNode && !isRemoveFormat && !_removeCheck.v) { return { ancestor: element, container: startCon, offset: startOff, endContainer: _endContainer, }; } isRemoveFormat &&= isRemoveNode; if (isRemoveFormat) { for (let i = 0; i < nNodeArray.length; i++) { const removeNode = nNodeArray[i]; const rChildren = removeNode.childNodes; const textNode = rChildren[0]; while (rChildren[0]) { pNode.insertBefore(rChildren[0], removeNode); } dom.utils.removeItem(removeNode); if (i === 0) container = textNode; } } else if (isRemoveNode) { newInnerNode = newInnerNode.firstChild; for (let i = 0; i < nNodeArray.length; i++) { SN_StripRemoveNode(nNodeArray[i]); } } if (!isRemoveFormat && pNode.childNodes.length === 0) { if (element.childNodes) { container = element.childNodes[0]; } else { container = dom.utils.createTextNode(unicode.zeroWidthSpace); element.appendChild(container); } } else { this.#$.nodeTransform.removeEmptyNode(pNode, newInnerNode, false); if (dom.check.isZeroWidth(pNode.textContent)) { container = pNode.firstChild; offset = 0; } // node change const offsets = { s: 0, e: 0, }; const path = dom.query.getNodePath(container, pNode, offsets); offset += offsets.s; // tag merge const newOffsets = this.#$.nodeTransform.mergeSameTags(pNode, [path], true); element.parentNode.replaceChild(pNode, element); container = dom.query.getNodeFromPath(path, pNode); offset += newOffsets[0]; } return { ancestor: pNode, container: container, offset: offset, endContainer: _endContainer, }; } /** * @description wraps mid lines selected text. * @param {HTMLElement} element The node of the line that contains the selected text node. * @param {Node} newInnerNode The dom that will wrap the selected text area * @param {(current: Node) => Node|null} validation Check if the node should be stripped. * @param {boolean} isRemoveFormat Is the remove all formats command? * @param {boolean} isRemoveNode `newInnerNode` is remove node? * @param {Object} _removeCheck Object tracking removal state. * @param {Node} _endContainer Offset node of last line already modified (`end.container`) * @returns {NodeStyleContainerType} { ancestor, endContainer: If end container is renewed, returned renewed node } */ #setNode_middleLine(element, newInnerNode, validation, isRemoveFormat, isRemoveNode, _removeCheck, _endContainer) { // not add tag if (!isRemoveNode) { // end container path let endPath = null; if (_endContainer && element.contains(_endContainer)) endPath = dom.query.getNodePath(_endContainer, element); const tempNode = element.cloneNode(true); const newNodeName = /** @type {HTMLElement} */ (newInnerNode).nodeName; const newCssText = /** @type {HTMLElement} */ (newInnerNode).style.cssText; const newClass = /** @type {HTMLElement} */ (newInnerNode).className; let children = tempNode.childNodes; let i = 0, len = children.length; for (let child; i < len; i++) { child = /** @type {HTMLElement} */ (children[i]); if (child.nodeType === 3) break; if (child.nodeName === newNodeName) { child.style.cssText += newCssText; dom.utils.addClass(child, newClass); } else if (!dom.check.isBreak(child) && this._isIgnoreNodeChange(child)) { continue; } else if (len === 1) { children = child.childNodes; len = children.length; i = -1; continue; } else { break; } } if (len > 0 && i === len) { element.innerHTML = /** @type {HTMLElement} */ (tempNode).innerHTML; return { ancestor: element, endContainer: endPath ? dom.query.getNodeFromPath(endPath, element) : null, }; } } // add tag _removeCheck.v = false; const inst = this; const pNode = element.cloneNode(false); const nNodeArray = [newInnerNode]; let noneChange = true; (function recursionFunc(current, ancestor) { const childNodes = current.childNodes; for (let i = 0, len = childNodes.length, vNode, cloneChild; i < len; i++) { const child = /** @type {HTMLElement} */ (childNodes[i]); if (!child) continue; let coverNode = ancestor; if (!dom.check.isBreak(child) && inst._isIgnoreNodeChange(child)) { if (newInnerNode.childNodes.length > 0) { pNode.appendChild(newInnerNode); newInnerNode = newInnerNode.cloneNode(false); } cloneChild = child.cloneNode(true); pNode.appendChild(cloneChild); pNode.appendChild(newInnerNode); nNodeArray.push(newInnerNode); ancestor = newInnerNode; // end container if (_endContainer && child.contains(_endContainer)) { const endPath = dom.query.getNodePath(_endContainer, child); _endContainer = dom.query.getNodeFromPath(endPath, cloneChild); } continue; } else { vNode = validation(child); if (vNode) { noneChange = false; ancestor.appendChild(vNode); if (child.nodeType === 1) coverNode = vNode; } } if (!dom.check.isBreak(child)) recursionFunc(child, coverNode); } })(element, newInnerNode); // not remove tag if (noneChange || (isRemoveNode && !isRemoveFormat && !_removeCheck.v)) return { ancestor: element, endContainer: _endContainer, }; pNode.appendChild(newInnerNode); if (isRemoveFormat && isRemoveNode) { for (let i = 0; i < nNodeArray.length; i++) { const removeNode = nNodeArray[i]; const rChildren = removeNode.childNodes; while (rChildren[0]) { pNode.insertBefore(rChildren[0], removeNode); } dom.utils.removeItem(removeNode); } } else if (isRemoveNode) { newInnerNode = newInnerNode.firstChild; for (let i = 0; i < nNodeArray.length; i++) { SN_StripRemoveNode(nNodeArray[i]); } } this.#$.nodeTransform.removeEmptyNode(pNode, newInnerNode, false); this.#$.nodeTransform.mergeSameTags(pNode, null, true); // node change element.parentNode.replaceChild(pNode, element); return { ancestor: pNode, endContainer: _endContainer, }; } /** * @description wraps last line selected text. * @param {Node} element The node of the line that contains the selected text node. * @param {Node} newInnerNode The dom that will wrap the selected text area * @param {(current: Node) => Node|null} validation Check if the node should be stripped. * @param {Node} endCon The `endContainer` property of the selection object. * @param {number} endOff The `endOffset` property of the selection object. * @param {boolean} isRemoveFormat Is the remove all formats command? * @param {boolean} isRemoveNode `newInnerNode` is remove node? * @param {Object} _removeCheck Object tracking removal state. * @param {(element: Node) => Node|null} _getMaintainedNode Function to get maintained parent node. * @param {(element: Node) => boolean} _isMaintainedNode Function to check if node should be maintained. * @returns {NodeStyleContainerType} { ancestor, container, offset } */ #setNode_endLine(element, newInnerNode, validation, endCon, endOff, isRemoveFormat, isRemoveNode, _removeCheck, _getMaintainedNode, _isMaintainedNode) { // not add tag let parentCon = endCon.parentNode; while (!parentCon.nextSibling && !parentCon.previousSibling && !this.#$.format.isLine(parentCon.parentNode) && !dom.check.isWysiwygFrame(parentCon.parentNode)) { if (parentCon.nodeName === newInnerNode.nodeName) break; parentCon = parentCon.parentNode; } if (!isRemoveNode && !isRemoveFormat && parentCon.nodeName === newInnerNode.nodeName && !this.#$.format.isLine(parentCon) && !parentCon.previousSibling && dom.check.isZeroWidth(endCon.textContent.slice(endOff))) { let sameTag = true; let e = endCon.nextSibling; while (e) { if (!dom.check.isZeroWidth(e)) { sameTag = false; break; } e = e.nextSibling; } if (sameTag) { dom.utils.copyTagAttributes(parentCon, newInnerNode); return { ancestor: element, container: endCon, offset: endOff, }; } } // add tag _removeCheck.v = false; const inst = this; const el = element; const nNodeArray = [newInnerNode]; const pNode = element.cloneNode(false); let container = endCon; let offset = endOff; let passNode = false; let pCurrent, newNode, appendNode, anchorNode; (function recursionFunc(current, ancestor) { const childNodes = current.childNodes; for (let i = childNodes.length - 1, vNode; 0 <= i; i--) { const child = childNodes[i]; if (!child) continue; let coverNode = ancestor; if (passNode && !dom.check.isBreak(child)) { if (child.nodeType === 1) { if (inst._isIgnoreNodeChange(child)) { newInnerNode = newInnerNode.cloneNode(false); const cloneChild = child.cloneNode(true); pNode.insertBefore(cloneChild, ancestor); pNode.insertBefore(newInnerNode, cloneChild); nNodeArray.push(newInnerNode); } else { recursionFunc(child, child); } continue; } newNode = child; pCurrent = []; const anchors = []; while (newNode.parentNode !== null && newNode !== el && newNode !== newInnerNode) { vNode = validation(newNode); if (vNode && newNode.nodeType === 1) { if (_isMaintainedNode(newNode)) { if (!anchorNode) anchors.push(vNode); } else { pCurrent.push(vNode); } } newNode = newNode.parentNode; } pCurrent = pCurrent.concat(anchors); const isTopNode = pCurrent.length > 0; const childNode = pCurrent.pop() || child; appendNode = newNode = childNode; while (pCurrent.length > 0) { newNode = pCurrent.pop(); appendNode.appendChild(newNode); appendNode = newNode; } if (_isMaintainedNode(newInnerNode.parentNode) && !_isMaintainedNode(childNode)) { newInnerNode = newInnerNode.cloneNode(false); pNode.insertBefore(newInnerNode, pNode.firstChild); nNodeArray.push(newInnerNode); } if (!anchorNode && _isMaintainedNode(childNode)) { newInnerNode = newInnerNode.cloneNode(false); const aChildren = childNode.childNodes; for (let a = 0, aLen = aChildren.length; a < aLen; a++) { newInnerNode.appendChild(aChildren[a]); } childNode.appendChild(newInnerNode); pNode.insertBefore(childNode, pNode.firstChild); nNodeArray.push(newInnerNode); if (/** @type {HTMLElement} */ (newInnerNode).children.length > 0) ancestor = newNode; else ancestor = newInnerNode; } else if (isTopNode) { newInnerNode.insertBefore(childNode, newInnerNode.firstChild); ancestor = newNode; } else { ancestor = newInnerNode; } if (ancho