suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
1,041 lines (889 loc) • 36 kB
JavaScript
import KernelInjector from '../kernel/kernelInjector';
import { dom, unicode, numbers, env, converter } from '../../helper';
import { _DragHandle } from '../../modules/ui';
// event handlers
import { ButtonsHandler, OnClick_menuTray, OnClick_toolbar } from '../event/handlers/handler_toolbar';
import { OnMouseDown_wysiwyg, OnMouseUp_wysiwyg, OnClick_wysiwyg, OnMouseMove_wysiwyg, OnMouseLeave_wysiwyg } from '../event/handlers/handler_ww_mouse';
import { OnBeforeInput_wysiwyg, OnInput_wysiwyg } from '../event/handlers/handler_ww_input';
import { OnKeyDown_wysiwyg, OnKeyUp_wysiwyg } from '../event/handlers/handler_ww_key';
import { OnPaste_wysiwyg, OnCopy_wysiwyg, OnCut_wysiwyg } from '../event/handlers/handler_ww_clipboard';
import { OnDragOver_wysiwyg, OnDragEnd_wysiwyg, OnDrop_wysiwyg } from '../event/handlers/handler_ww_dragDrop';
// logic
import DefaultLineManager from '../event/support/defaultLineManager';
import SelectionState from '../event/support/selectionState';
const { _w, isMobile, isTouchDevice } = env;
/**
* @description Event orchestrator
*/
class EventOrchestrator extends KernelInjector {
#store;
#contextProvider;
#context;
#options;
#eventManager;
#toolbar;
#ui;
#menu;
/** @type {number} */
#balloonDelay = null;
/** @type {?SunEditor.Event.GlobalInfo} */
#close_move = null;
/** @type {?SunEditor.Event.GlobalInfo} */
#selectionSyncEvent = null;
/** @type {?SunEditor.Event.GlobalInfo} */
#resize_editor = null;
/**
* @constructor
* @param {SunEditor.Kernel} kernel
*/
constructor(kernel) {
super(kernel);
this.#store = this.$.store;
this.#contextProvider = this.$.contextProvider;
this.#context = this.$.context;
this.#options = this.$.options;
this.#eventManager = this.$.eventManager;
this.#toolbar = this.$.toolbar;
this.#ui = this.$.ui;
this.#menu = this.$.menu;
/**
* @description Old browsers: When there is no `e.isComposing` in the `keyup` event
* @type {boolean}
*/
this.isComposing = false;
/**
* @description An array of parent containers that can be scrolled (in descending order)
* @type {Array<Element>}
*/
this.scrollparents = [];
// logic services (internal - receive EventManager reference)
this.defaultLineManager = new DefaultLineManager(this);
this.selectionState = new SelectionState(this);
// internal members
/** @internal @type {boolean} */
this._onShortcutKey = false;
/** @internal @type {boolean} */
this._handledInBefore = false;
/** @internal @type {ResizeObserver} */
this._wwFrameObserver = null;
/** @internal @type {ResizeObserver} */
this._toolbarObserver = null;
/** @internal @type {?Element} */
this._lineBreakComp = null;
/** @internal @type {?Object<string, *>} */
this._formatAttrsTemp = null;
/** @internal @type {number} */
this._resizeClientY = 0;
/** @internal @type {Array<Node>} */
this.__cacheStyleNodes = [];
this.__onDownEv = null;
// input plugins
/** @internal @type {boolean} */
this._inputFocus = false;
/** @internal @type {?Object<string, *>} */
this.__inputPlugin = null;
/** @internal @type {?SunEditor.Event.Info=} */
this.__inputBlurEvent = null;
/** @internal @type {?SunEditor.Event.Info=} */
this.__inputKeyEvent = null;
// viewport
/** @type {number|void} */
this.__retainTimer = null;
/** @type {Document} */
this.__eventDoc = null;
/** @type {string} */
this.__secopy = null;
/** @type {HTMLInputElement} */
this.__focusTemp = this.#contextProvider.carrierWrapper.querySelector('.__se__focus__temp__');
}
/**
* @description Activates the corresponding button with the tags information of the current cursor position,
* - such as `bold`, `underline`, etc., and executes the `active` method of the plugins.
* @param {?Node} [selectionNode] selectionNode
* @returns {Node|undefined} selectionNode
*/
applyTagEffect(selectionNode) {
return this.selectionState.update(selectionNode);
}
/**
* @internal
* @description Show toolbar-balloon with delay.
*/
_showToolbarBalloonDelay() {
if (this.#balloonDelay) {
_w.clearTimeout(this.#balloonDelay);
}
this.#balloonDelay = _w.setTimeout(() => {
_w.clearTimeout(this.#balloonDelay);
this.#balloonDelay = null;
if (this.#store.mode.isSubBalloon) this.$.subToolbar._showBalloon();
else this.#toolbar._showBalloon();
}, 250);
}
/**
* @description Toggle toolbar-balloon with delay (debounced for selectionchange).
*/
#toggleToolbarBalloonDelay() {
if (this.#balloonDelay) {
_w.clearTimeout(this.#balloonDelay);
}
this.#balloonDelay = _w.setTimeout(() => {
_w.clearTimeout(this.#balloonDelay);
this.#balloonDelay = null;
this._toggleToolbarBalloon();
}, 250);
}
/**
* @internal
* @description Show or hide the toolbar-balloon.
*/
_toggleToolbarBalloon() {
this.$.selection.init();
const range = this.$.selection.getRange();
const hasSubMode = this.#options.has('_subMode');
if (!(hasSubMode ? this.#store.mode.isSubBalloonAlways : this.#store.mode.isBalloonAlways) && range.collapsed) {
if (hasSubMode) this._hideToolbar_sub();
else this._hideToolbar();
} else {
if (hasSubMode) this.$.subToolbar._showBalloon(range);
else this.#toolbar._showBalloon(range);
}
}
/**
* @internal
* @description Hide the toolbar.
*/
_hideToolbar() {
if (!this.#ui.isPreventToolbarHide && !this.$.frameContext.get('isFullScreen')) {
this.#toolbar.hide();
}
}
/**
* @internal
* @description Hide the Sub-Toolbar.
*/
_hideToolbar_sub() {
if (this.$.subToolbar && !this.#ui.isPreventToolbarHide) {
this.$.subToolbar.hide();
}
}
/**
* @internal
* @description If there is no default format, add a `line` and move `selection`.
* @param {?string} formatName Format tag name (default: `P`)
*/
_setDefaultLine(formatName) {
return this.defaultLineManager.execute(formatName);
}
/**
* @internal
* @description Handles data transfer actions for `paste` and `drop` events.
* - It processes clipboard data, triggers relevant events, and inserts cleaned data into the editor.
* @param {"paste"|"drop"} type The type of event
* @param {Event} e The original event object
* @param {DataTransfer} clipboardData The clipboard data object
* @param {SunEditor.FrameContext} frameContext The frame context
* @returns {Promise<boolean>} Resolves to `false` if processing is complete, otherwise allows default behavior
*/
async _dataTransferAction(type, e, clipboardData, frameContext) {
try {
this.#ui.showLoading();
await this.#setClipboardData(type, e, clipboardData, frameContext);
e.preventDefault();
e.stopPropagation();
return false;
} catch (err) {
console.warn('[SUNEDITOR.paste.error]', err);
} finally {
this.#ui.hideLoading();
}
}
/**
* @internal
* @description Processes clipboard data for `paste` and `drop` events, handling text and HTML cleanup.
* - Supports specific handling for content from Microsoft Office applications.
* @param {"paste"|"drop"} type The type of event
* @param {Event} e The original event object
* @param {DataTransfer} clipboardData The clipboard data object
* @param {SunEditor.FrameContext} frameContext The frame context
* @returns {Promise<boolean>} Resolves to `false` if processing is complete, otherwise allows default behavior
*/
async #setClipboardData(type, e, clipboardData, frameContext) {
e.preventDefault();
e.stopPropagation();
let plainText = clipboardData.getData('text/plain');
let cleanData = clipboardData.getData('text/html');
const onlyText = !cleanData;
// SE copy data
const SEData = this.__secopy === plainText;
// MS word, OneNode, Excel
const MSData = /class=["']*Mso(Normal|List)/i.test(cleanData) || /content=["']*Word.Document/i.test(cleanData) || /content=["']*OneNote.File/i.test(cleanData) || /content=["']*Excel.Sheet/i.test(cleanData);
// from
const from = SEData ? 'SE' : MSData ? 'MS' : '';
if (onlyText) {
cleanData = converter.htmlToEntity(plainText).replace(/\n/g, '<br>');
} else {
cleanData = cleanData.replace(/^<html>\r?\n?<body>\r?\n?\x3C!--StartFragment-->|\x3C!--EndFragment-->\r?\n?<\/body>\r?\n?<\/html>$/g, '');
if (MSData) {
cleanData = cleanData.replace(/\n/g, ' ');
plainText = plainText.replace(/\n/g, ' ');
}
}
if (!SEData) {
const autoLinkify = this.#options.get('autoLinkify');
if (autoLinkify) {
const domParser = new DOMParser().parseFromString(cleanData, 'text/html');
dom.query.getListChildNodes(domParser.body, converter.textToAnchor, null);
cleanData = domParser.body.innerHTML;
}
}
if (!onlyText) {
cleanData = this.$.html.clean(cleanData, { forceFormat: false, whitelist: null, blacklist: null });
}
const maxCharCount = this.$.char.test(this.$.frameOptions.get('charCounter_type') === 'byte-html' ? cleanData : plainText, false);
// user event - paste
if (type === 'paste') {
const value = await this.#eventManager.triggerEvent('onPaste', { frameContext, event: e, data: cleanData, maxCharCount, from });
if (value === false) {
return false;
} else if (typeof value === 'string') {
if (!value) return false;
cleanData = value;
}
}
// user event - drop
if (type === 'drop') {
const value = await this.#eventManager.triggerEvent('onDrop', { frameContext, event: e, data: cleanData, maxCharCount, from });
if (value === false) {
return false;
} else if (typeof value === 'string') {
if (!value) return false;
cleanData = value;
}
}
// files
const files = clipboardData.files;
if (files.length > 0 && !MSData) {
for (let i = 0, len = files.length; i < len; i++) {
await this._callPluginEventAsync('onFilePasteAndDrop', { frameContext, event: e, file: files[i] });
}
return false;
}
if (!maxCharCount) {
return false;
}
if (cleanData) {
const domParser = new DOMParser().parseFromString(cleanData, 'text/html');
if ((await this._callPluginEventAsync('onPaste', { frameContext, event: e, data: cleanData, doc: domParser })) !== false) {
this.$.html.insert(cleanData, { selectInserted: false, skipCharCount: true, skipCleaning: true });
}
// document type
if (frameContext.has('documentType_use_header')) {
frameContext.get('documentType').reHeader();
}
return false;
}
}
/**
* @internal
* @description Registers common UI events such as toolbar and menu interactions.
* - Adds event listeners for various UI elements, sets up observers, and configures window events.
*/
_addCommonEvents() {
const buttonsHandler = ButtonsHandler.bind(this);
const toolbarHandler = OnClick_toolbar.bind(this);
/** menu event */
this.#eventManager.addEvent(this.#context.get('menuTray'), 'mousedown', buttonsHandler, false);
this.#eventManager.addEvent(this.#context.get('menuTray'), 'click', OnClick_menuTray.bind(this), true);
/** toolbar event */
this.#eventManager.addEvent(this.#context.get('toolbar_main'), 'mousedown', buttonsHandler, false);
this.#eventManager.addEvent(this.#context.get('toolbar_main'), 'click', toolbarHandler, false);
// subToolbar
if (this.#options.has('_subMode')) {
this.#eventManager.addEvent(this.#context.get('toolbar_sub_main'), 'mousedown', buttonsHandler, false);
this.#eventManager.addEvent(this.#context.get('toolbar_sub_main'), 'click', toolbarHandler, false);
}
/** set response toolbar */
this.#toolbar._setResponsive();
/** observer */
if (env.isResizeObserverSupported) {
this._toolbarObserver = new ResizeObserver(() => {
// Defer to avoid ResizeObserver loop limit — layout must settle before recalculating responsive buttons
_w.setTimeout(() => {
this.#toolbar.resetResponsiveToolbar();
}, 0);
});
this._wwFrameObserver = new ResizeObserver((entries) => {
// Defer to avoid ResizeObserver loop limit — measure final height after reflow completes
_w.setTimeout(() => {
entries.forEach((e) => {
this.#ui._emitResizeEvent(this.$.frameRoots.get(e.target.getAttribute('data-root-key')), -1, e);
});
if (this.#store.mode.isInline && this.#store.mode.isBottom) {
this.#toolbar._resetSticky();
}
}, 0);
});
}
/** modal outside click */
if (this.#options.get('closeModalOutsideClick')) {
this.#eventManager.addEvent(
this.#contextProvider.carrierWrapper.querySelector('.se-modal .se-modal-inner'),
'click',
(e) => {
if (e.target === this.#contextProvider.carrierWrapper.querySelector('.se-modal .se-modal-inner')) {
this.#ui.offCurrentModal();
}
},
false,
);
}
/** global event */
this.#eventManager.addEvent(_w, 'resize', this.#OnResize_window.bind(this), false);
this.#eventManager.addEvent(_w.visualViewport, 'resize', this.#OnResize_viewport.bind(this), false);
this.#eventManager.addEvent(_w, 'scroll', this.#OnScroll_window.bind(this), false);
if (isTouchDevice) {
this.#eventManager.addEvent(_w.visualViewport, 'scroll', this.#OnMobileScroll_viewport.bind(this), false);
}
}
/**
* @internal
* @description Registers event listeners for the editor's frame, including text `input`, selection, and UI interactions.
* - Handles events inside an `iframe` or within the standard wysiwyg editor.
* @param {SunEditor.FrameContext} fc The frame context object
*/
_addFrameEvents(fc) {
const isIframe = fc.get('options').get('iframe');
const eventWysiwyg = isIframe ? fc.get('_ww') : fc.get('wysiwyg');
fc.set('eventWysiwyg', /** @type {SunEditor.EventWysiwyg} */ (eventWysiwyg));
const codeArea = fc.get('code');
const dragCursor = this.#contextProvider.carrierWrapper.querySelector('.se-drag-cursor');
/** editor area */
const wwMouseMove = OnMouseMove_wysiwyg.bind(this, fc);
this.#eventManager.addEvent(eventWysiwyg, 'mousemove', wwMouseMove, false);
this.#eventManager.addEvent(eventWysiwyg, 'mouseleave', OnMouseLeave_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(eventWysiwyg, 'mousedown', OnMouseDown_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(eventWysiwyg, 'mouseup', OnMouseUp_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(eventWysiwyg, 'click', OnClick_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(eventWysiwyg, 'beforeinput', OnBeforeInput_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(eventWysiwyg, 'input', OnInput_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(eventWysiwyg, 'keydown', OnKeyDown_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(eventWysiwyg, 'keyup', OnKeyUp_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(eventWysiwyg, 'paste', OnPaste_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(eventWysiwyg, 'copy', OnCopy_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(eventWysiwyg, 'cut', OnCut_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(
eventWysiwyg,
'dragover',
OnDragOver_wysiwyg.bind(this, fc, dragCursor, isIframe ? this.$.frameContext.get('topArea') : null, !this.#options.get('toolbar_container') && !this.#store.mode.isBalloon && !this.#store.mode.isInline),
false,
);
this.#eventManager.addEvent(eventWysiwyg, 'dragend', OnDragEnd_wysiwyg.bind(this, dragCursor), false);
this.#eventManager.addEvent(eventWysiwyg, 'drop', OnDrop_wysiwyg.bind(this, fc, dragCursor), false);
this.#eventManager.addEvent(eventWysiwyg, 'scroll', this.#OnScroll_wysiwyg.bind(this, fc, eventWysiwyg), { passive: true, capture: false });
this.#eventManager.addEvent(eventWysiwyg, 'focus', this.#OnFocus_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(eventWysiwyg, 'blur', this.#OnBlur_wysiwyg.bind(this, fc), false);
this.#eventManager.addEvent(codeArea, 'mousedown', this.#OnFocus_code.bind(this, fc), false);
/** drag handle */
const dragHandle = fc.get('wrapper').querySelector('.se-drag-handle');
this.#eventManager.addEvent(
dragHandle,
'wheel',
(event) => {
event.preventDefault();
this.$.component.deselect();
},
false,
);
/** line breaker */
this.#eventManager.addEvent(fc.get('lineBreaker_t'), 'pointerdown', this.#DisplayLineBreak.bind(this, 't'), false);
this.#eventManager.addEvent(fc.get('lineBreaker_b'), 'pointerdown', this.#DisplayLineBreak.bind(this, 'b'), false);
/** Events are registered mobile. */
if (isTouchDevice) {
this.#eventManager.addEvent(eventWysiwyg, 'touchstart', wwMouseMove, {
passive: true,
capture: false,
});
}
/** code view area auto line */
if (!this.#options.get('hasCodeMirror')) {
const codeNumbers = fc.get('codeNumbers');
const cvAuthHeight = this.$.viewer._codeViewAutoHeight.bind(this.$.viewer, fc.get('code'), codeNumbers, this.$.frameOptions.get('height') === 'auto');
this.#eventManager.addEvent(codeArea, 'keydown', cvAuthHeight, false);
this.#eventManager.addEvent(codeArea, 'keyup', cvAuthHeight, false);
this.#eventManager.addEvent(codeArea, 'paste', cvAuthHeight, false);
/** code view tab key */
if (!this.#options.get('tabDisable')) {
this.#eventManager.addEvent(codeArea, 'keydown', InsertTab, false);
}
/** code view numbers */
if (codeNumbers) this.#eventManager.addEvent(codeArea, 'scroll', this.$.viewer._scrollLineNumbers.bind(codeArea, codeNumbers), false);
}
/** markdown view area */
const markdownArea = fc.get('markdown');
if (markdownArea) {
this.#eventManager.addEvent(markdownArea, 'mousedown', this.#OnFocus_markdown.bind(this, fc), false);
const mdNumbers = fc.get('markdownNumbers');
const mdAutoHeight = this.$.viewer._markdownViewAutoHeight.bind(this.$.viewer, markdownArea, mdNumbers, this.$.frameOptions.get('height') === 'auto');
this.#eventManager.addEvent(markdownArea, 'keydown', mdAutoHeight, false);
this.#eventManager.addEvent(markdownArea, 'keyup', mdAutoHeight, false);
this.#eventManager.addEvent(markdownArea, 'paste', mdAutoHeight, false);
/** markdown view tab key */
if (!this.#options.get('tabDisable')) {
this.#eventManager.addEvent(markdownArea, 'keydown', InsertTab, false);
}
if (mdNumbers) this.#eventManager.addEvent(markdownArea, 'scroll', this.$.viewer._scrollMarkdownLineNumbers.bind(markdownArea, mdNumbers), false);
}
if (fc.has('statusbar')) this.__addStatusbarEvent(fc, fc.get('options'));
const OnScrollAbs = this.#OnScroll_Abs.bind(this);
const scrollParents = dom.query.getScrollParents(fc.get('originElement'));
for (const parent of scrollParents) {
this.scrollparents.push(parent);
this.#eventManager.addEvent(parent, 'scroll', OnScrollAbs, false);
}
/** focus temp (mobile) */
this.#eventManager.addEvent(this.__focusTemp, 'focus', (e) => e.preventDefault(), false);
/** document event */
if (this.__eventDoc !== fc.get('_wd')) {
this.__eventDoc = fc.get('_wd');
this.#eventManager.addEvent(this.__eventDoc, 'selectionchange', this.#OnSelectionchange_document.bind(this, this.__eventDoc), false);
}
}
/**
* @internal
* @description Adds event listeners for resizing the status bar if resizing is enabled.
* - If resizing is not enabled, applies a `se-resizing-none` class.
* @param {SunEditor.FrameContext} fc The frame context object
* @param {SunEditor.FrameOptions} fo The frame options object
*/
__addStatusbarEvent(fc, fo) {
if (/\d+/.test(fo.get('height')) && fo.get('statusbar_resizeEnable')) {
fo.set('__statusbarEvent', this.#eventManager.addEvent(fc.get('statusbar'), 'mousedown', this.#OnMouseDown_statusbar.bind(this), false));
} else {
dom.utils.addClass(fc.get('statusbar'), 'se-resizing-none');
}
}
/**
* @internal
* @description Removes all registered event listeners from the editor.
* - Disconnects observers and clears stored event references.
*/
_removeAllEvents() {
this.#eventManager._init();
if (this._wwFrameObserver) {
this._wwFrameObserver.disconnect();
this._wwFrameObserver = null;
}
if (this._toolbarObserver) {
this._toolbarObserver.disconnect();
this._toolbarObserver = null;
}
// clear timers
if (this.#balloonDelay) {
_w.clearTimeout(this.#balloonDelay);
this.#balloonDelay = null;
}
if (this.__retainTimer) {
_w.clearTimeout(this.__retainTimer);
this.__retainTimer = null;
}
// remove global events
this.#selectionSyncEvent &&= this.#eventManager.removeGlobalEvent(this.#selectionSyncEvent);
this.#resize_editor &&= this.#eventManager.removeGlobalEvent(this.#resize_editor);
this.#close_move &&= this.#eventManager.removeGlobalEvent(this.#close_move);
// clear cached references
this._formatAttrsTemp = null;
this.__cacheStyleNodes = null;
this.__inputPlugin = null;
this.__inputBlurEvent = null;
this.__inputKeyEvent = null;
this.__focusTemp = null;
this.__eventDoc = null;
this.__secopy = null;
this._lineBreakComp = null;
this.scrollparents = null;
}
/**
* @internal
* @description Synchronizes the selection state by resetting it on `mouseup`.
* - Ensures selection updates correctly across different interactions.
*/
_setSelectionSync() {
this.#eventManager.removeGlobalEvent(this.#selectionSyncEvent);
this.#selectionSyncEvent = this.#eventManager.addGlobalEvent('mouseup', () => {
this.$.selection.init();
this.#eventManager.removeGlobalEvent(this.#selectionSyncEvent);
});
}
/**
* @internal
* @description Retains the style nodes for formatting consistency when applying styles.
* - Preserves nested styling by cloning and restructuring the style nodes.
* @param {HTMLElement} formatEl The format element where styles should be retained
* @param {Array<Node>} _styleNodes The list of style nodes to retain
*/
_retainStyleNodes(formatEl, _styleNodes) {
const el = _styleNodes[0].cloneNode(false);
let n = el;
for (let i = 1, len = _styleNodes.length, t; i < len; i++) {
t = _styleNodes[i].cloneNode(false);
n.appendChild(t);
n = t;
}
const { parent, inner } = this.$.nodeTransform.createNestedNode(_styleNodes, null);
const zeroWidth = dom.utils.createTextNode(unicode.zeroWidthSpace);
inner.appendChild(zeroWidth);
formatEl.innerHTML = '';
formatEl.appendChild(parent);
this.$.selection.setRange(zeroWidth, 1, zeroWidth, 1);
}
/**
* @internal
* @description Clears retained style nodes by replacing content with a single `line` break.
* - Resets the selection to the start of the cleared element.
* @param {HTMLElement} formatEl The format element where styles should be cleared
*/
_clearRetainStyleNodes(formatEl) {
formatEl.innerHTML = '<br>';
this.$.selection.setRange(formatEl, 0, formatEl, 0);
}
/**
* @internal
* @description Calls a registered plugin event synchronously.
* @param {string} name The name of the plugin event
* @param {SunEditor.EventParams.PluginEvent} e The event payload
* @returns {boolean|undefined} Returns `false` if any handler stops the event
*/
_callPluginEvent(name, e) {
return this.$.pluginManager.emitEvent(name, e);
}
/**
* @internal
* @description Calls a registered plugin event asynchronously.
* @param {string} name The name of the plugin event
* @param {SunEditor.EventParams.PluginEvent} e The event payload
* @returns {Promise<boolean|undefined>} Returns `false` if any handler stops the event
*/
async _callPluginEventAsync(name, e) {
return await this.$.pluginManager.emitEventAsync(name, e);
}
/**
* @internal
* @description Removes input event listeners and resets input-related properties.
*/
__removeInput() {
this.#store.set('_preventBlur', false);
this._inputFocus = false;
this.__inputBlurEvent = this.#eventManager.removeEvent(this.__inputBlurEvent);
this.__inputKeyEvent = this.#eventManager.removeEvent(this.__inputKeyEvent);
this.__inputPlugin = null;
}
/**
* @internal
* @description Focus Event Postprocessing
* @param {SunEditor.FrameContext} frameContext - frame context object
* @param {FocusEvent} event - Focus event object
*/
__postFocusEvent(frameContext, event) {
if (this.#store.mode.isInline || this.#store.mode.isBalloonAlways) this.#toolbar.show();
if (this.#store.mode.isSubBalloonAlways) this.$.subToolbar.show();
// sticky
this.#toolbar._resetSticky();
// user event
this.#eventManager.triggerEvent('onFocus', { frameContext, event });
// plugin event
this._callPluginEvent('onFocus', { frameContext, event });
}
/**
* @internal
* @description Blur Event Postprocessing
* @param {SunEditor.FrameContext} frameContext - frame context object
* @param {FocusEvent} event - Focus event object
*/
__postBlurEvent(frameContext, event) {
if (this.#store.mode.isInline) {
// Defer hide — prevents race with deferred focus show (setTimeout in #OnFocus_wysiwyg)
_w.setTimeout(() => {
if (!this.#store.get('hasFocus')) {
this._hideToolbar();
}
}, 0);
} else if (this.#store.mode.isBalloon) {
this._hideToolbar();
}
if (this.#store.mode.isSubBalloon) this._hideToolbar_sub();
// user event
this.#eventManager.triggerEvent('onBlur', { frameContext, event });
// plugin event
this._callPluginEvent('onBlur', { frameContext, event });
}
/**
* @internal
* @description Records the current viewport size.
*/
__setViewportSize() {
this.#store.set('currentViewportHeight', numbers.get(_w.visualViewport.height, 0));
}
/**
* @description Handles the scrolling of the editor container.
* - Repositions open controllers if necessary.
*/
#scrollContainer() {
if (this.#menu.currentDropdownActiveButton && this.#menu.currentDropdown) {
this.#menu.__resetMenuPosition(this.#menu.currentDropdownActiveButton, this.#menu.currentDropdown);
}
this.#ui._repositionControllers();
}
/**
* @description Resets the frame status, adjusting toolbar and UI elements based on the current state.
* - Handles `inline` editor adjustments, fullscreen mode, and responsive toolbar updates.
*/
#resetFrameStatus() {
if (!env.isResizeObserverSupported) {
this.#toolbar.resetResponsiveToolbar();
if (this.#options.get('_subMode')) this.$.subToolbar.resetResponsiveToolbar();
}
const toolbar = this.#context.get('toolbar_main');
const isToolbarHidden = toolbar.style.display === 'none' || (this.#store.mode.isInline && !this.#toolbar.inlineToolbarAttr.isShow);
if (toolbar.offsetWidth === 0 && !isToolbarHidden) return;
const opendBrowser = this.#ui.opendBrowser;
if (opendBrowser && opendBrowser.area.style.display === 'block') {
opendBrowser.body.style.maxHeight = dom.utils.getClientSize().h - opendBrowser.header.offsetHeight - 50 + 'px';
}
if (this.#menu.currentDropdownActiveButton && this.#menu.currentDropdown) {
this.#menu.__resetMenuPosition(this.#menu.currentDropdownActiveButton, this.#menu.currentDropdown);
}
if (this.$.viewer._resetFullScreenHeight()) return;
const fc = this.$.frameContext;
if (fc.get('isCodeView') && this.#store.mode.isInline) {
this.#toolbar._showInline();
return;
}
this.#ui._iframeAutoHeight(fc);
if (this.#toolbar.isSticky) {
if (!this.#toolbar.isCSSSticky) {
this.#context.get('toolbar_main').style.width = fc.get('topArea').offsetWidth - 2 + 'px';
}
this.#toolbar._resetSticky();
}
}
/**
* @param {SunEditor.FrameContext} frameContext - frame context object
* @param {SunEditor.EventWysiwyg} eventWysiwyg - wysiwyg event object
* @param {Event} e - Event object
*/
#OnScroll_wysiwyg(frameContext, eventWysiwyg, e) {
this.#ui._syncScrollPosition(eventWysiwyg);
this.#scrollContainer();
// plugin event
this._callPluginEvent('onScroll', { frameContext, event: e });
// document type page
if (frameContext.has('documentType_use_page')) {
frameContext.get('documentType').scrollPage();
}
// user event
this.#eventManager.triggerEvent('onScroll', { frameContext, event: e });
}
/**
* @param {SunEditor.FrameContext} frameContext - frame context object
* @param {FocusEvent} e - Focus event object
*/
#OnFocus_wysiwyg(frameContext, e) {
if (this.$.selection.__iframeFocus || frameContext.get('isReadOnly') || frameContext.get('isDisabled')) {
e.preventDefault();
return false;
}
this.#store.set('hasFocus', true);
this.$.component.__prevent = false;
this.#eventManager.triggerEvent('onNativeFocus', { frameContext, event: e });
const rootKey = frameContext.get('key');
if (this._inputFocus) {
if (this.#store.mode.isInline) {
// Defer inline toolbar show — browser focus event fires before selection is finalized
_w.setTimeout(() => {
this.#toolbar._showInline();
}, 0);
}
return;
}
if ((this.#store.get('rootKey') === rootKey && this.#store.get('_preventBlur')) || this.#store.get('_preventFocus')) return;
this.#store.set('_preventFocus', true);
dom.utils.removeClass(this.$.commandDispatcher.targets.get('codeView'), 'active');
this.#ui._toggleCodeViewButtons(false);
this.$.facade.changeFrameContext(rootKey);
this.$.history.resetButtons(rootKey, null);
// Defer focus event emission — allow blur handler on the previous frame to complete first
_w.setTimeout(() => {
this.__postFocusEvent(frameContext, e);
}, 0);
}
/**
* @param {SunEditor.FrameContext} frameContext - frame context object
* @param {FocusEvent} e - Focus event object
*/
#OnBlur_wysiwyg(frameContext, e) {
if (frameContext.get('isCodeView') || frameContext.get('isReadOnly') || frameContext.get('isDisabled')) return;
this.#store.set('hasFocus', false);
this.#store.set('_lastSelectionNode', null);
this.#eventManager.triggerEvent('onNativeBlur', { frameContext, event: e });
if (this._inputFocus || this.#store.get('_preventBlur')) return;
this.#store.set('_preventFocus', false);
this.selectionState.reset();
this.#store.set('currentNodes', []);
this.#store.set('currentNodesMap', []);
this.#ui.offCurrentController();
this.#contextProvider.applyToRoots((root) => {
if (root.get('navigation')) root.get('navigation').textContent = '';
});
this.$.history.check(frameContext.get('key'), this.#store.get('_range'));
this.__postBlurEvent(frameContext, e);
}
/**
* @param {MouseEvent} e - Event object
*/
#OnMouseDown_statusbar(e) {
e.stopPropagation();
this._resizeClientY = e.clientY;
this.#ui.enableBackWrapper('ns-resize');
this.#resize_editor = this.#eventManager.addGlobalEvent('mousemove', this.#__resizeEditor.bind(this));
this.#close_move = this.#eventManager.addGlobalEvent('mouseup', this.#__closeMove.bind(this));
}
/**
* @param {MouseEvent} e - Event object
*/
#__resizeEditor(e) {
const fc = this.$.frameContext;
const resizeInterval = fc.get('wrapper').offsetHeight + (e.clientY - this._resizeClientY);
const h = resizeInterval < fc.get('_minHeight') ? fc.get('_minHeight') : resizeInterval;
fc.get('wysiwygFrame').style.height = fc.get('code').style.height = h + 'px';
this._resizeClientY = e.clientY;
if (!env.isResizeObserverSupported) this.#ui._emitResizeEvent(fc, h, null);
}
#__closeMove() {
this.#ui.disableBackWrapper();
this.#resize_editor &&= this.#eventManager.removeGlobalEvent(this.#resize_editor);
this.#close_move &&= this.#eventManager.removeGlobalEvent(this.#close_move);
}
/**
* @param {"t"|"b"} dir - Direction
* @param {PointerEvent} e - Pointer event object
*/
#DisplayLineBreak(dir, e) {
e.preventDefault();
const component = this._lineBreakComp;
if (!component) return;
const isList = dom.check.isListCell(component.parentElement);
const format = dom.utils.createElement(isList ? 'BR' : dom.check.isTableCell(component.parentElement) ? 'DIV' : this.#options.get('defaultLine'));
if (!isList) format.innerHTML = '<br>';
if (this.$.frameOptions.get('charCounter_type') === 'byte-html' && !this.$.char.check(format.outerHTML)) return;
component.parentNode.insertBefore(format, dir === 't' ? component : component.nextSibling);
this.$.component.deselect();
try {
const focusEl = isList ? format : format.firstChild;
this.$.selection.setRange(focusEl, 1, focusEl, 1);
this.$.history.push(false);
} catch (err) {
console.warn('[SUNEDITOR.lineBreaker.error]', err);
}
}
#OnResize_window() {
this.#store.set('initViewportHeight', _w.visualViewport.height);
if (!isMobile) {
this.#ui.offCurrentController();
}
if (this.#store.mode.isBalloon) this.#toolbar.hide();
else if (this.#store.mode.isSubBalloon) this.$.subToolbar.hide();
this.#resetFrameStatus();
}
#OnResize_viewport() {
if (isMobile && this.#options.get('_toolbar_sticky') > -1) {
this.#toolbar._resetSticky();
this.#menu.__restoreMenuPosition();
}
this.#scrollContainer();
const prevHeight = this.#store.get('currentViewportHeight');
this.__setViewportSize();
if (isMobile && prevHeight > 0 && prevHeight - _w.visualViewport.height > 100 && this.#store.get('hasFocus')) {
this.$.selection.scrollTo(this.$.selection.getRange(), { behavior: 'auto', block: 'nearest', inline: 'nearest' });
}
}
#OnScroll_window() {
if (this.#options.get('_toolbar_sticky') > -1) {
this.#toolbar._resetSticky();
}
if (this.#store.mode.isBalloon && this.#context.get('toolbar_main').style.display === 'block') {
this.#toolbar._setBalloonOffset(this.#toolbar.balloonOffset.position === 'top');
} else if (this.#store.mode.isSubBalloon && this.#context.get('toolbar_sub_main').style.display === 'block') {
this.$.subToolbar._setBalloonOffset(this.$.subToolbar.balloonOffset.position === 'top');
}
this.#scrollContainer();
// document type page
if (this.$.frameContext.has('documentType_use_page')) {
this.$.frameContext.get('documentType').scrollWindow();
}
}
#OnMobileScroll_viewport() {
if (this.#options.get('_toolbar_sticky') > -1) {
this.#toolbar._resetSticky();
this.#menu.__restoreMenuPosition();
}
}
/**
* @param {Document} _wd - Wysiwyg document
*/
#OnSelectionchange_document(_wd) {
const selection = _wd.getSelection();
let anchorNode = selection.anchorNode;
this.#contextProvider.applyToRoots((root) => {
if (anchorNode && root.get('wysiwyg').contains(anchorNode)) {
if (root.get('isReadOnly') || root.get('isDisabled')) return;
anchorNode = null;
this.$.selection.init();
this.applyTagEffect();
// balloon toolbar - touch devices
if (isTouchDevice && (this.#store.mode.isBalloon || this.#store.mode.isSubBalloon)) {
this.#toggleToolbarBalloonDelay();
}
// document type
if (root.has('documentType_use_header')) {
const el = dom.query.getParentElement(this.$.selection.selectionNode, this.$.format.isLine.bind(this.$.format));
root.get('documentType').on(el);
}
}
});
}
#OnScroll_Abs() {
this.#menu.dropdownOff();
this.#scrollContainer();
}
/**
* @param {SunEditor.FrameContext} frameContext - frame context object
*/
#OnFocus_code(frameContext) {
this.$.facade.changeFrameContext(frameContext.get('key'));
dom.utils.addClass(this.$.commandDispatcher.targets.get('codeView'), 'active');
this.#ui._toggleCodeViewButtons(true);
}
/**
* @param {SunEditor.FrameContext} frameContext - frame context object
*/
#OnFocus_markdown(frameContext) {
this.$.facade.changeFrameContext(frameContext.get('key'));
dom.utils.addClass(this.$.commandDispatcher.targets.get('markdownView'), 'active');
this.#ui._toggleCodeViewButtons(true);
}
}
/**
* @description Inserts a tab character at the cursor position in a textarea
* @param {KeyboardEvent} e
*/
function InsertTab(e) {
if (e.key !== 'Tab') return;
e.preventDefault();
const textarea = /** @type {HTMLTextAreaElement} */ (e.target);
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + '\t' + textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 1;
}
export default EventOrchestrator;