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;
+ }
+}