diff --git a/system/scripts/gm/gm-monitor.js b/system/scripts/gm/gm-monitor.js index cfdc73d..80b653d 100644 --- a/system/scripts/gm/gm-monitor.js +++ b/system/scripts/gm/gm-monitor.js @@ -1,6 +1,8 @@ +import { L5r5ePopupManager } from '../misc/l5r5e-popup-manager.js'; const HandlebarsApplicationMixin = foundry.applications.api.HandlebarsApplicationMixin; const ApplicationV2 = foundry.applications.api.ApplicationV2; + export class GmMonitor extends HandlebarsApplicationMixin(ApplicationV2) { /** @override ApplicationV2 */ static get DEFAULT_OPTIONS() { @@ -136,33 +138,34 @@ export class GmMonitor extends HandlebarsApplicationMixin(ApplicationV2) { }).bind(this.element); // Tooltips - game.l5r5e.HelpersL5r5e.popupManager($(this.element).find(".actor-infos-control"), async (event) => { - const type = $(event.currentTarget).data("type"); - if (!type) { - return; + new L5r5ePopupManager( + $(this.element).find(".actor-infos-control"), + async (event) => { + const type = $(event.currentTarget).data("type"); + if (!type) return; + + if (type === "text") { + return $(event.currentTarget).data("text"); + } + + const uuid = $(event.currentTarget).data("actor-uuid"); + if (!uuid) return; + + const actor = this.context.actors.find(actor => actor.uuid === uuid); + if (!actor) return; + + switch (type) { + case "armors": + return this.#getTooltipArmors(actor); + case "weapons": + return this.#getTooltipWeapons(actor); + case "global": + return actor.isArmy + ? this.#getTooltipArmiesGlobal(actor) + : this.#getTooltipGlobal(actor); + } } - if (type === "text") { - return $(event.currentTarget).data("text"); - } - - const uuid = $(event.currentTarget).data("actor-uuid"); - if (!uuid) { - return; - } - const actor = this.context.actors.find((actor) => actor.uuid === uuid); - if (!actor) { - return; - } - - switch (type) { - case "armors": - return this.#getTooltipArmors(actor); - case "weapons": - return this.#getTooltipWeapons(actor); - case "global": - return actor.isArmy ? this.#getTooltipArmiesGlobal(actor) : this.#getTooltipGlobal(actor); - } - }); + ); } /** @override ApplicationV2 */ diff --git a/system/scripts/helpers.js b/system/scripts/helpers.js index e94fe81..f48d0a3 100644 --- a/system/scripts/helpers.js +++ b/system/scripts/helpers.js @@ -1,3 +1,5 @@ +import { L5r5ePopupManager } from './misc/l5r5e-popup-manager.js'; + /** * Extends the actor to process special things from L5R. */ @@ -509,13 +511,16 @@ export class HelpersL5r5e { }); // Item detail tooltips - this.popupManager(html.find(".l5r5e-tooltip"), async (event) => { - const item = await HelpersL5r5e.getEmbedItemByEvent(event, actor); - if (!item) { - return; + new L5r5ePopupManager( + html.find(".l5r5e-tooltip"), + async (event) => { + const item = await HelpersL5r5e.getEmbedItemByEvent(event, actor); + if (!item) { + return; + } + return await item.renderTextTemplate(); } - return await item.renderTextTemplate(); - }); + ); // Open actor sheet html.find(".open-sheet-from-uuid").on("click", async (event) => { @@ -534,52 +539,6 @@ export class HelpersL5r5e { }); } - /** - * Do the Popup for the selected element - * @param {Selector} selector HTML Selector - * @param {function} callback Callback function(event), must return the html to display - */ - static popupManager(selector, callback) { - const popupPosition = (event, popup) => { - let left = +event.clientX + 60, - top = +event.clientY; - - let maxY = window.innerHeight - popup.outerHeight(); - if (top > maxY) { - top = maxY - 10; - } - - let maxX = window.innerWidth - popup.outerWidth(); - if (left > maxX) { - left -= popup.outerWidth() + 100; - } - return { left: left + "px", top: top + "px", visibility: "visible" }; - }; - - selector - .on("mouseenter", async (event) => { - $(document.body).find("#l5r5e-tooltip-ct").remove(); - - const tpl = await callback(event); - if (!tpl) { - return; - } - - $(document.body).append( - `
${tpl}
` - ); - }) - .on("mousemove", (event) => { - const popup = $(document.body).find("#l5r5e-tooltip-ct"); - if (popup) { - popup.css(popupPosition(event, popup)); - } - }) - .on("mouseleave", () => { - $(document.body).find("#l5r5e-tooltip-ct").remove(); - }); // tooltips - } - /** * Get a Item from a Actor Sheet * @param {Event} event HTML Event diff --git a/system/scripts/misc/l5r5e-popup-manager.js b/system/scripts/misc/l5r5e-popup-manager.js new file mode 100644 index 0000000..ce9a07c --- /dev/null +++ b/system/scripts/misc/l5r5e-popup-manager.js @@ -0,0 +1,133 @@ +/** + * Manages tooltips for specified elements, showing asynchronously generated content on hover. + * Tooltips are appended to a customizable container element. + * Automatically cleans up event handlers and observers when target elements are removed. + */ +export class L5r5ePopupManager { + /** @type {string|jQuery} */ + #selector = null; + + /** @type {(event: MouseEvent) => Promise} */ + #callback = null; + + /** @type {jQuery|null} */ + #elements = null; + + /** @type {MutationObserver|null} */ + #observer = null; + + /** @type {HTMLElement} */ + #container = null; + + /** + * @param {string|jQuery} selector - Selector or jQuery object for tooltip-bound elements. + * @param {(event: MouseEvent) => Promise} callback - Async function returning tooltip HTML content. + * @param {HTMLElement|jQuery} [container=document.body] - DOM element or jQuery object to contain the tooltip. + */ + constructor(selector, callback, container = document.body) { + this.#selector = selector; + this.#callback = callback; + this.#container = container instanceof jQuery ? container[0] : container; + + this.#bindEvents(); + this.#observeDOM(); + } + + /** + * Bind mouseenter, mousemove, and mouseleave events to target elements. + * Creates/removes tooltip elements on hover, and updates position on mouse move. + * @private + */ + #bindEvents() { + this.#elements = $(this.#selector); + + this.#elements + .on("mouseenter.popup", async (event) => { + $(this.#container).find("#l5r5e-tooltip-ct").remove(); + + const tpl = await this.#callback(event); + + // Abort if no content or the target element is no longer in the DOM + if (!tpl || !document.body.contains(event.currentTarget)) return; + + $(this.#container).append( + `
${tpl}
` + ); + }) + .on("mousemove.popup", (event) => { + const popup = $(this.#container).find("#l5r5e-tooltip-ct"); + if (popup.length) { + popup.css(this.popupPosition(event, popup)); + } + }) + .on("mouseleave.popup", () => { + $(this.#container).find("#l5r5e-tooltip-ct").remove(); + }); + } + + /** + * Creates a MutationObserver that watches for removal of tooltip-bound elements + * and automatically destroys the manager if none remain. + * @private + */ + #observeDOM() { + this.#observer = new MutationObserver(() => { + const anyStillPresent = this.#elements?.toArray().some(el => document.body.contains(el)); + if (!anyStillPresent) { + this.destroy(); + } + }); + + this.#observer.observe(this.#container, { + childList: true, + subtree: true + }); + } + + /** + * Calculates CSS positioning for the tooltip based on mouse event, + * constraining it inside the viewport. + * @param {MouseEvent} event - The mouse event for position reference. + * @param {jQuery} popup - The jQuery object representing the tooltip element. + * @returns {{left: string, top: string, visibility: string}} CSS styles for tooltip positioning. + */ + popupPosition(event, popup) { + let left = event.clientX + 60; + let top = event.clientY; + + const maxY = window.innerHeight - popup.outerHeight(); + if (top > maxY) { + top = maxY - 10; + } + + const maxX = window.innerWidth - popup.outerWidth(); + if (left > maxX) { + left -= popup.outerWidth() + 100; + } + + return { + left: `${left}px`, + top: `${top}px`, + visibility: "visible" + }; + } + + /** + * Unbind all events, remove tooltip elements, and disconnect the MutationObserver. + * Cleans up all references for proper garbage collection. + */ + destroy() { + if (this.#elements) { + this.#elements.off(".popup"); + } + + $(this.#container).find("#l5r5e-tooltip-ct").remove(); + + if (this.#observer) { + this.#observer.disconnect(); + this.#observer = null; + } + + this.#elements = null; + } +}