/** * @typedef {object} ContextMenuEntry * @property {string} name The context menu label. Can be localized. * @property {string} icon A string containing an HTML icon element for the menu item * @property {string} [classes] Additional CSS classes to apply to this menu item. * @property {string} group An identifier for a group this entry belongs to. * @property {function(jQuery)} callback The function to call when the menu item is clicked. Receives the HTML element * of the entry that this context menu is for. * @property {ContextMenuCondition|boolean} [condition] A function to call or boolean value to determine if this entry * appears in the menu. */ /** * @callback ContextMenuCondition * @param {jQuery} html The HTML element of the context menu entry. * @returns {boolean} Whether the entry should be rendered in the context menu. */ /** * @callback ContextMenuCallback * @param {HTMLElement} target The element that the context menu has been triggered for. */ /** * Display a right-click activated Context Menu which provides a dropdown menu of options * A ContextMenu is constructed by designating a parent HTML container and a target selector * An Array of menuItems defines the entries of the menu which is displayed */ class ContextMenu { /** * @param {HTMLElement|jQuery} element The containing HTML element within which the menu is positioned * @param {string} selector A CSS selector which activates the context menu. * @param {ContextMenuEntry[]} menuItems An Array of entries to display in the menu * @param {object} [options] Additional options to configure the context menu. * @param {string} [options.eventName="contextmenu"] Optionally override the triggering event which can spawn the * menu * @param {ContextMenuCallback} [options.onOpen] A function to call when the context menu is opened. * @param {ContextMenuCallback} [options.onClose] A function to call when the context menu is closed. */ constructor(element, selector, menuItems, {eventName="contextmenu", onOpen, onClose}={}) { /** * The target HTMLElement being selected * @type {HTMLElement|jQuery} */ this.element = element; /** * The target CSS selector which activates the menu * @type {string} */ this.selector = selector || element.attr("id"); /** * An interaction event name which activates the menu * @type {string} */ this.eventName = eventName; /** * The array of menu items being rendered * @type {ContextMenuEntry[]} */ this.menuItems = menuItems; /** * A function to call when the context menu is opened. * @type {Function} */ this.onOpen = onOpen; /** * A function to call when the context menu is closed. * @type {Function} */ this.onClose = onClose; /** * Track which direction the menu is expanded in * @type {boolean} */ this._expandUp = false; // Bind to the current element this.bind(); } /** * The parent HTML element to which the context menu is attached * @type {HTMLElement} */ #target; /* -------------------------------------------- */ /** * A convenience accessor to the context menu HTML object * @returns {*|jQuery.fn.init|jQuery|HTMLElement} */ get menu() { return $("#context-menu"); } /* -------------------------------------------- */ /** * Create a ContextMenu for this Application and dispatch hooks. * @param {Application|ApplicationV2} app The Application this ContextMenu belongs to. * @param {JQuery|HTMLElement} html The Application's rendered HTML. * @param {string} selector The target CSS selector which activates the menu. * @param {ContextMenuEntry[]} menuItems The array of menu items being rendered. * @param {object} [options] Additional options to configure context menu initialization. * @param {string} [options.hookName="EntryContext"] The name of the hook to call. * @returns {ContextMenu} */ static create(app, html, selector, menuItems, {hookName="EntryContext", ...options}={}) { // FIXME ApplicationV2 does not support these hooks yet app._callHooks?.(className => `get${className}${hookName}`, menuItems); return new ContextMenu(html, selector, menuItems, options); } /* -------------------------------------------- */ /** * Attach a ContextMenu instance to an HTML selector */ bind() { const element = this.element instanceof HTMLElement ? this.element : this.element[0]; element.addEventListener(this.eventName, event => { const matching = event.target.closest(this.selector); if ( !matching ) return; event.preventDefault(); const priorTarget = this.#target; this.#target = matching; const menu = this.menu; // Remove existing context UI const prior = document.querySelector(".context"); prior?.classList.remove("context"); if ( this.#target.contains(menu[0]) ) return this.close(); // If the menu is already open, call its close handler on its original target. ui.context?.onClose?.(priorTarget); // Render a new context menu event.stopPropagation(); ui.context = this; this.onOpen?.(this.#target); return this.render($(this.#target), { event }); }); } /* -------------------------------------------- */ /** * Closes the menu and removes it from the DOM. * @param {object} [options] Options to configure the closing behavior. * @param {boolean} [options.animate=true] Animate the context menu closing. * @returns {Promise} */ async close({animate=true}={}) { if ( animate ) await this._animateClose(this.menu); this._close(); } /* -------------------------------------------- */ _close() { for ( const item of this.menuItems ) { delete item.element; } this.menu.remove(); $(".context").removeClass("context"); delete ui.context; this.onClose?.(this.#target); } /* -------------------------------------------- */ async _animateOpen(menu) { menu.hide(); return new Promise(resolve => menu.slideDown(200, resolve)); } /* -------------------------------------------- */ async _animateClose(menu) { return new Promise(resolve => menu.slideUp(200, resolve)); } /* -------------------------------------------- */ /** * Render the Context Menu by iterating over the menuItems it contains. * Check the visibility of each menu item, and only render ones which are allowed by the item's logical condition. * Attach a click handler to each item which is rendered. * @param {jQuery} target The target element to which the context menu is attached * @param {object} [options] * @param {PointerEvent} [options.event] The event that triggered the context menu opening. * @returns {Promise|void} A Promise that resolves when the open animation has completed. */ render(target, options={}) { const existing = $("#context-menu"); let html = existing.length ? existing : $(''); let ol = $('
    '); html.html(ol); if ( !this.menuItems.length ) return; const groups = this.menuItems.reduce((acc, entry) => { const group = entry.group ?? "_none"; acc[group] ??= []; acc[group].push(entry); return acc; }, {}); for ( const [group, entries] of Object.entries(groups) ) { let parent = ol; if ( group !== "_none" ) { const groupItem = $(`
    1. `); ol.append(groupItem); parent = groupItem.find("ol"); } for ( const item of entries ) { // Determine menu item visibility (display unless false) let display = true; if ( item.condition !== undefined ) { display = ( item.condition instanceof Function ) ? item.condition(target) : item.condition; } if ( !display ) continue; // Construct and add the menu item const name = game.i18n.localize(item.name); const classes = ["context-item", item.classes].filterJoin(" "); const li = $(`
    2. ${item.icon}${name}
    3. `); li.children("i").addClass("fa-fw"); parent.append(li); // Record a reference to the item item.element = li[0]; } } // Bail out if there are no children if ( ol.children().length === 0 ) return; // Append to target this._setPosition(html, target, options); // Apply interactivity if ( !existing.length ) this.activateListeners(html); // Deactivate global tooltip game.tooltip.deactivate(); // Animate open the menu return this._animateOpen(html); } /* -------------------------------------------- */ /** * Set the position of the context menu, taking into consideration whether the menu should expand upward or downward * @param {jQuery} html The context menu element. * @param {jQuery} target The element that the context menu was spawned on. * @param {object} [options] * @param {PointerEvent} [options.event] The event that triggered the context menu opening. * @protected */ _setPosition(html, target, { event }={}) { const container = target[0].parentElement; // Append to target and get the context bounds target.css("position", "relative"); html.css("visibility", "hidden"); target.append(html); const contextRect = html[0].getBoundingClientRect(); const parentRect = target[0].getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); // Determine whether to expand upwards const contextTop = parentRect.top - contextRect.height; const contextBottom = parentRect.bottom + contextRect.height; const canOverflowUp = (contextTop > containerRect.top) || (getComputedStyle(container).overflowY === "visible"); // If it overflows the container bottom, but not the container top const containerUp = ( contextBottom > containerRect.bottom ) && ( contextTop >= containerRect.top ); const windowUp = ( contextBottom > window.innerHeight ) && ( contextTop > 0 ) && canOverflowUp; this._expandUp = containerUp || windowUp; // Display the menu html.toggleClass("expand-up", this._expandUp); html.toggleClass("expand-down", !this._expandUp); html.css("visibility", ""); target.addClass("context"); } /* -------------------------------------------- */ /** * Local listeners which apply to each ContextMenu instance which is created. * @param {jQuery} html */ activateListeners(html) { html.on("click", "li.context-item", this.#onClickItem.bind(this)); } /* -------------------------------------------- */ /** * Handle click events on context menu items. * @param {PointerEvent} event The click event */ #onClickItem(event) { event.preventDefault(); event.stopPropagation(); const li = event.currentTarget; const item = this.menuItems.find(i => i.element === li); item?.callback($(this.#target)); this.close(); } /* -------------------------------------------- */ /** * Global listeners which apply once only to the document. */ static eventListeners() { document.addEventListener("click", ev => { if ( ui.context ) ui.context.close(); }); } } /* -------------------------------------------- */