suneditor
Version:
Vanilla JavaScript based WYSIWYG web editor
581 lines (526 loc) • 18 kB
JavaScript
import { dom, env, keyCodeMap } from '../../helper';
const { _w } = env;
const MENU_MIN_HEIGHT = 38;
/**
* @typedef {Object} SelectMenuParams
* @property {string} position Position of the select menu, specified as `"[left|right]-[middle|top|bottom]"` or `"[top|bottom]-[center|left|right]"`.
* ```js
* // position
* 'left-bottom' // menu appears below, aligned to the left
* 'top-center' // menu appears above, centered
* ```
* @property {boolean} [checkList=false] Flag to determine if the checklist is enabled (`true` or `false`)
* @property {"rtl" | "ltr"} [dir="ltr"] Optional text direction: `"rtl"` for right-to-left, `"ltr"` for left-to-right
* @property {number} [splitNum=0] Optional split number for horizontal positioning; defines how many items per row
* @property {() => void} [openMethod] Optional method to call when the menu is opened
* @property {() => void} [closeMethod] Optional method to call when the menu is closed
* @property {string} [maxHeight] Optional max-height CSS value (e.g. `"200px"`). Enables scrolling when items exceed this height.
* @property {string} [minWidth] Optional min-width CSS value (e.g. `"130px"`).
*/
/**
* @class
* @description Creates a select menu
*/
class SelectMenu {
#$;
#dirPosition;
#dirSubPosition;
#textDirDiff;
#eventHandlers;
#globalEventHandlers;
#refer = null;
#keydownTarget = null;
#selectMethod = null;
#bindClose_key = null;
#bindClose_mousedown = null;
#bindClose_click = null;
#events = null;
/**
* @constructor
* @param {SunEditor.Deps} $ Kernel dependencies
* @param {SelectMenuParams} params SelectMenu options
*/
constructor($, params) {
this.#$ = $;
// members
const positionItems = params.position.split('-');
this.form = null;
this.items = [];
/** @type {HTMLLIElement[]} */
this.menus = null;
this.menuLen = 0;
this.index = -1;
this.item = null;
this.isOpen = false;
this.checkList = !!params.checkList;
this.position = positionItems[0];
this.subPosition = positionItems[1];
this.splitNum = params.splitNum || 0;
this.horizontal = !!this.splitNum;
this.openMethod = params.openMethod;
this.closeMethod = params.closeMethod;
this.maxHeight = params.maxHeight || '';
this.minWidth = params.minWidth || '';
this.#dirPosition = /^(left|right)$/.test(this.position) ? (this.position === 'left' ? 'right' : 'left') : this.position;
this.#dirSubPosition = /^(left|right)$/.test(this.subPosition) ? (this.subPosition === 'left' ? 'right' : 'left') : this.subPosition;
this.#textDirDiff = params.dir === 'ltr' ? false : params.dir === 'rtl' ? true : null;
this.#eventHandlers = {
mousedown: this.#OnMousedown_list.bind(this),
mousemove: this.#OnMouseMove_list.bind(this),
click: this.#OnClick_list.bind(this),
keydown: this.#OnKeyDown_refer.bind(this),
};
this.#globalEventHandlers = { keydown: this.#CloseListener_key.bind(this), mousedown: this.#CloseListener_mousedown.bind(this), click: this.#CloseListener_click.bind(this) };
}
/**
* @description Creates the select menu items.
* @param {Array<string>|SunEditor.NodeCollection} items - Command list of selectable items.
* @param {Array<string>|SunEditor.NodeCollection} [menus] - Optional list of menu display elements; defaults to `items`.
*/
create(items, menus) {
this.form.firstElementChild.innerHTML = '';
menus ||= items;
let html = '';
for (let i = 0, len = menus.length; i < len; i++) {
if (i > 0 && i % this.splitNum === 0) {
this.#createFormat(html);
html = '';
}
html += `<li class="se-select-item" data-index="${i}">${typeof menus[i] === 'string' ? menus[i] : /** @type {HTMLElement} */ (menus[i]).outerHTML}</li>`;
}
this.#createFormat(html);
this.items = /** @type {Array<string|Node>} */ (items);
this.menus = Array.from(this.form.querySelectorAll('li'));
this.menuLen = this.menus.length;
}
/**
* @description Initializes the select menu and attaches it to a reference element.
* @param {Node} referElement - The element that triggers the select menu.
* @param {(command: string) => void} selectMethod - The function to execute when an item is selected.
* @param {{class?: string, style?: string}} [attr={}] - Additional attributes for the select menu container.
* @example
* // Basic: attach menu to a button with a selection callback
* selectMenu.on(this.alignButton, this.onAlignSelect.bind(this));
*
* // With custom attributes for styling
* selectMenu.on(this.alignButton, this.onAlignSelect.bind(this), { class: 'se-figure-select-list' });
*/
on(referElement, selectMethod, attr = {}) {
this.#refer = /** @type {HTMLElement} */ (referElement);
this.#keydownTarget = dom.check.isInputElement(referElement) ? referElement : this.#$.frameContext.get('_ww');
this.#selectMethod = selectMethod;
let innerStyle = '';
if (this.maxHeight) innerStyle += 'max-height:' + this.maxHeight + ';overflow-y:auto;';
if (this.minWidth) innerStyle += 'min-width:' + this.minWidth + ';';
this.form = dom.utils.createElement(
'DIV',
{
class: 'se-select-menu' + (attr.class ? ' ' + attr.class : ''),
style: attr.style || '',
},
'<div class="se-list-inner"' + (innerStyle ? ' style="' + innerStyle + '"' : '') + '></div>',
);
referElement.parentNode.insertBefore(this.form, referElement);
}
/**
* @description Select menu open
* @param {?string} [position] `"[left|right]-[middle|top|bottom] | [top|bottom]-[center|left|right]"`
* Always specify in LTR orientation. In RTL environments, left/right are automatically swapped.
* @param {?string} [onItemQuerySelector] The querySelector string of the menu to be activated
* @example
* // Open with default position (uses constructor's position param)
* selectMenu.open();
*
* // Open at a specific position (always use LTR basis; RTL is auto-mirrored)
* selectMenu.open('bottom-left');
*
* // Open with an active item highlighted via querySelector
* selectMenu.open('', '[data-command="' + this.align + '"]');
*/
open(position, onItemQuerySelector) {
this.#$.ui.selectMenuOn = true;
this.openMethod?.();
this.#addEvents();
this.#addGlobalEvent();
const positionItems = position ? position.split('-') : [];
const mainPosition = positionItems[0] || (this.#textDirDiff !== null && this.#textDirDiff !== this.#$.options.get('_rtl') ? this.#dirPosition : this.position);
const subPosition = positionItems[1] || (this.#textDirDiff !== null && this.#textDirDiff !== this.#$.options.get('_rtl') ? this.#dirSubPosition : this.subPosition);
this.#setPosition(mainPosition, subPosition, onItemQuerySelector);
this.isOpen = true;
}
/**
* @description Select menu close
*/
close() {
this.#$.ui.selectMenuOn = false;
dom.utils.removeClass(this.#refer, 'on');
this.#init();
this.form?.removeAttribute('style');
this.isOpen = false;
this.closeMethod?.();
}
/**
* @description Get the index of the selected item
* @param {number} index Item index
* @returns
*/
getItem(index) {
return this.items[index];
}
/**
* @description Set the index of the selected item
* @param {number} index Item index
*/
setItem(index) {
this.#selectItem(index);
}
/**
* @description Appends a formatted list of items to the menu.
* @param {string} html - The HTML string representing the menu items.
*/
#createFormat(html) {
this.form.firstElementChild.innerHTML += `<ul class="se-list-basic se-list-checked${this.horizontal ? ' se-list-horizontal' : ''}">${html}</ul>`;
}
/**
* @description Resets the menu state and removes event listeners.
*/
#init() {
this.#removeEvents();
this.#removeGlobalEvent();
this.index = -1;
this.item = null;
if (this._onItem) {
dom.utils.removeClass(this._onItem, 'se-select-on');
this._onItem = null;
}
}
/**
* @description Moves the selection up or down by a specified number of items.
* @param {number} num - The number of items to move (negative for up, positive for down).
*/
#moveItem(num) {
num = this.index + num;
const len = this.menuLen;
const selectIndex = (this.index = num >= len ? 0 : num < 0 ? len - 1 : num);
this.#selectItem(selectIndex);
}
/**
* @description Highlights and selects an item by index.
* @param {number} selectIndex - The index of the item to select.
*/
#selectItem(selectIndex) {
dom.utils.removeClass(this.form, 'se-select-menu-mouse-move');
const len = this.menuLen;
for (let i = 0; i < len; i++) {
if (i === selectIndex) {
dom.utils.addClass(this.menus[i], 'active');
} else {
dom.utils.removeClass(this.menus[i], 'active');
}
}
this.index = selectIndex;
this.item = this.items[selectIndex];
}
/**
* @description Sets the position of the select menu relative to the reference element.
* @param {string} position Menu position (`"left"`|`"right"`) | (`"top"`|`"bottom"`)
* @param {string} subPosition Sub position (`"middle"`|`"top"`|`"bottom"`) | (`"center"`|`"left"`|`"right"`)
* @param {string} [onItemQuerySelector] - A query selector string to highlight a specific item.
* @param {boolean} [_re=false] - Whether this is a retry after adjusting the position.
*/
#setPosition(position, subPosition, onItemQuerySelector, _re) {
const originP = position;
const form = this.form;
const target = this.#refer;
form.style.visibility = 'hidden';
form.style.display = 'block';
dom.utils.removeClass(form, 'se-select-menu-scroll');
dom.utils.addClass(target, 'on');
const formW = form.offsetWidth;
const targetW = target.offsetWidth;
const targetL = target.offsetLeft;
let side = false;
let l = 0,
t = 0;
if (position === 'left') {
l = targetL - formW - 1;
position = subPosition;
side = true;
} else if (position === 'right') {
l = targetL + targetW + 1;
position = subPosition;
side = true;
}
// set top position
const globalTarget = this.#$.offset.get(target);
const targetOffsetTop = target.offsetTop;
const targetGlobalTop = globalTarget.top;
const targetHeight = target.offsetHeight;
const wbottom = dom.utils.getClientSize().h - (targetGlobalTop - _w.scrollY + targetHeight);
const sideAddH = side ? targetHeight : 0;
let overH = 10000;
switch (position) {
case 'middle': {
let h = form.offsetHeight;
const th = targetHeight / 2;
t = targetOffsetTop - h / 2 + th;
// over top
if (targetGlobalTop < h / 2) {
t += h / 2 - targetGlobalTop - th + 4;
form.style.top = t + 'px';
}
// over bottom
let formT = this.#$.offset.getGlobal(form).top;
const modH = h - (targetGlobalTop - formT) - wbottom - targetHeight;
if (modH > 0) {
t -= modH + 4;
form.style.top = t + 'px';
}
// over height
formT = this.#$.offset.getGlobal(form).top;
if (formT < 0) {
h += formT - 4;
t -= formT - 4;
}
form.style.height = h + 'px';
break;
}
case 'top':
if (targetGlobalTop < form.offsetHeight - sideAddH) {
if (!_re) {
overH = 0;
break;
}
overH = targetGlobalTop - 4 + sideAddH;
if (overH >= MENU_MIN_HEIGHT) form.style.height = overH + 'px';
}
t = targetOffsetTop - form.offsetHeight + sideAddH;
break;
case 'bottom':
if (wbottom < form.offsetHeight + sideAddH) {
if (!_re) {
overH = 0;
break;
}
overH = wbottom - 4 + sideAddH;
if (overH >= MENU_MIN_HEIGHT) form.style.height = overH + 'px';
}
t = targetOffsetTop + (side ? 0 : targetHeight);
break;
}
if (overH < MENU_MIN_HEIGHT && !_re && position !== 'middle') {
this.#setPosition(position === 'top' ? 'bottom' : 'top', subPosition, onItemQuerySelector, true);
return;
}
if (!side) {
switch (subPosition) {
case 'center':
l = targetL + targetW / 2 - formW / 2;
break;
case 'left':
l = targetL;
break;
case 'right':
l = targetL - (formW - targetW);
break;
}
}
form.style.left = l + 'px';
const fl = this.#$.offset.getGlobal(form).left;
let overW = 0;
switch (side + '-' + (side ? originP : subPosition)) {
case 'true-left':
overW = globalTarget.left - _w.scrollX + fl;
if (overW < 0) l = l = targetL + targetW + 1;
break;
case 'true-right':
overW = _w.innerWidth - (fl + formW);
if (overW < 0) l = targetL - formW - 1;
break;
case 'false-center': {
overW = _w.innerWidth - (fl + formW);
if (overW < 0) l += overW - 4;
form.style.left = l + 'px';
const centerfl = this.#$.offset.getGlobal(form).left;
if (centerfl < 0) l -= centerfl - 4;
break;
}
case 'false-left':
overW = _w.innerWidth - (globalTarget.left - _w.scrollX + formW);
if (overW < 0) l += overW - 4;
break;
case 'false-right':
if (fl < 0) l -= fl - 4;
break;
}
if (onItemQuerySelector) {
const item = form.firstElementChild.querySelector(onItemQuerySelector);
if (item) {
this._onItem = item;
dom.utils.addClass(item, 'se-select-on');
}
}
form.style.left = l + 'px';
form.style.top = t + 'px';
form.style.visibility = '';
}
/**
* @description Selects an item and triggers the callback function.
* @param {number} index - The index of the item to select.
*/
#select(index) {
if (this.checkList) dom.utils.toggleClass(this.menus[index], 'se-checked');
this.#selectMethod(this.getItem(index));
}
/**
* @description Adds event listeners for menu interactions.
*/
#addEvents() {
this.#removeEvents();
this.#events = {
mousedown: this.#$.eventManager.addEvent(this.form, 'mousedown', this.#eventHandlers.mousedown),
mousemove: this.#$.eventManager.addEvent(this.form, 'mousemove', this.#eventHandlers.mousemove),
click: this.#$.eventManager.addEvent(this.form, 'click', this.#eventHandlers.click),
keydown: this.#$.eventManager.addEvent(this.#keydownTarget, 'keydown', this.#eventHandlers.keydown),
};
}
/**
* @description Removes event listeners for menu interactions.
*/
#removeEvents() {
if (!this.#events) return;
this.#$.eventManager.removeEvent(this.#events.mousedown);
this.#$.eventManager.removeEvent(this.#events.mousemove);
this.#$.eventManager.removeEvent(this.#events.click);
this.#$.eventManager.removeEvent(this.#events.keydown);
this.#events = null;
}
/**
* @description Adds global event listeners for closing the menu.
*/
#addGlobalEvent() {
this.#removeGlobalEvent();
this.#bindClose_key = this.#$.eventManager.addGlobalEvent('keydown', this.#globalEventHandlers.keydown, true);
this.#bindClose_mousedown = this.#$.eventManager.addGlobalEvent('mousedown', this.#globalEventHandlers.mousedown, true);
}
/**
* @description Removes global event listeners for closing the menu.
*/
#removeGlobalEvent() {
this.#bindClose_key &&= this.#$.eventManager.removeGlobalEvent(this.#bindClose_key);
this.#bindClose_mousedown &&= this.#$.eventManager.removeGlobalEvent(this.#bindClose_mousedown);
this.#bindClose_click &&= this.#$.eventManager.removeGlobalEvent(this.#bindClose_click);
}
/**
* @param {KeyboardEvent} e - Event object
*/
#OnKeyDown_refer(e) {
let moveIndex;
switch (e.code) {
case 'ArrowUp': // up
e.preventDefault();
e.stopPropagation();
if (this.horizontal && this.index > -1) {
const num = this.splitNum;
moveIndex = this.index - num < 0 ? num : -num;
} else {
moveIndex = -1;
}
break;
case 'ArrowDown': // down
e.preventDefault();
e.stopPropagation();
if (this.horizontal && this.index > -1) {
const num = this.splitNum;
moveIndex = this.index + num > this.menuLen ? -num : num;
} else {
moveIndex = 1;
}
break;
case 'ArrowLeft': // left
e.preventDefault();
e.stopPropagation();
moveIndex = -1;
break;
case 'ArrowRight': //right
e.preventDefault();
e.stopPropagation();
moveIndex = 1;
break;
case 'Enter':
case 'Space': // enter, space
if (this.index > -1) {
e.preventDefault();
e.stopPropagation();
this.#select(this.index);
} else {
this.close();
}
break;
}
if (moveIndex) this.#moveItem(moveIndex);
}
/**
* @param {MouseEvent} e - Event object
*/
#OnMousedown_list(e) {
if (env.isGecko) {
const eventTarget = dom.query.getEventTarget(e);
const target = dom.query.getParentElement(eventTarget, '.se-select-item');
if (target) this.#$.eventManager._injectActiveEvent(target);
}
}
/**
* @param {MouseEvent} e - Event object
*/
#OnMouseMove_list(e) {
const eventTarget = dom.query.getEventTarget(e);
dom.utils.addClass(this.form, 'se-select-menu-mouse-move');
const index = eventTarget.getAttribute('data-index');
if (!index) return;
this.index = Number(index);
}
/**
* @param {MouseEvent} e - Event object
*/
#OnClick_list(e) {
let target = dom.query.getEventTarget(e);
let index = null;
while (!index && !/UL/i.test(target.tagName) && !dom.utils.hasClass(target, 'se-select-menu')) {
index = target.getAttribute('data-index');
target = target.parentElement;
}
if (!index) return;
this.#select(Number(index));
}
/**
* @param {KeyboardEvent} e - Event object
*/
#CloseListener_key(e) {
if (!keyCodeMap.isEsc(e.code)) return;
this.close();
}
/**
* @param {MouseEvent} e - Event object
*/
#CloseListener_mousedown(e) {
const eventTarget = dom.query.getEventTarget(e);
if (this.form.contains(eventTarget)) return;
if (!this.#refer.contains(eventTarget)) {
this.close();
} else if (!dom.check.isInputElement(eventTarget)) {
this.#bindClose_click = this.#$.eventManager.addGlobalEvent('click', this.#globalEventHandlers.click, true);
}
}
/**
* @param {MouseEvent} e - Event object
*/
#CloseListener_click(e) {
this.#bindClose_click = this.#$.eventManager.removeGlobalEvent(this.#bindClose_click);
if (e.target === this.#refer) {
e.stopPropagation();
this.close();
}
}
}
export default SelectMenu;