UNPKG

suneditor

Version:

Pure JavaScript based WYSIWYG web editor

1,191 lines (1,010 loc) 212 kB
/* * 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 = [];