UNPKG

@nicobarbieri/quickctx

Version:

Fast & easy custom context menus for your web projects.

1,163 lines (1,104 loc) 75 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 _window$crypto, _this = this; var _ref$id = _ref.id, id = _ref$id === void 0 ? (_window$crypto = window.crypto) !== null && _window$crypto !== void 0 && _window$crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).substring(2) : _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. * @property {number} [holdDuration=500] - Duration in ms for a 'hold' gesture on touch devices. */ /** * Configuration options for the QuickCTX context menu. * @typedef {object} QuickCTXOptions * @property {'contextmenu' | 'click' | 'dblclick' | 'hover'} [defaultTrigger='contextmenu'] - The default trigger event for all menus ('contextmenu', 'click', 'dblclick', 'hover'). * @property {'click' | 'mouseout' | 'auto'} [defaultCloseTrigger='auto'] - The default close behavior. * 'click' closes on an outside click, 'mouseout' closes on mouse leave, * and 'auto' uses 'mouseout' for hover-triggered menus and 'click' for all others. * @property {'tap' | 'hold'} [defaultMobileTrigger='tap'] - The default trigger for touch devices ('tap', 'hold'). * @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 {string} [submenuArrow] - Optional string containing the raw SVG markup for the submenu arrow icon. * If omitted, a default chevron icon will be used. * @example * const myArrow = `<svg viewBox="0 0 24 24"><path d="M5 3l3.057-3 11.943 12-11.943 12-3.057-3 9-9z"/></svg>`; * const ctx = new QuickCTX({ submenuArrow: myArrow }); * @property {Boolean} [ignoreLinks = true] - If true, menus will not be triggered by events originating from `<a>` tags or elements with an `href` attribute. * @property {boolean} [ignoreButtons=true] - If true, menus will not be triggered by events originating from `<button>` or `<input>` elements of type button/submit/reset. * @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 {'contextmenu' | 'click' | 'dblclick' | 'hover'} [triggerEvent] - Optional specific trigger event for this menu, overriding the global default. * @property {'tap' | 'hold'} [mobileTriggerEvent] - Optional specific trigger for this menu on touch devices ('tap', 'hold'), overriding the global mobile default. * @property {'contextmenu' | 'mouseout'} [closeTriggerEvent] - Optional close behavior for this menu ('click', 'mouseout'), overriding the default. * @property {'hide' | 'disable'} [filterStrategy] - Overrides the global filter strategy for this menu. * @property {string} [additionalClasses] - Space-separated list of additional classes to add to this menu. * @property {boolean} [ignoreLinks] - Overrides the global `ignoreLinks` setting for this menu. Can be set to `false` to enable menus on links for this instance only. * @property {boolean} [ignoreButtons] - Overrides the global `ignoreButtons` setting 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 {'contextmenu' | 'click' | 'dblclick' | 'hover'} [triggerEvent] - Overrides the default trigger for this specific menu. * @property {'tap' | 'hold'} [mobileTriggerEvent] - Optional specific trigger for this menu on touch devices ('tap', 'hold'), overriding the global mobile default. * @property {'click' | 'mouseout'} [closeTriggerEvent] - Optional close behavior for this menu ('click', 'mouseout'), overriding the default. * @property {'hide' | 'disable'} [filterStrategy] - Overrides the global filter strategy for this menu. * @property {string} [additionalClasses] - Space-separated list of additional classes to add to a specific menu. * @property {boolean} [ignoreLinks] - Overrides the global `ignoreLinks` setting for this menu. Can be set to `false` to enable menus on links for this instance only. * @property {boolean} [ignoreButtons] - Overrides the global `ignoreButtons` setting 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); this.isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0; var defaultSubmenuArrow = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 16 16\"><path fill=\"currentColor\" d=\"M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L9.94 8L6.22 4.28a.75.75 0 0 1 0-1.06z\"/></svg>"; var defaultOptions = { defaultTrigger: "contextmenu", //choice of trigger defaultMobileTrigger: "tap", defaultCloseTrigger: "auto", overlapStrategy: "closest", // closest or deepest globalFilterStrategy: "hide", // hide or gray out filtered commands submenuArrow: defaultSubmenuArrow, ignoreButtons: true, ignoreLinks: true, 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 holdDuration: 500 } }; /** * 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, ContextMenuConfigOptions>} * @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; this.touchState = {}; // 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._boundHandleScroll = this._handleScroll.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); // touch events this._boundHandleTouchStart = this._handleTouchStart.bind(this); this._boundHandleTouchMove = this._handleTouchMove.bind(this); this._boundHandleTouchEnd = this._handleTouchEnd.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(); } /********** TOUCH EVENT HANDLERS **********/ }, { key: "_handleTouchStart", value: function _handleTouchStart(event) { var _config$ignoreLinks, _config$ignoreButtons, _this = this; if (this.activeMenuElement) return; var targetElement = event.target.closest("[data-custom-ctxmenu]"); if (!targetElement) return; var menuId = targetElement.getAttribute("data-custom-ctxmenu"); var config = this.menuConfigurations[menuId]; if (!config) return; var shouldIgnoreLinks = (_config$ignoreLinks = config.ignoreLinks) !== null && _config$ignoreLinks !== void 0 ? _config$ignoreLinks : this.options.ignoreLinks; var shouldIgnoreButtons = (_config$ignoreButtons = config.ignoreButtons) !== null && _config$ignoreButtons !== void 0 ? _config$ignoreButtons : this.options.ignoreButtons; if (shouldIgnoreLinks && event.target.closest("a, [href]")) return; if (shouldIgnoreButtons && event.target.closest('button, input[type="button"], input[type="submit"], input[type="reset"]')) return; var expectedMobileTrigger = config.mobileTriggerEvent || this.options.defaultMobileTrigger; if (expectedMobileTrigger === "hold") { event.preventDefault(); } document.addEventListener("touchmove", this._boundHandleTouchMove, { passive: true }); document.addEventListener("touchend", this._boundHandleTouchEnd); var touch = event.touches[0]; this.touchState = { targetElement: targetElement, config: config, startTime: Date.now(), startCoords: { x: touch.clientX, y: touch.clientY }, isHolding: false, timeout: null }; if (expectedMobileTrigger === "hold") { this.touchState.timeout = setTimeout(function () { _this.touchState.isHolding = true; var mockEvent = { clientX: _this.touchState.startCoords.x, clientY: _this.touchState.startCoords.y, preventDefault: function preventDefault() {}, stopPropagation: function stopPropagation() {} }; _this._openMenu(_this.touchState.config, _this.touchState.targetElement, mockEvent); }, this.options.animations.holdDuration); } } }, { key: "_handleTouchMove", value: function _handleTouchMove(event) { if (!this.touchState || !this.touchState.timeout) return; var touch = event.touches[0]; var deltaX = Math.abs(touch.clientX - this.touchState.startCoords.x); var deltaY = Math.abs(touch.clientY - this.touchState.startCoords.y); if (deltaX > 15 || deltaY > 15) { clearTimeout(this.touchState.timeout); this.touchState = null; document.removeEventListener("touchmove", this._boundHandleTouchMove); document.removeEventListener("touchend", this._boundHandleTouchEnd); } } }, { key: "_handleTouchEnd", value: function _handleTouchEnd(event) { if (!this.touchState) return; clearTimeout(this.touchState.timeout); var duration = Date.now() - this.touchState.startTime; document.removeEventListener("touchmove", this._boundHandleTouchMove); document.removeEventListener("touchend", this._boundHandleTouchEnd); if (this.touchState.isHolding) { this.touchState = null; return; } var expectedMobileTrigger = this.touchState.config.mobileTriggerEvent || this.options.defaultMobileTrigger; if (duration < 300 && expectedMobileTrigger === "tap") { var mockEvent = { clientX: this.touchState.startCoords.x, clientY: this.touchState.startCoords.y, preventDefault: function preventDefault() {}, stopPropagation: function stopPropagation() {} }; event.preventDefault(); this._openMenu(this.touchState.config, this.touchState.targetElement, mockEvent); } this.touchState = null; } /********** 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 _this2 = 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, _this2._boundHandleTrigger); }); document.removeEventListener("touchstart", this._boundHandleTouchStart); if (this.isTouchDevice) { document.addEventListener("touchstart", this._boundHandleTouchStart, { passive: false }); this._log({ event: "setupListeners", message: "Touch event listeners enabled." }); } // 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, _this2._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 _config$ignoreLinks2, _config$ignoreButtons2, _this3 = this; if (this.isTouchDevice && event.type === "contextmenu") { event.preventDefault(); return; } 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 shouldIgnoreLinks = (_config$ignoreLinks2 = config.ignoreLinks) !== null && _config$ignoreLinks2 !== void 0 ? _config$ignoreLinks2 : this.options.ignoreLinks; var shouldIgnoreButtons = (_config$ignoreButtons2 = config.ignoreButtons) !== null && _config$ignoreButtons2 !== void 0 ? _config$ignoreButtons2 : this.options.ignoreButtons; if (shouldIgnoreLinks && event.target.closest("a, [href]")) return; if (shouldIgnoreButtons && event.target.closest('button, input[type="button"], input[type="submit"], input[type="reset"]')) return; 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 () { _this3._openMenu(config, targetElement, event); }, this.options.animations.hoverMenuOpenDelay); } else { this._openMenu(config, targetElement, event); } } /** * Handles the key "escape" event to close any active menu instantly. * @private */ }, { key: "_handleKeydown", value: function _handleKeydown(event) { if (event.key === "Escape") this._hideMenu(this.activeMenuElement, false); } /** * Handles the scroll event to close any active menu instantly. * @private */ }, { key: "_handleScroll", value: function _handleScroll() { if (this.activeMenuElement) { 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) { var _this4 = this; // 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(this.menuConfigurations.id, " for target type: ").concat(targetType) }); var trigger = this.isTouchDevice ? config.mobileTriggerEvent || this.options.defaultMobileTrigger : config.triggerEvent || this.options.defaultTrigger; var effectiveCloseTrigger = config.closeTriggerEvent || this.options.defaultCloseTrigger; if (effectiveCloseTrigger === "auto") { effectiveCloseTrigger = trigger === "hover" ? "mouseout" : "click"; } if (effectiveCloseTrigger === "mouseout" && !this.isTouchDevice) { this._setupHoverListeners(targetElement, this.activeMenuElement); this._log({ event: "setupCloseListeners", message: "Using 'mouseout' close trigger." }); } else { //add listener to close at the end setTimeout(function () { document.addEventListener("click", _this4._boundOutsideClick, true); if (_this4.activeMenuElement) ; }, 0); this._log({ event: "setupCloseListeners", message: "Using 'click' close trigger." }); } document.addEventListener("keydown", this._boundHandleKeydown); window.addEventListener("scroll", this._boundHandleScroll, true); } }, { key: "_scheduleHoverHide", value: function _scheduleHoverHide() { var _this5 = this; this._cancelHoverHide(); this.hoverHideTimeout = setTimeout(function () { _this5._hideMenu(_this5.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 _this6 = this; this._cancelSubmenuOpen(command); command.openTimeout = setTimeout(function () { _this6._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("mousemove", 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 _this7 = this; this._cancelAllSubmenusClose(); // Previene timer duplicati this.submenuCloseTimeout = setTimeout(function () { _this7._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("mousemove", this._cancelAllSubmenusClose); submenu.removeEventListener("mouseleave", this._scheduleAllSubmenusClose); 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 _this8 = 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 = _this8.activeSubmenus.pop(); if (!submenuInfo) return 1; // continue var submenuEl = submenuInfo.element; var close = function close() { submenuEl.style.display = "none"; submenuEl.classList.remove(_this8.options.classes.open, _this8.options.classes.opening, _this8.options.classes.closing); submenuEl.innerHTML = ""; if (submenuEl.parentElement === document.body) document.body.removeChild(submenuEl); }; if (instant) close();else { submenuEl.classList.remove(_this8.options.classes.open); submenuEl.classList.add(_this8.options.classes.closing); setTimeout(close, _this8.options.animations.menuCloseDuration); } }; while (this.activeSubmenus.length > fromLevel) { if (_loop()) continue; } } }, { key: "_hideMenu", value: function _hideMenu(menuToHide) { var _this9 = 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 (_this9.activeMenuElement === menuToHide) { var target = _this9.currentTargetElement; _this9.activeMenuElement = null; _this9.currentTargetElement = null; // Clean up global listeners associated with an open menu. document.removeEventListener("click", _this9._boundOutsideClick, true); document.removeEventListener("keydown", _this9._boundHandleKeydown); window.removeEventListener("scroll", _this9._boundHandleScroll, true); if (target) { target.removeEventListener("mouseleave", _this9._boundHandleHoverLeave); target.removeEventListener("mouseenter", _this9._boundHandleHoverEnter); } menuToHide.removeEventListener("mouseleave", _this9._boundHandleHoverLeave); menuToHide.removeEventListener("mouseenter", _this9._boundHandleHoverEnter); } }; 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 } }); } /** * Programmatically opens a context menu for a given target element. * @param {string|HTMLElement} targetOrSelector - The target element or a CSS selector for it. * @param {number} [x=-1] - Optional X coordinate. If -1, the menu is centered on the target. * @param {number} [y=-1] - Optional Y coor