suneditor
Version:
Pure JavaScript based WYSIWYG web editor
1,191 lines (1,010 loc) • 212 kB
JavaScript
/*
* wysiwyg web editor
*
* suneditor.js
* Copyright 2017 JiHong Lee.
* MIT license.
*/
'use strict';
import _Constructor from './constructor';
import _Context from './context';
import _history from './history';
import _util from './util';
import notice from '../plugins/modules/notice';
/**
* @description SunEditor constuctor function.
* create core object and event registration.
* core, event, userFunction
* @param context
* @param plugins
* @param lang
* @param _options
* @returns {Object} UserFunction Object
*/
export default function (context, pluginCallButtons, plugins, lang, _options) {
const _d = context.element.originElement.ownerDocument || document;
const _w = _d.defaultView || window;
const util = _util;
/**
* @description editor core object
* should always bind this object when registering an event in the plug-in.
*/
const core = {
_d: _d,
_w: _w,
/**
* @description Elements and user options parameters of the suneditor
*/
context: context,
/**
* @description Plugin buttons
*/
pluginCallButtons: pluginCallButtons,
/**
* @description Loaded plugins
*/
plugins: plugins || {},
/**
* @description Util object
*/
util: util,
/**
* @description Whether the plugin is initialized
*/
initPlugins: {},
/**
* @description loaded language
*/
lang: lang,
/**
* @description submenu element
*/
submenu: null,
/**
* @description current resizing component name
* @private
*/
_resizingName: '',
/**
* @description current subment name
* @private
*/
_submenuName: '',
/**
* @description binded submenuOff method
* @private
*/
_bindedSubmenuOff: null,
/**
* @description active button element in submenu
*/
submenuActiveButton: null,
/**
* @description The elements array to be processed unvisible when the controllersOff function is executed (resizing, link modified button, table controller)
*/
controllerArray: [],
/**
* @description An array of buttons whose class name is not "code-view-enabled"
*/
codeViewDisabledButtons: null,
/**
* @description History object for undo, redo
*/
history: null,
/**
* @description binded controllersOff method
* @private
*/
_bindControllersOff: null,
/**
* @description Is inline mode?
* @private
*/
_isInline: null,
/**
* @description Is balloon mode?
* @private
*/
_isBalloon: null,
/**
* @description Required value when using inline mode to sticky toolbar
* @private
*/
_inlineToolbarAttr: {top: '', width: '', isShow: false},
/**
* @description Variable that controls the "blur" event in the editor of inline or balloon mode when the focus is moved to submenu
* @private
*/
_notHideToolbar: false,
/**
* @description Variable value that sticky toolbar mode
* @private
*/
_sticky: false,
/**
* @description If true, (initialize, reset) all indexes of image information
* @private
*/
_imagesInfoInit: true,
_imagesInfoReset: false,
/**
* @description An user event function when image uploaded success or remove image
* @private
*/
_imageUpload: function (targetImgElement, index, state, imageInfo, remainingFilesCount) {
if (typeof userFunction.onImageUpload === 'function') userFunction.onImageUpload(targetImgElement, index * 1, state, imageInfo, remainingFilesCount);
},
/**
* @description An user event function when image upload failed
* @private
*/
_imageUploadError: function (errorMessage, result) {
if (typeof userFunction.onImageUploadError === 'function') return userFunction.onImageUploadError(errorMessage, result);
return true;
},
/**
* @description Elements that need to change text or className for each selection change
* @property {Element} FORMAT format button > span.txt
* @property {Element} FONT font family button > span.txt
* @property {Element} FONT_TOOLTIP font family tooltip element
* @property {Element} SIZE font size button > span.txt
* @property {Element} ALIGN align button > div.icon
* @property {Element} LI list button
* @property {Element} STRONG bold button
* @property {Element} INS underline button
* @property {Element} EM italic button
* @property {Element} DEL strike button
* @property {Element} SUB subscript button
* @property {Element} SUP superscript button
* @property {Element} OUTDENT outdent button
*/
commandMap: null,
/**
* @description Variables used internally in editor operation
* @property {Boolean} isCodeView State of code view
* @property {Boolean} isFullScreen State of full screen
* @property {Number} innerHeight_fullScreen InnerHeight in editor when in full screen
* @property {Number} resizeClientY Remember the vertical size of the editor before resizing the editor (Used when calculating during resize operation)
* @property {Number} tabSize Indent size of tab (4)
* @property {Number} codeIndent Indent size of Code view mode (4)
* @property {Number} minResizingSize Minimum size of editing area when resized {Number} (.se-wrapper-inner {min-height: 65px;} || 65)
* @property {Array} currentNodes An array of the current cursor's node structure
* @private
*/
_variable: {
isCodeView: false,
isFullScreen: false,
innerHeight_fullScreen: 0,
resizeClientY: 0,
tabSize: 4,
codeIndent: 4,
minResizingSize: util.getNumber((context.element.wysiwygFrame.style.minHeight || '65'), 0),
currentNodes: [],
_range: null,
_selectionNode: null,
_originCssText: context.element.topArea.style.cssText,
_bodyOverflow: '',
_editorAreaOriginCssText: '',
_wysiwygOriginCssText: '',
_codeOriginCssText: '',
_fullScreenAttrs: {sticky: false, balloon: false, inline: false},
_imagesInfo: [],
_imageIndex: 0,
_videosCnt: 0
},
/**
* @description If the plugin is not added, add the plugin and call the 'add' function.
* If the plugin is added call callBack function.
* @param {String} pluginName The name of the plugin to call
* @param {function} callBackFunction Function to be executed immediately after module call
*/
callPlugin: function (pluginName, callBackFunction) {
if (!this.plugins[pluginName]) {
throw Error('[SUNEDITOR.core.callPlugin.fail] The called plugin does not exist or is in an invalid format. (pluginName:"' + pluginName + '")');
} else if (!this.initPlugins[pluginName]){
this.plugins[pluginName].add(this, pluginCallButtons[pluginName]);
this.initPlugins[pluginName] = true;
}
if (typeof callBackFunction === 'function') callBackFunction();
},
/**
* @description If the module is not added, add the module and call the 'add' function
* @param {Array} moduleArray module object's Array [dialog, resizing]
*/
addModule: function (moduleArray) {
for (let i = 0, len = moduleArray.length, moduleName; i < len; i++) {
moduleName = moduleArray[i].name;
if (!this.plugins[moduleName]) {
this.plugins[moduleName] = moduleArray[i];
if (typeof this.plugins[moduleName].add === 'function') this.plugins[moduleName].add(this);
}
}
},
/**
* @description Enabled submenu
* @param {Element} element Submenu's button element to call
*/
submenuOn: function (element) {
if (this._bindedSubmenuOff) this._bindedSubmenuOff();
const submenuName = this._submenuName = element.getAttribute('data-command');
this.submenu = element.nextElementSibling;
this.submenu.style.display = 'block';
util.addClass(element, 'on');
this.submenuActiveButton = element;
const overLeft = this.context.element.toolbar.offsetWidth - (element.parentElement.offsetLeft + this.submenu.offsetWidth);
if (overLeft < 0) this.submenu.style.left = overLeft + 'px';
else this.submenu.style.left = '1px';
this._bindedSubmenuOff = this.submenuOff.bind(this);
this.addDocEvent('mousedown', this._bindedSubmenuOff, false);
if (this.plugins[submenuName].on) this.plugins[submenuName].on.call(this);
},
/**
* @description Disable submenu
*/
submenuOff: function () {
this.removeDocEvent('mousedown', this._bindedSubmenuOff);
this._bindedSubmenuOff = null;
if (this.submenu) {
this._submenuName = '';
this.submenu.style.display = 'none';
this.submenu = null;
util.removeClass(this.submenuActiveButton, 'on');
this.submenuActiveButton = null;
this._notHideToolbar = false;
}
this.focus();
},
/**
* @description Show controller at editor area (link button, image resize button, init function, etc..)
* @param {*} arguments controller elements, functions..
*/
controllersOn: function () {
if (this._bindControllersOff) {
const tempName = this._resizingName;
this._bindControllersOff();
this._resizingName = tempName;
}
for (let i = 0; i < arguments.length; i++) {
if (arguments[i].style) arguments[i].style.display = 'block';
this.controllerArray[i] = arguments[i];
}
this._notHideToolbar = true;
this._bindControllersOff = this.controllersOff.bind(this);
this.addDocEvent('mousedown', this._bindControllersOff, false);
this.addDocEvent('keydown', this._bindControllersOff, false);
},
/**
* @description Hide controller at editor area (link button, image resize button..)
*/
controllersOff: function (e) {
if (this._resizingName && e && e.type === 'keydown' && e.keyCode !== 27) return;
this._notHideToolbar = false;
this._resizingName = '';
if (!this._bindControllersOff) return;
this.removeDocEvent('mousedown', this._bindControllersOff);
this.removeDocEvent('keydown', this._bindControllersOff);
this._bindControllersOff = null;
const len = this.controllerArray.length;
if (len > 0) {
for (let i = 0; i < len; i++) {
if (typeof this.controllerArray[i] === 'function') this.controllerArray[i]();
else this.controllerArray[i].style.display = 'none';
}
this.controllerArray = [];
}
},
/**
* @description javascript execCommand
* @param {String} command javascript execCommand function property
* @param {Boolean} showDefaultUI javascript execCommand function property
* @param {String} value javascript execCommand function property
*/
execCommand: function (command, showDefaultUI, value) {
this._wd.execCommand(command, showDefaultUI, (command === 'formatBlock' ? '<' + value + '>' : value));
// history stack
this.history.push(true);
},
/**
* @description Focus to wysiwyg area
*/
focus: function () {
if (context.element.wysiwygFrame.style.display === 'none') return;
try {
const range = this.getRange();
this.setRange(range.startContainer, range.startOffset, range.endContainer, range.endOffset);
} catch (e) {
const caption = util.getParentElement(this.getSelectionNode(), 'figcaption');
if (caption) {
caption.focus();
} else {
context.element.wysiwyg.focus();
}
this._editorRange();
}
event._applyTagEffects();
},
/**
* @description If "focusEl" is a component, then that component is selected; if it is a format element, the last text is selected
* @param {Element} focusEl Focus element
*/
focusEdge: function (focusEl) {
if (util.isComponent(focusEl)) {
const imageComponent = focusEl.querySelector('IMG');
const videoComponent = focusEl.querySelector('IFRAME');
if (imageComponent) {
this.selectComponent(imageComponent, 'image');
} else if (videoComponent) {
this.selectComponent(videoComponent, 'video');
}
} else {
focusEl = util.getChildElement(focusEl, function (current) { return current.childNodes.length === 0 || current.nodeType === 3; }, true);
this.setRange(focusEl, focusEl.textContent.length, focusEl, focusEl.textContent.length);
}
},
/**
* @description Set current editor's range object
* @param {Element} startCon The startContainer property of the selection object.
* @param {Number} startOff The startOffset property of the selection object.
* @param {Element} endCon The endContainer property of the selection object.
* @param {Number} endOff The endOffset property of the selection object.
*/
setRange: function (startCon, startOff, endCon, endOff) {
if (!startCon || !endCon) return;
if (startOff > startCon.textContent.length) startOff = startCon.textContent.length;
if (endOff > endCon.textContent.length) endOff = endCon.textContent.length;
const range = this._wd.createRange();
range.setStart(startCon, startOff);
range.setEnd(endCon, endOff);
const selection = this.getSelection();
if (selection.removeAllRanges) {
selection.removeAllRanges();
}
selection.addRange(range);
this._editorRange();
},
/**
* @description Remove range object and button effect
*/
removeRange: function () {
this.getSelection().removeAllRanges();
const commandMap = this.commandMap;
util.changeTxt(commandMap.FORMAT, lang.toolbar.formats);
util.changeTxt(commandMap.FONT, lang.toolbar.font);
util.changeTxt(commandMap.FONT_TOOLTIP, lang.toolbar.font);
util.changeTxt(commandMap.SIZE, lang.toolbar.fontSize);
util.removeClass(commandMap.LI_ICON, 'se-icon-list-bullets');
util.addClass(commandMap.LI_ICON, 'se-icon-list-number');
util.removeClass(commandMap.LI, 'active');
util.removeClass(commandMap.STRONG, 'active');
util.removeClass(commandMap.INS, 'active');
util.removeClass(commandMap.EM, 'active');
util.removeClass(commandMap.DEL, 'active');
util.removeClass(commandMap.SUB, 'active');
util.removeClass(commandMap.SUP, 'active');
if (commandMap.OUTDENT) commandMap.OUTDENT.setAttribute('disabled', true);
if (commandMap.LI) commandMap.LI.removeAttribute('data-focus');
if (commandMap.ALIGN) {
commandMap.ALIGN.className = 'se-icon-align-left';
commandMap.ALIGN.removeAttribute('data-focus');
}
},
/**
* @description Get current editor's range object
* @returns {Object}
*/
getRange: function () {
return this._variable._range || this._createDefaultRange();
},
/**
* @description Get window selection obejct
* @returns {Object}
*/
getSelection: function () {
return this._ww.getSelection();
},
/**
* @description Get current select node
* @returns {Node}
*/
getSelectionNode: function () {
if (!this._variable._selectionNode || util.isWysiwygDiv(this._variable._selectionNode)) this._editorRange();
return this._variable._selectionNode || context.element.wysiwyg.firstChild;
},
/**
* @description Saving the range object and the currently selected node of editor
* @private
*/
_editorRange: function () {
const selection = this.getSelection();
let range = null;
let selectionNode = null;
if (selection.rangeCount > 0) {
range = selection.getRangeAt(0);
}
else {
range = this._createDefaultRange();
}
this._variable._range = range;
if (range.collapsed) {
selectionNode = range.commonAncestorContainer;
} else {
selectionNode = selection.extentNode || selection.anchorNode;
}
this._variable._selectionNode = selectionNode;
},
/**
* @description Return the range object of editor's first child node
* @returns {Object}
* @private
*/
_createDefaultRange: function () {
context.element.wysiwyg.focus();
const range = this._wd.createRange();
if (!context.element.wysiwyg.firstChild) this.execCommand('formatBlock', false, 'P');
range.setStart(context.element.wysiwyg.firstChild, 0);
range.setEnd(context.element.wysiwyg.firstChild, 0);
return range;
},
/**
* @description Returns a "formatElement"(P, DIV, H[1-6], LI) array from the currently selected range.
* @param {Function|null} validation The validation function. (Replaces the default validation function-util.isFormatElement(current))
* @returns {Array}
*/
getSelectedElements: function (validation) {
let range = this.getRange();
if (util.isWysiwygDiv(range.startContainer)) {
const children = context.element.wysiwyg.children;
if (children.length === 0) return null;
this.setRange(children[0], 0, children[children.length - 1], children[children.length - 1].textContent.trim().length);
range = this.getRange();
}
const startCon = range.startContainer;
const endCon = range.endContainer;
const commonCon = range.commonAncestorContainer;
// get line nodes
const lineNodes = util.getListChildren(commonCon, function (current) {
return validation ? validation(current) : util.isFormatElement(current);
});
if (!util.isWysiwygDiv(commonCon) && !util.isRangeFormatElement(commonCon)) lineNodes.unshift(util.getFormatElement(commonCon));
if (startCon === endCon || lineNodes.length === 1) return lineNodes;
let startLine = util.getFormatElement(startCon);
let endLine = util.getFormatElement(endCon);
let startIdx = null;
let endIdx = null;
const onlyTable = function (current) {
return util.isTable(current) ? /^TABLE$/i.test(current.nodeName) : true;
};
const startRangeEl = util.getRangeFormatElement(startLine, onlyTable);
const endRangeEl = util.getRangeFormatElement(endLine, onlyTable);
const sameRange = startRangeEl === endRangeEl;
for (let i = 0, len = lineNodes.length, line; i < len; i++) {
line = lineNodes[i];
if (startLine === line || (!sameRange && line === startRangeEl)) {
startIdx = i;
continue;
}
if (endLine === line || (!sameRange && line === endRangeEl)) {
endIdx = i;
break;
}
}
if (startIdx === null) startIdx = 0;
if (endIdx === null) endIdx = lineNodes.length - 1;
return lineNodes.slice(startIdx, endIdx + 1);
},
/**
* @description Get format elements and components from the selected area. (P, DIV, H[1-6], OL, UL, TABLE..)
* If some of the component are included in the selection, get the entire that component.
* @returns {Array}
*/
getSelectedElementsAndComponents: function () {
const commonCon = this.getRange().commonAncestorContainer;
const myComponent = util.getParentElement(commonCon, util.isComponent);
const selectedLines = util.isTable(commonCon) ?
this.getSelectedElements() :
this.getSelectedElements(function (current) {
const component = this.getParentElement(current, this.isComponent);
return (this.isFormatElement(current) && (!component || component === myComponent)) || (this.isComponent(current) && !this.getFormatElement(current));
}.bind(util));
return selectedLines;
},
/**
* @description Determine if this offset is the edge offset of container
* @param {Object} container The container property of the selection object.
* @param {Number} offset The offset property of the selection object.
* @returns {Boolean}
*/
isEdgePoint: function (container, offset) {
return (offset === 0) || (offset === container.nodeValue.length);
},
/**
* @description Show loading box
*/
showLoading: function () {
context.element.loading.style.display = 'block';
},
/**
* @description Close loading box
*/
closeLoading: function () {
context.element.loading.style.display = 'none';
},
/**
* @description Append format element to sibling node of argument element.
* If the "formatNodeName" argument value is present, the tag of that argument value is inserted,
* If not, the currently selected format tag is inserted.
* @param {Element} element Insert as siblings of that element
* @param {String|null} formatNodeName Node name to be inserted
* @returns {Element}
*/
appendFormatTag: function (element, formatNodeName) {
const formatEl = element;
const currentFormatEl = util.getFormatElement(this.getSelectionNode());
const oFormatName = formatNodeName ? formatNodeName : util.isFormatElement(currentFormatEl) ? currentFormatEl.nodeName : 'P';
const oFormat = util.createElement(oFormatName);
oFormat.innerHTML = '<br>';
if (util.isCell(formatEl)) formatEl.insertBefore(oFormat, element.nextElementSibling);
else formatEl.parentNode.insertBefore(oFormat, formatEl.nextElementSibling);
return oFormat;
},
/**
* @description The method to insert a element. (used elements : table, hr, image, video)
* This method is add the element next line and insert the new line.
* When used in a tag in "LI", it is inserted into the LI tag.
* Returns the next line added.
* @param {Element} element Element to be inserted
* @param {Boolean} notHistoryPush When true, it does not update the history stack and the selection object and return EdgeNodes (util.getEdgeChildNodes)
* @returns {Element}
*/
insertComponent: function (element, notHistoryPush) {
let oNode = null;
const selectionNode = this.getSelectionNode();
const formatEl = util.getFormatElement(selectionNode);
if (util.isListCell(formatEl)) {
if (/^HR$/i.test(element.nodeName)) {
const newLi = util.createElement('LI');
const textNode = util.createTextNode(util.zeroWidthSpace);
newLi.appendChild(element);
newLi.appendChild(textNode);
formatEl.parentNode.insertBefore(newLi, formatEl.nextElementSibling);
this.setRange(textNode, 1, textNode, 1);
} else {
this.insertNode(element, selectionNode === formatEl ? null : selectionNode);
oNode = util.createElement('LI');
formatEl.parentNode.insertBefore(oNode, formatEl.nextElementSibling);
}
} else {
this.insertNode(element, formatEl);
oNode = this.appendFormatTag(element);
}
// history stack
if (!notHistoryPush) this.history.push(false);
return oNode;
},
/**
* @description The component(image, video) is selected and the resizing module is called.
* @param {Element} element Element tag (img or iframe)
* @param {String} componentName Component name (image or video)
*/
selectComponent: function (element, componentName) {
if (componentName === 'image') {
if (!core.plugins.image) return;
core.removeRange();
core.callPlugin('image', function () {
const size = core.plugins.resizing.call_controller_resize.call(core, element, 'image');
core.plugins.image.onModifyMode.call(core, element, size);
if (!util.getParentElement(element, '.se-image-container')) {
core.plugins.image.openModify.call(core, true);
core.plugins.image.update_image.call(core, true, true, true);
}
});
} else if (componentName === 'video') {
if (!core.plugins.video) return;
core.removeRange();
core.callPlugin('video', function () {
const size = core.plugins.resizing.call_controller_resize.call(core, element, 'video');
core.plugins.video.onModifyMode.call(core, element, size);
});
}
},
/**
* @description Delete selected node and insert argument value node
* If the "afterNode" exists, it is inserted after the "afterNode"
* Inserting a text node merges with both text nodes on both sides and returns a new "{ startOffset, endOffset }".
* @param {Element} oNode Element to be inserted
* @param {Element|null} afterNode If the node exists, it is inserted after the node
* @returns {undefined|Object}
*/
insertNode: function (oNode, afterNode) {
const range = this.getRange();
let parentNode = null;
if (!afterNode) {
const startCon = range.startContainer;
const startOff = range.startOffset;
const endCon = range.endContainer;
const endOff = range.endOffset;
const commonCon = range.commonAncestorContainer;
parentNode = startCon;
if (startCon.nodeType === 3) {
parentNode = startCon.parentNode;
}
/** No Select range node */
if (range.collapsed) {
if (commonCon.nodeType === 3) {
afterNode = commonCon.splitText(endOff);
}
else {
if (parentNode.lastChild !== null && util.isBreak(parentNode.lastChild)) {
parentNode.removeChild(parentNode.lastChild);
}
afterNode = null;
}
}
/** Select range nodes */
else {
const isSameContainer = startCon === endCon;
if (isSameContainer) {
if (this.isEdgePoint(endCon, endOff)) afterNode = endCon.nextSibling;
else afterNode = endCon.splitText(endOff);
let removeNode = startCon;
if (!this.isEdgePoint(startCon, startOff)) removeNode = startCon.splitText(startOff);
parentNode.removeChild(removeNode);
}
else {
this.removeNode();
parentNode = commonCon;
afterNode = endCon;
while (afterNode.parentNode !== commonCon) {
afterNode = afterNode.parentNode;
}
}
}
}
else {
parentNode = afterNode.parentNode;
afterNode = afterNode.nextElementSibling;
}
try {
parentNode.insertBefore(oNode, afterNode);
} catch (e) {
parentNode.appendChild(oNode);
} finally {
if (oNode.nodeType === 3) {
const previous = oNode.previousSibling;
const next = oNode.nextSibling;
const previousText = (!previous || util.onlyZeroWidthSpace(previous)) ? '' : previous.textContent;
const nextText = (!next || util.onlyZeroWidthSpace(next)) ? '' : next.textContent;
if (previous) {
oNode.textContent = previousText + oNode.textContent;
util.removeItem(previous);
}
if (next) {
oNode.textContent += nextText;
util.removeItem(next);
}
return {
startOffset: previousText.length,
endOffset: oNode.textContent.length - nextText.length
};
}
// history stack
this.history.push(true);
}
},
/**
* @description Delete the currently selected node
*/
removeNode: function () {
const range = this.getRange();
if (range.deleteContents) {
range.deleteContents();
// history stack
this.history.push(false);
return;
}
const startCon = range.startContainer;
const startOff = range.startOffset;
const endCon = range.endContainer;
const endOff = range.endOffset;
const commonCon = range.commonAncestorContainer;
let beforeNode = null;
let afterNode = null;
const childNodes = util.getListChildNodes(commonCon);
let startIndex = util.getArrayIndex(childNodes, startCon);
let endIndex = util.getArrayIndex(childNodes, endCon);
for (let i = startIndex + 1, startNode = startCon; i >= 0; i--) {
if (childNodes[i] === startNode.parentNode && childNodes[i].firstChild === startNode && startOff === 0) {
startIndex = i;
startNode = startNode.parentNode;
}
}
for (let i = endIndex - 1, endNode = endCon; i > startIndex; i--) {
if (childNodes[i] === endNode.parentNode && childNodes[i].nodeType === 1) {
childNodes.splice(i, 1);
endNode = endNode.parentNode;
--endIndex;
}
}
for (let i = startIndex; i <= endIndex; i++) {
const item = childNodes[i];
if (item.length === 0 || (item.nodeType === 3 && item.data === undefined)) {
util.removeItem(item);
continue;
}
if (item === startCon) {
if (startCon.nodeType === 1) {
beforeNode = util.createTextNode(startCon.textContent);
} else {
beforeNode = util.createTextNode(startCon.substringData(0, startOff));
}
if (beforeNode.length > 0) {
startCon.data = beforeNode.data;
} else {
util.removeItem(startCon);
}
continue;
}
if (item === endCon) {
if (endCon.nodeType === 1) {
afterNode = util.createTextNode(endCon.textContent);
} else {
afterNode = util.createTextNode(endCon.substringData(endOff, (endCon.length - endOff)));
}
if (afterNode.length > 0) {
endCon.data = afterNode.data;
} else {
util.removeItem(endCon);
}
continue;
}
util.removeItem(item);
// history stack
this.history.push(false);
}
},
/**
* @description Appended all selected format Element to the argument element and insert
* @param {Element} rangeElement Element of wrap the arguments (PRE, BLOCKQUOTE...)
*/
applyRangeFormatElement: function (rangeElement) {
const rangeLines = this.getSelectedElementsAndComponents();
if (!rangeLines || rangeLines.length === 0) return;
let last = rangeLines[rangeLines.length - 1];
let standTag, beforeTag, pElement;
if (util.isRangeFormatElement(last) || util.isFormatElement(last)) {
standTag = last;
} else {
standTag = util.getRangeFormatElement(last) || util.getFormatElement(last);
}
if (util.isCell(standTag)) {
beforeTag = null;
pElement = standTag;
} else {
beforeTag = standTag.nextSibling;
pElement = standTag.parentNode;
}
let parentDepth = util.getElementDepth(standTag);
let listParent = null;
const lineArr = [];
const removeItems = function (parent, origin, before) {
let cc = null;
if (parent !== origin && !util.isTable(origin)) {
cc = util.removeItemAllParents(origin);
}
return cc ? cc.ec : before;
};
for (let i = 0, len = rangeLines.length, line, originParent, depth, before; i < len; i++) {
line = rangeLines[i];
originParent = line.parentNode;
depth = util.getElementDepth(line);
if (util.isList(originParent)) {
if (listParent === null) listParent = util.createElement(originParent.nodeName);
listParent.innerHTML += line.outerHTML;
lineArr.push(line);
if (i === len - 1 || !util.getParentElement(rangeLines[i + 1], function (current) { return current === originParent; })) {
const edge = this.detachRangeFormatElement(originParent, lineArr, null, true, true);
if (parentDepth >= depth) {
parentDepth = depth;
pElement = edge.cc;
beforeTag = removeItems(pElement, originParent, edge.ec);
if (beforeTag) pElement = beforeTag.parentNode;
} else if (pElement === edge.cc) {
beforeTag = edge.ec;
}
if (pElement !== edge.cc) {
before = removeItems(pElement, edge.cc);
if (before !== undefined) beforeTag = before;
}
rangeElement.appendChild(listParent);
listParent = null;
}
}
else {
if (parentDepth >= depth) {
parentDepth = depth;
pElement = originParent;
beforeTag = line.nextSibling;
}
rangeElement.appendChild(line);
if (pElement !== originParent) {
before = removeItems(pElement, originParent);
if (before !== undefined) beforeTag = before;
}
}
}
pElement.insertBefore(rangeElement, beforeTag);
removeItems(rangeElement, beforeTag);
// history stack
this.history.push(false);
const edge = util.getEdgeChildNodes(rangeElement.firstElementChild, rangeElement.lastElementChild);
if (rangeLines.length > 1) {
this.setRange(edge.sc, 0, edge.ec, edge.ec.textContent.length);
} else {
this.setRange(edge.ec, edge.ec.textContent.length, edge.ec, edge.ec.textContent.length);
}
},
/**
* @description The elements of the "selectedFormats" array are detached from the "rangeElement" element. ("LI" tags are converted to "P" tags)
* When "selectedFormats" is null, all elements are detached and return {cc: parentNode, sc: nextSibling, ec: previousSibling}.
* @param {Element} rangeElement Range format element (PRE, BLOCKQUOTE, OL, UL...)
* @param {Array|null} selectedFormats Array of format elements (P, DIV, LI...) to remove.
* If null, Applies to all elements and return {cc: parentNode, sc: nextSibling, ec: previousSibling}
* @param {Element|null} newRangeElement The node(rangeElement) to replace the currently wrapped node.
* @param {Boolean} remove Delete without detached.
* @param {Boolean} notHistoryPush When true, it does not update the history stack and the selection object and return EdgeNodes (util.getEdgeChildNodes)
*/
detachRangeFormatElement: function (rangeElement, selectedFormats, newRangeElement, remove, notHistoryPush) {
const range = this.getRange();
const so = range.startOffset;
const eo = range.endOffset;
const children = rangeElement.childNodes;
const parent = rangeElement.parentNode;
let firstNode = null;
let lastNode = null;
let rangeEl = rangeElement.cloneNode(false);
const newList = util.isList(newRangeElement);
let insertedNew = false;
function appendNode (parent, insNode, sibling) {
if (util.onlyZeroWidthSpace(insNode)) insNode.innerHTML = util.zeroWidthSpace;
if (insNode.nodeType === 3) {
parent.insertBefore(insNode, sibling);
return insNode;
}
const children = insNode.childNodes;
let format = insNode.cloneNode(false);
let first = null;
let c = null;
while (children[0]) {
c = children[0];
if (util.isIgnoreNodeChange(c) && !util.isListCell(format)) {
if (format.childNodes.length > 0) {
if (!first) first = format;
parent.insertBefore(format, sibling);
format = insNode.cloneNode(false);
}
parent.insertBefore(c, sibling);
if (!first) first = c;
} else {
format.appendChild(c);
}
}
if (format.childNodes.length > 0) {
parent.insertBefore(format, sibling);
if (!first) first = format;
}
return first;
}
for (let i = 0, len = children.length, insNode; i < len; i++) {
insNode = children[i];
if (remove && i === 0) {
if (!selectedFormats || selectedFormats.length === len || selectedFormats[0] === insNode) {
firstNode = rangeElement.previousSibling;
} else {
firstNode = rangeEl;
}
}
if (selectedFormats && selectedFormats.indexOf(insNode) === -1) {
if (!rangeEl) rangeEl = rangeElement.cloneNode(false);
insNode = insNode.cloneNode(true);
rangeEl.appendChild(insNode);
}
else {
if (rangeEl && rangeEl.children.length > 0) {
parent.insertBefore(rangeEl, rangeElement);
rangeEl = null;
}
if (!newList && util.isListCell(insNode)) {
const inner = insNode;
insNode = util.isCell(rangeElement.parentNode) ? util.createElement('DIV') : util.createElement('P');
insNode.innerHTML = inner.innerHTML;
util.copyFormatAttributes(insNode, inner);
} else {
insNode = insNode.cloneNode(true);
}
if (!remove) {
if (newRangeElement) {
if (!insertedNew) {
parent.insertBefore(newRangeElement, rangeElement);
insertedNew = true;
}
insNode = appendNode(newRangeElement, insNode, null);
} else {
insNode = appendNode(parent, insNode, rangeElement);
}
if (selectedFormats) {
lastNode = insNode;
if (!firstNode) {
firstNode = insNode;
}
} else if (!firstNode) {
firstNode = lastNode = insNode;
}
}
}
}
const rangeParent = rangeElement.parentNode;
const rangeRight = rangeElement.nextSibling;
if (rangeEl && rangeEl.children.length > 0) {
rangeParent.insertBefore(rangeEl, rangeRight);
}
util.removeItem(rangeElement);
const edge = remove ? {
cc: rangeParent,
sc: firstNode,
ec: firstNode && firstNode.parentNode ? firstNode.nextSibling : rangeEl && rangeEl.children.length > 0 ? rangeEl : rangeRight ? rangeRight : null
} : util.getEdgeChildNodes(firstNode, lastNode);
if (notHistoryPush) return edge;
if (!remove && edge) {
if (!selectedFormats) {
this.setRange(edge.sc, 0, edge.sc, 0);
} else {
this.setRange(edge.sc, so, edge.ec, eo);
}
}
// history stack
this.history.push(false);
event._applyTagEffects();
},
/**
* @description Add, update, and delete nodes from selected text.
* 1. If there is a node in the "appendNode" argument, a node with the same tags and attributes as "appendNode" is added to the selection text.
* 2. If it is in the same tag, only the tag's attributes are changed without adding a tag.
* 3. If the "appendNode" argument is null, the node of the selection is update or remove without adding a new node.
* 4. The same style as the style attribute of the "styleArray" argument is deleted.
* (Styles should be put with attribute names from css. ["background-color"])
* 5. The same class name as the class attribute of the "styleArray" argument is deleted.
* (The class name is preceded by "." [".className"])
* 6. Use a list of styles and classes of "appendNode" in "styleArray" to avoid duplicate property values.
* 7. If a node with all styles and classes removed has the same tag name as "appendNode" or "removeNodeArray", or "appendNode" is null, that node is deleted.
* 8. Regardless of the style and class of the node, the tag with the same name as the "removeNodeArray" argument value is deleted.
* 9. If the "strictRemove" argument is true, only nodes with all styles and classes removed from the nodes of "removeNodeArray" are removed.
*10. It won't work if the parent node has the same class and same value style.
* However, if there is a value in "removeNodeArray", it works and the text node is separated even if there is no node to replace.
* @param {Element|null} appendNode The element to be added to the selection. If it is null, only delete the node.
* @param {Array|null} styleArray The style or className attribute name Array to check (['font-size'], ['.className'], ['font-family', 'color', '.className']...])
* @param {Array|null} removeNodeArray An array of node names to remove types from, remove all formats when "appendNode" is null and there is an empty array or null value. (['span'], ['strong', 'em'] ...])
* @param {Boolean|null} strictRemove If true, only nodes with all styles and classes removed from the nodes of "removeNodeArray" are removed.
*/
nodeChange: function (appendNode, styleArray, removeNodeArray, strictRemove) {
const range = this.getRange();
styleArray = styleArray && styleArray.length > 0 ? styleArray : false;
removeNodeArray = removeNodeArray && removeNodeArray.length > 0 ? removeNodeArray : false;
const isRemoveNode = !appendNode;
const isRemoveFormat = isRemoveNode && !removeNodeArray && !styleArray;
let startCon = range.startContainer;
let startOff = range.startOffset;
let endCon = range.endContainer;
let endOff = range.endOffset;
let tempCon, tempOffset, tempChild;
if (isRemoveFormat && range.collapsed && util.isFormatElement(startCon.parentNode) && util.isFormatElement(endCon.parentNode)) {
return;
}
if (isRemoveNode) {
appendNode = util.createElement('DIV');
}
const newNodeName = appendNode.nodeName;
/* checked same style property */
if (!isRemoveFormat && startCon === endCon && !removeNodeArray && appendNode) {
let sNode = startCon;
let checkCnt = 0;
const checkAttrs = [];