From e594b6beb033017bcd030d6c08e73b598e0a8086 Mon Sep 17 00:00:00 2001 From: Oliver-Akins Date: Fri, 14 Mar 2025 22:52:40 -0600 Subject: [PATCH] Get the reusable foundations of custom popovers finished. --- eslint.config.mjs | 1 + module/Apps/ActorSheets/HeroSkillsCardV1.mjs | 72 +++------- module/Apps/popovers/AmmoTracker.mjs | 9 +- module/Apps/popovers/GenericPopoverMixin.mjs | 43 +++++- module/utils/PopoverEventManager.mjs | 134 ++++++++++++++++++ templates/Apps/apps.css | 2 + templates/Apps/popovers/AmmoTracker/style.css | 4 + templates/css/popover.css | 17 ++- 8 files changed, 219 insertions(+), 63 deletions(-) create mode 100644 module/utils/PopoverEventManager.mjs create mode 100644 templates/Apps/popovers/AmmoTracker/style.css diff --git a/eslint.config.mjs b/eslint.config.mjs index aabd56c..70ae128 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,6 +36,7 @@ export default [ Combatant: `readonly`, canvas: `readonly`, Token: `readonly`, + Tour: `readonly`, }, }, }, diff --git a/module/Apps/ActorSheets/HeroSkillsCardV1.mjs b/module/Apps/ActorSheets/HeroSkillsCardV1.mjs index 6921b54..9cd0016 100644 --- a/module/Apps/ActorSheets/HeroSkillsCardV1.mjs +++ b/module/Apps/ActorSheets/HeroSkillsCardV1.mjs @@ -1,10 +1,11 @@ import { deleteItemFromElement, editItemFromElement } from "../utils.mjs"; -import { documentSorter, filePath, getTooltipDelay } from "../../consts.mjs"; +import { documentSorter, filePath } from "../../consts.mjs"; +import { AmmoTracker } from "../popovers/AmmoTracker.mjs"; import { gameTerms } from "../../gameTerms.mjs"; import { GenericAppMixin } from "../GenericApp.mjs"; import { localizer } from "../../utils/Localizer.mjs"; import { Logger } from "../../utils/Logger.mjs"; -import { AmmoTracker } from "../popovers/AmmoTracker.mjs"; +import { PopoverEventManager } from "../../utils/PopoverEventManager.mjs"; const { HandlebarsApplicationMixin } = foundry.applications.api; const { ActorSheetV2 } = foundry.applications.sheets; @@ -40,19 +41,15 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin }; // #endregion - // #region Instance Data - /** @type {number | undefined} */ - #ammoTrackerHoverTimeout = null; - - /** @type {AmmoTracker | null} */ - #ammoTracker = null; - // #endregion - // #region Lifecycle + async _onFirstRender(context, options) { + await super._onFirstRender(context, options); + HeroSkillsCardV1._createPopoverListeners.bind(this)(); + }; + async _onRender(context, options) { await super._onRender(context, options); HeroSkillsCardV1._onRender.bind(this)(context, options); - await this.#createAmmoTrackerEvents(); }; static async _onRender(_context, options) { @@ -85,11 +82,15 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin ); }; - async #createAmmoTrackerEvents() { + /** @type {Map} */ + #popoverManagers = new Map(); + /** @this {HeroSkillsCardV1} */ + static async _createPopoverListeners() { const ammoInfoIcon = this.element.querySelector(`.ammo-info-icon`); - ammoInfoIcon.addEventListener(`pointerenter`, this.#ammoInfoPointerEnter.bind(this)); - ammoInfoIcon.addEventListener(`pointerout`, this.#ammoInfoPointerOut.bind(this)); - ammoInfoIcon.addEventListener(`click`, this.#ammoInfoClick.bind(this)); + this.#popoverManagers.set( + `.ammo-info-icon`, + new PopoverEventManager(ammoInfoIcon, AmmoTracker, { lockable: true }), + ); }; async _preparePartContext(partId, ctx, opts) { @@ -186,45 +187,14 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin } return ctx; }; - // #endregion - // #region Event Listeners - /** - * @param {PointerEvent} event - */ - async #ammoInfoPointerEnter(event) { - const pos = event.target.getBoundingClientRect(); - const x = pos.x + Math.floor(pos.width / 2); - const y = pos.y; - - this.#ammoTrackerHoverTimeout = setTimeout( - () => { - this.#ammoTrackerHoverTimeout = null; - const tracker = new AmmoTracker({ - popover: { - framed: false, - x, y, - }, - }); - tracker.render({ force: true }); - this.#ammoTracker = tracker; - }, - getTooltipDelay(), - ); - }; - - async #ammoInfoPointerOut() { - if (this.#ammoTracker) { - this.#ammoTracker.close(); - }; - - if (this.#ammoTrackerHoverTimeout !== null) { - clearTimeout(this.#ammoTrackerHoverTimeout); - this.#ammoTrackerHoverTimeout = null; + _tearDown(options) { + for (const manager of this.#popoverManagers.values()) { + manager.destroy(); }; + this.#popoverManagers.clear(); + super._tearDown(options); }; - - async #ammoInfoClick() {}; // #endregion // #region Actions diff --git a/module/Apps/popovers/AmmoTracker.mjs b/module/Apps/popovers/AmmoTracker.mjs index aeb96a9..a86e569 100644 --- a/module/Apps/popovers/AmmoTracker.mjs +++ b/module/Apps/popovers/AmmoTracker.mjs @@ -8,11 +8,12 @@ export class AmmoTracker extends GenericPopoverMixin(HandlebarsApplicationMixin( static DEFAULT_OPTIONS = { classes: [ `ripcrypt`, - `ripcrypt--AmmoTracker`, ], - position: { - width: 100, - height: 30, + window: { + title: `Ammo Tracker`, + contentClasses: [ + `ripcrypt--AmmoTracker`, + ], }, actions: {}, }; diff --git a/module/Apps/popovers/GenericPopoverMixin.mjs b/module/Apps/popovers/GenericPopoverMixin.mjs index dfe2518..81e5721 100644 --- a/module/Apps/popovers/GenericPopoverMixin.mjs +++ b/module/Apps/popovers/GenericPopoverMixin.mjs @@ -1,8 +1,14 @@ const { ApplicationV2 } = foundry.applications.api; +/** + * This mixin provides the ability to designate an Application as a "popover", + * which means that it will spawn near the x/y coordinates provided it won't + * overflow the bounds of the screen. + */ export function GenericPopoverMixin(HandlebarsApp) { class GenericRipCryptPopover extends HandlebarsApp { static DEFAULT_OPTIONS = { + id: `popover-{id}`, classes: [ `popover`, ], @@ -21,21 +27,48 @@ export function GenericPopoverMixin(HandlebarsApp) { // For when the caller doesn't provide anything, we want this to behave // like a normal Application instance. popover.framed ??= true; - popover.locked ??= true; + popover.locked ??= false; + if (popover.framed) { + options.window ??= {}; options.window.frame = true; options.window.minimizable = true; - }; + } + + options.classes ??= []; + options.classes.push(popover.framed ? `framed` : `frameless`); super(options); - this.popover = popover; }; + toggleLock() { + this.popover.locked = !this.popover.locked; + this.classList.toggle(`locked`, this.popover.locked); + }; + + /** + * This render utility is intended in order to make the popovers able to be + * used in both framed and frameless mode, making sure that the content classes + * from the framed mode get shunted onto the frameless Application's root + * element. + */ + async _onFirstRender(...args) { + await super._onFirstRender(...args); + + const hasContentClasses = this.options?.window?.contentClasses?.length > 0; + if (!this.popover.framed && hasContentClasses) { + this.classList.add(...this.options.window.contentClasses); + }; + }; + async close(options = {}) { + // prevent locked popovers from being closed + if (this.popover.locked && !options.force) { return }; + if (!this.popover.framed) { - options.animate ??= false; + options.animate = false; }; return super.close(options); }; @@ -51,7 +84,7 @@ export function GenericPopoverMixin(HandlebarsApp) { */ _updatePosition(position) { if (!this.element) { return position }; - if (this.popover.framed) { return position } + if (this.popover.framed) { return super._updatePosition(position) }; const el = this.element; let {width, height, left, top, scale} = position; diff --git a/module/utils/PopoverEventManager.mjs b/module/utils/PopoverEventManager.mjs new file mode 100644 index 0000000..777fbbd --- /dev/null +++ b/module/utils/PopoverEventManager.mjs @@ -0,0 +1,134 @@ +import { getTooltipDelay } from "../consts.mjs"; +import { Logger } from "./Logger.mjs"; + +export class PopoverEventManager { + #options; + + /** + * @param {HTMLElement} element The element to attach the listeners to. + * @param {GenericPopoverMixin} popoverClass The class reference that represents the popover app + */ + constructor(element, popoverClass, options = {}) { + options.locked ??= false; + options.lockable ??= true; + + this.#options = options; + this.#element = element; + this.#class = popoverClass; + + element.addEventListener(`pointerenter`, this.#pointerEnterHandler.bind(this)); + element.addEventListener(`pointerout`, this.#pointerOutHandler.bind(this)); + element.addEventListener(`click`, this.#clickHandler.bind(this)); + + if (options.lockable) { + element.addEventListener(`pointerup`, this.#pointerUpHandler.bind(this)); + }; + }; + + destroy() { + this.close(); + this.#element.removeEventListener(`pointerenter`, this.#pointerEnterHandler); + this.#element.removeEventListener(`pointerout`, this.#pointerOutHandler); + this.#element.removeEventListener(`click`, this.#clickHandler); + if (this.#options.lockable) { + this.#element.removeEventListener(`pointerup`, this.#pointerUpHandler); + }; + this.stopOpen(); + this.stopClose(); + }; + + close() { + this.#frameless?.close({ force: true }); + this.#framed?.close({ force: true }); + }; + + stopOpen() { + if (this.#openTimeout != null) { + clearTimeout(this.#openTimeout); + this.#openTimeout = null; + }; + }; + + stopClose() { + if (this.#closeTimeout != null) { + clearTimeout(this.#closeTimeout); + this.#closeTimeout = null; + } + }; + + #element; + #class; + #openTimeout = null; + #closeTimeout = null; + + #frameless; + #framed; + + #clickHandler() { + // Cleanup for the frameless lifecycle + this.stopOpen(); + this.#frameless?.close({ force: true }); + + if (!this.#framed) { + const app = new this.#class({ popover: { ...this.#options, framed: true } }); + this.#framed = app; + } + this.#framed.render({ force: true }); + }; + + #pointerEnterHandler(event) { + this.stopClose(); + + const pos = event.target.getBoundingClientRect(); + const x = pos.x + Math.floor(pos.width / 2); + const y = pos.y; + + this.#openTimeout = setTimeout( + () => { + this.#openTimeout = null; + + // When we have the framed version rendered, we might as well just focus + // it instead of rendering a new application + if (this.#framed?.rendered) { + this.#framed.bringToFront(); + return; + }; + + if (this.#frameless?.rendered) { + const { width, height } = this.#frameless.position; + this.#frameless.render({ position: { left: x - Math.floor(width / 2), top: y - height }}); + return; + } + + this.#frameless = new this.#class({ + popover: { + ...this.#options, + framed: false, + x, y, + }, + }); + this.#frameless.render({ force: true }); + }, + getTooltipDelay(), + ); + }; + + #pointerOutHandler() { + this.stopOpen(); + + this.#closeTimeout = setTimeout( + () => { + this.#closeTimeout = null; + this.#frameless?.close(); + }, + getTooltipDelay(), + ); + }; + + #pointerUpHandler(event) { + Logger.debug(event); + if (event.button !== 1 || !this.#frameless?.rendered || Tour.tourInProgress) { return }; + event.preventDefault(); + this.#frameless.toggleLock(); + }; +}; diff --git a/templates/Apps/apps.css b/templates/Apps/apps.css index 2ec33cc..728dfb9 100644 --- a/templates/Apps/apps.css +++ b/templates/Apps/apps.css @@ -6,3 +6,5 @@ @import url("./HeroSummaryCardV1/style.css"); @import url("./HeroSkillsCardV1/style.css"); @import url("./RichEditor/style.css"); + +@import url("./popovers/AmmoTracker/style.css"); diff --git a/templates/Apps/popovers/AmmoTracker/style.css b/templates/Apps/popovers/AmmoTracker/style.css new file mode 100644 index 0000000..63e4d92 --- /dev/null +++ b/templates/Apps/popovers/AmmoTracker/style.css @@ -0,0 +1,4 @@ +.ripcrypt--AmmoTracker { + color: var(--base-text); + background: var(--base-background); +} diff --git a/templates/css/popover.css b/templates/css/popover.css index 1fbb7c9..05ab2dd 100644 --- a/templates/css/popover.css +++ b/templates/css/popover.css @@ -1,5 +1,16 @@ .ripcrypt.popover { - position: absolute; - z-index: var(--z-index-tooltip); - transform-origin: top left; + border-width: 2px; + border-style: solid; + border-color: transparent; + border-radius: 4px; + + &.frameless { + position: absolute; + z-index: var(--z-index-tooltip); + transform-origin: top left; + } + + &.locked { + border-color: var(--accent-3); + } }