suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
969 lines (831 loc) • 34.3 kB
JavaScript
import { dom, env, numbers, unicode, keyCodeMap } from '../../../helper';
import { Figure } from '../../../modules/contract';
import { _DragHandle } from '../../../modules/ui';
const { _w, ON_OVER_COMPONENT, isMobile } = env;
const DIR_KEYCODE = /^Arrow(Left|Up|Right|Down)$/;
const DIR_UP_KEYCODE = /^Arrow(Left|Up)$/;
/**
* @description Class for managing components such as images and tables that are not in `line` format
*/
class Component {
#kernel;
#$;
#store;
#contextProvider;
#carrierWrapper;
#options;
#frameContext;
#eventManager;
/** @type {Object<string, (...args: *) => *>} */
#globalEvents;
/** @type {?SunEditor.Event.GlobalInfo} */
#bindClose_copy = null;
/** @type {?SunEditor.Event.GlobalInfo} */
#bindClose_cut = null;
/** @type {?SunEditor.Event.GlobalInfo} */
#bindClose_keydown = null;
/** @type {?SunEditor.Event.GlobalInfo} */
#bindClose_mousedown = null;
/**
* @constructor
* @param {SunEditor.Kernel} kernel
*/
constructor(kernel) {
this.#kernel = kernel;
this.#$ = kernel.$;
this.#store = kernel.store;
this.#contextProvider = this.#$.contextProvider;
this.#carrierWrapper = this.#contextProvider.carrierWrapper;
this.#options = this.#$.options;
this.#frameContext = this.#$.frameContext;
this.#eventManager = this.#$.eventManager;
/**
* @description The current component information, used copy, cut, and keydown events
* @type {SunEditor.ComponentInfo}
*/
this.info = null;
/**
* @description Component is selected
* @type {boolean}
*/
this.isSelected = false;
/**
* @description Currently selected component target
* @type {?Node}
*/
this.currentTarget = null;
/**
* @description Currently selected component plugin instance
* @type {*}
*/
this.currentPlugin = null;
/**
* @description Currently selected component plugin name
* @type {*}
*/
this.currentPluginName = '';
/**
* @description Currently selected component information
* @type {?SunEditor.ComponentInfo}
*/
this.currentInfo = null;
this.#globalEvents = {
copy: this.#OnCopy_component.bind(this),
cut: this.#OnCut_component.bind(this),
keydown: this.#OnKeyDown_component.bind(this),
mousedown: this.#CloseListener_mousedown.bind(this),
};
/** @internal */
this.__selectionSelected = false;
/** @internal */
this.__prevent = false;
}
/**
* @description Initialize eventManager reference after EventManager is created.
*/
_init() {
this.#contextProvider.applyToRoots((e) => {
// drag
const dragHandle = dom.utils.createElement('DIV', { class: 'se-drag-handle', draggable: 'true' }, this.#contextProvider.icons.selection);
e.get('wrapper').appendChild(dragHandle);
this.#eventManager.addEvent(dragHandle, 'mouseenter', this.#OnDragEnter.bind(this));
this.#eventManager.addEvent(dragHandle, 'mouseleave', this.#OnDragLeave.bind(this));
this.#eventManager.addEvent(dragHandle, 'dragstart', this.#OnDragStart.bind(this));
this.#eventManager.addEvent(dragHandle, 'dragend', this.#OnDragEnd.bind(this));
this.#eventManager.addEvent(dragHandle, 'click', this.#OnDragClick.bind(this));
});
}
/**
* @description Inserts an element and returns it. (Used for elements: table, hr, image, video)
* - If `element` is `HR`, inserts and returns the new line.
* @param {Node} element Element to be inserted
* @param {Object} [options] Options
* @param {boolean} [options.skipCharCount=false] If `true`, it will be inserted even if `frameOptions.get('charCounter_max')` is exceeded.
* @param {boolean} [options.skipHistory=false] If `true`, do not push to history.
* @param {boolean} [options.scrollTo=true] `true` : Scroll to the inserted element, `false` : Do not scroll.
* @param {SunEditor.ComponentInsertType} [options.insertBehavior] If `true`, do not automatically select the inserted component. [default: `options.get('componentInsertBehavior')`]
* - If `null`, no action is performed after insertion.
* @returns {HTMLElement} The inserted element or new line (for HR)
*/
insert(element, { skipCharCount = false, skipHistory = false, scrollTo = true, insertBehavior } = {}) {
if (this.#frameContext.get('isReadOnly') || (!skipCharCount && !this.#$.char.check(element))) {
return null;
}
if (insertBehavior === undefined) insertBehavior = this.#options.get('componentInsertBehavior');
const r = this.#$.html.remove();
const isInline = this.isInline(element);
this.#$.selection.getRangeAndAddLine(this.#$.selection.getRange(), r.container);
const selectionNode = this.#$.selection.getNode();
let oNode = null;
let formatEl = this.#$.format.getLine(selectionNode, null);
try {
if (dom.check.isListCell(formatEl)) {
this.#$.html.insertNode(element, { afterNode: isInline ? null : !dom.check.isZeroWidth(selectionNode) ? null : (selectionNode || r.container || {}).nextSibling, skipCharCount: true });
if (!isInline && !element.nextSibling) element.parentNode.appendChild(dom.utils.createElement('BR'));
} else {
if (!isInline && this.#$.selection.getRange().collapsed && (r.container?.nodeType === 3 || dom.check.isBreak(r.container))) {
const depthFormat = dom.query.getParentElement(r.container, this.#$.format.isBlock.bind(this.#$.format));
oNode = this.#$.nodeTransform.split(r.container, r.offset, !depthFormat ? 0 : dom.query.getNodeDepth(depthFormat) + 1);
if (oNode) formatEl = /** @type {HTMLElement} */ (oNode.previousSibling);
}
this.#$.html.insertNode(element, { afterNode: isInline ? null : this.#$.format.isBlock(formatEl) ? null : formatEl, skipCharCount: true });
if (!isInline && formatEl && dom.check.isZeroWidth(formatEl)) dom.utils.removeItem(formatEl);
}
if (isInline) {
const empty = dom.utils.createTextNode(unicode.zeroWidthSpace);
element.parentNode.insertBefore(empty, element.nextSibling);
}
} catch (e) {
console.error('Component insert error:', e);
}
if (!skipHistory) this.#$.history.push(false);
// document type
if (this.#frameContext.has('documentType_use_header')) {
this.#frameContext.get('documentType').reHeader();
}
const targetElement = /** @type {HTMLElement} */ (oNode || element);
if (scrollTo) this.#$.selection.scrollTo(targetElement, { behavior: 'auto' });
if (insertBehavior !== null) this.applyInsertBehavior(element, oNode, insertBehavior);
return targetElement;
}
/**
* @description Handles post-insertion behavior for a newly created component based on the specified mode.
* @param {Node} container The inserted component element.
* @param {?Node} [oNode] Optional node to use for selection if the component cannot be selected.
* @param {SunEditor.ComponentInsertType} [insertBehavior] Behavior mode after component insertion.
*/
applyInsertBehavior(container, oNode, insertBehavior) {
const cInfo = this.get(container);
if (this.isInline(container)) {
const nr = this.#$.selection.getNearRange(container);
if (nr) {
this.#$.selection.setRange(nr.container, nr.offset, nr.container, nr.offset);
} else {
this.select(cInfo.target, cInfo.pluginName);
}
return;
}
switch (insertBehavior) {
case 'auto': {
if (!this.#moveToNextLineOrAdd(container)) {
this.select(cInfo.target, cInfo.pluginName);
}
break;
}
case 'select': {
this.#$.selection.setRange(container, 0, container, 0);
if (cInfo) {
this.select(cInfo.target, cInfo.pluginName);
} else if (oNode) {
oNode = dom.query.getEdgeChildNodes(oNode, null).sc || oNode;
this.#$.selection.setRange(oNode, 0, oNode, 0);
}
break;
}
case 'line': {
if (!this.#moveToNextLineOrAdd(container)) {
const line = this.#$.format.addLine(container, null);
if (line) this.#$.selection.setRange(line, 0, line, 0);
}
break;
}
case 'none': {
// Do not select the component and remove the editor focus
break;
}
}
}
/**
* @description Gets the file component and that plugin name
* - return: {target, component, pluginName} | `null`
* @param {Node} element Target element (figure tag, component div, file tag)
* @returns {SunEditor.ComponentInfo|null}
*/
get(element) {
if (!element) return null;
let target;
let pluginName = '';
let options = {};
let isFile = false;
let launcher = null;
if (this.is(element)) {
if (dom.check.isComponentContainer(element) && !dom.utils.hasClass(element, 'se-inline-component')) element = /** @type {HTMLElement} */ (element).firstElementChild || element;
if (/^FIGURE$/i.test(element.nodeName)) element = /** @type {HTMLElement} */ (element).firstElementChild;
if (!element) return null;
const comp = this.#$.pluginManager.findComponentInfo(element);
if (!comp) return null;
target = comp.target;
pluginName = comp.pluginName;
options = comp.options;
launcher = comp.launcher;
}
if (!target && element.nodeName) {
if (this.#isFiles(element)) {
isFile = true;
}
const comp = this.#$.pluginManager.findComponentInfo(element);
if (!comp) return null;
target = comp.target;
pluginName = comp.pluginName;
options = comp.options;
launcher = comp.launcher;
}
if (!target) {
return null;
}
const figureInfo = Figure.GetContainer(target);
const container = figureInfo.container || figureInfo.cover || target;
return (this.info = {
target: figureInfo.target,
pluginName,
options,
container: container,
cover: figureInfo.cover,
inlineCover: figureInfo.inlineCover,
caption: figureInfo.caption,
isFile,
launcher,
isInputType: dom.utils.hasClass(container, 'se-input-component'),
});
}
/**
* @description The component(media, file component, table, etc) is selected and the resizing module is called.
* @param {Node} element Target element
* @param {string} pluginName The plugin name for the selected target.
* @param {Object} [options] Options
* @param {boolean} [options.isInput=false] Whether the target is an input component (table).
*/
select(element, pluginName, { isInput = false } = {}) {
const info = this.get(element);
if (!info || dom.check.isUneditable(dom.query.getParentElement(element, this.is.bind(this))) || dom.check.isUneditable(element)) return false;
const plugin = info.launcher || this.#$.plugins[pluginName];
if (!plugin) return;
const notOver = _DragHandle.get('__overInfo') !== ON_OVER_COMPONENT;
if (!isInput && notOver) {
if (this.#store.get('_mousedown')) return;
this.#store.set('_preventBlur', true);
this.__selectionSelected = true;
if (this.isInline(info.container)) {
this.#$.selection.setRange(info.container, 0, info.container, 0);
}
this.#$.focusManager.blur();
// Defer flag reset — blur event fires synchronously and checks __selectionSelected to distinguish component-initiated blur
_w.setTimeout(() => {
this.__selectionSelected = false;
});
}
this.isSelected = true;
this.__prevent = true;
let isNonFigureComponent;
if (typeof plugin.componentSelect === 'function') isNonFigureComponent = plugin.componentSelect(element);
const isBreakComponent = dom.utils.hasClass(info.target, 'se-component-line-break');
if (isBreakComponent || (!isNonFigureComponent && !dom.utils.hasClass(info.container, 'se-inline-component'))) this._setComponentLineBreaker(/** @type {HTMLElement} */ (info.container || info.cover || element));
this.currentTarget = element;
this.currentPlugin = plugin;
this.currentPluginName = pluginName;
this.currentInfo = info;
_DragHandle.set('__dragInst', this);
const __overInfo = _DragHandle.get('__overInfo');
// Defer drag handle setup — let the current click/mousedown event complete before attaching global listeners
_w.setTimeout(() => {
_DragHandle.set('__overInfo', __overInfo === ON_OVER_COMPONENT ? undefined : false);
if (__overInfo !== ON_OVER_COMPONENT) this.#addGlobalEvent();
if (!info.isFile) this.#addNotFileGlobalEvent();
dom.utils.addClass(info.container, 'se-component-selected');
}, 0);
if (notOver && !this.#store.get('hasFocus') && !this.#store.get('_preventFocus')) {
this.#kernel._eventOrchestrator.__postFocusEvent(this.#frameContext, null);
this.#store.set('_preventFocus', true);
}
if (!isBreakComponent && __overInfo !== ON_OVER_COMPONENT) {
// set zero width space
if (!this.isInline(info.container)) return;
const oNode = info.container;
let zeroWidth = null;
if (!oNode.previousSibling || dom.check.isBreak(oNode.previousSibling)) {
zeroWidth = dom.utils.createTextNode(unicode.zeroWidthSpace);
oNode.parentNode.insertBefore(zeroWidth, oNode);
}
if (!oNode.nextSibling || dom.check.isBreak(oNode.nextSibling)) {
zeroWidth = dom.utils.createTextNode(unicode.zeroWidthSpace);
oNode.parentNode.insertBefore(zeroWidth, oNode.nextSibling);
}
this.#store.set('controlActive', true);
} else if (isBreakComponent || !dom.utils.hasClass(info.container, 'se-input-component')) {
const dragHandle = /** @type {HTMLElement} */ (this.#frameContext.get('wrapper').querySelector('.se-drag-handle'));
dom.utils.addClass(dragHandle, 'se-drag-handle-full');
this.#$.ui._visibleControllers(false, false);
const sizeTarget = info.caption ? info.target : info.cover || info.container || info.target;
const w = sizeTarget.offsetWidth;
const h = sizeTarget.offsetHeight;
const { top, left } = this.#$.offset.getLocal(sizeTarget);
dragHandle.style.opacity = '0';
dragHandle.style.width = w + 'px';
dragHandle.style.height = h + 'px';
dragHandle.style.top = top + 'px';
dragHandle.style.left = left + 'px';
_DragHandle.set('__dragHandler', dragHandle);
_DragHandle.set('__dragContainer', info.container);
_DragHandle.set('__dragCover', info.cover);
dragHandle.style.display = 'block';
}
}
/**
* @description Deselects the selected component.
*/
deselect() {
// Defer controlActive reset — synchronous blur handlers during deselect still need to see it as active
_w.setTimeout(() => {
this.#store.set('controlActive', false);
}, 0);
this.__deselect();
this.#$.ui.setControllerOnDisabledButtons(false);
if (this.#store.get('_preventFocus') && !this.#store.get('hasFocus') && !this.__prevent) {
this.#kernel._eventOrchestrator.__postBlurEvent(this.#frameContext, null);
this.#store.set('_preventFocus', false);
}
}
/**
* @description Determines if the specified node is a block component (e.g., img, iframe, video, audio, table) with the class `se-component`
* - or a direct `FIGURE` node. This function checks if the node itself is a component
* - or if it belongs to any components identified by the component manager.
* @param {Node} element The DOM node to check.
* @returns {boolean} `true` if the node is a block component or part of it, otherwise `false`.
*/
is(element) {
if (!element) return false;
if (/^FIGURE$/i.test(element.nodeName) || dom.check.isComponentContainer(element)) return true;
if (this.#$.pluginManager.findComponentInfo(element)) return true;
return false;
}
/**
* @description Checks if the given node is an inline component (class `se-inline-component`).
* - If the node is a `FIGURE`, it checks the parent element instead.
* - It also verifies whether the node is part of an inline component recognized by the component manager.
* @param {Node} element The DOM node to check.
* @returns {boolean} `true` if the node is an inline component or part of it, otherwise `false`.
*/
isInline(element) {
if (!element) return false;
if (/^FIGURE$/i.test(element.nodeName)) element = element.parentElement;
if (dom.utils.hasClass(element, 'se-inline-component')) return true;
const comp = this.#$.pluginManager.findComponentInfo(element);
if (comp && (dom.utils.hasClass(element, 'se-inline-component') || dom.utils.hasClass(element.parentElement, 'se-inline-component'))) return true;
return false;
}
/**
* @description Checks if the specified node qualifies as a basic component within the editor.
* - This function verifies whether the node is recognized as a component by the `is` function, while also ensuring that it is not an inline component as determined by the `isInline` function.
* - This is used to identify block-level elements or standalone components that are not part of the inline component classification.
* @param {Node} element The DOM node to check.
* @returns {boolean} `true` if the node is a basic (non-inline) component, otherwise `false`.
*/
isBasic(element) {
return this.is(element) && !this.isInline(element);
}
/**
* @description Copies the specified component node to the clipboard.
* - This function is different from the one called when the user presses the `Ctrl + C` key combination.
* @param {Node} container The DOM node to check.
*/
async copy(container) {
const cloneContainer = /** @type {HTMLElement} */ (dom.utils.clone(container, true));
RemoveSelectedClass(cloneContainer);
// copy to clipboard
if ((await this.#$.html.copy(cloneContainer)) === false) return;
// copy effect
dom.utils.flashClass(container, 'se-copy');
}
/**
* @description Temporarily selects a component without showing its controller.
* This is a lightweight selection mode used for:
* - Mouse hover: Shows visual selection while hovering, auto-deselects on mouse out
* - Table column/row resize: Maintains selection after resize without showing controller
*
* Key differences from `select()`:
* - Does NOT show the component's controller (resize handles, toolbar, etc.)
* - Sets `__overInfo` flag so selection is automatically cleared on mouse out
* - Calling `select()` afterward will upgrade to full selection with controller
*
* @param {Element} target The element to hover-select
*/
hoverSelect(target) {
const figure = dom.query.getParentElement(target, dom.check.isFigure);
let info = this.get(target);
if (info || figure) {
info ||= this.get(figure);
if (info && !dom.utils.hasClass(info.container, 'se-component-selected')) {
this.#$.ui.offCurrentController();
_DragHandle.set('__overInfo', ON_OVER_COMPONENT);
this.select(info.target, info.pluginName);
}
} else if (_DragHandle.get('__overInfo') !== null && !dom.utils.hasClass(target, 'se-drag-handle')) {
this.__deselect();
_DragHandle.set('__overInfo', null);
}
}
/**
* @internal
* @description Deselects the currently selected component, removing any selection effects and associated event listeners.
* - This method resets the selection state and hides UI elements related to the component selection.
*/
__deselect() {
this.#store.set('_preventBlur', false);
_DragHandle.set('__overInfo', null);
this.__removeDragEvent();
if (this.currentInfo) {
const infoContainer = this.currentInfo.container;
const infoCover = this.currentInfo.cover;
_w.setTimeout(() => {
RemoveSelectedClass(infoContainer);
dom.utils.removeClass(infoCover, 'se-figure-over-selected');
}, 0);
}
if (this.#frameContext.get('lineBreaker_t')) {
this.#frameContext.get('lineBreaker_t').style.display = this.#frameContext.get('lineBreaker_b').style.display = 'none';
}
if (this.currentPlugin) {
this.currentPlugin.componentDeselect?.(this.currentTarget);
}
this.isSelected = false;
this.currentPlugin = null;
this.currentTarget = null;
this.currentPluginName = '';
this.currentInfo = null;
this.__removeGlobalEvent();
this.#$.ui._offControllers();
}
/**
* @internal
* @description Set line breaker of component
* @param {HTMLElement} element Element tag
*/
_setComponentLineBreaker(element) {
const _overInfo = _DragHandle.get('__overInfo') === ON_OVER_COMPONENT;
this.#kernel._eventOrchestrator._lineBreakComp = null;
const info = this.get(element);
if (!info) return;
const fc = this.#frameContext;
const container = info.container;
const isNonSelected = dom.utils.hasClass(container, 'se-flex-component');
const lb_t = fc.get('lineBreaker_t');
const lb_b = fc.get('lineBreaker_b');
const t_style = lb_t.style;
const b_style = lb_b.style;
const offsetTarget = container.offsetWidth < element.offsetWidth ? container : element;
const target = this.#$.ui.getVisibleFigure() || offsetTarget;
const isList = dom.check.isListCell(container.parentNode);
// top
let componentTop, w;
const isRtl = this.#options.get('_rtl');
const dir = isRtl ? ['right', 'left'] : ['left', 'right'];
const { top, left, right, scrollX, scrollY } = this.#$.offset.getLocal(offsetTarget);
const sideOffset = isRtl ? right : left;
if (isList ? (!dom.check.isBreak(container.previousElementSibling) && !container.previousSibling?.textContent?.trim()) || this.is(container.previousElementSibling) : !this.#$.format.isLine(container.previousElementSibling)) {
const cStyle = _w.getComputedStyle(lb_t);
const cH = numbers.get(cStyle.height, 1);
const cW = numbers.get(cStyle.width, 1);
this.#kernel._eventOrchestrator._lineBreakComp = container;
componentTop = top;
w = target.offsetWidth / 2 / 2;
t_style.top = componentTop - cH / 2 + 'px';
t_style[dir[0]] = (isNonSelected ? sideOffset - cW / 2 : sideOffset + w) + 'px';
t_style[dir[1]] = '';
lb_t.setAttribute('data-offset', scrollY + ',' + scrollX);
if (_overInfo) dom.utils.removeClass(lb_t, 'se-on-selected');
else dom.utils.addClass(lb_t, 'se-on-selected');
t_style.display = 'block';
t_style.visibility = '';
} else {
t_style.display = 'none';
}
// bottom
if (isList ? (!dom.check.isBreak(container.nextElementSibling) && !container.nextSibling?.textContent?.trim()) || this.is(container.nextElementSibling) : !this.#$.format.isLine(container.nextElementSibling)) {
const cStyle = _w.getComputedStyle(lb_b);
const cH = numbers.get(cStyle.height, 1);
const cW = numbers.get(cStyle.width, 1);
if (!componentTop) {
this.#kernel._eventOrchestrator._lineBreakComp = container;
componentTop = top;
w = target.offsetWidth / 2 / 2;
}
b_style.top = componentTop + target.offsetHeight - cH / 2 + 'px';
b_style[dir[0]] = sideOffset + target.offsetWidth - (isNonSelected ? 0 : w) - (isNonSelected ? cW / 2 : cW) + 'px';
b_style[dir[1]] = '';
lb_b.setAttribute('data-offset', scrollY + ',' + dir[0] + ',' + scrollX);
if (_overInfo) dom.utils.removeClass(lb_b, 'se-on-selected');
else dom.utils.addClass(lb_b, 'se-on-selected');
b_style.display = 'block';
b_style.visibility = '';
} else {
b_style.display = 'none';
}
}
/**
* @internal
* @description Removes global event listeners that were previously added for component interactions.
*/
__removeGlobalEvent() {
this.#removeNotFileGlobalEvent();
this.#bindClose_copy &&= this.#eventManager.removeGlobalEvent(this.#bindClose_copy);
this.#bindClose_cut &&= this.#eventManager.removeGlobalEvent(this.#bindClose_cut);
this.#bindClose_keydown &&= this.#eventManager.removeGlobalEvent(this.#bindClose_keydown);
}
/**
* @internal
* @description Removes drag-related events and resets drag-related states.
*/
__removeDragEvent() {
/** @type {HTMLElement} */ (this.#carrierWrapper.querySelector('.se-drag-cursor')).style.left = '-10000px';
if (_DragHandle.get('__dragHandler')) _DragHandle.get('__dragHandler').style.display = 'none';
dom.utils.removeClass([_DragHandle.get('__dragHandler'), _DragHandle.get('__dragContainer')], 'se-dragging');
dom.utils.removeClass([_DragHandle.get('__dragCover'), _DragHandle.get('__dragContainer')], 'se-drag-over');
_DragHandle.set('__figureInst', null);
_DragHandle.set('__dragInst', null);
_DragHandle.set('__dragHandler', null);
_DragHandle.set('__dragContainer', null);
_DragHandle.set('__dragCover', null);
_DragHandle.set('__dragMove', null);
_DragHandle.set('__overInfo', null);
}
/**
* @description Adds global event listeners for component interactions such as copy, cut, and keydown events.
*/
#addGlobalEvent() {
this.__removeGlobalEvent();
this.#bindClose_copy = this.#eventManager.addGlobalEvent('copy', this.#globalEvents.copy);
this.#bindClose_cut = this.#eventManager.addGlobalEvent('cut', this.#globalEvents.cut);
this.#bindClose_keydown = this.#eventManager.addGlobalEvent('keydown', this.#globalEvents.keydown);
}
/**
* @description Adds global event listeners for non-file-related interactions such as mouse and touch events.
*/
#addNotFileGlobalEvent() {
this.#removeNotFileGlobalEvent();
this.#bindClose_mousedown = this.#eventManager.addGlobalEvent(isMobile ? 'click' : 'mousedown', this.#globalEvents.mousedown, true);
}
/**
* @internal
* @description Removes global event listeners related to non-file interactions.
*/
#removeNotFileGlobalEvent() {
this.#bindClose_mousedown &&= this.#eventManager.removeGlobalEvent(this.#bindClose_mousedown);
}
/**
* @description
* Attempts to move the cursor to a valid line after the given container.
* - If a valid next sibling line exists, moves the selection there.
* - If no next sibling exists, creates a new line after the container and moves the selection there.
* - If the next sibling exists but is not a valid line element and cannot create a new line, returns `false`.
* @param {Node} container The component container element.
* @returns {boolean} Returns `true` if the selection moved to a line (existing or newly created), otherwise `false`.
*/
#moveToNextLineOrAdd(container) {
const nextSibling = /** @type {Element} */ (container).nextElementSibling;
if (!nextSibling) {
const line = this.#$.format.addLine(container, null);
if (line) this.#$.selection.setRange(line, 0, line, 0);
return true;
} else if (this.#$.format.isLine(nextSibling)) {
this.#$.selection.setRange(nextSibling, 0, nextSibling, 0);
return true;
}
return false;
}
/**
* @description Checks if the given element is a file component by matching its tag name against the file manager's regular expressions.
* - It also verifies whether the element has the required attributes based on the tag type.
* @param {Node} element The element to check.
* @returns {boolean} Returns `true` if the element is a file component, otherwise `false`.
*/
#isFiles(element) {
const nodeName = element.nodeName.toLowerCase();
return (
this.#$.pluginManager.fileInfo.regExp.test(nodeName) &&
(!this.#$.pluginManager.fileInfo.tagAttrs[nodeName] || this.#$.pluginManager.fileInfo.tagAttrs[nodeName]?.every((v) => /** @type {HTMLElement} */ (element).hasAttribute(v)))
);
}
#OnDragEnter() {
this.#store.set('_preventBlur', true);
this.#$.ui._visibleControllers(false, dom.utils.hasClass(_DragHandle.get('__dragHandler'), 'se-drag-handle-full'));
dom.utils.addClass(_DragHandle.get('__dragCover') || _DragHandle.get('__dragContainer'), 'se-drag-over');
}
#OnDragLeave() {
this.#store.set('_preventBlur', false);
if (!dom.utils.hasClass(_DragHandle.get('__dragHandler'), 'se-drag-handle-full')) this.#$.ui._visibleControllers(true, true);
dom.utils.removeClass([_DragHandle.get('__dragCover'), _DragHandle.get('__dragContainer')], 'se-drag-over');
}
/**
* @param {DragEvent} e - Drag event
*/
#OnDragStart(e) {
const cover = _DragHandle.get('__dragCover') || _DragHandle.get('__dragContainer');
if (!cover) {
e.preventDefault();
return;
}
this.#store.set('_preventBlur', false);
dom.utils.addClass(_DragHandle.get('__dragHandler'), 'se-dragging');
dom.utils.addClass(_DragHandle.get('__dragContainer'), 'se-dragging');
e.dataTransfer.setDragImage(cover, this.#options.get('_rtl') ? cover.offsetWidth : -5, -5);
}
#OnDragEnd() {
this.#store.set('_preventBlur', false);
dom.utils.removeClass([_DragHandle.get('__dragHandler'), _DragHandle.get('__dragContainer')], 'se-dragging');
this.__removeDragEvent();
}
/**
* @param {MouseEvent} e - Mouse event
*/
#OnDragClick(e) {
const target = dom.query.getEventTarget(e);
if (!dom.utils.hasClass(target, 'se-drag-handle-full')) return;
const dragInst = _DragHandle.get('__dragInst');
if (!dragInst) return;
this.__removeDragEvent();
this.select(dragInst.currentTarget, dragInst.currentPluginName);
}
/**
* @param {MouseEvent} e - Mouse event
*/
#CloseListener_mousedown(e) {
const target = dom.query.getEventTarget(e);
if (
this.currentTarget?.contains(target) ||
dom.query.getParentElement(target, '.se-controller') ||
dom.utils.hasClass(target, 'se-drag-handle') ||
(this.currentPluginName === this.#$.ui.currentControllerName && this.#$.ui.opendControllers.some(({ form }) => form.contains(target)))
) {
return;
}
this.deselect();
}
/**
* @param {ClipboardEvent} e - Event object
*/
#OnCopy_component(e) {
const target = dom.query.getEventTarget(e);
if (dom.check.isInputElement(target) && dom.query.getParentElement(target, '.se-modal')) return;
const info = this.info;
if (!info) return;
const cloneContainer = info.container.cloneNode(true);
if (typeof this.#$.plugins[info.pluginName]?.componentCopy !== 'function' || this.#$.plugins[info.pluginName].componentCopy({ event: e, cloneContainer, info }) === false) {
SetClipboardComponent(e, cloneContainer, e.clipboardData);
}
// copy effect
dom.utils.flashClass(info.container, 'se-copy');
}
/**
* @param {ClipboardEvent} e - Event object
*/
#OnCut_component(e) {
const info = this.info;
if (!info) return;
const cloneContainer = info.container.cloneNode(true);
dom.utils.removeClass(cloneContainer, 'se-component-selected');
SetClipboardComponent(e, cloneContainer, e.clipboardData);
this.deselect();
dom.utils.removeItem(info.container);
}
/**
* @param {KeyboardEvent} e - Event object
*/
async #OnKeyDown_component(e) {
if (this.#$.ui.selectMenuOn) return;
const keyCode = e.code;
const ctrl = keyCodeMap.isCtrl(e);
// redo, undo
if (ctrl) {
if (keyCode !== 'ControlRight' && keyCode !== 'ControlLeft') {
const info = this.#$.shortcuts.keyMap.get(keyCode + (e.shiftKey ? '1000' : ''));
if (/^(redo|undo)$/.test(info?.command)) {
e.preventDefault();
e.stopPropagation();
this.#$.commandDispatcher.run(info.command, info.type, info.button);
}
}
return;
}
// backspace key, delete key
if (keyCodeMap.isRemoveKey(keyCode)) {
e.preventDefault();
e.stopPropagation();
if (typeof this.currentPlugin?.componentDestroy === 'function' && (!this.info.isInputType || !this.#store.get('hasFocus'))) {
const focusNode = this.info.container.previousSibling;
await this.currentPlugin.componentDestroy(this.currentTarget);
this.deselect();
if (focusNode) {
const offset = focusNode.nodeType === 3 ? focusNode.textContent.length : 1;
this.#$.selection.setRange(focusNode, offset, focusNode, offset);
} else {
this.#$.focusManager.focus();
}
return;
}
}
// enter key
if (keyCodeMap.isEnter(keyCode)) {
e.preventDefault();
const compContext = this.currentInfo || this.get(this.currentTarget);
const container = compContext.container || compContext.target;
const sibling = container.previousElementSibling || container.nextElementSibling;
let newEl = null;
if (dom.check.isListCell(container.parentNode)) {
newEl = dom.utils.createElement('BR');
} else {
newEl = dom.utils.createElement(this.#$.format.isLine(sibling) ? sibling.nodeName : this.#options.get('defaultLine'), null, '<br>');
}
const pluginName = this.currentPluginName;
this.deselect();
container.parentNode.insertBefore(newEl, container);
if (this.select(compContext.target, pluginName) === false) this.#$.focusManager.blur();
this.#$.history.push(false);
return;
}
// up down, left right
DIR_KEYCODE.lastIndex = 0;
if (DIR_KEYCODE.test(keyCode)) {
const { container } = this.get(this.currentTarget);
const isInline = this.isInline(container || this.currentTarget);
let el = null;
let offset = 1;
if (isInline) {
switch (keyCode) {
case 'ArrowLeft': // left
el = container.previousSibling;
offset = el?.nodeType === 3 ? el.textContent.length : 1;
break;
case 'ArrowRight': // right
el = container.nextSibling;
offset = 0;
break;
case 'ArrowUp': {
// up
const line = this.#$.format.getLine(container, null);
el = line?.previousElementSibling;
offset = 0;
break;
}
case 'ArrowDown': {
// down
const line = this.#$.format.getLine(container, null);
el = line?.nextElementSibling;
break;
}
}
} else {
DIR_UP_KEYCODE.lastIndex = 0;
if (DIR_UP_KEYCODE.test(keyCode)) {
el = container.previousElementSibling;
} else {
el = container.nextElementSibling;
offset = 0;
}
}
if (!el) return;
this.deselect();
const elComp = this.get(el);
if (elComp?.container) {
e.stopPropagation();
e.preventDefault();
this.select(elComp.target, elComp.pluginName);
} else {
try {
this.#store.set('_preventBlur', true);
e.stopPropagation();
e.preventDefault();
this.#$.selection.setRange(el, offset, el, offset);
} finally {
this.#store.set('_preventBlur', false);
}
}
return;
}
// ESC
if (keyCodeMap.isEsc(keyCode)) {
this.deselect();
return;
}
}
/**
* @internal
* @description Destroy the Component instance and release memory
*/
_destroy() {
this.__removeGlobalEvent();
this.__removeDragEvent();
}
}
function SetClipboardComponent(e, container, clipboardData) {
e.preventDefault();
e.stopPropagation();
RemoveSelectedClass(container);
clipboardData.setData('text/html', container.outerHTML);
}
function RemoveSelectedClass(container) {
dom.utils.removeClass(container, 'se-component-selected');
dom.utils.removeClass(container.querySelectorAll('.se-figure-selected'), 'se-figure-selected');
dom.utils.removeClass(container.querySelectorAll('.se-selected-table-cell'), 'se-selected-table-cell');
dom.utils.removeClass(container.querySelector('.se-selected-cell-focus'), 'se-selected-cell-focus');
}
export default Component;