suneditor
Version:
Vanilla JavaScript WYSIWYG web editor (2.x legacy version, actively maintained)
1,306 lines (1,133 loc) • 437 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, 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