suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
1,066 lines (928 loc) • 33.4 kB
JavaScript
import { dom, converter, keyCodeMap, env, numbers } from '../../../helper';
import { _DragHandle } from '../../../modules/ui';
import BlockHandle from '../panel/blockHandle.js';
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;
/**
* @description Block handle instance
* @type {BlockHandle}
*/
#blockHandle = 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();
toastPopup.setAttribute('popover', 'manual');
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 {"rtl"|"ltr"} dir `rtl` or `ltr`
*/
setDir(dir) {
const rtl = dir === 'rtl';
if (this.#options.get('_rtl') === rtl) return;
const prevDir = this.#options.get('textDirection');
const prevEditableClass = this.#options.get('_editableClass');
const prevPrintClass = this.#options.get('printClass');
try {
this.#options.set('_rtl', rtl);
this.#options.set('textDirection', dir);
// update _editableClass / printClass
const editableClass = rtl
? this.#options.get('_editableClass').replace(/\s*se-rtl/, '') + ' se-rtl'
: this.#options.get('_editableClass').replace(/\s*se-rtl/, '');
this.#options.set('_editableClass', editableClass);
if (this.#options.get('printClass')) {
this.#options.set(
'printClass',
rtl
? this.#options.get('printClass').replace(/\s*se-rtl/, '') + ' se-rtl'
: this.#options.get('printClass').replace(/\s*se-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');
e.get('wysiwyg').dir = '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',
);
e.get('wysiwyg').removeAttribute('dir');
});
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);
// reverse toolbar buttons
this.#reverseToolbarButtons(this.#context.get('toolbar_buttonTray'));
if (this.#context.has('toolbar_sub_buttonTray')) {
this.#reverseToolbarButtons(this.#context.get('toolbar_sub_buttonTray'));
}
if (this.#store.mode.isBalloon) this.#$.toolbar._showBalloon();
else if (this.#store.mode.isSubBalloon) this.#$.subToolbar._showBalloon();
} catch (e) {
this.#options.set('_rtl', !rtl);
this.#options.set('textDirection', prevDir);
this.#options.set('_editableClass', prevEditableClass);
if (prevPrintClass !== null) this.#options.set('printClass', prevPrintClass);
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) {
const el = /** @type {HTMLElement} */ (
(rootKey ? this.#frameRoots.get(rootKey).get('container') : this.#carrierWrapper).querySelector(
'.se-loading-box',
)
);
el.style.display = 'block';
el.showPopover?.();
}
/**
* @description Hides the loading spinner.
* @param {string} [rootKey] Root key
*/
hideLoading(rootKey) {
const el = /** @type {HTMLElement} */ (
(rootKey ? this.#frameRoots.get(rootKey).get('container') : this.#carrierWrapper).querySelector(
'.se-loading-box',
)
);
el.hidePopover?.();
el.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';
this.#alertArea.showPopover?.();
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.hidePopover?.();
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.toastPopup.showPopover?.();
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.hidePopover?.();
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';
this.#backWrapper.showPopover?.();
}
/**
* @description Disabled background `div`
*/
disableBackWrapper() {
this.#backWrapper.hidePopover?.();
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
*/
/**
* @description Block handle instance for external access.
* @type {BlockHandle}
*/
get blockHandle() {
return this.#blockHandle;
}
reset(rt) {
rt.set('_editorHeight', rt.get('wysiwygFrame').offsetHeight);
this.#lineBreaker_t = rt.get('lineBreaker_t');
this.#lineBreaker_b = rt.get('lineBreaker_b');
// block handle init
const blockHandleOpt = this.#options.get('blockHandle');
if (blockHandleOpt && rt.get('blockHandleArea') && !this.#blockHandle) {
this.#blockHandle = new BlockHandle(
this.#$,
rt.get('blockHandleArea'),
rt.get('blockHandle'),
rt.get('blockHandlePlus'),
rt.get('blockHandleDrag'),
blockHandleOpt.menu,
);
}
}
/**
* @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';
}
// block handle scroll sync
this.#blockHandle?.syncScroll();
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');
}
}
/**
* @description Reverse the order of toolbar button groups (excluding the more-layer).
* @param {HTMLElement} buttonTray - The `.se-btn-tray` element.
*/
#reverseToolbarButtons(buttonTray) {
if (!buttonTray) return;
const moreLayer = buttonTray.querySelector('.se-toolbar-more-layer');
const children = Array.from(buttonTray.children).filter((c) => c !== moreLayer);
for (let i = children.length - 1; i >= 0; i--) {
buttonTray.appendChild(children[i]);
}
if (moreLayer) buttonTray.appendChild(moreLayer);
}
/**
* @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 isCodeView = fc.get('isMarkdownView') || fc.get('isCodeView');
const lineShown = !isCodeView && this.#updateLinePlaceholder(fc);
const placeholder = fc.get('placeholder');
if (placeholder) {
placeholder.style.display = !isCodeView && !lineShown && this.#$.facade.isEmpty(fc) ? 'block' : 'none';
}
}
/**
* @internal
* @description Notion-style per-line placeholder. Marks the focused empty line so a CSS `::before`
* renders the hint text on it.
* @param {SunEditor.FrameContext} fc - Frame context
* @returns {boolean} Whether the line placeholder is currently shown.
*/
#updateLinePlaceholder(fc) {
const text = fc.get('placeholder_line');
if (!text) return false;
const wysiwyg = fc.get('wysiwyg');
const line = this.#store.get('hasFocus') ? this.#$.format.getLine(this.#$.selection.selectionNode) : null;
const inTableCell = !!line && !!dom.query.getParentElement(line, dom.check.isTableCell);
const target = dom.check.isEmptyLine(line) && !dom.check.isListCell(line) && !inTableCell ? line : null;
const prevMarkers = wysiwyg.querySelectorAll('.se-placeholder-line');
for (let i = 0; i < prevMarkers.length; i++) {
const prev = prevMarkers[i];
if (prev === target) continue;
prev.classList.remove('se-placeholder-line');
prev.removeAttribute('data-se-placeholder-line');
if (!prev.getAttribute('class')) prev.removeAttribute('class');
}
if (!target) return false;
target.classList.add('se-placeholder-line');
target.setAttribute('data-se-placeholder-line', text);
return true;
}
/**
* @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.#blockHandle?.destroy();
this.#blockHandle = null;
this.#controllerOnDisabledButtons = null;
this.#codeViewDisabledButtons = null;
}
}
function CreateAlertHTML({ lang, icons }) {
const html =
'<div><button type="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;