suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
913 lines (789 loc) • 29.4 kB
JavaScript
import { dom, converter, keyCodeMap, env, numbers } from '../../../helper';
import { _DragHandle } from '../../../modules/ui';
import { COMMAND_BUTTONS } from './commandDispatcher';
const DISABLE_BUTTONS_CODEVIEW = `${COMMAND_BUTTONS}:not([class~="se-code-view-enabled"]):not([data-type="MORE"])`;
const DISABLE_BUTTONS_CONTROLLER = `${COMMAND_BUTTONS}:not([class~="se-component-enabled"]):not([data-type="MORE"])`;
const { _w } = env;
/**
* @description The UI class is a class that handles operations related to the user interface of SunEditor.
* - This class sets the editor's style, theme, editor mode, etc., and controls the state of various UI elements.
*/
class UIManager {
#kernel;
#$;
#store;
#contextProvider;
#carrierWrapper;
#options;
#context;
#frameRoots;
#frameContext;
#eventManager;
#alertArea;
#alertInner;
#closeListener;
#closeSignal;
#bindAlertClick = null;
#backWrapper;
#controllerOnBtnDisabled = false;
#bindClose = null;
#toastToggle = null;
/**
* @description List of buttons that are disabled when `controller` is opened
* @type {Array<HTMLButtonElement|HTMLInputElement>}
*/
#controllerOnDisabledButtons = [];
/**
* @description List of buttons that are disabled when `codeView` mode opened
* @type {Array<HTMLButtonElement|HTMLInputElement>}
*/
#codeViewDisabledButtons = [];
/**
* @description Variable that controls the `blur` event in the editor of `inline` or `balloon` mode when the focus is moved to dropdown
* @type {boolean}
*/
#notHideToolbar = false;
/**
* @description Line breaker (top)
* @type {HTMLElement}
*/
#lineBreaker_t = null;
/**
* @description Line breaker (bottom)
* @type {HTMLElement}
*/
#lineBreaker_b = 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.#context = this.#$.context;
this.#frameRoots = this.#$.frameRoots;
this.#frameContext = this.#$.frameContext;
this.#eventManager = this.#$.eventManager;
// alert
const alertModal = CreateAlertHTML(this.#contextProvider);
this.alertModal = alertModal;
this.alertMessage = alertModal.querySelector('span');
// toast
const toastPopup = CreateToastHTML();
this.toastPopup = toastPopup;
this.toastContainer = toastPopup.querySelector('.se-toast-container');
this.toastMessage = toastPopup.querySelector('span');
this.#carrierWrapper.appendChild(toastPopup);
// init
this.#alertArea = /** @type {HTMLElement} */ (this.#carrierWrapper.querySelector('.se-alert'));
this.#alertInner = /** @type {HTMLElement} */ (this.#carrierWrapper.querySelector('.se-alert .se-modal-inner'));
this.#alertInner.appendChild(alertModal);
this.#closeListener = [this.#OnCloseListener.bind(this), this.#OnClick_alert.bind(this)];
this.#closeSignal = false;
this.#backWrapper = /** @type {HTMLElement} */ (this.#carrierWrapper.querySelector('.se-back-wrapper'));
/**
* @description Whether `SelectMenu` is open
* @type {boolean}
*/
this.selectMenuOn = false;
/**
* @description Currently open `Controller` info array
* @type {Array<SunEditor.Module.Controller.Info>}
*/
this.opendControllers = [];
/**
* @description Controller target's frame div (`editor.frameContext.get('topArea')`)
* @type {?HTMLElement}
*/
this.controllerTargetContext = null;
/**
* @internal
* @description Current Figure container.
* @type {?HTMLElement}
*/
this._figureContainer = null;
}
/**
* @description Set editor frame styles.
* - Define the style of the edit area
* - It can also be defined with the `setOptions` method, but the `setEditorStyle` method does not render the editor again.
* @param {string} style Style string
* @param {?SunEditor.FrameContext} [fc] Frame context
*/
setEditorStyle(style, fc) {
fc ||= this.#frameContext;
const fo = fc.get('options');
fo.set('editorStyle', style);
const newStyles = converter._setDefaultOptionStyle(fo, style);
fo.set('_defaultStyles', newStyles);
// top area
fc.get('topArea').style.cssText = newStyles.top;
// code view
const code = fc.get('code');
if (this.#options.get('hasCodeMirror')) {
const frameStyleArr = fo.get('_defaultStyles').frame.split(';');
for (let i = 0, len = frameStyleArr.length, s; i < len; i++) {
s = frameStyleArr[i].trim();
if (!s) continue;
const [prop, val] = s.split(':');
code.style.setProperty(prop.trim(), val.trim());
}
} else {
code.style.cssText = fo.get('_defaultStyles').frame;
}
// wysiwyg frame
if (!fo.get('iframe')) {
fc.get('wysiwygFrame').style.cssText = newStyles.frame + newStyles.editor;
} else {
fc.get('wysiwygFrame').style.cssText = newStyles.frame;
fc.get('wysiwyg').style.cssText = newStyles.editor;
}
}
/**
* @description Set the theme to the editor
* @param {string} theme Theme name
*/
setTheme(theme) {
if (typeof theme !== 'string') return;
const o = this.#options;
const prevTheme = o.get('_themeClass').trim();
o.set('theme', theme || '');
o.set('_themeClass', theme ? ` se-theme-${theme}` : '');
theme = o.get('_themeClass').trim();
const applyTheme = (target) => {
if (!target) return;
if (prevTheme) dom.utils.removeClass(target, prevTheme);
if (theme) dom.utils.addClass(target, theme);
};
applyTheme(this.#carrierWrapper);
this.#contextProvider.applyToRoots((e) => {
applyTheme(e.get('topArea'));
applyTheme(e.get('wysiwyg'));
});
applyTheme(this.#context.get('statusbar_wrapper'));
applyTheme(this.#context.get('toolbar_wrapper'));
}
/**
* @description Set direction to `rtl` or `ltr`.
* @param {string} dir `rtl` or `ltr`
*/
setDir(dir) {
const rtl = dir === 'rtl';
if (this.#options.get('_rtl') === rtl) return;
try {
this.#options.set('_rtl', rtl);
this.offCurrentController();
const fc = this.#frameContext;
const plugins = this.#$.pluginManager.plugins;
for (const k in plugins) {
plugins[k].setDir?.(dir);
}
const toolbarWrapper = this.#context.get('toolbar_wrapper');
const statusbarWrapper = this.#context.get('statusbar_wrapper');
if (rtl) {
this.#contextProvider.applyToRoots((e) => {
dom.utils.addClass([e.get('topArea'), e.get('wysiwyg'), e.get('documentTypePageMirror')], 'se-rtl');
});
dom.utils.addClass([this.#carrierWrapper, toolbarWrapper, statusbarWrapper], 'se-rtl');
} else {
this.#contextProvider.applyToRoots((e) => {
dom.utils.removeClass([e.get('topArea'), e.get('wysiwyg'), e.get('documentTypePageMirror')], 'se-rtl');
});
dom.utils.removeClass([this.#carrierWrapper, toolbarWrapper, statusbarWrapper], 'se-rtl');
}
const lineNodes = dom.query.getListChildren(
fc.get('wysiwyg'),
(current) => {
return this.#$.format.isLine(current) && !!(current.style.marginRight || current.style.marginLeft || current.style.textAlign);
},
null,
);
for (let i = 0, n, l, r; (n = lineNodes[i]); i++) {
n = lineNodes[i];
// indent margin
r = n.style.marginRight;
l = n.style.marginLeft;
if (r || l) {
n.style.marginRight = l;
n.style.marginLeft = r;
}
// text align
r = n.style.textAlign;
if (r === 'left') n.style.textAlign = 'right';
else if (r === 'right') n.style.textAlign = 'left';
}
this.#activeDirBtn(rtl);
// document type
if (fc.has('documentType_use_header')) {
if (rtl) fc.get('wrapper').appendChild(fc.get('documentTypeInner'));
else fc.get('wrapper').insertBefore(fc.get('documentTypeInner'), fc.get('wysiwygFrame'));
}
if (fc.has('documentType_use_page')) {
if (rtl) fc.get('wrapper').insertBefore(fc.get('documentTypePage'), fc.get('wysiwygFrame'));
else fc.get('wrapper').appendChild(fc.get('documentTypePage'));
}
if (this.#store.mode.isBalloon) this.#$.toolbar._showBalloon();
else if (this.#store.mode.isSubBalloon) this.#$.subToolbar._showBalloon();
} catch (e) {
this.#options.set('_rtl', !rtl);
console.warn(`[SUNEDITOR.ui.setDir.fail] ${e.toString()}`);
}
this.#store.set('_lastSelectionNode', null);
this.#kernel._eventOrchestrator.applyTagEffect();
}
/**
* @description Switch to or off `ReadOnly` mode.
* @param {boolean} value `readOnly` boolean value.
* @param {string} [rootKey] Root key
*/
readOnly(value, rootKey) {
const fc = rootKey ? this.#frameRoots.get(rootKey) : this.#frameContext;
fc.set('isReadOnly', !!value);
this._toggleControllerButtons(!!value);
if (value) {
this.offCurrentController();
this.offCurrentModal();
if (this.#$.toolbar?.currentMoreLayerActiveButton?.disabled) this.#$.toolbar._moreLayerOff();
if (this.#$.subToolbar?.currentMoreLayerActiveButton?.disabled) this.#$.subToolbar._moreLayerOff();
if (this.#$.menu?.currentDropdownActiveButton?.disabled) this.#$.menu.dropdownOff();
if (this.#$.menu?.currentContainerActiveButton?.disabled) this.#$.menu.containerOff();
fc.get('code').setAttribute('readOnly', 'true');
dom.utils.addClass(fc.get('wysiwyg'), 'se-read-only');
} else {
fc.get('code').removeAttribute('readOnly');
dom.utils.removeClass(fc.get('wysiwyg'), 'se-read-only');
}
if (this.#options.get('hasCodeMirror')) {
this.#$.viewer._codeMirrorEditor('readonly', !!value, rootKey);
}
}
/**
* @description Disables the editor.
* @param {string} [rootKey] Root key
*/
disable(rootKey) {
const fc = rootKey ? this.#frameRoots.get(rootKey) : this.#frameContext;
this.#$.toolbar.disable();
this.offCurrentController();
this.offCurrentModal();
fc.get('wysiwyg').setAttribute('contenteditable', 'false');
fc.set('isDisabled', true);
if (this.#options.get('hasCodeMirror')) {
this.#$.viewer._codeMirrorEditor('readonly', true, rootKey);
} else {
fc.get('code').disabled = true;
}
}
/**
* @description Enables the editor.
* @param {string} [rootKey] Root key
*/
enable(rootKey) {
const fc = rootKey ? this.#frameRoots.get(rootKey) : this.#frameContext;
this.#$.toolbar.enable();
fc.get('wysiwyg').setAttribute('contenteditable', 'true');
fc.set('isDisabled', false);
if (this.#options.get('hasCodeMirror')) {
this.#$.viewer._codeMirrorEditor('readonly', false, rootKey);
} else {
fc.get('code').disabled = false;
}
}
/**
* @description Shows the editor interface.
* @param {string} [rootKey] Root key
*/
show(rootKey) {
const fc = rootKey ? this.#frameRoots.get(rootKey) : this.#frameContext;
const topAreaStyle = fc.get('topArea').style;
if (topAreaStyle.display === 'none') topAreaStyle.display = 'block';
}
/**
* @description Hides the editor interface.
* @param {string} [rootKey] Root key
*/
hide(rootKey) {
const fc = rootKey ? this.#frameRoots.get(rootKey) : this.#frameContext;
fc.get('topArea').style.display = 'none';
}
/**
* @description Shows the loading spinner.
* @param {string} [rootKey] Root key
*/
showLoading(rootKey) {
/** @type {HTMLElement} */ ((rootKey ? this.#frameRoots.get(rootKey).get('container') : this.#carrierWrapper).querySelector('.se-loading-box')).style.display = 'block';
}
/**
* @description Hides the loading spinner.
* @param {string} [rootKey] Root key
*/
hideLoading(rootKey) {
/** @type {HTMLElement} */ ((rootKey ? this.#frameRoots.get(rootKey).get('container') : this.#carrierWrapper).querySelector('.se-loading-box')).style.display = 'none';
}
/**
* @description Open the alert panel
* @param {string} text alert message
* @param {""|"error"|"success"} type alert type
*/
alertOpen(text, type) {
this.alertMessage.textContent = text;
dom.utils.removeClass(this.alertModal, 'se-alert-error|se-alert-success');
if (type) dom.utils.addClass(this.alertModal, `se-alert-${type}`);
if (this.#closeSignal) this.#bindAlertClick = this.#eventManager.addEvent(this.#alertInner, 'click', this.#closeListener[1]);
this.#bindClose &&= this.#eventManager.removeGlobalEvent(this.#bindClose);
this.#bindClose = this.#eventManager.addGlobalEvent('keydown', this.#closeListener[0]);
this.#alertArea.style.display = 'block';
dom.utils.addClass(this.alertModal, 'se-modal-show');
}
/**
* @description Close the alert panel
*/
alertClose() {
dom.utils.removeClass(this.alertModal, 'se-modal-show');
dom.utils.removeClass(this.alertModal, 'se-alert-*');
this.#alertArea.style.display = 'none';
this.#bindAlertClick &&= this.#eventManager.removeEvent(this.#bindAlertClick);
this.#bindClose &&= this.#eventManager.removeGlobalEvent(this.#bindClose);
}
/**
* @description Show toast
* @param {string} message toast message
* @param {number} [duration=1000] duration time(ms)
* @param {""|"error"|"success"} [type=""] duration time(ms)
*/
showToast(message, duration = 1000, type) {
if (dom.utils.hasClass(this.toastContainer, 'se-toast-show')) {
this.closeToast();
}
dom.utils.removeClass(this.toastPopup, 'se-toast-error|se-toast-success');
if (type) dom.utils.addClass(this.toastPopup, `se-toast-${type}`);
this.toastPopup.style.display = 'block';
this.toastMessage.textContent = message;
dom.utils.addClass(this.toastContainer, 'se-toast-show');
// Auto-dismiss toast after display duration (cleared if toast is manually closed)
this.#toastToggle = _w.setTimeout(() => {
this.closeToast();
}, duration);
}
/**
* @description Close toast
*/
closeToast() {
if (this.#toastToggle) _w.clearTimeout(this.#toastToggle);
this.#toastToggle = null;
dom.utils.removeClass(this.toastContainer, 'se-toast-show');
this.toastPopup.style.display = 'none';
}
/**
* @description This method disables or enables the toolbar buttons when the `controller` is activated or deactivated.
* - When the `controller` is activated, the toolbar buttons are disabled; when the `controller` is deactivated, the buttons are enabled.
* @param {boolean} active If `true`, the toolbar buttons will be disabled. If `false`, the toolbar buttons will be enabled.
* @returns {boolean} The current state of the controller on disabled buttons.
*/
setControllerOnDisabledButtons(active) {
if (active && !this.#controllerOnBtnDisabled) {
this._toggleControllerButtons(true);
this.#controllerOnBtnDisabled = true;
} else if (!active && this.#controllerOnBtnDisabled) {
this._toggleControllerButtons(false);
this.#controllerOnBtnDisabled = false;
}
return this.#controllerOnBtnDisabled;
}
/**
* @description Set the controller target context to the current top area.
*/
onControllerContext() {
this.controllerTargetContext = this.#frameContext.get('topArea');
}
/**
* @description Reset the controller target context.
*/
offControllerContext() {
this.controllerTargetContext = null;
}
/**
* @description Activate the transparent background `div` so that other elements are not affected during resizing.
* @param {string} cursor cursor css property
*/
enableBackWrapper(cursor) {
this.#backWrapper.style.cursor = cursor;
this.#backWrapper.style.display = 'block';
}
/**
* @description Disabled background `div`
*/
disableBackWrapper() {
this.#backWrapper.style.display = 'none';
this.#backWrapper.style.cursor = 'default';
}
/**
* @description Closes the currently active controller by delegating to the component's deselect logic.
* Use this method to close a single active controller from external code.
* @see _offControllers - For closing all open controllers at once (internal use)
*/
offCurrentController() {
this.#$.component.__deselect();
}
/**
* @description Closes the currently open modal dialog.
*/
offCurrentModal() {
this.opendModal?.close();
}
/**
* @description Get the current figure container only if it is visible (active).
* @returns {?HTMLElement} The active figure element or `null`.
*/
getVisibleFigure() {
return this._figureContainer?.style.display === 'block' ? this._figureContainer : null;
}
/**
* @description Set the active figure element (image, video) being resized.
* @param {?HTMLElement} figure
*/
setFigureContainer(figure) {
this._figureContainer = figure;
}
preventToolbarHide(allow) {
this.#notHideToolbar = allow;
}
get isPreventToolbarHide() {
return this.#notHideToolbar;
}
/**
* @param {SunEditor.FrameContext} rt Root target[key] FrameContext
*/
reset(rt) {
rt.set('_editorHeight', rt.get('wysiwygFrame').offsetHeight);
this.#lineBreaker_t = rt.get('lineBreaker_t');
this.#lineBreaker_b = rt.get('lineBreaker_b');
}
/**
* @internal
* @description Closes all open controllers except those marked as `fixed`.
* Iterates through `opendControllers`, calls `controllerClose` on each non-fixed controller,
* hides their forms, and resets the controller state.
* @see offCurrentController - Public method for closing a single controller via component deselect
*/
_offControllers() {
const cont = this.opendControllers;
const fixedCont = [];
for (let i = 0, c; i < cont.length; i++) {
c = cont[i];
if (c.fixed) {
fixedCont.push(c);
continue;
}
c.inst.controllerClose?.();
if (c.form) c.form.style.display = 'none';
}
this.opendControllers = fixedCont;
this.currentControllerName = '';
this.#store.set('_preventBlur', false);
}
/**
* @internal
* @description Synchronizes floating UI element positions with the current scroll offset.
* Called by eventManager when the wysiwyg area is scrolled.
* - Adjusts balloon toolbar position based on scroll offset
* - Closes controllers if scroll target changes
* - Updates line breaker positions
* @param {SunEditor.EventWysiwyg} eventWysiwyg - The scroll event source (Window or element with scroll data)
*/
_syncScrollPosition(eventWysiwyg) {
const y = eventWysiwyg.scrollTop || eventWysiwyg.scrollY || 0;
const x = eventWysiwyg.scrollLeft || eventWysiwyg.scrollX || 0;
if (this.#store.mode.isBalloon && this.#context.get('toolbar_main').style.display === 'block') {
this.#context.get('toolbar_main').style.top = this.#$.toolbar.balloonOffset.top - y + 'px';
this.#context.get('toolbar_main').style.left = this.#$.toolbar.balloonOffset.left - x + 'px';
} else if (this.#store.mode.isSubBalloon && this.#context.get('toolbar_sub_main').style.display === 'block') {
this.#context.get('toolbar_sub_main').style.top = this.#$.subToolbar.balloonOffset.top - y + 'px';
this.#context.get('toolbar_sub_main').style.left = this.#$.subToolbar.balloonOffset.left - x + 'px';
}
if (this.controllerTargetContext !== this.#frameContext.get('topArea')) {
this.offCurrentController();
}
this.#resetLineBreaker(x, y);
}
/**
* @internal
* @description Repositions all currently open controllers after scroll.
* Called by eventManager during container scroll events.
* - Triggers drag handle repositioning if active
* - Calls _scrollReposition on each open controller
*/
_repositionControllers() {
const openCont = this.opendControllers;
if (openCont.length === 0) return;
if (_DragHandle.get('__dragMove')) _DragHandle.get('__dragMove')();
for (let i = 0; i < openCont.length; i++) {
if (openCont[i].notInCarrier) continue;
openCont[i].inst?._scrollReposition();
}
}
/**
*
* @param {number} x
* @param {number} y
*/
#resetLineBreaker(x, y) {
if (this.#lineBreaker_t) {
const t_style = this.#lineBreaker_t.style;
if (t_style.display !== 'none') {
const t_offset = (this.#lineBreaker_t.getAttribute('data-offset') || ',').split(',');
t_style.top = numbers.get(t_style.top, 0) - (y - numbers.get(t_offset[0], 0)) + 'px';
t_style.left = numbers.get(t_style.left, 0) - (x - numbers.get(t_offset[1], 0)) + 'px';
this.#lineBreaker_t.setAttribute('data-offset', y + ',' + x);
}
}
if (this.#lineBreaker_b) {
const b_style = this.#lineBreaker_b.style;
if (b_style.display !== 'none') {
const b_offset = (this.#lineBreaker_b.getAttribute('data-offset') || ',').split(',');
b_style.top = numbers.get(b_style.top, 0) - (y - numbers.get(b_offset[0], 0)) + 'px';
b_style[b_offset[1]] = numbers.get(b_style[b_offset[1]], 0) - (x - numbers.get(b_offset[2], 0)) + 'px';
this.#lineBreaker_b.setAttribute('data-offset', y + ',' + b_offset[1] + ',' + x);
}
}
const openCont = this.opendControllers;
for (let i = 0; i < openCont.length; i++) {
if (!openCont[i].notInCarrier) continue;
openCont[i].form.style.top = openCont[i].inst.__offset.top - y + 'px';
openCont[i].form.style.left = openCont[i].inst.__offset.left - x + 'px';
}
}
/**
* @internal
* @description Visible controllers
* @param {boolean} value hidden/show
* @param {?boolean} [lineBreakShow] Line break hidden/show (default: Follows the value `value`.)
*/
_visibleControllers(value, lineBreakShow) {
const visible = value ? '' : 'hidden';
const breakerVisible = (lineBreakShow ?? visible) ? '' : 'hidden';
const cont = this.opendControllers;
for (let i = 0, c; i < cont.length; i++) {
c = cont[i];
if (c.form) c.form.style.visibility = visible;
}
this.#lineBreaker_t.style.visibility = breakerVisible;
this.#lineBreaker_b.style.visibility = breakerVisible;
}
setCurrentControllerContext;
/**
* @description Toggles direction button active state.
* @param {boolean} rtl - Whether the text direction is right-to-left.
*/
#activeDirBtn(rtl) {
const icons = this.#contextProvider.icons;
const commandTargets = this.#$.commandDispatcher?.targets;
if (!commandTargets) return;
const shortcutsKeyMap = this.#$.shortcuts?.keyMap;
// change reverse shortcuts key
this.#$.shortcuts?.reverseKeys?.forEach((e) => {
const info = shortcutsKeyMap?.get(e);
if (!info) return;
[info.command, info.r] = [info.r, info.command];
});
// change dir buttons
this.#$.commandDispatcher.applyTargets('dir', (e) => {
dom.utils.changeTxt(e.querySelector('.se-tooltip-text'), this.#contextProvider.lang[rtl ? 'dir_ltr' : 'dir_rtl']);
dom.utils.changeElement(e.firstElementChild, icons[rtl ? 'dir_ltr' : 'dir_rtl']);
});
if (rtl) {
dom.utils.addClass(commandTargets.get('dir_rtl'), 'active');
dom.utils.removeClass(commandTargets.get('dir_ltr'), 'active');
} else {
dom.utils.addClass(commandTargets.get('dir_ltr'), 'active');
dom.utils.removeClass(commandTargets.get('dir_rtl'), 'active');
}
}
/**
* @internal
* @description Set the disabled button list
*/
_initToggleButtons() {
const ctx = this.#context;
this.#codeViewDisabledButtons = converter.nodeListToArray(ctx.get('toolbar_buttonTray').querySelectorAll(DISABLE_BUTTONS_CODEVIEW));
this.#controllerOnDisabledButtons = converter.nodeListToArray(ctx.get('toolbar_buttonTray').querySelectorAll(DISABLE_BUTTONS_CONTROLLER));
if (this.#options.has('_subMode')) {
this.#codeViewDisabledButtons = this.#codeViewDisabledButtons.concat(converter.nodeListToArray(ctx.get('toolbar_sub_buttonTray').querySelectorAll(DISABLE_BUTTONS_CODEVIEW)));
this.#controllerOnDisabledButtons = this.#controllerOnDisabledButtons.concat(converter.nodeListToArray(ctx.get('toolbar_sub_buttonTray').querySelectorAll(DISABLE_BUTTONS_CONTROLLER)));
}
}
/**
* @internal
* @description Toggle the disabled state of buttons reserved for Code View.
* @param {boolean} isCodeView
*/
_toggleCodeViewButtons(isCodeView) {
dom.utils.setDisabled(this.#codeViewDisabledButtons, isCodeView);
}
/**
* @internal
* @description Toggle the disabled state of buttons when a controller is active.
* @param {boolean} isOpen
*/
_toggleControllerButtons(isOpen) {
dom.utils.setDisabled(this.#controllerOnDisabledButtons, isOpen);
}
/**
* @description Check if the button can be executed in the current state (ReadOnly, etc.)
* @param {Node} button
* @returns {boolean}
*/
isButtonDisabled(button) {
if (this.#frameContext.get('isReadOnly') && dom.utils.arrayIncludes(this.#controllerOnDisabledButtons, button)) {
return true;
}
return false;
}
/**
* @internal
* @description Updates `placeholder` visibility based on editor state.
* Shows `placeholder` when editor is empty, hides it in code view or when content exists.
* @param {SunEditor.FrameContext} [fc] - Frame context (defaults to current frameContext)
*/
_updatePlaceholder(fc) {
fc ||= this.#frameContext;
const placeholder = fc.get('placeholder');
if (placeholder) {
if (fc.get('isCodeView')) {
placeholder.style.display = 'none';
return;
}
if (this.#$.facade.isEmpty(fc)) {
placeholder.style.display = 'block';
} else {
placeholder.style.display = 'none';
}
}
}
/**
* @internal
* @description Synchronizes frame UI state after content changes.
* Coordinates `iframe` height adjustment, `placeholder` visibility, and document type page sync.
* @param {SunEditor.FrameContext} fc - Frame context to synchronize
*/
_syncFrameState(fc) {
if (!fc) return;
this._iframeAutoHeight(fc);
this._updatePlaceholder(fc);
// document type page
if (fc.has('documentType_use_page')) {
fc.get('documentTypePageMirror').innerHTML = fc.get('wysiwyg').innerHTML;
fc.get('documentType').rePage(true);
}
}
/**
* @internal
* @description Adjusts `iframe` height to match content height.
* Handles `auto`-height `iframe`s and manages scrolling based on `maxHeight` option.
* @param {SunEditor.FrameContext} fc - Frame context containing the `iframe`
*/
_iframeAutoHeight(fc) {
if (!fc) return;
const autoFrame = fc.get('_iframeAuto');
if (autoFrame) {
// Defer iframe height measurement — content must render/reflow before measuring offsetHeight
_w.setTimeout(() => {
const h = autoFrame.offsetHeight;
const wysiwygFrame = fc.get('wysiwygFrame');
if (!wysiwygFrame) return;
wysiwygFrame.style.height = h + 'px';
// maxHeight
const fo = fc.get('options');
if (fo?.get('iframe')) {
const maxHeight = fo.get('maxHeight');
if (maxHeight) {
wysiwygFrame.setAttribute('scrolling', h > numbers.get(maxHeight) ? 'auto' : 'no');
}
}
if (!env.isResizeObserverSupported) this._emitResizeEvent(fc, h, null);
}, 0);
} else if (!env.isResizeObserverSupported) {
const wysiwygFrame = fc.get('wysiwygFrame');
if (wysiwygFrame) {
this._emitResizeEvent(fc, wysiwygFrame.offsetHeight, null);
}
}
}
/**
* @internal
* @description Emits the `onResizeEditor` event when editor height changes.
* Calculates height from `ResizeObserverEntry` if not provided directly.
* @param {SunEditor.FrameContext} fc - Frame context
* @param {number} h - Height value (`-1` to calculate from `resizeObserverEntry`)
* @param {ResizeObserverEntry|null} resizeObserverEntry - `ResizeObserver` entry for height calculation
*/
_emitResizeEvent(fc, h, resizeObserverEntry) {
h =
h === -1
? resizeObserverEntry?.borderBoxSize && resizeObserverEntry.borderBoxSize[0]
? resizeObserverEntry.borderBoxSize[0].blockSize
: resizeObserverEntry.contentRect.height + numbers.get(fc.get('wwComputedStyle').getPropertyValue('padding-left')) + numbers.get(fc.get('wwComputedStyle').getPropertyValue('padding-right'))
: h;
if (fc.get('_editorHeight') !== h) {
this.#eventManager.triggerEvent('onResizeEditor', { height: h, prevHeight: fc.get('_editorHeight'), frameContext: fc, observerEntry: resizeObserverEntry });
fc.set('_editorHeight', h);
}
// document type page
if (fc.has('documentType_use_page')) {
fc.get('documentType').resizePage();
}
}
init() {
this.#closeSignal = !this.#eventManager.addEvent(this.alertModal.querySelector('[data-command="close"]'), 'click', this.alertClose.bind(this));
this._initToggleButtons();
}
/**
* @param {MouseEvent} e - Event object
*/
#OnClick_alert(e) {
const eventTarget = dom.query.getEventTarget(e);
if (/close/.test(eventTarget.getAttribute('data-command')) || eventTarget === this.#alertInner) {
this.alertClose();
}
}
/**
* @param {KeyboardEvent} e - Event object
*/
#OnCloseListener(e) {
if (!keyCodeMap.isEsc(e.code)) return;
this.alertClose();
}
/**
* @internal
* @description Destroy the UI instance and release memory
*/
_destroy() {
// Clear timer
if (this.#toastToggle) {
_w.clearTimeout(this.#toastToggle);
}
// Remove global event
this.#bindClose &&= this.#eventManager.removeGlobalEvent(this.#bindClose);
// Remove alert click event listener
this.#bindAlertClick &&= this.#eventManager.removeEvent(this.#bindAlertClick);
this.opendModal = null;
this.opendBrowser = null;
this.#lineBreaker_t = null;
this.#lineBreaker_b = null;
this.#controllerOnDisabledButtons = null;
this.#codeViewDisabledButtons = null;
}
}
function CreateAlertHTML({ lang, icons }) {
const html = '<div><button class="close" data-command="close" title="' + lang.close + '">' + icons.cancel + '</button></div><div><span></span></div>';
return dom.utils.createElement('DIV', { class: 'se-alert-content' }, html);
}
function CreateToastHTML() {
const html = '<div class="se-toast-container"><span></span></div>';
return dom.utils.createElement('DIV', { class: 'se-toast' }, html);
}
export default UIManager;