Initial
This commit is contained in:
335
resources/app/client/ui/context.js
Normal file
335
resources/app/client/ui/context.js
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* @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<void>}
|
||||
*/
|
||||
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<jQuery>|void} A Promise that resolves when the open animation has completed.
|
||||
*/
|
||||
render(target, options={}) {
|
||||
const existing = $("#context-menu");
|
||||
let html = existing.length ? existing : $('<nav id="context-menu"></nav>');
|
||||
let ol = $('<ol class="context-items"></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 = $(`<li class="context-group" data-group-id="${group}"><ol></ol></li>`);
|
||||
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 = $(`<li class="${classes}">${item.icon}${name}</li>`);
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
Reference in New Issue
Block a user