suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
627 lines (542 loc) • 21.5 kB
JavaScript
import { dom, env, keyCodeMap } from '../../helper';
import { _DragHandle } from '../ui/_DragHandle';
const { _w, isMobile, ON_OVER_COMPONENT } = env;
const INDEX_00 = '2147483646';
const INDEX_0 = '2147483645';
const INDEX_S_1 = '2147483642';
const INDEX_1 = '2147483641';
const ADD_OFFSET_VALUE = { left: 0, right: 0, top: 0 };
/**
* Controller information object
* @typedef {Object} ControllerInfo
* @property {*} inst - The controller instance
* @property {string} [position="bottom"] - The controller position (`"bottom"`|`"top"`)
* @property {HTMLElement} [form=null] - The controller element
* @property {HTMLElement|Range} [target=null] - The controller target element
* @property {boolean} [notInCarrier=false] - If the controller is not in the `carrierWrapper`, set it to `true`.
* @property {boolean} [isRangeTarget=false] - If the target is a `Range`, set it to `true`.
* @property {boolean} [fixed=false] - If the controller is fixed and should not be closed, set it to `true`.
*/
/**
* @typedef {Object} ControllerParams
* @property {"top"|"bottom"} [position="bottom"] Controller position
* @property {boolean} [isWWTarget=true] If the controller is in the WYSIWYG area, set it to `true`.
* @property {() => void} [initMethod=null] Method to be called when the controller is closed.
* @property {boolean} [disabled=false] If `true`, When the `controller` is opened, buttons without the `se-component-enabled` class are disabled.
* @property {Array<Controller|HTMLElement>} [parents=[]] The parent `controller` instance array when `controller` is opened nested.
* @property {boolean} [parentsHide=false] If `true`, the parent element is hidden when the controller is opened.
* @property {HTMLElement} [sibling=null] The related sibling controller element that this controller is positioned relative to.
* - e.g.) table plugin :: 118
* @property {boolean} [siblingMain=false] If `true`, This sibling controller is the main controller.
* - You must specify this option, if use `sibling`
* @property {boolean} [isInsideForm=false] If the controller is inside a form, set it to `true`.
* @property {boolean} [isOutsideForm=false] If the controller is outside a form, set it to `true`.
*/
/**
* @class
* @description Controller module class that handles the UI and interaction logic for a specific editor controller element.
* @see EditorComponent for `inst._element` requirement
*/
class Controller {
#$;
#initMethod;
#globalEventHandlers;
#addOffset = ADD_OFFSET_VALUE;
#reserveIndex = false;
#preventClose = false;
#bindShadowRootEvent = null;
#bindClose_key = null;
#bindClose_mouse = null;
/**
* @type {Array<Controller>}
* @description Child controllers opened by this controller
*/
#__childrenControllers__ = [];
/**
* @type {Map<Controller, boolean>}
* @description Track each parent's desired visibility state: `true` = wants hidden, `false` = wants visible
*/
#__hiddenByParents__ = new Map();
/**
* @constructor
* @param {*} host The instance object that called the constructor.
* @param {SunEditor.Deps} $ Kernel dependencies
* @param {Node} element Controller element
* @param {ControllerParams} params Controller options
* @param {?string} [_name] An optional name for the controller key.
*/
constructor(host, $, element, params, _name) {
this.#$ = $;
// members
this.kind = _name || host.constructor['key'] || host.constructor.name;
this.host = host;
this.form = /** @type {HTMLFormElement} */ (element);
this.isOpen = false;
this.currentTarget = null;
this.currentPositionTarget = null;
this.isWWTarget = params.isWWTarget ?? true;
this.position = params.position || 'bottom';
this.disabled = !!params.disabled;
this.parents = params.parents || [];
this.parentsForm = [];
this.parentsHide = !!params.parentsHide;
this.sibling = /** @type {HTMLElement} */ (params.sibling || null);
this.siblingMain = !!params.siblingMain;
this.isInsideForm = !!params.isInsideForm;
this.isOutsideForm = !!params.isOutsideForm;
this.toTop = false;
/** @type {{left?: number, top?: number, addOfffset?: {left?: number, top?: number}}} */
this.__offset = {};
this.#initMethod = typeof params.initMethod === 'function' ? params.initMethod : null;
this.#globalEventHandlers = { keydown: this.#CloseListener_keydown.bind(this), mousedown: this.#CloseListener_mousedown.bind(this) };
for (const parent of this.parents) {
if (dom.check.isElement(parent)) {
this.parentsForm.push(parent);
continue;
}
parent.#__childrenControllers__.push(this);
this.parentsForm.push(parent.form);
}
// add element
this.#$.contextProvider.carrierWrapper.appendChild(element);
// init
this.#$.eventManager.addEvent(element, 'click', this.#Action.bind(this));
this.#$.eventManager.addEvent(element, 'mouseenter', this.#MouseEnter.bind(this));
this.#$.eventManager.addEvent(element, 'mouseleave', this.#MouseLeave.bind(this));
}
/**
* @description Open a modal plugin
* @param {Node|Range} target Target element
* @param {Node} [positionTarget] Position target element
* @param {Object} [params={}] params
* @param {boolean} [params.isWWTarget] If the controller is in the WYSIWYG area, set it to `true`.
* @param {boolean} [params.passive] If `true`, opens the controller visually without affecting editor state
* - (`_preventBlur`, `controlActive`, `onControllerContext`, `opendControllers`).
* - Used for lightweight, non-intrusive display such as hover-triggered UI (e.g., codeLang selector on `<pre>` hover).
* - Automatically set to `true` when opened during component hover selection (`ON_OVER_COMPONENT`).
* @param {() => void} [params.initMethod] Method to be called when the controller is closed.
* @param {boolean} [params.disabled] If `true`, When the `controller` is opened, buttons without the `se-component-enabled` class are disabled. (default: `this.disabled`)
* @param {{left?: number, right?:number, top?: number}} [params.addOffset] Additional offset values
* @example
* // Open controller on a target element with default options
* this.controller.open(target);
*
* // Open with explicit options and additional offset
* this.controller.open(target, null, { isWWTarget: false, initMethod: null, addOffset: null });
*
* // Open on a Range target (e.g., text selection)
* this.controller.open(this.$.selection.getRange());
*/
open(target, positionTarget, { isWWTarget, passive, initMethod, disabled, addOffset } = {}) {
if (_DragHandle.get('__overInfo') === ON_OVER_COMPONENT) {
passive = true;
}
if (!target) {
console.warn('[SUNEDITOR.Controller.open.fail] The target element is required.');
return;
}
this.form.removeAttribute('data-se-hidden-by-parent');
this.form.removeAttribute('data-se-hidden-by-children');
this.#__hiddenByParents__.clear();
if (!passive) {
if (this.#$.store.mode.isBalloon) this.#$.toolbar.hide();
else if (this.#$.store.mode.isSubBalloon) this.#$.subToolbar.hide();
if (!this.#$.store.get('hasFocus')) {
if (disabled ?? this.disabled) {
this.#$.ui.setControllerOnDisabledButtons(true);
} else {
this.#$.ui.setControllerOnDisabledButtons(false);
}
}
}
this.currentPositionTarget = positionTarget || target;
this.isWWTarget = isWWTarget ?? this.isWWTarget;
if (typeof initMethod === 'function') this.#initMethod = initMethod;
if (!passive) this.#$.ui.currentControllerName = this.kind;
this.#addOffset = { left: 0, right: 0, top: 0, ...addOffset };
if (!passive) {
const parents = this.isOutsideForm ? this.parentsForm : [];
this.#$.ui.opendControllers?.forEach((e) => {
if (!parents.includes(e.form)) e.form.style.zIndex = INDEX_1;
});
if (this.parentsHide) {
this.parentsForm.forEach((e) => {
e.style.display = 'none';
e.setAttribute('data-se-hidden-by-children', '1');
});
}
}
this.#addGlobalEvent();
// display controller
this.#setControllerPosition(this.form, this.currentPositionTarget, false);
const isRangeTarget = this.#$.instanceCheck.isRange(target);
this.currentTarget = /** @type {HTMLElement} */ (isRangeTarget ? null : target);
this.#controllerOn(this.form, target, isRangeTarget, passive);
_w.setTimeout(() => _DragHandle.set('__overInfo', false), 0);
}
/**
* @description Close a modal plugin
* - The plugin's `init` method is called.
* @param {boolean} [force] If `true`, parent controllers are forcibly closed.
* @example
* // Close the controller (skips if not open or preventClose is set)
* this.controller.close();
*
* // Force close, also closing parent controllers in the hierarchy
* this.controller.close(true);
*/
close(force) {
if (!force && (!this.isOpen || this.#preventClose)) return;
this.form.removeAttribute('data-se-hidden-by-parent');
this.form.removeAttribute('data-se-hidden-by-children');
this.#__hiddenByParents__.clear();
this.#childrenSync('close');
this.toTop = false;
this.isOpen = false;
this.#preventClose = false;
this.__offset = {};
this.#addOffset = ADD_OFFSET_VALUE;
this.#removeGlobalEvent();
this.#initMethod?.();
this.#controllerOff();
if (this.parentsHide && !force) {
this.parentsForm.forEach((e) => {
e.style.display = 'block';
e.removeAttribute('data-se-hidden-by-children');
});
}
this.host.controllerClose?.();
if (this.parentsForm.length > 0) return;
this.#$.component.deselect();
}
/**
* @description Hide controller
*/
hide() {
this.#childrenSync('hide');
this.form.style.display = 'none';
}
/**
* @description Show controller
*/
show() {
this.#setControllerPosition(this.form, this.currentPositionTarget, false);
}
/**
* @description Sets whether the element (form) should be brought to the top based on `z-index`.
* @param {boolean} value - `true`: `'2147483646'`, `false`: `'2147483645'`.
* @example
* // Bring controller to the highest z-index layer (2147483646)
* this.controller_cell.bringToTop(true);
*
* // Restore to the default top z-index (2147483645)
* this.controller_cell.bringToTop(false);
*/
bringToTop(value) {
this.toTop = value;
this.form.style.zIndex = value ? INDEX_00 : INDEX_0;
}
/**
* @description Reset controller position
* @param {Node} [target]
* @example
* // Reposition using a new target element
* this.controller_cell.resetPosition(tdElement);
*
* // Reposition using the previously set target
* this.controller.resetPosition();
*/
resetPosition(target) {
this.#setControllerPosition(this.form, target || this.currentPositionTarget, true);
}
/**
* @description Reposition controller on scroll event
*/
_scrollReposition() {
if (this.form.hasAttribute('data-se-hidden-by-parent') || this.form.hasAttribute('data-se-hidden-by-children')) return;
if (this.#setControllerPosition(this.form, this.currentPositionTarget, false)) {
_w.setTimeout(() => {
this.#childrenSync('show');
}, 0);
}
}
/**
* @description Synchronously all child controllers.
* @param {"show"|"hide"|"close"} state - State to apply to child controllers.
*/
#childrenSync(state) {
for (const children of this.#__childrenControllers__) {
if (state === 'hide' && children.form.style.display !== 'none') {
const wasHidden = this.#calculateShouldBeHidden(children);
children.#__hiddenByParents__.set(this, true);
const shouldBeHidden = this.#calculateShouldBeHidden(children);
// Only hide if state changed from visible to hidden
if (!wasHidden && shouldBeHidden) {
children.form.setAttribute('data-se-hidden-by-parent', '1');
children.hide();
}
} else if (state === 'show') {
if (children.form.hasAttribute('data-se-hidden-by-children')) {
children.#childrenSync('show');
continue;
}
const wasHidden = this.#calculateShouldBeHidden(children);
children.#__hiddenByParents__.set(this, false);
const shouldBeHidden = this.#calculateShouldBeHidden(children);
// Only show if state changed from hidden to visible
if (wasHidden && !shouldBeHidden) {
children.form.removeAttribute('data-se-hidden-by-parent');
children.show();
}
} else {
children[state]?.(true);
}
}
}
/**
* @description Calculate if a child controller should be hidden based on all parent states
* @param {Controller} children - The child controller
* @returns {boolean} `true` if any parent wants it hidden
*/
#calculateShouldBeHidden(children) {
for (const wantsHidden of children.#__hiddenByParents__.values()) {
if (wantsHidden === true) return true;
}
return false;
}
/**
* @description Show controller at editor area (controller elements, function, `controller target element(@Required)`, `controller name(@Required)`, etc..)
* @param {HTMLFormElement} form Controller element
* @param {Node|Range} target Controller target element
* @param {boolean} isRangeTarget If the target is a `Range`, set it to `true`.
* @param {boolean} [passive=false] If `true`, opens without affecting editor state (_preventBlur, controlActive, etc.)
*/
async #controllerOn(form, target, isRangeTarget, passive) {
/** @type {ControllerInfo} */
const info = {
position: this.position,
inst: this,
form: /** @type {HTMLElement} */ (form),
target: /** @type {HTMLElement} */ (target),
isRangeTarget,
notInCarrier: !this.#$.contextProvider.carrierWrapper.contains(form),
};
if ((await this.#$.eventManager.triggerEvent('onBeforeShowController', { caller: this.kind, frameContext: this.#$.frameContext, info })) === false) return;
form.style.display = 'block';
if (this.#$.contextProvider.shadowRoot) {
this.#bindShadowRootEvent = this.#$.eventManager.addEvent(form, 'mousedown', (e) => e.stopPropagation());
}
if (!passive) {
this.#$.ui.onControllerContext();
this.#$.store.set('controlActive', true);
this.#$.store.set('_preventBlur', true);
}
if (!this.isOpen) {
this.#$.ui.opendControllers.push(info);
}
this.isOpen = true;
this.host.controllerOn?.(form, target);
this.#$.eventManager.triggerEvent('onShowController', { caller: this.kind, frameContext: this.#$.frameContext, info });
}
/**
* @description Hide controller at editor area (link button, image resize button..)
*/
#controllerOff() {
this.form.style.display = 'none';
this.#$.ui.opendControllers = this.#$.ui.opendControllers.filter((v) => v.form !== this.form);
if (this.#$.ui.currentControllerName !== this.kind && this.#$.ui.opendControllers.length > 0) return;
this.#$.ui.setControllerOnDisabledButtons(false);
this.#$.ui.offControllerContext();
this.#$.frameContext.get('lineBreaker_t').style.display = this.#$.frameContext.get('lineBreaker_b').style.display = 'none';
this.#$.store.set('_lastSelectionNode', null);
this.#$.ui.currentControllerName = '';
this.#$.store.set('_preventBlur', false);
_w.setTimeout(() => {
this.#$.store.set('controlActive', false);
}, 0);
this.#bindShadowRootEvent &&= this.#$.eventManager.removeEvent(this.#bindShadowRootEvent);
}
/**
* @description Specify the position of the controller.
* @param {HTMLElement} controller Controller element.
* @param {Node|Range} refer Element or `Range` that is the basis of the controller's position.
* @param {boolean} [skipAutoReposition=false] If `true`, skips scroll/resize-based automatic positioning logic.
* @returns {boolean} - view : `true` || hide : `false`
*/
#setControllerPosition(controller, refer, skipAutoReposition) {
if (!refer) return false;
controller.style.visibility = 'hidden';
controller.style.display = 'block';
if (this.sibling && this.sibling.style.display !== 'block') {
this.sibling.style.visibility = 'hidden';
this.sibling.style.display = 'block';
}
if (this.#$.selection.isRange(refer)) {
if (!this.#$.offset.setRangePosition(this.form, /** @type {Range} */ (refer), { position: 'bottom' })) {
this.hide();
return false;
}
} else {
const positionResult = this.#$.offset.setAbsPosition(controller, /** @type {HTMLElement} */ (refer), {
addOffset: this.#addOffset,
position: this.position,
isWWTarget: this.isWWTarget,
inst: this,
sibling: this.sibling,
});
if (!positionResult) {
this.hide();
return false;
}
if (!skipAutoReposition && this.sibling && !this.siblingMain) {
const resetPosition = controller.offsetTop - this.#addOffset.top;
if (positionResult.position === 'bottom') {
this.#reserveIndex = true;
controller.style.top = resetPosition + this.sibling.offsetHeight - 1 + 'px';
} else {
this.#reserveIndex = false;
controller.style.top = resetPosition - this.sibling.offsetHeight + 2 + 'px';
}
} else {
this.#reserveIndex = false;
}
}
controller.style.zIndex = this.toTop ? INDEX_0 : this.#reserveIndex ? INDEX_S_1 : INDEX_1;
controller.style.visibility = '';
return true;
}
/**
* @description Adds global event listeners.
* - When the controller is opened
*/
#addGlobalEvent() {
this.#removeGlobalEvent();
this.#bindClose_key = this.#$.eventManager.addGlobalEvent('keydown', this.#globalEventHandlers.keydown, true);
this.#bindClose_mouse = this.#$.eventManager.addGlobalEvent(isMobile ? 'click' : 'mousedown', this.#globalEventHandlers.mousedown, true);
}
/**
* @description Removes global event listeners.
* - When the ESC key is pressed, the controller is closed.
*/
#removeGlobalEvent() {
this.#$.component.__removeGlobalEvent();
this.#bindClose_key &&= this.#$.eventManager.removeGlobalEvent(this.#bindClose_key);
this.#bindClose_mouse &&= this.#$.eventManager.removeGlobalEvent(this.#bindClose_mouse);
}
/**
* @description Checks if the controller is fixed and should not be closed.
* @returns {boolean} `true` if the controller is fixed.
*/
#checkFixed() {
if (this.#$.ui.selectMenuOn) return true;
const cont = this.#$.ui.opendControllers;
for (let i = 0; i < cont.length; i++) {
if (cont[i].inst === this && cont[i].fixed) {
return true;
}
}
return false;
}
/**
* @description Checks if the given target is within a form or controller.
* @param {Element} target The target element.
* @returns {boolean} `true` if the target is inside a form or controller.
*/
#checkForm(target) {
if (dom.check.isWysiwygFrame(target) || target.contains(this.form)) return false;
if (dom.utils.hasClass(target, 'se-drag-handle')) return true;
let isParentForm = false;
if (this.isInsideForm && this.parentsForm?.length > 0) {
this.parentsForm.some((e) => {
if (e.contains(target)) {
isParentForm = true;
return true;
}
});
}
const _element = this.host._element;
return !isParentForm && (!!dom.query.getParentElement(target, '.se-controller') || (this.#$.component.isInline(_element) ? target === _element : target?.contains(_element)));
}
/**
* @param {MouseEvent} e - Event object
*/
#Action(e) {
const eventTarget = dom.query.getEventTarget(e);
const target = dom.query.getCommandTarget(eventTarget);
if (!target) return;
e.stopPropagation();
e.preventDefault();
if (target.getAttribute('data-command') === 'close') {
this.close();
return;
}
this.host.controllerAction(target);
}
/**
* @param {MouseEvent} e - Event object
*/
#MouseEnter(e) {
this.#$.ui.currentControllerName = this.kind;
if (this.parentsForm.length > 0 && this.isInsideForm) return;
const eventTarget = dom.query.getEventTarget(e);
eventTarget.style.zIndex = this.toTop ? INDEX_00 : INDEX_0;
}
/**
* @param {MouseEvent} e - Event object
*/
#MouseLeave(e) {
if (this.parentsForm.length > 0 && this.isInsideForm) return;
const eventTarget = dom.query.getEventTarget(e);
eventTarget.style.zIndex = this.toTop ? INDEX_0 : this.#reserveIndex ? INDEX_S_1 : INDEX_1;
}
/**
* @param {KeyboardEvent} e - Event object
*/
#CloseListener_keydown(e) {
if (this.#checkFixed()) return;
const keyCode = e.code;
const ctrl = keyCodeMap.isCtrl(e);
if (ctrl || !keyCodeMap.isNonResponseKey(keyCode)) return;
const eventTarget = dom.query.getEventTarget(e);
if (!keyCodeMap.isEsc(keyCode)) {
if (this.form.contains(eventTarget) || this.#checkForm(eventTarget)) return;
if (this.#$.pluginManager.fileInfo.pluginRegExp.test(this.kind)) return;
} else {
if (this.#__childrenControllers__.some(({ isOpen }) => isOpen)) return;
}
this.#PostCloseEvent(eventTarget);
this.close();
}
/**
* @param {KeyboardEvent} e - Event object
*/
#CloseListener_mousedown(e) {
const eventTarget = dom.query.getEventTarget(e);
if (this.host?._element?.contains(eventTarget)) {
this.#preventClose = true;
return;
}
this.#preventClose = false;
if (
eventTarget === this.host._element ||
eventTarget === this.currentTarget ||
this.#checkFixed() ||
this.form.contains(eventTarget) ||
this.#checkForm(eventTarget) ||
dom.query.getParentElement(eventTarget, '.se-line-breaker-component')
) {
return;
}
this.#PostCloseEvent(eventTarget);
this.close(true);
}
/**
* @param {HTMLElement} eventTarget - The target element that triggered the event.
*/
#PostCloseEvent(eventTarget) {
if (!this.#$.frameContext.get('wysiwyg').contains(eventTarget)) {
this.#$.component.__prevent = false;
}
}
}
export default Controller;