UNPKG

@nicobarbieri/quickctx

Version:

Fast & easy custom context menus for your web projects.

1,222 lines (1,158 loc) 59.3 kB
function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; } function _arrayWithoutHoles(r) { if (Array.isArray(r)) return _arrayLikeToArray(r); } function _classCallCheck(a, n) { if (!(a instanceof n)) throw new TypeError("Cannot call a class as a function"); } function _defineProperties(e, r) { for (var t = 0; t < r.length; t++) { var o = r[t]; o.enumerable = o.enumerable || !1, o.configurable = !0, "value" in o && (o.writable = !0), Object.defineProperty(e, _toPropertyKey(o.key), o); } } function _createClass(e, r, t) { return r && _defineProperties(e.prototype, r), t && _defineProperties(e, t), Object.defineProperty(e, "prototype", { writable: !1 }), e; } function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var n = 0, F = function () {}; return { s: F, n: function () { return n >= r.length ? { done: !0 } : { done: !1, value: r[n++] }; }, e: function (r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function () { t = t.call(r); }, n: function () { var r = t.next(); return a = r.done, r; }, e: function (r) { u = !0, o = r; }, f: function () { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; } function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; } function _iterableToArray(r) { if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r); } function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } function _objectSpread2(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } function _toConsumableArray(r) { return _arrayWithoutHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableSpread(); } function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; } function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } } /** * @typedef {'action' | 'sublist' | 'separator'} CommandType * The type of the menu command. * - 'action': Executes an action. * - 'sublist': Displays a submenu. * - 'separator': Displays a divider line or a sub-header. */ var MenuCommand = /*#__PURE__*/function () { /** * Creates a MenuCommand instance. MenuCommand class contains the configuration for a single command in a context menu. * @param {object} options - Options for the command. * @param {string} [options.id=crypto.randomUUID()] - Unique ID for the command. * @param {string} [options.label] - The displayed text for the command (not used for 'separator' unless no content is provided). * @param {CommandType} [options.type='action'] - The type of command. * @param {Function|string} [options.action] - The callback function to execute (for 'action' type) or the name of a registered action. * @param {string[]} [options.targetTypes=['*']] - Array of strings specifying for which target types this command is active. ['*'] for all. * @param {Array<object|MenuCommand>} [options.subCommands=[]] - Array of MenuCommand configurations or instances for submenus (for 'sublist' type). * @param {string|null} [options.iconClass=null] - CSS class for an icon (e.g., from Font Awesome). * @param {boolean} [options.disabled=false] - If true, the command is displayed but not clickable. * @param {boolean} [options.visible=true] - If true, the command is visible. * @param {number} [options.order=0] - Number for ordering commands within the menu. * @param {string|HTMLElement|null} [options.content=null] - HTML content or text for a separator, turning it into a sub-header. * @param {boolean} [options.isHtmlDefined=false] - Internal flag to indicate if the command was defined via HTML. */ function MenuCommand(_ref) { var _this = this; var _ref$id = _ref.id, id = _ref$id === void 0 ? crypto.randomUUID() : _ref$id, label = _ref.label, _ref$type = _ref.type, type = _ref$type === void 0 ? "action" : _ref$type, action = _ref.action, _ref$targetTypes = _ref.targetTypes, targetTypes = _ref$targetTypes === void 0 ? ["*"] : _ref$targetTypes, _ref$subCommands = _ref.subCommands, subCommands = _ref$subCommands === void 0 ? [] : _ref$subCommands, _ref$iconClass = _ref.iconClass, iconClass = _ref$iconClass === void 0 ? null : _ref$iconClass, _ref$disabled = _ref.disabled, disabled = _ref$disabled === void 0 ? false : _ref$disabled, _ref$visible = _ref.visible, visible = _ref$visible === void 0 ? true : _ref$visible, _ref$order = _ref.order, order = _ref$order === void 0 ? 0 : _ref$order, _ref$content = _ref.content, content = _ref$content === void 0 ? null : _ref$content; _classCallCheck(this, MenuCommand); if (this.type === "separator" && !label) { throw new Error("MenuCommand (ID: ".concat(id, "): 'label' is required for types other than 'separator'.")); } this.id = id; this.label = label; this.type = type; this.action = action; this.targetTypes = Array.isArray(targetTypes) && targetTypes.length > 0 ? targetTypes : ["*"]; this.subCommands = subCommands.map(function (cmdConfig) { var subCmd = cmdConfig instanceof MenuCommand ? cmdConfig : new MenuCommand(cmdConfig); subCmd.parentCommand = _this; return subCmd; }); this.iconClass = iconClass; this.disabled = disabled; this.visible = visible; this.order = order; this.content = content; } /** * A static factory method to create a separator command. * @param {string|HTMLElement|null} [content=null] - Optional text or HTML element to display. If provided, the separator acts as a sub-header. * @returns {MenuCommand} A new MenuCommand instance of type 'separator'. */ return _createClass(MenuCommand, null, [{ key: "Separator", value: function Separator() { var content = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; return new MenuCommand({ type: "separator", content: content }); } }]); }(); /** * Creates a DOM element with specified classes and attributes. * @param {string} tag - The HTML tag of the element to create. * @param {string|string[]} [classes=[]] - A string or array of strings for CSS classes. * @param {object} [attributes={}] - An object with key-value pairs for attributes. * @param {string} [textContent=''] - The text content of the element. * @returns {HTMLElement} The created HTML element. */ function createElement(tag) { var classes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; var attributes = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; var textContent = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : ''; var el = document.createElement(tag); if (classes) { var classArray = Array.isArray(classes) ? classes.flatMap(function (cl) { return cl.split(' '); }) : _toConsumableArray(classes.split(' ')); classArray.forEach(function (cls) { return cls && el.classList.add(cls); }); } for (var attr in attributes) { if (Object.prototype.hasOwnProperty.call(attributes, attr)) { el.setAttribute(attr, attributes[attr]); } } if (textContent) { el.textContent = textContent; } return el; } // QuickCTX input definitions /** * Configurable CSS classes for the context menu elements. * @typedef {object} QuickCTXClassesOptions * @property {string|string[]} [container='quickctx-container'] - CSS class for the main menu container. * @property {string|string[]} [header='quickctx-header'] - CSS class for the menu header. * @property {string|string[]} [list='quickctx-list'] - CSS class for the <ul> list of commands. * @property {string|string[]} [item='quickctx-item'] - CSS class for menu <li> elements (commands). * @property {string|string[]} [separator='quickctx-separator'] - CSS class for separators. * @property {string|string[]} [sublist='quickctx-sublist'] - CSS class for <li> items that open submenus. * @property {string|string[]} [sublistCommand='quickctx-sublist-command'] - CSS class for <li> items that open submenus. * @property {string|string[]} [disabled='quickctx-item--disabled'] - CSS class for disabled items. * @property {string|string[]} [hidden='quickctx-item--hidden'] - CSS class for hidden items. * @property {string|string[]} [icon='quickctx-icon'] - Icons class. * @property {string|string[]} [opening='quickctx--opening'] - Class added during the opening animation. * @property {string|string[]} [open='quickctx--open'] - Class added when the menu is fully open. * @property {string|string[]} [closing='quickctx--closing'] - Class added during the closing animation. */ /** * Configurable animation options for the context menu. * @typedef {object} QuickCTXAnimationsOptions * @property {number} [submenuOpenDelay=150] - Delay in ms for opening submenus on hover. * @property {number} [menuOpenDuration=200] - Duration in ms of the main menu opening animation. * @property {number} [menuCloseDuration=200] - Duration in ms of the main menu closing animation. * @property {number} [hoverMenuOpenDelay=300] - Delay in ms before opening a hover-triggered menu. * @property {number} [hoverMenuCloseDelay=300] - Delay in ms before closing a hover-triggered menu. * @property {number} [submenuCloseDelay=200] - Delay in ms before closing submenus after mouse leave. */ /** * Configuration options for the QuickCTX context menu. * @typedef {object} QuickCTXOptions * @property {string} [defaultTrigger='contextmenu'] - The default trigger event for all menus ('contextmenu', 'click', 'dblclick', 'hover'). * @property {'closest' | 'deepest'} [overlapStrategy='closest'] - Strategy for finding the target element when multiple are nested. * @property {'hide' | 'disable'} [globalFilterStrategy='hide'] - Global filter strategy for irrelevant commands. * @property {QuickCTXClassesOptions} [classes] - Object containing customizable CSS classes. * @property {QuickCTXAnimationsOptions} [animations] - Object containing options for animations. */ // Main params definitions /** * Defines the structure of a single item used in the `createAndBindMenu` helper. * @typedef {object} MenuItemDefinition * @property {string} label - The visible text of the menu item. * @property {Function|string} [action] - The function to execute or the name of a registered action. Required for 'action' type commands. * @property {string} [iconClass] - Optional CSS class for an icon (e.g., from Font Awesome). * @property {string[]} [targetTypes] - Optional array of target types. Overrides the default type set for the menu. * @property {MenuItemDefinition[]} [subCommands] - An array of nested menu item definitions to create a submenu. */ /** * Defines the configuration object for the `createAndBindMenu` helper method. * @typedef {object} MenuCreationOptions * @property {string} menuId - The unique ID for this menu. This ID is used to link the menu to HTML elements via the `data-custom-ctxmenu` attribute. * @property {string} [defaultTargetType] - A default 'type' or category to apply to all commands in this menu. This can be overridden by individual commands. It's used for filtering. * @property {string|HTMLElement|HTMLElement[]} [selector] - An optional CSS selector, a single HTML element, or an array of elements to bind this menu to automatically. * @property {Array<MenuItemDefinition|MenuCommand>} structure - The array that defines the menu's structure and items. * @property {string} [headerText] - Optional text for the menu's header. If header is missing or empty, header will not be displayed. * @property {string} [triggerEvent] - Optional specific trigger event for this menu, overriding the global default. * @property {'hide' | 'disable'} [filterStrategy] - Overrides the global filter strategy for this menu. */ /** * @typedef {object} ContextMenuConfigOptions * @property {string} id - The unique ID for this menu configuration, used to link it to HTML elements. * @property {string} [headerTextTemplate=""] - A template for the menu's header text. If header is missing or empty, header will not be displayed. Use `{type}` to insert the target type dynamically (ex. "Element: {type}"). * @property {Array<object|MenuCommand>} commands - An array of MenuCommand instances or configuration objects that define the menu's items. * @property {string} [triggerEvent] - Overrides the default trigger for this specific menu. * @property {'hide' | 'disable'} [filterStrategy] - Overrides the global filter strategy for this menu. */ /** * Represents the state of an active submenu. * @typedef {object} SubmenuInfo * @property {HTMLElement} element - The DOM element of the submenu container. * @property {MenuCommand} parentCommand - The command that opened this submenu. */ /** * @typedef {object} Log * @property {string} event - The type of event being logged (e.g., 'init', 'action'). * @property {string} message - A descriptive message about the event. * @property {object} [data] - Optional additional data related to the event, such as element IDs or action names. * @property {Date} timestamp - The date and time when the event occurred. * @property {Boolean} isError - Indicates if the log entry is an error. */ /** * Manages the creation, display, and interaction of custom context menus. */ var QuickCTX = /*#__PURE__*/function () { /** * Creates a context menu manager instance. * @param {QuickCTXOptions} [options={}] - Global configuration options for the library. */ function QuickCTX() { var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; _classCallCheck(this, QuickCTX); var defaultOptions = { defaultTrigger: "contextmenu", //choice of trigger overlapStrategy: "closest", // closest or deepest globalFilterStrategy: "hide", // hide or gray out filtered commands classes: { // css classes to assign to various elements container: "quickctx-container", header: "quickctx-header", list: "quickctx-list", item: "quickctx-item", separator: "quickctx-separator", sublist: "quickctx-sublist", sublistCommand: "quickctx-sublist-command", disabled: "quickctx-item--disabled", hidden: "quickctx-item--hidden", icon: "quickctx-icon", opening: "quickctx--opening", open: "quickctx--open", closing: "quickctx--closing" }, animations: { // timing for animations submenuOpenDelay: 150, menuOpenDuration: 200, menuCloseDuration: 200, hoverMenuOpenDelay: 450, hoverMenuCloseDelay: 300, // Option for hover-triggered menus submenuCloseDelay: 200 // Delay before closing submenus } }; /** * The active configuration options for the instance, merged from defaults and user-provided options. * @type {QuickCTXOptions} */ this.options = _objectSpread2(_objectSpread2(_objectSpread2({}, defaultOptions), options), {}, { classes: _objectSpread2(_objectSpread2({}, defaultOptions.classes), options.classes || {}), animations: _objectSpread2(_objectSpread2({}, defaultOptions.animations), options.animations || {}) }); this.logger = console.log; // Default logger function this.loggerIsEnabled = false; // Flag to enable or disable logging /** * A map of menu configurations, keyed by menu ID. Each menu configuration represent a combination of commands associated with a specific target type. * @type {Object.<string, object>} * @private */ this.menuConfigurations = {}; /** * A map of registered action names to their callback functions. This allows for easy registration and retrieval of actions that can be executed by menu commands (without needing either to pass the function directly or duplicate them if they are used in multiple menus). * @type {Object.<string, Function>} * @private */ this.registeredActions = {}; /** * A map to track functions that have been automatically registered to avoid duplication. * @type {Map<Function, string>} * @private */ this.functionActionMap = new Map(); /** * The DOM element for the currently visible main menu. * @type {HTMLElement|null} * @private */ this.activeMenuElement = null; /** * The DOM element that triggered the currently active menu. * @type {HTMLElement|null} * @private */ this.currentTargetElement = null; /** * An array of DOM elements for currently open submenus. * @type {SubmenuInfo[]} * @private */ this.activeSubmenus = []; /** * A timeout ID for the menu closing animation. * @type {number|null} * @private */ this.menuHideTimeout = null; /** * A timeout ID for the hover-triggered menu hiding delay. * @type {number|null} * @private */ this.hoverHideTimeout = null; /** * A timeout ID for the hover-triggered menu opening delay. * @type {number|null} * @private */ this.hoverOpenTimeout = null; this.submenuCloseTimeout = null; // EVENT HANDLERS TO OPEN AND CLOSE MENUS // Bind 'this' context for all event handlers consistently to maintain instance scope. this._boundHandleTrigger = this._handleTriggerEvent.bind(this); this._boundHandleKeydown = this._handleKeydown.bind(this); this._boundOutsideClick = this._handleOutsideClick.bind(this); this._boundScheduleAllSubmenusClose = this._scheduleAllSubmenusClose.bind(this); this._boundCancelAllSubmenusClose = this._cancelAllSubmenusClose.bind(this); // for hover-triggered menus this._boundHandleHoverEnter = this._cancelHoverHide.bind(this); this._boundHandleHoverLeave = this._scheduleHoverHide.bind(this); this._boundCancelHoverOpen = this._cancelHoverOpen.bind(this); this._init(); // Initialize the context menu manager } /** * Updates the library's options at runtime and re-initializes event listeners. * @param {QuickCTXOptions} [newOptions={}] - The new options to merge with the current configuration. */ return _createClass(QuickCTX, [{ key: "updateOptions", value: function updateOptions() { var newOptions = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; this.options = _objectSpread2(_objectSpread2(_objectSpread2({}, this.options), newOptions), {}, { classes: _objectSpread2(_objectSpread2({}, this.options.classes), newOptions.classes || {}), animations: _objectSpread2(_objectSpread2({}, this.options.animations), newOptions.animations || {}) }); this._log({ event: "updateOptions", message: "Updating options...", data: this.options }); // Re-setup event listeners to reflect potential changes in the default trigger. this._setupEventListeners(); } /********** LOG **********/ /** * Sets a custom logger function for logging messages. * @param {Function} logger - A function that takes a message and optional data to log. */ }, { key: "setLogger", value: function setLogger(logger) { if (typeof logger === "function") { this.logger = logger; } else { console.warn("Logger must be a function. Default logging will be used."); this.logger = console.log; // Fallback to console.log if no valid logger is provided } } /** * Enables or disables logging. * @param {boolean} enabled - If true, logging is enabled; if false, logging is disabled. */ }, { key: "setLoggerIsEnabled", value: function setLoggerIsEnabled(enabled) { this.loggerIsEnabled = !!enabled; } /** * Internal log handler. Checks if logging is enabled before calling the logger function. * @param {Log} logContent - The arguments to log. * @private */ }, { key: "_log", value: function _log(logContent) { if (!this.loggerIsEnabled) { return; } this.logger(_objectSpread2(_objectSpread2({}, logContent), {}, { timestamp: new Date() })); } /********** INIT **********/ /** * Initializes the library, setting up event listeners * @private */ }, { key: "_init", value: function _init() { this._log({ event: "init", message: "Initializing QuickCTX" }); this._setupEventListeners(); this._log({ event: "init", message: "QuickCTX initialized" }); } /********** HANDLING EVENTS **********/ /** * Sets up global event listeners based on configured triggers. This method also removes old * listeners before adding new ones, making it safe to call on option updates. * @private */ }, { key: "_setupEventListeners", value: function _setupEventListeners() { var _this = this; var supportedTriggers = ["contextmenu", "click", "dblclick", "mouseover", "mouseout"]; // Clear all potential listeners to ensure a clean state before re-adding. supportedTriggers.forEach(function (trigger) { document.removeEventListener(trigger, _this._boundHandleTrigger); }); // Collect all unique triggers from the default options and all registered menu configurations. var activeTriggers = new Set([this.options.defaultTrigger]); Object.values(this.menuConfigurations).forEach(function (config) { if (config.triggerEvent) { var eventName = config.triggerEvent === "hover" ? ["mouseover", "mouseout"] : [config.triggerEvent]; eventName.forEach(function (e) { return activeTriggers.add(e); }); } }); // Add listeners only for the active triggers. activeTriggers.forEach(function (trigger) { if (supportedTriggers.includes(trigger)) { document.addEventListener(trigger, _this._boundHandleTrigger); } }); this._log({ event: "setupListeners", message: "Event listeners set up", data: { activeTriggers: Array.from(activeTriggers) } }); } /** * Handles the trigger event for showing the context menu. * @param {Event} event - The event that triggered the context menu (e.g., 'contextmenu', 'click', 'mouseover'). * @private */ }, { key: "_handleTriggerEvent", value: function _handleTriggerEvent(event) { var _this2 = this; this._log({ event: "handleTrigger", message: "Handling trigger event: ".concat(event.type), data: { target: event.target } }); // if cursor leaves an element, avoid hover menus opening if (event.type === "mouseout") { this._cancelHoverOpen(); return; } var targetElement = this.options.overlapStrategy === "deepest" && event.target.matches("[data-custom-ctxmenu]") ? event.target : event.target.closest("[data-custom-ctxmenu]"); // if no target is found, return if (!targetElement) return; var menuId = targetElement.getAttribute("data-custom-ctxmenu"); if (!menuId) { this._log({ event: "handleTrigger", message: "No menu ID found on target element: ".concat(targetElement.tagName), data: { targetElementId: targetElement.id || "unknown" }, isError: true }); throw new Error("No menu ID found on target element: ".concat(targetElement.tagName)); } var config = this.menuConfigurations[menuId]; if (!config) { this._log({ event: "handleTrigger", message: "No menu configuration found for ID: ".concat(menuId), data: { menuId: menuId }, isError: true }); throw new Error("No menu configuration found for ID: ".concat(menuId)); } var eventTriggerType = event.type === "mouseover" ? "hover" : event.type; var expectedTrigger = config.triggerEvent || this.options.defaultTrigger; if (eventTriggerType !== expectedTrigger) return; // avoid annoying recreation of menu during hover, only for hover-triggered menus if (expectedTrigger === "hover" && this.currentTargetElement === targetElement) return; if (eventTriggerType === "hover" && this.options.animations.hoverMenuOpenDelay > 0) { this._cancelHoverOpen(); this.hoverOpenTimeout = setTimeout(function () { _this2._openMenu(config, targetElement, event, expectedTrigger, menuId); }, this.options.animations.hoverMenuOpenDelay); } else { this._openMenu(config, targetElement, event, expectedTrigger, menuId); } /* if ( this.activeMenuElement && !this.activeMenuElement.classList.contains( this.options.classes.closing ) ) { this._hideMenu(this.activeMenuElement, false); // false = with animation } event.preventDefault(); event.stopPropagation(); this.currentTargetElement = targetElement; const targetType = targetElement.getAttribute("data-custom-ctxmenu-type") || "default"; // Create a NEW menu element for this instance and set it as active. const newMenuElement = createElement( "div", this.options.classes.container ); Object.assign(newMenuElement.style, { position: "fixed", zIndex: "10000", display: "none", }); document.body.appendChild(newMenuElement); this.activeMenuElement = newMenuElement; this._buildAndShowMenu( config, targetElement, targetType, event.clientX, event.clientY ); // logic to build the menu based on the configuration this._log({ event: "_handleTriggerEvent", message: `Opened menu ${menuId} for target type: ${targetType}`, }); // Setup hover-out listeners only for hover-triggered menus if (expectedTrigger === "hover") { this._setupHoverListeners(targetElement, this.activeMenuElement); } */ } }, { key: "_handleKeydown", value: function _handleKeydown(event) { if (event.key === "Escape") this._hideMenu(this.activeMenuElement, false); } /** * Handles document clicks to close the menu if the click is outside. * @param {Event} event - The click event. * @private */ }, { key: "_handleOutsideClick", value: function _handleOutsideClick(event) { // If there's no active menu, or the menu is just opening, do nothing. if (!this.activeMenuElement || this.activeMenuElement.classList.contains(this.options.classes.opening)) { return; } // If the click is inside the active menu or a submenu, do nothing. if (this.activeMenuElement.contains(event.target)) { return; } var _iterator = _createForOfIteratorHelper(this.activeSubmenus), _step; try { for (_iterator.s(); !(_step = _iterator.n()).done;) { var submenuInfo = _step.value; if (submenuInfo.element && submenuInfo.element.contains(event.target)) { return; } } } catch (err) { _iterator.e(err); } finally { _iterator.f(); } this._hideMenu(this.activeMenuElement, false); } }, { key: "_setupHoverListeners", value: function _setupHoverListeners(target, menu) { target.addEventListener("mouseleave", this._boundHandleHoverLeave); menu.addEventListener("mouseleave", this._boundHandleHoverLeave); target.addEventListener("mouseenter", this._boundHandleHoverEnter); menu.addEventListener("mouseenter", this._boundHandleHoverEnter); } /********** OPEN/CLOSE LOGIC **********/ /** * Handles the logic of building and displaying a menu for a given trigger. * This method centralizes the opening logic to be called either directly or with a delay. * @param {object} config - The menu configuration. * @param {HTMLElement} targetElement - The element that triggered the menu. * @param {Event} event - The original trigger event. * @private */ }, { key: "_openMenu", value: function _openMenu(config, targetElement, event, expectedTrigger, menuId) { // If another menu is already active, start its closing animation without waiting. if (this.activeMenuElement && !this.activeMenuElement.classList.contains(this.options.classes.closing)) { this._hideMenu(this.activeMenuElement, false); // false = with animation } event.preventDefault(); event.stopPropagation(); this.currentTargetElement = targetElement; var targetType = targetElement.getAttribute("data-custom-ctxmenu-type") || "default"; var newMenuElement = createElement("div", this.options.classes.container); Object.assign(newMenuElement.style, { position: "fixed", zIndex: "10000", display: "none" }); document.body.appendChild(newMenuElement); this.activeMenuElement = newMenuElement; this._buildAndShowMenu(config, targetElement, targetType, event.clientX, event.clientY); this._log({ event: "_openMenu", message: "Opened menu ".concat(menuId, " for target type: ").concat(targetType) }); // Setup hover-out listeners only for hover-triggered menus if (expectedTrigger === "hover") { this._setupHoverListeners(targetElement, this.activeMenuElement); } } }, { key: "_scheduleHoverHide", value: function _scheduleHoverHide() { var _this3 = this; this._cancelHoverHide(); this.hoverHideTimeout = setTimeout(function () { _this3._hideMenu(_this3.activeMenuElement); }, this.options.animations.hoverMenuCloseDelay); } }, { key: "_cancelHoverHide", value: function _cancelHoverHide() { if (this.hoverHideTimeout) { clearTimeout(this.hoverHideTimeout); this.hoverHideTimeout = null; } } }, { key: "_cancelHoverOpen", value: function _cancelHoverOpen() { if (this.hoverOpenTimeout) { clearTimeout(this.hoverOpenTimeout); this.hoverOpenTimeout = null; } } }, { key: "_scheduleSubmenuOpen", value: function _scheduleSubmenuOpen(command, parentLi, targetElement) { var _this4 = this; this._cancelSubmenuOpen(command); command.openTimeout = setTimeout(function () { _this4._openSubmenu(command, parentLi, targetElement); }, this.options.animations.submenuOpenDelay); } }, { key: "_cancelSubmenuOpen", value: function _cancelSubmenuOpen(command) { if (command.openTimeout) { clearTimeout(command.openTimeout); command.openTimeout = null; } } }, { key: "_openSubmenu", value: function _openSubmenu(command, parentLi, targetElement) { var _command$submenuEleme; if ((_command$submenuEleme = command.submenuElement) !== null && _command$submenuEleme !== void 0 && _command$submenuEleme.classList.contains(this.options.classes.open)) return; var subMenuEl = command.submenuElement; if (!subMenuEl || !document.body.contains(subMenuEl)) { subMenuEl = createElement("div", [this.options.classes.container, this.options.classes.sublist]); Object.assign(subMenuEl.style, { position: "fixed", zIndex: "10001", display: "none" }); document.body.appendChild(subMenuEl); command.submenuElement = subMenuEl; } // Add listeners to the submenu itself to prevent it from closing when entered subMenuEl.addEventListener("mouseenter", this._boundCancelAllSubmenusClose); subMenuEl.addEventListener("mouseleave", this._boundScheduleAllSubmenusClose); var rect = parentLi.getBoundingClientRect(); this._buildAndShowMenu({ commands: command.subCommands }, targetElement, targetElement.dataset.customCtxmenuType || "default", rect.right, rect.top, subMenuEl, command); } }, { key: "_scheduleAllSubmenusClose", value: function _scheduleAllSubmenusClose() { var _this5 = this; this._cancelAllSubmenusClose(); // Previene timer duplicati this.submenuCloseTimeout = setTimeout(function () { _this5._closeSubmenus(); // Chiude TUTTI i sottomenu }, this.options.animations.submenuCloseDelay); } }, { key: "_cancelAllSubmenusClose", value: function _cancelAllSubmenusClose() { clearTimeout(this.submenuCloseTimeout); } /** * Closes all active submenus that are not in the direct ancestry * of the currently hovered command. * @param {MenuCommand} command - The command of the item being hovered. * @private */ }, { key: "_closeSiblingSubmenus", value: function _closeSiblingSubmenus(command) { var ancestors = new Set(); var current = command; while (current) { ancestors.add(current); current = current.parentCommand; } for (var i = this.activeSubmenus.length - 1; i >= 0; i--) { var submenuInfo = this.activeSubmenus[i]; if (!ancestors.has(submenuInfo.parentCommand)) { this._closeSingleSubmenu(submenuInfo); this.activeSubmenus.splice(i, 1); } } } }, { key: "_closeSingleSubmenu", value: function _closeSingleSubmenu(submenuInfo) { var instant = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; var submenu = submenuInfo.element; if (!submenu) return; // Clean up its listeners submenu.removeEventListener("mouseenter", this._cancelSubmenuClose); submenu.removeEventListener("mouseleave", this._scheduleSubmenuClose); if (instant) { if (submenu.parentElement) document.body.removeChild(submenu); } else { submenu.classList.remove(this.options.classes.open); submenu.classList.add(this.options.classes.closing); setTimeout(function () { if (submenu.parentElement) document.body.removeChild(submenu); }, this.options.animations.menuCloseDuration); } } /** * Recursively closes open submenus. * @private */ }, { key: "_closeSubmenus", value: function _closeSubmenus() { var _this6 = this; var fromLevel = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; var instant = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; var _loop = function _loop() { var submenuInfo = _this6.activeSubmenus.pop(); if (!submenuInfo) return 1; // continue var submenuEl = submenuInfo.element; var close = function close() { submenuEl.style.display = "none"; submenuEl.classList.remove(_this6.options.classes.open, _this6.options.classes.opening, _this6.options.classes.closing); submenuEl.innerHTML = ""; if (submenuEl.parentElement === document.body) document.body.removeChild(submenuEl); }; if (instant) close();else { submenuEl.classList.remove(_this6.options.classes.open); submenuEl.classList.add(_this6.options.classes.closing); setTimeout(close, _this6.options.animations.menuCloseDuration); } }; while (this.activeSubmenus.length > fromLevel) { if (_loop()) continue; } } }, { key: "_hideMenu", value: function _hideMenu(menuToHide) { var _this7 = this; var instant = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; if (!menuToHide) return; this._log({ event: "hideMenu", message: "Hiding menu: ".concat(this.activeMenuElement ? this.activeMenuElement.id : "none"), data: { activeMenuId: this.activeMenuElement ? this.activeMenuElement.id : null } }); this._closeSubmenus(0, instant); var hide = function hide() { if (menuToHide.parentElement) { menuToHide.parentElement.removeChild(menuToHide); } // Reset state only if the menu being hidden is the active one. if (_this7.activeMenuElement === menuToHide) { _this7.activeMenuElement = null; _this7.currentTargetElement = null; // Clean up global listeners associated with an open menu. document.removeEventListener("click", _this7._boundOutsideClick, true); document.removeEventListener("keydown", _this7._boundHandleKeydown); } }; if (instant || this.options.animations.menuCloseDuration === 0) { hide(); } else { menuToHide.classList.remove(this.options.classes.open, this.options.classes.opening); menuToHide.classList.add(this.options.classes.closing); setTimeout(hide, this.options.animations.menuCloseDuration); } this._log({ event: "hideMenu", message: "Menu hidden successfully", data: { activeMenuId: this.activeMenuElement ? this.activeMenuElement.id : null } }); } /********** DOM BUILD **********/ /** * Creates the DOM element (LI) for a single menu command. * @private */ }, { key: "_createMenuItemDOM", value: function _createMenuItemDOM(command, targetElement, isDisabled, isSubmenuItem) { var _this8 = this, _command$subCommands2; this._log({ event: "createMenuItemDOM", message: "Creating DOM for command: ".concat(command.label), data: { commandId: command.id, targetElementId: targetElement.id || "unknown", isDisabled: isDisabled, isSubmenuItem: isSubmenuItem } }); var li = createElement("li", [this.options.classes.item]); li.style.position = "relative"; // Ensure the LI is positioned correctly for submenus if (command.type === "separator") { li.classList.add(this.options.classes.separator); if (command.content) { li.classList.add("quickctx-subheader"); if (typeof command.content === "string") li.textContent = command.content;else if (command.content instanceof HTMLElement) li.appendChild(command.content); } return li; } if (isDisabled) li.classList.add(this.options.classes.disabled); if (command.iconClass) { var iconSpan = createElement("span", this.options.classes.icon); command.iconClass.split(" ").filter(Boolean).forEach(function (ic) { return iconSpan.classList.add(ic, ic.replace(".", "_")); }); li.appendChild(iconSpan); } li.innerHTML = li.innerHTML + command.label; command.element = li; li.addEventListener("mouseenter", function () { var _command$subCommands; if (isDisabled) return; _this8._boundCancelAllSubmenusClose(); _this8._closeSiblingSubmenus(command); // If this item is a sublist, schedule its opening. if (command.type === "sublist" && ((_command$subCommands = command.subCommands) === null || _command$subCommands === void 0 ? void 0 : _command$subCommands.length) > 0) { _this8._scheduleSubmenuOpen(command, li, targetElement); } }); li.addEventListener("mouseleave", this._boundScheduleAllSubmenusClose); if (command.type === "sublist" && ((_command$subCommands2 = command.subCommands) === null || _command$subCommands2 === void 0 ? void 0 : _command$subCommands2.length) > 0) { li.classList.add(this.options.classes.sublistCommand, "has-submenu-arrow"); li.appendChild(createElement("span", "submenu-arrow", {}, " \u25B6")); } if (command.type === "action") { //if it's an action, add click listener li.addEventListener("click", function (event) { var _this8$currentTargetE, _this8$currentTargetE2; event.stopPropagation(); if (isDisabled) return; var action = command.action; if (typeof action === "string") { action = _this8.registeredActions[action]; } if (action === "undefined") { _this8._log({ event: "actionError", message: "No action registered for command \"".concat(command.label, "\""), data: { commandId: command.id, commandLabel: command.label }, isError: true }); throw new Error("No action registered for command \"".concat(command.label, "\"")); } if (typeof action === "function") { try { action({ target: _this8.currentTargetElement, command: command }); } catch (error) { _this8._log({ event: "actionError", message: "Error executing action for command \"".concat(command.label, "\""), data: { commandId: command.id, commandLabel: command.label, error: err.message }, isError: true }); throw new Error("Error executing action for command \"".concat(command.label, "\": ").concat(err.message)); } } (_this8$currentTargetE = _this8.currentTargetElement) === null || _this8$currentTargetE === void 0 || _this8$currentTargetE.dispatchEvent(new CustomEvent("QuickCTXActionSelected", { detail: { commandId: command.id, commandLabel: command.label, targetElement: _this8.currentTargetElement, targetType: (_this8$currentTargetE2 = _this8.currentTargetElement) === null || _this8$currentTargetE2 === void 0 ? void 0 : _this8$currentTargetE2.dataset.customCtxmenuType }, bubbles: true, composed: true })); _this8._hideMenu(_this8.activeMenuElement); }); } return li; } /** * Constructs the DOM for a menu based on its configuration. * @private */ }, { key: "_buildAndShowMenu", value: function _buildAndShowMenu(config, targetElement, targetType, x, y) { var _this9 = this; var parentMenuElement = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : null; var parentCommand = arguments.length > 6 && arguments[6] !== undefined ? arguments[6] : null; this._log({ event: "buildMenu", message: "Building menu for target type: ".concat(targetType), data: { targetElementId: targetElement.id || "unknown", parentMenuId: parentMenuElement ? parentMenuElement.id : null } }); // If a parent menu is provided, we are building a submenu. var menuToBuild = parentMenuElement || this.activeMenuElement; if (parentMenuElement) { this.activeSubmenus.push({ element: menuToBuild, parentCommand: parentCommand }); } menuToBuild.innerHTML = ""; var headerText = (config.headerText || config.headerTextTemplate || "").replace(/{type}/g, targetType); if (!parentMenuElement && headerText) { menuToBuild.appendChild(createElement("div", this.options.classes.header, {}, headerText)); } var ul = createElement("ul", this.options.classes.list); var visibleItems = 0; config.commands.forEach(function (command) { if (parentCommand) command.parentCommand = parentCommand; // Hierarchical tracking var typeMatch = command.targetTypes.includes("*") || command.targetTypes.includes(targetType); if (!command.visible) return; var effectiveDisabled = command.disabled; if (!typeMatch) { if ((config.filterStrategy || _this9.options.globalFilterStrategy) === "hide") { return; } effectiveDisabled = true; } ul.appendChild(_this9._createMenuItemDOM(command, targetElement, effectiveDisabled)); visibleItems++; }); if (visibleItems > 0) { menuToBuild.appendChild(ul); this._showMenuDOM(menuToBuild, x, y, parentCommand); if (!parentMenuElement) { // add closing listeners when the main menu is shown document.addEventListener("click", this._boundOutsideClick, true); document.addEventListener("keydown", this._boundHandleKeydown); } } else { this._hideMenu(this.activeMenuElement, true); //hide instantly if empty } if (visibleItems === 0) { if (!parentMenuElement) this._hideMenu(this.activeMenuElement, true);else parentCommand === null || parentCommand === void 0 || parentCommand.element.classList.remove("has-submenu-arrow"); return null; } menuToBuild.appendChild(ul); this._showMenuDOM(menuToBuild, x, y, targetElement, parentMenuElement !== null, parentCommand); if (!parentMenuElement) { document.addEventListener("click", this._boundOutsideClick, true); document.addEventListener("keydown", this._boundHandleKeydown); } return menuToBuild; } /** * Positions and animates the menu into view. * @private */ }, { key: "_showMenuDOM", value: function _showMenuDOM(menuDomElement, x, y, targetElement, isSubmenu, parentCommand) { menuDomElement.style.display = "block"; menuDomElement.classList.remove(this.options.classes.closing); menuDomElement.classList.add(this.options.classes.opening); var menuRect = menuDomElement.getBoundingClientRect(); var docWidth = window.innerWidth; var docHeight = window.innerHeight; if (isSubmenu && parentCommand !== null && parentCommand !== void 0 && parentCommand.element) { var parentLiRect = parentCommand.element.getBoundingClientRect(); x = parentLiRect.right - 5; y = parentLiRect.top; } if (x + menuRect.width > docWidth) x = isSubmenu ? x - menuRect.width - ((parentCommand === null || parentCommand === void 0 ? void 0 : parentCommand.element.getBoundingClientRect().width) || 0) + 10 : docWidth - menuRect.width - 10; if (x < 10) x = 10; if (y + menuRect.height > docHeight) y = docHeight - menuRect.height - 10; if (y < 10) y = 10; menuDomElement.style.left = "".concat(x, "px"); menuDomElement.style.top = "".concat(y, "px"); void menuDomElement.offsetWidth; menuDomElement.classList.remove(this.options.classes.opening); menuDomElement.classList.add