suneditor
Version:
Vanilla JavaScript WYSIWYG web editor (2.x legacy version, actively maintained)
1,333 lines (1,162 loc) • 84.3 kB
JavaScript
/*
* wysiwyg web editor
*
* suneditor.js
* Copyright 2017 JiHong Lee.
* MIT license.
*/
'use strict';
/**
* @description utility function
*/
const util = {
_d: null,
_w: null,
isIE: null,
isIE_Edge: null,
isOSX_IOS: null,
isChromium: null,
isMobile: null,
isResizeObserverSupported: null,
_propertiesInit: function () {
if (this._d) return;
this._d = document;
this._w = window;
this.isIE = navigator.userAgent.indexOf('Trident') > -1;
this.isIE_Edge = (navigator.userAgent.indexOf('Trident') > -1) || (navigator.appVersion.indexOf('Edge') > -1);
this.isOSX_IOS = /(Mac|iPhone|iPod|iPad)/.test(navigator.platform);
this.isChromium = !!window.chrome;
this.isResizeObserverSupported = (typeof ResizeObserver === 'function');
this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || ((navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0) && 'ontouchstart' in window);
},
_allowedEmptyNodeList: '.se-component, pre, blockquote, hr, li, table, img, iframe, video, audio, canvas',
/**
* @description HTML Reserved Word Converter.
* @param {String} contents
* @returns {String} HTML string
* @private
*/
_HTMLConvertor: function (contents) {
const ec = {'&': '&', '\u00A0': ' ', '\'': ''', '"': '"', '<': '<', '>': '>'};
return contents.replace(/&|\u00A0|'|"|<|>/g, function (m) {
return (typeof ec[m] === 'string') ? ec[m] : m;
});
},
/**
* @description Unicode Character 'ZERO WIDTH SPACE' (\u200B)
*/
zeroWidthSpace: String.fromCharCode(8203),
/**
* @description Regular expression to find 'zero width space' (/\u200B/g)
*/
zeroWidthRegExp: new RegExp(String.fromCharCode(8203), 'g'),
/**
* @description Regular expression to find only 'zero width space' (/^\u200B+$/)
*/
onlyZeroWidthRegExp: new RegExp('^' + String.fromCharCode(8203) + '+$'),
fontValueMap: {
'xx-small': 1,
'x-small': 2,
'small': 3,
'medium': 4,
'large': 5,
'x-large': 6,
'xx-large': 7
},
/**
* @description A method that checks If the text is blank or to see if it contains 'ZERO WIDTH SPACE' or empty (util.zeroWidthSpace)
* @param {String|Node} text String value or Node
* @returns {Boolean}
*/
onlyZeroWidthSpace: function (text) {
if (text === null || text === undefined) return false;
if (typeof text !== 'string') text = text.textContent;
return text === '' || this.onlyZeroWidthRegExp.test(text);
},
/**
* @description Gets XMLHttpRequest object
* @returns {XMLHttpRequest|ActiveXObject}
*/
getXMLHttpRequest: function () {
/** IE */
if (this._w.ActiveXObject) {
try {
return new ActiveXObject('Msxml2.XMLHTTP');
} catch (e) {
try {
return new ActiveXObject('Microsoft.XMLHTTP');
} catch (e1) {
return null;
}
}
}
/** netscape */
else if (this._w.XMLHttpRequest) {
return new XMLHttpRequest();
}
/** fail */
else {
return null;
}
},
/**
* @description Object.values
* @param {Object|null} obj Object parameter.
* @returns {Array}
*/
getValues: function (obj) {
return !obj ? [] : this._w.Object.keys(obj).map(function (i) {
return obj[i];
});
},
/**
* @description Convert the CamelCase To the KebabCase.
* @param {String|Array} param [Camel string]
* @returns {String|Array}
*/
camelToKebabCase: function (param) {
if (typeof param === 'string') {
return param.replace(/[A-Z]/g, function (letter) { return "-" + letter.toLowerCase(); });
} else {
return param.map(function(str) { return util.camelToKebabCase(str); });
}
},
/**
* @description Convert the KebabCase To the CamelCase.
* @param {String|Array} param [KebabCase string]
* @returns {String|Array}
*/
kebabToCamelCase: function (param) {
if (typeof param === 'string') {
return param.replace(/-[a-zA-Z]/g, function (letter) { return letter.replace('-', '').toUpperCase(); });
} else {
return param.map(function(str) { return util.camelToKebabCase(str); });
}
},
/**
* @description Create Element node
* @param {String} elementName Element name
* @returns {Element}
*/
createElement: function (elementName) {
return this._d.createElement(elementName);
},
/**
* @description Create text node
* @param {String} text text contents
* @returns {Node}
*/
createTextNode: function (text) {
return this._d.createTextNode(text || '');
},
/**
* @description The editor checks tags by string.
* If there is "<" or ">" in the attribute of tag, HTML is broken when checking the tag.
* When using an attribute with "<" or ">", use "HTMLEncoder" to save. (ex: math(katex))
* @param {String} contents HTML or Text string
* @returns {String}
*/
HTMLEncoder: function (contents) {
const ec = {'<': '$lt;', '>': '$gt;'};
return contents.replace(/<|>/g, function (m) {
return (typeof ec[m] === 'string') ? ec[m] : m;
});
},
/**
* @description The editor checks tags by string.
* If there is "<" or ">" in the attribute of tag, HTML is broken when checking the tag.
* Decoder of data stored as "HTMLEncoder" (ex: math(katex))
* @param {String} contents HTML or Text string
* @returns {String}
*/
HTMLDecoder: function (contents) {
const ec = {'$lt;': '<', '$gt;': '>'};
return contents.replace(/\$lt;|\$gt;/g, function (m) {
return (typeof ec[m] === 'string') ? ec[m] : m;
});
},
/**
* @description This method run Object.prototype.hasOwnProperty.call(obj, key)
* @param {Object} obj Object
* @param {String} key obj.key
* @returns {Boolean}
*/
hasOwn: function (obj, key) {
return this._hasOwn.call(obj, key);
},
_hasOwn: Object.prototype.hasOwnProperty,
/**
* @deprecated
* @description Get the the tag path of the arguments value
* If not found, return the first found value
* @param {Array} nameArray File name array
* @param {String} extension js, css
* @returns {String}
*/
getIncludePath: function (nameArray, extension) {
let path = '';
const pathList = [];
const tagName = extension === 'js' ? 'script' : 'link';
const src = extension === 'js' ? 'src' : 'href';
let fileName = '(?:';
for (let i = 0, len = nameArray.length; i < len; i++) {
fileName += nameArray[i] + (i < len - 1 ? '|' : ')');
}
const regExp = new this._w.RegExp('(^|.*[\\/])' + fileName + '(\\.[^\\/]+)?\.' + extension + '(?:\\?.*|;.*)?$', 'i');
const extRegExp = new this._w.RegExp('.+\\.' + extension + '(?:\\?.*|;.*)?$', 'i');
for (let c = this._d.getElementsByTagName(tagName), i = 0; i < c.length; i++) {
if (extRegExp.test(c[i][src])) {
pathList.push(c[i]);
}
}
for (let i = 0; i < pathList.length; i++) {
let editorTag = pathList[i][src].match(regExp);
if (editorTag) {
path = editorTag[0];
break;
}
}
if (path === '') path = pathList.length > 0 ? pathList[0][src] : '';
-1 === path.indexOf(':/') && '//' !== path.slice(0, 2) && (path = 0 === path.indexOf('/') ? location.href.match(/^.*?:\/\/[^\/]*/)[0] + path : location.href.match(/^[^\?]*\/(?:)/)[0] + path);
if (!path) throw '[SUNEDITOR.util.getIncludePath.fail] The SUNEDITOR installation path could not be automatically detected. (name: +' + name + ', extension: ' + extension + ')';
return path;
},
/**
* @deprecated
* @description Returns the CSS text that has been applied to the current page.
* @param {Document|null} doc To get the CSS text of an document(core._wd). If null get the current document.
* @returns {String} Styles string
*/
getPageStyle: function (doc) {
let cssText = '';
const sheets = (doc || this._d).styleSheets;
for (let i = 0, len = sheets.length, rules; i < len; i++) {
try {
rules = sheets[i].cssRules;
} catch (e) {
continue;
}
if (rules) {
for (let c = 0, cLen = rules.length; c < cLen; c++) {
cssText += rules[c].cssText;
}
}
}
return cssText;
},
/**
* @description Get the argument iframe's document object
* @param {Element} iframe Iframe element (context.element.wysiwygFrame)
* @returns {Document}
*/
getIframeDocument: function (iframe) {
let wDocument = iframe.contentWindow || iframe.contentDocument;
if (wDocument.document) wDocument = wDocument.document;
return wDocument;
},
/**
* @description Get attributes of argument element to string ('class="---" name="---" ')
* @param {Element} element Element object
* @param {Array|null} exceptAttrs Array of attribute names to exclude from the result
* @returns {String}
*/
getAttributesToString: function (element, exceptAttrs) {
if (!element.attributes) return '';
const attrs = element.attributes;
let attrString = '';
for (let i = 0, len = attrs.length; i < len; i++) {
if (exceptAttrs && exceptAttrs.indexOf(attrs[i].name) > -1) continue;
attrString += attrs[i].name + '="' + attrs[i].value + '" ';
}
return attrString;
},
/**
* @descriptionGets Get the length in bytes of a string.
* referencing code: "https://github.com/shaan1974/myrdin/blob/master/expressions/string.js#L11"
* @param {String} text String text
* @returns {Number}
*/
getByteLength: function(text) {
if (!text || !text.toString) return 0;
text = text.toString();
const encoder = this._w.encodeURIComponent;
let cr, cl;
if (this.isIE_Edge) {
cl = this._w.unescape(encoder(text)).length;
cr = 0;
if (encoder(text).match(/(%0A|%0D)/gi) !== null) {
cr = encoder(text).match(/(%0A|%0D)/gi).length;
}
return cl + cr;
} else {
cl = (new this._w.TextEncoder('utf-8').encode(text)).length;
cr = 0;
if (encoder(text).match(/(%0A|%0D)/gi) !== null) {
cr = encoder(text).match(/(%0A|%0D)/gi).length;
}
return cl + cr;
}
},
/**
* @description It is judged whether it is the edit region top div element or iframe's body tag.
* @param {Node} element The node to check
* @returns {Boolean}
*/
isWysiwygDiv: function (element) {
return element && element.nodeType === 1 && (this.hasClass(element, 'se-wrapper-wysiwyg') || /^BODY$/i.test(element.nodeName));
},
/**
* @description It is judged whether it is the contenteditable property is false.
* @param {Node} element The node to check
* @returns {Boolean}
*/
isNonEditable: function (element) {
return element && element.nodeType === 1 && element.getAttribute('contenteditable') === 'false';
},
/**
* @description It is judged whether it is a node related to the text style.
* (strong|span|font|b|var|i|em|u|ins|s|strike|del|sub|sup|mark|a|label|code)
* @param {Node} element The node to check
* @returns {Boolean}
*/
isTextStyleElement: function (element) {
return element && element.nodeType !== 3 && /^(strong|span|font|b|var|i|em|u|ins|s|strike|del|sub|sup|mark|a|label|code|summary)$/i.test(element.nodeName);
},
/**
* @description It is judged whether it is the input element (INPUT, TEXTAREA)
* @param {Node} element The node to check
* @returns
*/
isInputElement: function (element) {
return element && element.nodeType === 1 && /^(INPUT|TEXTAREA)$/i.test(element.nodeName);
},
/**
* @description It is judged whether it is the format element (P, DIV, H[1-6], PRE, LI | class="__se__format__replace_xxx")
* Format element also contain "free format Element"
* @param {Node} element The node to check
* @returns {Boolean}
*/
isFormatElement: function (element) {
return element && element.nodeType === 1 && (/^(P|DIV|H[1-6]|PRE|LI|TH|TD|DETAILS)$/i.test(element.nodeName) || this.hasClass(element, '(\\s|^)__se__format__replace_.+(\\s|$)|(\\s|^)__se__format__free_.+(\\s|$)')) && !this.isComponent(element) && !this.isWysiwygDiv(element);
},
/**
* @description It is judged whether it is the range format element. (BLOCKQUOTE, OL, UL, FIGCAPTION, TABLE, THEAD, TBODY, TR, TH, TD | class="__se__format__range_xxx")
* Range format element is wrap the "format element" and "component"
* @param {Node} element The node to check
* @returns {Boolean}
*/
isRangeFormatElement: function (element) {
return element && element.nodeType === 1 && (/^(BLOCKQUOTE|OL|UL|FIGCAPTION|TABLE|THEAD|TBODY|TR|TH|TD|DETAILS)$/i.test(element.nodeName) || this.hasClass(element, '(\\s|^)__se__format__range_.+(\\s|$)'));
},
/**
* @description It is judged whether it is the closure range format element. (TH, TD | class="__se__format__range__closure_xxx")
* Closure range format elements is included in the range format element.
* - Closure range format element is wrap the "format element" and "component"
* ※ You cannot exit this format with the Enter key or Backspace key.
* ※ Use it only in special cases. ([ex] format of table cells)
* @param {Node} element The node to check
* @returns {Boolean}
*/
isClosureRangeFormatElement: function (element) {
return element && element.nodeType === 1 && (/^(TH|TD)$/i.test(element.nodeName) || this.hasClass(element, '(\\s|^)__se__format__range__closure_.+(\\s|$)'));
},
/**
* @description It is judged whether it is the free format element. (PRE | class="__se__format__free_xxx")
* Free format elements is included in the format element.
* Free format elements's line break is "BR" tag.
* ※ Entering the Enter key in the space on the last line ends "Free Format" and appends "Format".
* @param {Node} element The node to check
* @returns {Boolean}
*/
isFreeFormatElement: function (element) {
return element && element.nodeType === 1 && (/^PRE$/i.test(element.nodeName) || this.hasClass(element, '(\\s|^)__se__format__free_.+(\\s|$)')) && !this.isComponent(element) && !this.isWysiwygDiv(element);
},
/**
* @description It is judged whether it is the closure free format element. (class="__se__format__free__closure_xxx")
* Closure free format elements is included in the free format element.
* - Closure free format elements's line break is "BR" tag.
* ※ You cannot exit this format with the Enter key or Backspace key.
* ※ Use it only in special cases. ([ex] format of table cells)
* @param {Node} element The node to check
* @returns {Boolean}
*/
isClosureFreeFormatElement: function (element) {
return element && element.nodeType === 1 && this.hasClass(element, '(\\s|^)__se__format__free__closure_.+(\\s|$)');
},
/**
* @description It is judged whether it is the component[img, iframe, video, audio, table] cover(class="se-component") and table, hr
* @param {Node} element The node to check
* @returns {Boolean}
*/
isComponent: function (element) {
return element && (/se-component/.test(element.className) || /^(TABLE|HR)$/.test(element.nodeName));
},
/**
* @description Checks for "__se__uneditable" in the class list.
* Components with class "__se__uneditable" cannot be modified.
* @param {Element} element The element to check
* @returns {Boolean}
*/
isUneditableComponent: function (element) {
return element && this.hasClass(element, '__se__uneditable');
},
/**
* @description It is judged whether it is the component [img, iframe] cover(class="se-component")
* @param {Node} element The node to check
* @returns {Boolean}
*/
isMediaComponent: function (element) {
return element && /se-component/.test(element.className);
},
/**
* @description It is judged whether it is the not checking node. (class="katex", "__se__tag")
* @param {Node} element The node to check
* @returns {Boolean}
*/
isNotCheckingNode: function (element) {
return element && /katex|__se__tag/.test(element.className);
},
/**
* @description If a parent node that contains an argument node finds a format node (util.isFormatElement), it returns that node.
* @param {Node} element Reference node.
* @param {Function|null} validation Additional validation function.
* @returns {Element|null}
*/
getFormatElement: function (element, validation) {
if (!element) return null;
if (!validation) {
validation = function () { return true; };
}
while (element) {
if (this.isWysiwygDiv(element)) return null;
if (this.isRangeFormatElement(element)) element.firstElementChild;
if (this.isFormatElement(element) && validation(element)) return element;
element = element.parentNode;
}
return null;
},
/**
* @description If a parent node that contains an argument node finds a format node (util.isRangeFormatElement), it returns that node.
* @param {Node} element Reference node.
* @param {Function|null} validation Additional validation function.
* @returns {Element|null}
*/
getRangeFormatElement: function (element, validation) {
if (!element) return null;
if (!validation) {
validation = function () { return true; };
}
while (element) {
if (this.isWysiwygDiv(element)) return null;
if (this.isRangeFormatElement(element) && !/^(THEAD|TBODY|TR)$/i.test(element.nodeName) && validation(element)) return element;
element = element.parentNode;
}
return null;
},
/**
* @description If a parent node that contains an argument node finds a free format node (util.isFreeFormatElement), it returns that node.
* @param {Node} element Reference node.
* @param {Function|null} validation Additional validation function.
* @returns {Element|null}
*/
getFreeFormatElement: function (element, validation) {
if (!element) return null;
if (!validation) {
validation = function () { return true; };
}
while (element) {
if (this.isWysiwygDiv(element)) return null;
if (this.isFreeFormatElement(element) && validation(element)) return element;
element = element.parentNode;
}
return null;
},
/**
* @description If a parent node that contains an argument node finds a closure free format node (util.isClosureFreeFormatElement), it returns that node.
* @param {Node} element Reference node.
* @param {Function|null} validation Additional validation function.
* @returns {Element|null}
*/
getClosureFreeFormatElement: function (element, validation) {
if (!element) return null;
if (!validation) {
validation = function () { return true; };
}
while (element) {
if (this.isWysiwygDiv(element)) return null;
if (this.isClosureFreeFormatElement(element) && validation(element)) return element;
element = element.parentNode;
}
return null;
},
/**
* @description Add style and className of copyEl to originEl
* @param {Element} originEl Origin element
* @param {Element} copyEl Element to copy
* @param {Array|null} blacklist Blacklist array(LowerCase)
*/
copyTagAttributes: function (originEl, copyEl, blacklist) {
if (copyEl.style.cssText) {
const copyStyles = copyEl.style;
for (let i = 0, len = copyStyles.length; i < len; i++) {
originEl.style[copyStyles[i]] = copyStyles[copyStyles[i]];
}
}
const attrs = copyEl.attributes;
for (let i = 0, len = attrs.length, name; i < len; i++) {
name = attrs[i].name.toLowerCase();
if ((blacklist && blacklist.indexOf(name) > -1) || !attrs[i].value) originEl.removeAttribute(name);
else if (name !== 'style') originEl.setAttribute(attrs[i].name, attrs[i].value);
}
},
/**
* @description Copy and apply attributes of format tag that should be maintained. (style, class) Ignore "__se__format__" class
* @param {Element} originEl Origin element
* @param {Element} copyEl Element to copy
*/
copyFormatAttributes: function (originEl, copyEl) {
copyEl = copyEl.cloneNode(false);
copyEl.className = copyEl.className.replace(/(\s|^)__se__format__[^\s]+/g, '');
this.copyTagAttributes(originEl, copyEl);
},
/**
* @description Get the item from the array that matches the condition.
* @param {Array|HTMLCollection|NodeList} array Array to get item
* @param {Function|null} validation Conditional function
* @param {Boolean} multi If true, returns all items that meet the criteria otherwise, returns an empty array.
* If false, returns only one item that meet the criteria otherwise return null.
* @returns {Array|Node|null}
*/
getArrayItem: function (array, validation, multi) {
if (!array || array.length === 0) return null;
validation = validation || function () { return true; };
const arr = [];
for (let i = 0, len = array.length, a; i < len; i++) {
a = array[i];
if (validation(a)) {
if (!multi) return a;
else arr.push(a);
}
}
return !multi ? null : arr;
},
/**
* @description Check if an array contains an element
* @param {Array|HTMLCollection|NodeList} array element array
* @param {Node} element The element to check for
* @returns {Boolean}
*/
arrayIncludes: function(array, element) {
for (let i = 0; i < array.length; i++) {
if (array[i] === element) {
return true;
}
}
return false;
},
/**
* @description Get the index of the argument value in the element array
* @param {Array|HTMLCollection|NodeList} array element array
* @param {Node} element The element to find index
* @returns {Number}
*/
getArrayIndex: function (array, element) {
let idx = -1;
for (let i = 0, len = array.length; i < len; i++) {
if (array[i] === element) {
idx = i;
break;
}
}
return idx;
},
/**
* @description Get the next index of the argument value in the element array
* @param {Array|HTMLCollection|NodeList} array element array
* @param {Node} item The element to find index
* @returns {Number}
*/
nextIdx: function (array, item) {
let idx = this.getArrayIndex(array, item);
if (idx === -1) return -1;
return idx + 1;
},
/**
* @description Get the previous index of the argument value in the element array
* @param {Array|HTMLCollection|NodeList} array Element array
* @param {Node} item The element to find index
* @returns {Number}
*/
prevIdx: function (array, item) {
let idx = this.getArrayIndex(array, item);
if (idx === -1) return -1;
return idx - 1;
},
/**
* @description Returns the index compared to other sibling nodes.
* @param {Node} node The Node to find index
* @returns {Number}
*/
getPositionIndex: function (node) {
let idx = 0;
while ((node = node.previousSibling)) {
idx += 1;
}
return idx;
},
/**
* @description Returns the position of the "node" in the "parentNode" in a numerical array.
* ex) <p><span>aa</span><span>bb</span></p> : getNodePath(node: "bb", parentNode: "<P>") -> [1, 0]
* @param {Node} node The Node to find position path
* @param {Node|null} parentNode Parent node. If null, wysiwyg div area
* @param {Object|null} _newOffsets If you send an object of the form "{s: 0, e: 0}", the text nodes that are attached together are merged into one, centered on the "node" argument.
* "_newOffsets.s" stores the length of the combined characters after "node" and "_newOffsets.e" stores the length of the combined characters before "node".
* Do not use unless absolutely necessary.
* @returns {Array}
*/
getNodePath: function (node, parentNode, _newOffsets) {
const path = [];
let finds = true;
this.getParentElement(node, function (el) {
if (el === parentNode) finds = false;
if (finds && !this.isWysiwygDiv(el)) {
// merge text nodes
if (_newOffsets && el.nodeType === 3) {
let temp = null, tempText = null;
_newOffsets.s = _newOffsets.e = 0;
let previous = el.previousSibling;
while (previous && previous.nodeType === 3) {
tempText = previous.textContent.replace(this.zeroWidthRegExp, '');
_newOffsets.s += tempText.length;
el.textContent = tempText + el.textContent;
temp = previous;
previous = previous.previousSibling;
this.removeItem(temp);
}
let next = el.nextSibling;
while (next && next.nodeType === 3) {
tempText = next.textContent.replace(this.zeroWidthRegExp, '');
_newOffsets.e += tempText.length;
el.textContent += tempText;
temp = next;
next = next.nextSibling;
this.removeItem(temp);
}
}
// index push
path.push(el);
}
return false;
}.bind(this));
return path.map(this.getPositionIndex).reverse();
},
/**
* @description Returns the node in the location of the path array obtained from "util.getNodePath".
* @param {Array} offsets Position array, array obtained from "util.getNodePath"
* @param {Node} parentNode Base parent element
* @returns {Node}
*/
getNodeFromPath: function (offsets, parentNode) {
let current = parentNode;
let nodes;
for (let i = 0, len = offsets.length; i < len; i++) {
nodes = current.childNodes;
if (nodes.length === 0) break;
if (nodes.length <= offsets[i]) {
current = nodes[nodes.length - 1];
} else {
current = nodes[offsets[i]];
}
}
return current;
},
/**
* @description Compares the style and class for equal values.
* Returns true if both are text nodes.
* @param {Node} a Node to compare
* @param {Node} b Node to compare
* @returns {Boolean}
*/
isSameAttributes: function (a, b) {
if (a.nodeType === 3 && b.nodeType === 3) return true;
if (a.nodeType === 3 || b.nodeType === 3) return false;
const style_a = a.style;
const style_b = b.style;
let compStyle = 0;
for (let i = 0, len = style_a.length; i < len; i++) {
if (style_a[style_a[i]] === style_b[style_a[i]]) compStyle++;
}
const class_a = a.classList;
const class_b = b.classList;
const reg = this._w.RegExp;
let compClass = 0;
for (let i = 0, len = class_a.length; i < len; i++) {
if (reg('(\s|^)' + class_a[i] + '(\s|$)').test(class_b.value)) compClass++;
}
return (compStyle === style_b.length && compStyle === style_a.length) && (compClass === class_b.length && compClass === class_a.length);
},
/**
* @description Check the line element(util.isFormatElement) is empty.
* @param {Element} element Format element node
* @returns {Boolean}
*/
isEmptyLine: function (element) {
return !element || !element.parentNode || (!element.querySelector('IMG, IFRAME, AUDIO, VIDEO, CANVAS, TABLE') && element.children.length === 0 && this.onlyZeroWidthSpace(element.textContent));
},
/**
* @description Check the span's attributes are empty.
* @param {Element|null} element Element node
* @returns {Boolean}
*/
isSpanWithoutAttr: function (element) {
return !!element && element.nodeType === 1 && /^SPAN$/i.test(element.nodeName) && !element.className && !element.style.cssText;
},
/**
* @description Check the node is a list (ol, ul)
* @param {Node|String} node The element or element name to check
* @returns {Boolean}
*/
isList: function (node) {
return node && /^(OL|UL)$/i.test(typeof node === 'string' ? node : node.nodeName);
},
/**
* @description Check the node is a list cell (li)
* @param {Node|String} node The element or element name to check
* @returns {Boolean}
*/
isListCell: function (node) {
return node && /^LI$/i.test(typeof node === 'string' ? node : node.nodeName);
},
/**
* @description Check the node is a table (table, thead, tbody, tr, th, td)
* @param {Node|String} node The element or element name to check
* @returns {Boolean}
*/
isTable: function (node) {
return node && /^(TABLE|THEAD|TBODY|TR|TH|TD)$/i.test(typeof node === 'string' ? node : node.nodeName);
},
/**
* @description Check the node is a table cell (td, th)
* @param {Node|String} node The element or element name to check
* @returns {Boolean}
*/
isCell: function (node) {
return node && /^(TD|TH)$/i.test(typeof node === 'string' ? node : node.nodeName);
},
/**
* @description Check the node is a break node (BR)
* @param {Node|String} node The element or element name to check
* @returns {Boolean}
*/
isBreak: function (node) {
return node && /^BR$/i.test(typeof node === 'string' ? node : node.nodeName);
},
/**
* @description Check the node is a anchor node (A)
* @param {Node|String} node The element or element name to check
* @returns {Boolean}
*/
isAnchor: function (node) {
return node && /^A$/i.test(typeof node === 'string' ? node : node.nodeName);
},
/**
* @description Check the node is a media node (img, iframe, audio, video, canvas)
* @param {Node|String} node The element or element name to check
* @returns {Boolean}
*/
isMedia: function (node) {
return node && /^(IMG|IFRAME|AUDIO|VIDEO|CANVAS)$/i.test(typeof node === 'string' ? node : node.nodeName);
},
/**
* @description Check the node is a figure tag or util.isMedia()
* @param {Node|String} node The element or element name to check
* @returns {Boolean}
*/
isFigures: function (node) {
return node && (this.isMedia(node) || /^(FIGURE)$/i.test(typeof node === 'string' ? node : node.nodeName));
},
/**
* @description Checks for numeric (with decimal point).
* @param {String|Number} text Text string or number
* @returns {Boolean}
*/
isNumber: function (text) {
return !!text && /^-?\d+(\.\d+)?$/.test(text + '');
},
/**
* @description Get a number.
* @param {String|Number} text Text string or number
* @param {Number} maxDec Maximum number of decimal places (-1 : Infinity)
* @returns {Number}
*/
getNumber: function (text, maxDec) {
if (!text) return 0;
let number = (text + '').match(/-?\d+(\.\d+)?/);
if (!number || !number[0]) return 0;
number = number[0];
return maxDec < 0 ? number * 1 : maxDec === 0 ? this._w.Math.round(number * 1) : (number * 1).toFixed(maxDec) * 1;
},
/**
* @description Get all "children" of the argument value element (Without text nodes)
* @param {Element} element element to get child node
* @param {Function|null} validation Conditional function
* @returns {Array}
*/
getListChildren: function (element, validation) {
const children = [];
if (!element || !element.children || element.children.length === 0) return children;
validation = validation || function () { return true; };
(function recursionFunc(current) {
if (element !== current && validation(current)) {
children.push(current);
}
if (!!current.children) {
for (let i = 0, len = current.children.length; i < len; i++) {
recursionFunc(current.children[i]);
}
}
})(element);
return children;
},
/**
* @description Get all "childNodes" of the argument value element (Include text nodes)
* @param {Node} element element to get child node
* @param {Function|null} validation Conditional function
* @returns {Array}
*/
getListChildNodes: function (element, validation) {
const children = [];
if (!element || element.childNodes.length === 0) return children;
validation = validation || function () { return true; };
(function recursionFunc(current) {
if (element !== current && validation(current)) {
children.push(current);
}
for (let i = 0, len = current.childNodes.length; i < len; i++) {
recursionFunc(current.childNodes[i]);
}
})(element);
return children;
},
/**
* @description Returns the number of parents nodes.
* "0" when the parent node is the WYSIWYG area.
* "-1" when the element argument is the WYSIWYG area.
* @param {Node} element The element to check
* @returns {Number}
*/
getElementDepth: function (element) {
if (!element || this.isWysiwygDiv(element)) return -1;
let depth = 0;
element = element.parentNode;
while (element && !this.isWysiwygDiv(element)) {
depth += 1;
element = element.parentNode;
}
return depth;
},
/**
* @description Compares two elements to find a common ancestor, and returns the order of the two elements.
* @param {Node} a Node to compare.
* @param {Node} b Node to compare.
* @returns {Object} { ancesstor, a, b, result: (a > b ? 1 : a < b ? -1 : 0) };
*/
compareElements: function (a, b) {
let aNode = a, bNode = b;
while (aNode && bNode && aNode.parentNode !== bNode.parentNode) {
aNode = aNode.parentNode;
bNode = bNode.parentNode;
}
if (!aNode || !bNode) return { ancestor: null, a: a, b: b, result: 0 };
const children = aNode.parentNode.childNodes;
const aIndex = this.getArrayIndex(children, aNode);
const bIndex = this.getArrayIndex(children, bNode);
return {
ancestor: aNode.parentNode,
a: aNode,
b: bNode,
result: aIndex > bIndex ? 1 : aIndex < bIndex ? -1 : 0
};
},
/**
* @description Get the parent element of the argument value.
* A tag that satisfies the query condition is imported.
* Returns null if not found.
* @param {Node} element Reference element
* @param {String|Function} query Query String (nodeName, .className, #ID, :name) or validation function.
* Not use it like jquery.
* Only one condition can be entered at a time.
* @returns {Element|null}
*/
getParentElement: function (element, query) {
let check;
if (typeof query === 'function') {
check = query;
} else {
let attr;
if (/^\./.test(query)) {
attr = 'className';
query = query.split('.')[1];
} else if (/^#/.test(query)) {
attr = 'id';
query = '^' + query.split('#')[1] + '$';
} else if (/^:/.test(query)) {
attr = 'name';
query = '^' + query.split(':')[1] + '$';
} else {
attr = 'nodeName';
query = '^' + query + '$';
}
const regExp = new this._w.RegExp(query, 'i');
check = function (el) {
return regExp.test(el[attr]);
};
}
while (element && !check(element)) {
if (this.isWysiwygDiv(element)) {
return null;
}
element = element.parentNode;
}
return element;
},
/**
* @description Gets the previous sibling last child. If there is no sibling, then it'll take it from the closest ancestor with child
* Returns null if not found.
* @param {Node} node Reference element
* @param {Node|null} ceiling Highest boundary allowed
* @returns {Node|null}
*/
getPreviousDeepestNode: function (node, ceiling) {
let previousNode = node.previousSibling;
if (!previousNode) {
for (let parentNode = node.parentNode; parentNode; parentNode = parentNode.parentNode) {
if (parentNode === ceiling) return null;
if (parentNode.previousSibling) {
previousNode = parentNode.previousSibling;
break;
}
}
if (!previousNode) return null;
}
while (previousNode.lastChild) previousNode = previousNode.lastChild;
return previousNode;
},
/**
* @description Gets the next sibling first child. If there is no sibling, then it'll take it from the closest ancestor with child
* Returns null if not found.
* @param {Node} node Reference element
* @param {Node|null} ceiling Highest boundary allowed
* @returns {Node|null}
*/
getNextDeepestNode: function (node, ceiling) {
let nextNode = node.nextSibling;
if (!nextNode) {
for (let parentNode = node.parentNode; parentNode; parentNode = parentNode.parentNode) {
if (parentNode === ceiling) return null;
if (parentNode.nextSibling) {
nextNode = parentNode.nextSibling;
break;
}
}
if (!nextNode) return null;
}
while (nextNode.firstChild) nextNode = nextNode.firstChild;
return nextNode;
},
/**
* @description Get the child element of the argument value.
* A tag that satisfies the query condition is imported.
* Returns null if not found.
* @param {Node} element Reference element
* @param {String|Function} query Query String (nodeName, .className, #ID, :name) or validation function.
* @param {Boolean} last If true returns the last node among the found child nodes. (default: first node)
* Not use it like jquery.
* Only one condition can be entered at a time.
* @returns {Element|null}
*/
getChildElement: function (element, query, last) {
let check;
if (typeof query === 'function') {
check = query;
} else {
let attr;
if (/^\./.test(query)) {
attr = 'className';
query = query.split('.')[1];
} else if (/^#/.test(query)) {
attr = 'id';
query = '^' + query.split('#')[1] + '$';
} else if (/^:/.test(query)) {
attr = 'name';
query = '^' + query.split(':')[1] + '$';
} else {
attr = 'nodeName';
query = '^' + (query === 'text' ? '#' + query : query) + '$';
}
const regExp = new this._w.RegExp(query, 'i');
check = function (el) {
return regExp.test(el[attr]);
};
}
const childList = this.getListChildNodes(element, function (current) {
return check(current);
});
return childList[last ? childList.length - 1 : 0];
},
/**
* @description 1. The first node of all the child nodes of the "first" element is returned.
* 2. The last node of all the child nodes of the "last" element is returned.
* 3. When there is no "last" element, the first and last nodes of all the children of the "first" element are returned.
* { sc: "first", ec: "last" }
* @param {Node} first First element
* @param {Node|null} last Last element
* @returns {Object}
*/
getEdgeChildNodes: function (first, last) {
if (!first) return;
if (!last) last = first;
while (first && first.nodeType === 1 && first.childNodes.length > 0 && !this.isBreak(first)) first = first.firstChild;
while (last && last.nodeType === 1 && last.childNodes.length > 0 && !this.isBreak(last)) last = last.lastChild;
return {
sc: first,
ec: last || first
};
},
/**
* @description Returns the position of the left and top of argument. {left:0, top:0}
* @param {Node} element Target node
* @param {Element|null} wysiwygFrame When use iframe option, iframe object should be sent (context.element.wysiwygFrame)
* @returns {Object}
*/
getOffset: function (element, wysiwygFrame) {
let offsetLeft = 0;
let offsetTop = 0;
let offsetElement = element.nodeType === 3 ? element.parentElement : element;
const wysiwyg = this.getParentElement(element, this.isWysiwygDiv.bind(this));
while (offsetElement && !this.hasClass(offsetElement, 'se-container') && offsetElement !== wysiwyg) {
offsetLeft += offsetElement.offsetLeft;
offsetTop += offsetElement.offsetTop;
offsetElement = offsetElement.offsetParent;
}
const iframe = wysiwygFrame && /iframe/i.test(wysiwygFrame.nodeName);
return {
left: offsetLeft + (iframe ? wysiwygFrame.parentElement.offsetLeft : 0),
top: (offsetTop - (wysiwyg ? wysiwyg.scrollTop : 0)) + (iframe ? wysiwygFrame.parentElement.offsetTop : 0)
};
},
/**
* @description It compares the start and end indexes of "a" and "b" and returns the number of overlapping indexes in the range.
* ex) 1, 5, 4, 6 => "2" (4 ~ 5)
* @param {Number} aStart Start index of "a"
* @param {Number} aEnd End index of "a"
* @param {Number} bStart Start index of "b"
* @param {Number} bEnd Start index of "b"
* @returns {Number}
*/
getOverlapRangeAtIndex: function (aStart, aEnd, bStart, bEnd) {
if (aStart <= bEnd ? aEnd < bStart : aEnd > bStart) return 0;
const overlap = (aStart > bStart ? aStart : bStart) - (aEnd < bEnd ? aEnd : bEnd);
return (overlap < 0 ? overlap * -1 : overlap) + 1;
},
/**
* @description Set the text content value of the argument value element
* @param {Node} element Element to replace text content
* @param {String} txt Text to be applied
*/
changeTxt: function (element, txt) {
if (!element || !txt) return;
element.textContent = txt;
},
/**
* @description Replace element
* @param {Element} element Target element
* @param {String|Element} newElement String or element of the new element to apply
*/
changeElement: function (element, newElement) {
if (typeof newElement === 'string') {
if (element.outerHTML) {
element.outerHTML = newElement;
} else {
const doc = this.createElement('DIV');
doc.innerHTML = newElement;
newElement = doc.firstChild;
element.parentNode.replaceChild(newElement, element);
}
} else if (newElement.nodeType === 1) {
element.parentNode.replaceChild(newElement, element);
}
},
/**
* @description Set style, if all styles are deleted, the style properties are deleted.
* @param {Element} element Element to set style
* @param {String} styleName Style attribute name (marginLeft, textAlign...)
* @param {String|Number} value Style value
*/
setStyle: function (element, styleName, value) {
element.style[styleName] = value;
if (!value && !element.style.cssText) {
element.removeAttribute('style');
}
},
/**
* @description Determine whether any of the matched elements are assigned the given class
* @param {Element} element Elements to search class name
* @param {String} className Class name to search for
* @returns {Boolean}
*/
hasClass: function (element, className) {
if (!element) return;
return (new this._w.RegExp(className)).test(element.className);
},
/**
* @description Append the className value of the argument value element
* @param {Element} element Elements to add class name
* @param {String} className Class name to be add
*/
addClass: function (element, className) {
if (!element) return;
const check = new this._w.RegExp('(\\s|^)' + className + '(\\s|$)');
if (check.test(element.className)) return;
element.className += (element.className.length > 0 ? ' ' : '') + className;
},
/**
* @description Delete the className value of the argument value element
* @param {Element} element Elements to remove class name
* @param {String} className Class name to be remove
*/
removeClass: function (element, className) {
if (!element) return;
const check = new this._w.RegExp('(\\s|^)' + className + '(\\s|$)');
element.className = element.className.replace(check, ' ').trim();
if (!element.className.trim()) element.removeAttribute('class');
},
/**
* @description Argument value If there is no class name, insert it and delete the class name if it exists
* @param {Element} element Elements to replace class name
* @param {String} className Class name to be change
* @returns {Boolean|undefined}
*/
toggleClass: function (element, className) {
if (!element) return;
let result = false;
const check = new this._w.RegExp('(\\s|^)' + className + '(\\s|$)');
if (check.test(element.className)) {
element.className = element.className.replace(check, ' ').trim();
} else {
element.className += ' ' + className;
result = true;
}
if (!element.className.trim()) element.removeAttribute('class');
return result;
},
/**
* @description Checks if element can't be easily enabled
* @param {Element} element Element to check for
*/
isImportantDisabled: function (element) {
return element.hasAttribute('data-important-disabled');
},
/**
* @description In the predefined code view mode, the buttons except the executable button are changed to the 'disabled' state.
* core.codeViewDisabledButtons (An array of buttons whose class name is not "se-code-view