UNPKG

suneditor

Version:

Vanilla JavaScript WYSIWYG web editor (2.x legacy version, actively maintained)

1,306 lines (1,133 loc) 437 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, functions * @param {Object} context * @param {Object} pluginCallButtons * @param {Object} plugins * @param {Object} lang * @param {Object} options * @param {Object} _responsiveButtons * @returns {Object} functions Object */ export default function (context, pluginCallButtons, plugins, lang, options, _responsiveButtons) { const _d = context.element.originElement.ownerDocument || document; const _w = _d.defaultView || window; const util = _util; const icons = options.icons; /** * @description editor core object * should always bind this object when registering an event in the plug-in. */ const core = { _d: _d, _w: _w, _parser: new _w.DOMParser(), _prevRtl: options.rtl, _editorHeight: 0, _editorHeightPadding: 0, _listCamel: options.__listCommonStyle, _listKebab: util.camelToKebabCase(options.__listCommonStyle), __focusTemp: context.element._focusTemp, /** * @description Document object of the iframe if created as an iframe || _d * @private */ _wd: null, /** * @description Window object of the iframe if created as an iframe || _w * @private */ _ww: null, /** * @description Closest ShadowRoot to editor if found * @private */ _shadowRoot: null, /** * @description Block controller mousedown events in "shadowRoot" environment * @private */ _shadowRootControllerEventTarget: null, /** * @description Util object */ util: util, /** * @description Functions object */ functions: null, /** * @description Editor options */ options: null, /** * @description Computed style of the wysiwyg area (window.getComputedStyle(context.element.wysiwyg)) */ wwComputedStyle: null, /** * @description Notice object */ notice: _notice, /** * @description Default icons object */ icons: icons, /** * @description History object for undo, redo */ history: null, /** * @description Elements and user options parameters of the suneditor */ context: context, /** * @description Plugin buttons */ pluginCallButtons: pluginCallButtons, /** * @description Loaded plugins */ plugins: plugins || {}, /** * @description Whether the plugin is initialized */ initPlugins: {}, /** * @description Object for managing submenu elements * @private */ _targetPlugins: {}, /** * @description Save rendered submenus and containers * @private */ _menuTray: {}, /** * @description loaded language */ lang: lang, /** * @description The selection node (core.getSelectionNode()) to which the effect was last applied */ effectNode: null, /** * @description submenu element */ submenu: null, /** * @description container element */ container: null, /** * @description current subment name * @private */ _submenuName: '', /** * @description binded submenuOff method * @private */ _bindedSubmenuOff: null, /** * @description binded containerOff method * @private */ _bindedContainerOff: null, /** * @description active button element in submenu */ submenuActiveButton: null, /** * @description active button element in container */ containerActiveButton: null, /** * @description The elements array to be processed unvisible when the controllersOff function is executed (resizing, link modified button, table controller) */ controllerArray: [], /** * @description The name of the plugin that called the currently active controller */ currentControllerName: '', /** * @description The target element of current controller */ currentControllerTarget: null, /** * @description The file component object of current selected file tag (getFileComponent) */ currentFileComponentInfo: null, /** * @description An array of buttons whose class name is not "se-code-view-enabled" */ codeViewDisabledButtons: [], /** * @description An array of buttons whose class name is not "se-resizing-enabled" */ resizingDisabledButtons: [], /** * @description active more layer element in submenu * @private */ _moreLayerActiveButton: null, /** * @description Tag whitelist RegExp object used in "_consistencyCheckOfHTML" method * ^(options._editorTagsWhitelist)$ * @private */ _htmlCheckWhitelistRegExp: null, /** * @description Tag blacklist RegExp object used in "_consistencyCheckOfHTML" method * @private */ _htmlCheckBlacklistRegExp: null, /** * @description RegExp when using check disallowd tags. (b, i, ins, strike, s) * @private */ _disallowedTextTagsRegExp: null, /** * @description Editor tags whitelist (RegExp object) * util.createTagsWhitelist(options._editorTagsWhitelist) */ editorTagsWhitelistRegExp: null, /** * @description Editor tags blacklist (RegExp object) * util.createTagsBlacklist(options.tagsBlacklist) */ editorTagsBlacklistRegExp: null, /** * @description Tag whitelist when pasting (RegExp object) * util.createTagsWhitelist(options.pasteTagsWhitelist) */ pasteTagsWhitelistRegExp: null, /** * @description Tag blacklist when pasting (RegExp object) * util.createTagsBlacklist(options.pasteTagsBlacklist) */ pasteTagsBlacklistRegExp: null, /** * @description Boolean value of whether the editor has focus */ hasFocus: false, /** * @description Boolean value of whether the editor is disabled */ isDisabled: false, /** * @description Boolean value of whether the editor is readOnly */ isReadOnly: false, /** * @description Attributes whitelist used by the cleanHTML method * @private */ _attributesWhitelistRegExp: null, _attributesWhitelistRegExp_all_data: null, /** * @description Attributes blacklist used by the cleanHTML method * @private */ _attributesBlacklistRegExp: null, /** * @description Attributes of tags whitelist used by the cleanHTML method * @private */ _attributesTagsWhitelist: null, /** * @description Attributes of tags blacklist used by the cleanHTML method * @private */ _attributesTagsBlacklist: null, /** * @description binded controllersOff method * @private */ _bindControllersOff: null, /** * @description Is inline mode? * @private */ _isInline: null, /** * @description Is balloon|balloon-always mode? * @private */ _isBalloon: null, /** * @description Is balloon-always mode? * @private */ _isBalloonAlways: 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 Variables for controlling focus and blur events * @private */ _antiBlur: false, /** * @description Component line breaker element * @private */ _lineBreaker: null, _lineBreakerButton: null, /** * @description If true, (initialize, reset) all indexes of image, video information * @private */ _componentsInfoInit: true, _componentsInfoReset: false, /** * @description Plugins array with "active" method. * "activePlugins" runs the "add" method when creating the editor. */ activePlugins: null, /** * @description Information of tags that should maintain HTML structure, style, class name, etc. (In use by "math" plugin) * When inserting "html" such as paste, it is executed on the "html" to be inserted. (core.cleanHTML) * Basic Editor Actions: * 1. All classes not starting with "__se__" or "se-" in the editor are removed. * 2. The style of all tags except the "span" tag is removed from the editor. * "managedTagsInfo" structure ex: * managedTagsInfo: { * query: '.__se__xxx, se-xxx' * map: { * '__se__xxx': method.bind(core), * 'se-xxx': method.bind(core), * } * } * @example * Define in the following return format in the "managedTagInfo" function of the plugin. * managedTagInfo() => { * return { * className: 'string', // Class name to identify the tag. ("__se__xxx", "se-xxx") * // Change the html of the "element". ("element" is the element found with "className".) * // "method" is executed by binding "core". * method: function (element) { * // this === core * element.innerHTML = // (rendered html); * } * } * } */ managedTagsInfo: null, /** * @description cashing: options.charCounterType === 'byte-html' * @private */ _charTypeHTML: false, /** * @description Array of "checkFileInfo" functions with the core bound * (Plugins with "checkFileInfo" and "resetFileInfo" methods) * "fileInfoPlugins" runs the "add" method when creating the editor. * "checkFileInfo" method is always call just before the "change" event. * @private */ _fileInfoPluginsCheck: null, /** * @description Array of "resetFileInfo" functions with the core bound * (Plugins with "checkFileInfo" and "resetFileInfo" methods) * "checkFileInfo" method is always call just before the "functions.setOptions" method. * @private */ _fileInfoPluginsReset: null, /** * @description Variables for file component management * @private */ _fileManager: { tags: null, regExp: null, queryString: null, pluginRegExp: null, pluginMap: null }, /** * @description Elements that need to change text or className for each selection change * After creating the editor, "activePlugins" are added. * @property {Element} STRONG bold button * @property {Element} U 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 * @property {Element} INDENT indent button */ commandMap: {}, /** * @description CSS properties related to style tags * @private */ _commandMapStyles: { STRONG: ['font-weight'], U: ['text-decoration'], EM: ['font-style'], DEL: ['text-decoration'] }, /** * @description Style button related to edit area * @property {Element} fullScreen fullScreen button element * @property {Element} showBlocks showBlocks button element * @property {Element} codeView codeView button element * @private */ _styleCommandMap: null, /** * @private */ _cleanStyleRegExp: { div: new _w.RegExp('\\s*[^-a-zA-Z](.+)\\s*:[^;]+(?!;)*', 'ig'), span: new _w.RegExp('\\s*[^-a-zA-Z](font-family|font-size|color|background-color)\\s*:[^;]+(?!;)*', 'ig'), format: new _w.RegExp('\\s*[^-a-zA-Z](text-align|margin-left|margin-right|width|height|line-height)\\s*:[^;]+(?!;)*', 'ig'), fontSizeUnit: new _w.RegExp('\\d+' + options.fontSizeUnit + '$', 'i'), }, /** * @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 (2) * @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: { isChanged: false, isCodeView: false, isFullScreen: false, innerHeight_fullScreen: 0, resizeClientY: 0, tabSize: 4, codeIndent: 2, minResizingSize: util.getNumber((context.element.wysiwygFrame.style.minHeight || '65'), 0), currentNodes: [], currentNodesMap: [], _range: null, _selectionNode: null, _originCssText: context.element.topArea.style.cssText, _bodyOverflow: '', _editorAreaOriginCssText: '', _wysiwygOriginCssText: '', _codeOriginCssText: '', _fullScreenAttrs: {sticky: false, balloon: false, inline: false}, _lineBreakComp: null, _lineBreakDir: '' }, /** * @description Temp variable for set line attrs * @private */ _formatAttrsTemp: null, /** * @description Save the current buttons states to "allCommandButtons" object * @private */ _saveButtonStates: function () { if (!this.allCommandButtons) this.allCommandButtons = {}; const currentButtons = this.context.element._buttonTray.querySelectorAll('.se-menu-list button[data-display]'); for (let i = 0, element, command; i < currentButtons.length; i++) { element = currentButtons[i]; command = element.getAttribute('data-command'); this.allCommandButtons[command] = element; } }, /** * @description Recover the current buttons states from "allCommandButtons" object * @private */ _recoverButtonStates: function () { if (this.allCommandButtons) { const currentButtons = this.context.element._buttonTray.querySelectorAll('.se-menu-list button[data-display]'); for (let i = 0, button, command, oldButton; i < currentButtons.length; i++) { button = currentButtons[i]; command = button.getAttribute('data-command'); oldButton = this.allCommandButtons[command]; if (oldButton) { button.parentElement.replaceChild(oldButton, button); if (this.context.tool[command]) this.context.tool[command] = oldButton; } } } }, /** * @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 * @param {Element|null} _target Plugin target button (This is not necessary if you have a button list when creating the editor) */ callPlugin: function (pluginName, callBackFunction, _target) { _target = _target || pluginCallButtons[pluginName]; 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, _target); this.initPlugins[pluginName] = true; } else if (typeof this._targetPlugins[pluginName] === 'object' && !!_target) { this.initMenuTarget(pluginName, _target, this._targetPlugins[pluginName]); } if (this.plugins[pluginName].active && !this.commandMap[pluginName] && !!_target) { this.commandMap[pluginName] = _target; this.activePlugins.push(pluginName); } 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 (!this.initPlugins[moduleName]) { this.initPlugins[moduleName] = true; if (typeof this.plugins[moduleName].add === 'function') this.plugins[moduleName].add(this); } } }, /** * @description Gets the current editor-relative scroll offset. * @returns {Object} {top, left} */ getGlobalScrollOffset: function () { let t = 0, l = 0; let el = context.element.topArea; while (el) { t += el.scrollTop; l += el.scrollLeft; el = el.parentElement; } el = this._shadowRoot ? this._shadowRoot.host : null; while (el) { t += el.scrollTop; l += el.scrollLeft; el = el.parentElement; } return { top: t, left: l }; }, /** * @description Method for managing submenu element. * You must add the "submenu" element using the this method at custom plugin. * @param {String} pluginName Plugin name * @param {Element|null} target Target button * @param {Element} menu Submenu element */ initMenuTarget: function (pluginName, target, menu) { if (!target) { this._targetPlugins[pluginName] = menu; } else { context.element._menuTray.appendChild(menu); this._targetPlugins[pluginName] = true; this._menuTray[target.getAttribute('data-command')] = menu; } }, /** * @description Enable submenu * @param {Element} element Submenu's button element to call */ submenuOn: function (element) { if (this._bindedSubmenuOff) this._bindedSubmenuOff(); if (this._bindControllersOff) this.controllersOff(); const submenuName = this._submenuName = element.getAttribute('data-command'); const menu = this.submenu = this._menuTray[submenuName]; this.submenuActiveButton = element; this._setMenuPosition(element, menu); this._bindedSubmenuOff = this.submenuOff.bind(this); this.addDocEvent('mousedown', this._bindedSubmenuOff, false); if (this.plugins[submenuName].on) this.plugins[submenuName].on.call(this); this._antiBlur = true; }, /** * @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._antiBlur = false; }, /** * @description Disable more layer */ moreLayerOff: function() { if (this._moreLayerActiveButton) { const layer = context.element.toolbar.querySelector('.' + this._moreLayerActiveButton.getAttribute('data-command')); layer.style.display = 'none'; util.removeClass(this._moreLayerActiveButton, 'on'); this._moreLayerActiveButton = null; } }, /** * @description Enable container * @param {Element} element Container's button element to call */ containerOn: function (element) { if (this._bindedContainerOff) this._bindedContainerOff(); const containerName = this._containerName = element.getAttribute('data-command'); const menu = this.container = this._menuTray[containerName]; this.containerActiveButton = element; this._setMenuPosition(element, menu); this._bindedContainerOff = this.containerOff.bind(this); this.addDocEvent('mousedown', this._bindedContainerOff, false); if (this.plugins[containerName].on) this.plugins[containerName].on.call(this); this._antiBlur = true; }, /** * @description Disable container */ containerOff: function () { this.removeDocEvent('mousedown', this._bindedContainerOff); this._bindedContainerOff = null; if (this.container) { this._containerName = ''; this.container.style.display = 'none'; this.container = null; util.removeClass(this.containerActiveButton, 'on'); this.containerActiveButton = null; this._notHideToolbar = false; } this._antiBlur = false; }, /** * @description Set the menu position. (submenu, container) * @param {*} element Button element * @param {*} menu Menu element * @private */ _setMenuPosition: function (element, menu) { menu.style.visibility = 'hidden'; menu.style.display = 'block'; menu.style.height = ''; util.addClass(element, 'on'); const toolbar = this.context.element.toolbar; const toolbarW = toolbar.offsetWidth; const toolbarOffset = event._getEditorOffsets(context.element.toolbar); const menuW = menu.offsetWidth; const l = element.parentElement.offsetLeft + 3; // rtl if (options.rtl) { const elementW = element.offsetWidth; const rtlW = menuW > elementW ? menuW - elementW : 0; const rtlL = rtlW > 0 ? 0 : elementW - menuW; menu.style.left = (l - rtlW + rtlL) + 'px'; if (toolbarOffset.left > event._getEditorOffsets(menu).left) { menu.style.left = '0px'; } } else { const overLeft = toolbarW <= menuW ? 0 : toolbarW - (l + menuW); if (overLeft < 0) menu.style.left = (l + overLeft) + 'px'; else menu.style.left = l + 'px'; } // get element top let t = 0; let offsetEl = element; while (offsetEl && offsetEl !== toolbar) { t += offsetEl.offsetTop; offsetEl = offsetEl.offsetParent; } const bt = t; if (this._isBalloon) { t += toolbar.offsetTop + element.offsetHeight; } else { t -= element.offsetHeight; } // set menu position const toolbarTop = toolbarOffset.top; const menuHeight = menu.offsetHeight; const scrollTop = this.getGlobalScrollOffset().top; const menuHeight_bottom = _w.innerHeight - (toolbarTop - scrollTop + bt + element.parentElement.offsetHeight); if (menuHeight_bottom < menuHeight) { let menuTop = -1 * (menuHeight - bt + 3); const insTop = toolbarTop - scrollTop + menuTop; const menuHeight_top = menuHeight + (insTop < 0 ? insTop : 0); if (menuHeight_top > menuHeight_bottom) { menu.style.height = menuHeight_top + 'px'; menuTop = -1 * (menuHeight_top - bt + 3); } else { menu.style.height = menuHeight_bottom + 'px'; menuTop = bt + element.parentElement.offsetHeight; } menu.style.top = menuTop + 'px'; } else { menu.style.top = (bt + element.parentElement.offsetHeight) + 'px'; } menu.style.visibility = ''; }, /** * @description Show controller at editor area (controller elements, function, "controller target element(@Required)", "controller name(@Required)", etc..) * @param {*} arguments controller elements, functions.. */ controllersOn: function () { if (this._bindControllersOff) this._bindControllersOff(); this.controllerArray = []; for (let i = 0, arg; i < arguments.length; i++) { arg = arguments[i]; if (!arg) continue; if (typeof arg === 'string') { this.currentControllerName = arg; continue; } if (typeof arg === 'function') { this.controllerArray.push(arg); continue; } if (!util.hasClass(arg, 'se-controller')) { this.currentControllerTarget = arg; this.currentFileComponentInfo = this.getFileComponent(arg); continue; } if (arg.style) { arg.style.display = 'block'; if (this._shadowRoot && this._shadowRootControllerEventTarget.indexOf(arg) === -1) { arg.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); }); this._shadowRootControllerEventTarget.push(arg); } } this.controllerArray.push(arg); } this._bindControllersOff = this.controllersOff.bind(this); this.addDocEvent('mousedown', this._bindControllersOff, false); this.addDocEvent('keydown', this._bindControllersOff, false); this._antiBlur = true; if (typeof functions.showController === 'function') functions.showController(this.currentControllerName, this.controllerArray, this); }, /** * @description Hide controller at editor area (link button, image resize button..) * @param {KeyboardEvent|MouseEvent|null} e Event object when called from mousedown and keydown events registered in "core.controllersOn" */ controllersOff: function (e) { this._lineBreaker.style.display = 'none'; const len = this.controllerArray.length; if (e && e.target && len > 0) { for (let i = 0; i < len; i++) { if (typeof this.controllerArray[i].contains === 'function' && this.controllerArray[i].contains(e.target)) return; } } if (this._fileManager.pluginRegExp.test(this.currentControllerName) && e && e.type === 'keydown' && e.keyCode !== 27) return; context.element.lineBreaker_t.style.display = context.element.lineBreaker_b.style.display = 'none'; this._variable._lineBreakComp = null; this.currentControllerName = ''; this.currentControllerTarget = null; this.currentFileComponentInfo = null; this.effectNode = null; if (!this._bindControllersOff) return; this.removeDocEvent('mousedown', this._bindControllersOff); this.removeDocEvent('keydown', this._bindControllersOff); this._bindControllersOff = null; 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 = []; } this._antiBlur = false; }, /** * @description Specify the position of the controller. * @param {Element} controller Controller element. * @param {Element} referEl Element that is the basis of the controller's position. * @param {String} position Type of position ("top" | "bottom") * When using the "top" position, there should not be an arrow on the controller. * When using the "bottom" position there should be an arrow on the controller. * @param {Object} addOffset These are the left and top values that need to be added specially. * This argument is required. - {left: 0, top: 0} * Please enter the value based on ltr mode. * Calculated automatically in rtl mode. */ setControllerPosition: function (controller, referEl, position, addOffset) { if (options.rtl) addOffset.left *= -1; const offset = util.getOffset(referEl, context.element.wysiwygFrame); controller.style.visibility = 'hidden'; controller.style.display = 'block'; // Height value of the arrow element is 11px const topMargin = position === 'top' ? -(controller.offsetHeight + 2) : (referEl.offsetHeight + 12); controller.style.top = (offset.top + topMargin + addOffset.top) + 'px'; const l = offset.left - context.element.wysiwygFrame.scrollLeft + addOffset.left; const controllerW = controller.offsetWidth; const referElW = referEl.offsetWidth; const allow = util.hasClass(controller.firstElementChild, 'se-arrow') ? controller.firstElementChild : null; // rtl (Width value of the arrow element is 22px) if (options.rtl) { const rtlW = (controllerW > referElW) ? controllerW - referElW : 0; const rtlL = rtlW > 0 ? 0 : referElW - controllerW; controller.style.left = (l - rtlW + rtlL) + 'px'; if (rtlW > 0) { if (allow) allow.style.left = ((controllerW - 14 < 10 + rtlW) ? (controllerW - 14) : (10 + rtlW)) + 'px'; } const overSize = context.element.wysiwygFrame.offsetLeft - controller.offsetLeft; if (overSize > 0) { controller.style.left = '0px'; if (allow) allow.style.left = overSize + 'px'; } } else { controller.style.left = l + 'px'; const overSize = context.element.wysiwygFrame.offsetWidth - (controller.offsetLeft + controllerW); if (overSize < 0) { controller.style.left = (controller.offsetLeft + overSize) + 'px'; if (allow) allow.style.left = (20 - overSize) + 'px'; } else { if (allow) allow.style.left = '20px'; } } controller.style.visibility = ''; }, /** * @description javascript execCommand * @param {String} command javascript execCommand function property * @param {Boolean|undefined} showDefaultUI javascript execCommand function property * @param {String|undefined} 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 using "native focus function" */ nativeFocus: function () { this.__focus(); this._editorRange(); }, /** * @description Focus method * @private */ __focus: function () { const caption = util.getParentElement(this.getSelectionNode(), 'figcaption'); if (caption) { caption.focus(); } else { context.element.wysiwyg.focus(); } }, /** * @description Focus to wysiwyg area */ focus: function () { if (context.element.wysiwygFrame.style.display === 'none') return; if (options.iframe) { this.nativeFocus(); } else { try { const range = this.getRange(); if (range.startContainer === range.endContainer && util.isWysiwygDiv(range.startContainer)) { const currentNode = range.commonAncestorContainer.children[range.startOffset]; if (!util.isFormatElement(currentNode) && !util.isComponent(currentNode)) { const format = util.createElement(options.defaultTag); const br = util.createElement('BR'); format.appendChild(br); context.element.wysiwyg.insertBefore(format, currentNode); this.setRange(br, 0, br, 0); return; } } this.setRange(range.startContainer, range.startOffset, range.endContainer, range.endOffset); } catch (e) { this.nativeFocus(); } } event._applyTagEffects(); if (this._isBalloon) event._toggleToolbarBalloon(); }, /** * @description If "focusEl" is a component, then that component is selected; if it is a format element, the last text is selected * If "focusEdge" is null, then selected last element * @param {Element|null} focusEl Focus element */ focusEdge: function (focusEl) { if (!focusEl) focusEl = context.element.wysiwyg.lastElementChild; const fileComponentInfo = this.getFileComponent(focusEl); if (fileComponentInfo) { this.selectComponent(fileComponentInfo.target, fileComponentInfo.pluginName); } else if (focusEl) { focusEl = util.getChildElement(focusEl, function (current) { return current.childNodes.length === 0 || current.nodeType === 3; }, true); if (!focusEl) this.nativeFocus(); else this.setRange(focusEl, focusEl.textContent.length, focusEl, focusEl.textContent.length); } else { this.focus(); } }, /** * @description Focusout to wysiwyg area (.blur()) */ blur: function () { if (options.iframe) { context.element.wysiwygFrame.blur(); } else { context.element.wysiwyg.blur(); } }, /** * @description Set current editor's range object and return. * @param {Node} startCon The startContainer property of the selection object. * @param {Number} startOff The startOffset property of the selection object. * @param {Node} endCon The endContainer property of the selection object. * @param {Number} endOff The endOffset property of the selection object. * @returns {Object} Range 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; if (util.isFormatElement(startCon)) { startCon = startCon.childNodes[startOff] || startCon.childNodes[startOff - 1] || startCon; startOff = startOff > 0 ? startCon.nodeType === 1 ? 1 : startCon.textContent ? startCon.textContent.length : 0 : 0; } if (util.isFormatElement(endCon)) { endCon = endCon.childNodes[endOff] || endCon.childNodes[endOff - 1] || endCon; endOff = endOff > 0 ? endCon.nodeType === 1 ? 1 : endCon.textContent ? endCon.textContent.length : 0 : 0; } const range = this._wd.createRange(); try { range.setStart(startCon, startOff); range.setEnd(endCon, endOff); } catch (error) { console.warn('[SUNEDITOR.core.focus.error] ' + error); this.nativeFocus(); return; } const selection = this.getSelection(); if (selection.removeAllRanges) { selection.removeAllRanges(); } selection.addRange(range); this._rangeInfo(range, this.getSelection()); if (options.iframe) this.__focus(); return range; }, /** * @description Remove range object and button effect */ removeRange: function () { this._variable._range = null; this._variable._selectionNode = null; if (this.hasFocus) this.getSelection().removeAllRanges(); this._setKeyEffect([]); }, /** * @description Get current editor's range object * @returns {Object} */ getRange: function () { const range = this._variable._range || this._createDefaultRange(); const selection = this.getSelection(); if (range.collapsed === selection.isCollapsed || !context.element.wysiwyg.contains(selection.focusNode)) return range; if (selection.rangeCount > 0) { this._variable._range = selection.getRangeAt(0); return this._variable._range; } else { const sc = selection.anchorNode, ec = selection.focusNode, so = selection.anchorOffset, eo = selection.focusOffset; const compareValue = util.compareElements(sc, ec); const rightDir = compareValue.ancestor && (compareValue.result === 0 ? so <= eo : compareValue.result > 1 ? true : false); return this.setRange( rightDir ? sc : ec, rightDir ? so : eo, rightDir ? ec : sc, rightDir ? eo : so ); } }, /** * @description If the "range" object is a non-editable area, add a line at the top of the editor and update the "range" object. * Returns a new "range" or argument "range". * @param {Object} range core.getRange() * @param {Element|null} container If there is "container" argument, it creates a line in front of the container. * @returns {Object} range */ getRange_addLine: function (range, container) { if (this._selectionVoid(range)) { const wysiwyg = context.element.wysiwyg; const op = util.createElement(options.defaultTag); op.innerHTML = '<br>'; wysiwyg.insertBefore(op, container && container !== wysiwyg ? container.nextElementSibling : wysiwyg.firstElementChild); this.setRange(op.firstElementChild, 0, op.firstElementChild, 1); range = this._variable._range; } return range; }, /** * @description Get window selection obejct * @returns {Object} */ getSelection: function () { const selection = this._shadowRoot && this._shadowRoot.getSelection ? this._shadowRoot.getSelection() : this._ww.getSelection(); if (!this._variable._range && !context.element.wysiwyg.contains(selection.focusNode)) { selection.removeAllRanges(); selection.addRange(this._createDefaultRange()); } return selection; }, /** * @description Get current select node * @returns {Node} */ getSelectionNode: function () { if (!context.element.wysiwyg.contains(this._variable._selectionNode)) this._editorRange(); if (!this._variable._selectionNode) { const selectionNode = util.getChildElement(context.element.wysiwyg.firstChild, function (current) { return current.childNodes.length === 0 || current.nodeType === 3; }, false); if (!selectionNode) { this._editorRange(); } else { this._variable._selectionNode = selectionNode; return selectionNode; } } return this._variable._selectionNode; }, /** * @description Saving the range object and the currently selected node of editor * @private */ _editorRange: function () { const activeEl = this._wd.activeElement; if (util.isInputElement(activeEl)) { this._variable._selectionNode = activeEl; return activeEl; } const selection = this.getSelection(); if (!selection) return null; let range = null; if (selection.rangeCount > 0) { range = selection.getRangeAt(0); } else { range = this._createDefaultRange(); } this._rangeInfo(range, selection); }, /** * @description Set "range" and "selection" info. * @param {Object} range range object. * @param {Object} selection selection object. */ _rangeInfo: function (range, selection) { let selectionNode = null; this._variable._range = range; if (range.collapsed) { if (util.isWysiwygDiv(range.commonAncestorContainer)) selectionNode = range.commonAncestorContainer.children[range.startOffset] || range.commonAncestorContainer; else selectionNode = range.commonAncestorContainer; } else { // extentNode - Firefox legacy 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 () { const wysiwyg = context.element.wysiwyg; const range = this._wd.createRange(); let firstFormat = wysiwyg.firstElementChild; let focusEl = null; if (!firstFormat) { firstFormat = util.createElement(options.defaultTag); focusEl = util.createElement('BR'); firstFormat.appendChild(focusEl); wysiwyg.appendChild(firstFormat); } else { focusEl = firstFormat.firstChild; if (!focusEl) { focusEl = util.createElement('BR'); firstFormat.appendChild(focusEl); } } range.setStart(focusEl, 0); range.setEnd(focusEl, 0); return range; }, /** * @description Returns true if there is no valid "selection". * @param {Object} range core.getRange() * @returns {Object} range * @private */ _selectionVoid: function (range) { const comm = range.commonAncestorContainer; return (util.isWysiwygDiv(range.startContainer) && util.isWysiwygDiv(range.endContainer)) || /FIGURE/i.test(comm.nodeName) || this._fileManager.regExp.test(comm.nodeName) || util.isMediaComponent(comm); }, /** * @description Reset range object to text node selected status. * @returns {Boolean} Returns false if there is no valid selection. * @private */ _resetRangeToTextNode: function () { const range = this.getRange(); if (this._selectionVoid(range)) return false; const collapsed = range.collapsed; let startCon = range.startContainer; let startOff = range.startOffset; let endCon = range.endContainer; let endOff = range.endOffset; let tempCon, tempOffset, tempChild; if (util.isFormatElement(startCon)) { if (!startCon.childNodes[startOff]) { startCon = startCon.lastChild || startCon; startOff = startCon.textContent.length; } else { startCon = startCon.childNodes[startOff] || startCon; startOff = 0; } while (startCon && startCon.nodeType === 1 && startCon.firstChild) { startCon = startCon.firstChild || startCon; startOff = 0; } } if (util.isFormatElement(endCon)) { endCon = endCon.childNodes[endOff] || endCon.lastChild || endCon; while (endCon && endCon.nodeType === 1 && endCon.lastChild) { endCon = endCon.lastChild; } endOff = collapsed ? 0 : endCon.textContent.length; } // startContainer tempCon = util.isWysiwygDiv(startCon) ? context.element.wysiwyg.firstChild : startCon; tempOffset = startOff; if (util.isBreak(tempCon