diff --git a/assets/_credit.txt b/assets/_credit.txt
index 43cf3fb..bac6dc2 100644
--- a/assets/_credit.txt
+++ b/assets/_credit.txt
@@ -1,6 +1,8 @@
Oliver Akins:
- geist-silhouette.v2.svg : All rights reserved.
- caster-silhouette.v1.svg : All rights reserved.
+ - icons/star-empty.svg : Modified from https://thenounproject.com/icon/star-7711815/ by Llisole
+ - icons/star.svg : Modified from https://thenounproject.com/icon/star-7711815/ by Llisole
Kýnan Antos (Gritsilk Games):
- hero-silhouette.svg : Licensed to Distribute and Modify within the bounds of the "Foundry-RipCrypt" system.
diff --git a/assets/icons/star-empty.svg b/assets/icons/star-empty.svg
new file mode 100644
index 0000000..8760cc9
--- /dev/null
+++ b/assets/icons/star-empty.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/star.svg b/assets/icons/star.svg
new file mode 100644
index 0000000..829431b
--- /dev/null
+++ b/assets/icons/star.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/eslint.config.mjs b/eslint.config.mjs
index aabd56c..a0d500d 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -22,9 +22,7 @@ export default [
Hooks: `readonly`,
ui: `readonly`,
Actor: `readonly`,
- Actors: `readonly`,
Item: `readonly`,
- Items: `readonly`,
foundry: `readonly`,
ChatMessage: `readonly`,
ActiveEffect: `readonly`,
@@ -36,6 +34,7 @@ export default [
Combatant: `readonly`,
canvas: `readonly`,
Token: `readonly`,
+ Tour: `readonly`,
},
},
},
diff --git a/langs/en-ca.json b/langs/en-ca.json
index c40e853..8eab18e 100644
--- a/langs/en-ca.json
+++ b/langs/en-ca.json
@@ -21,6 +21,9 @@
"HeroCraftCardV1": "Hero Craft Card",
"HeroSkillsCardV1": "Hero Skill Card"
},
+ "app-titles": {
+ "AmmoTracker": "Ammo Tracker"
+ },
"common": {
"abilities": {
"grit": "Grit",
@@ -172,12 +175,21 @@
"numberOfDice": "# of Dice",
"rollTarget": "Target",
"difficulty": "(DC: {dc})",
- "RichEditor-no-collaborative": "Warning: This editor is not collaborative, that means that if you and someone else are editing it at the same time, you won't see that someone else is making changes until they save, and then your changes will be lost."
+ "RichEditor-no-collaborative": "Warning: This editor is not collaborative, that means that if you and someone else are editing it at the same time, you won't see that someone else is making changes until they save, and then your changes will be lost.",
+ "starred-ammo-placeholder": "Starred Ammo Slot",
+ "AmmoTracker": {
+ "no-ammo": "You don't have any ammo!",
+ "star-button": "Add {name} as a starred ammo",
+ "star-button-tooltip": "Add Star",
+ "unstar-button": "Remove {name} as a starred ammo",
+ "unstar-button-tooltip": "Remove Star"
+ }
},
"notifs": {
"error": {
"cannot-equip": "Cannot equip the {itemType}, see console for more details.",
- "invalid-delta": "The delta for \"{name}\" is not a number, cannot finish processing the action."
+ "invalid-delta": "The delta for \"{name}\" is not a number, cannot finish processing the action.",
+ "at-favourite-limit": "Cannot favourite more than three items, unfavourite one to make space."
},
"warn": {
"cannot-go-negative": "\"{name}\" is unable to be a negative number."
@@ -197,5 +209,8 @@
"heavy": "The distance of your aura when using Heavycraft"
}
}
+ },
+ "USER": {
+ "GM": "Keeper"
}
}
diff --git a/module/Apps/ActorSheets/HeroSkillsCardV1.mjs b/module/Apps/ActorSheets/HeroSkillsCardV1.mjs
index b0e83fb..76f0e9d 100644
--- a/module/Apps/ActorSheets/HeroSkillsCardV1.mjs
+++ b/module/Apps/ActorSheets/HeroSkillsCardV1.mjs
@@ -1,9 +1,12 @@
import { deleteItemFromElement, editItemFromElement } from "../utils.mjs";
import { documentSorter, filePath } from "../../consts.mjs";
+import { AmmoTracker } from "../popovers/AmmoTracker.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { GenericAppMixin } from "../GenericApp.mjs";
+import { ItemFlags } from "../../flags/item.mjs";
import { localizer } from "../../utils/Localizer.mjs";
import { Logger } from "../../utils/Logger.mjs";
+import { PopoverEventManager } from "../../utils/PopoverEventManager.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
@@ -43,6 +46,7 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin
async _onRender(context, options) {
await super._onRender(context, options);
HeroSkillsCardV1._onRender.bind(this)(context, options);
+ HeroSkillsCardV1._createPopoverListeners.bind(this)();
};
static async _onRender(_context, options) {
@@ -75,6 +79,18 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin
);
};
+ /** @this {HeroSkillsCardV1} */
+ static async _createPopoverListeners() {
+ const ammoInfoIcon = this.element.querySelector(`.ammo-info-icon`);
+ const idPrefix = this.actor.uuid;
+
+ const manager = new PopoverEventManager(`${idPrefix}.ammo-info-icon`, ammoInfoIcon, AmmoTracker);
+ this._popoverManagers.set(`.ammo-info-icon`, manager);
+ this._hookIDs.set(Hooks.on(`prepare${manager.id}Context`, (ctx) => {
+ ctx.ammos = this.actor.itemTypes.ammo;
+ }), `prepare${manager.id}Context`);
+ };
+
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.actor = this.document;
@@ -120,7 +136,24 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin
};
static async prepareAmmo(ctx) {
- ctx.ammo = 0;
+ let total = 0;
+ let favouriteCount = 0;
+ ctx.favouriteAmmo = new Array(3).fill(null);
+
+ for (const ammo of ctx.actor.itemTypes.ammo) {
+ total += ammo.system.quantity;
+
+ if (favouriteCount < 3 && ammo.getFlag(game.system.id, ItemFlags.FAVOURITE)) {
+ ctx.favouriteAmmo[favouriteCount] = {
+ uuid: ammo.uuid,
+ name: ammo.name,
+ quantity: ammo.system.quantity,
+ };
+ favouriteCount++;
+ };
+ };
+
+ ctx.ammo = total;
return ctx;
};
diff --git a/module/Apps/GenericApp.mjs b/module/Apps/GenericApp.mjs
index 5f3a75b..1fe5edf 100644
--- a/module/Apps/GenericApp.mjs
+++ b/module/Apps/GenericApp.mjs
@@ -1,4 +1,4 @@
-import { createItemFromElement, deleteItemFromElement, editItemFromElement } from "./utils.mjs";
+import { createItemFromElement, deleteItemFromElement, editItemFromElement, updateForeignDocumentFromEvent } from "./utils.mjs";
import { DicePool } from "./DicePool.mjs";
import { RichEditor } from "./RichEditor.mjs";
import { toBoolean } from "../consts.mjs";
@@ -31,6 +31,13 @@ export function GenericAppMixin(HandlebarsApp) {
};
// #endregion
+ // #region Instance Data
+ /** @type {Map} */
+ _popoverManagers = new Map();
+ /** @type {Map} */
+ _hookIDs = new Map();
+ // #endregion
+
// #region Lifecycle
/**
* @override
@@ -38,13 +45,43 @@ export function GenericAppMixin(HandlebarsApp) {
* top after being re-rendered as normal
*/
async render(options = {}, _options = {}) {
- super.render(options, _options);
+ await super.render(options, _options);
const instance = foundry.applications.instances.get(this.id);
if (instance !== undefined && options.orBringToFront) {
instance.bringToFront();
};
};
+ /** @override */
+ async _onRender(...args) {
+ await super._onRender(...args);
+
+ /*
+ Rendering each of the popover managers associated with this app allows us
+ to have them be dynamic and update when their parent application is rerendered,
+ this could eventually be something we can move into the Document's apps
+ collection so Foundry auto-rerenders it, but because it isn't actually
+ associated with the Document (as it's dependendant on the Application), I
+ decided that it would be best to do my own handling for it.
+ */
+ for (const manager of this._popoverManagers.values()) {
+ manager.render();
+ };
+
+ /*
+ Foreign update listeners so that we can easily update items that may not
+ be this document itself, but are useful to be able to be edited from this
+ sheet. Primarily useful for editing the Actors' Item collection, or an Items'
+ ActiveEffect collection.
+ */
+ this.element.querySelectorAll(`input[data-foreign-update-on]`).forEach(el => {
+ const events = el.dataset.foreignUpdateOn.split(`,`);
+ for (const event of events) {
+ el.addEventListener(event, updateForeignDocumentFromEvent);
+ };
+ });
+ };
+
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
delete ctx.document;
@@ -60,6 +97,22 @@ export function GenericAppMixin(HandlebarsApp) {
return ctx;
};
+
+ _tearDown(options) {
+ // Clear all popovers associated with the app
+ for (const manager of this._popoverManagers.values()) {
+ manager.destroy();
+ };
+ this._popoverManagers.clear();
+
+ // Remove any hooks added for this app
+ for (const [id, hook] of this._hookIDs.entries()) {
+ Hooks.off(hook, id);
+ };
+ this._hookIDs.clear();
+
+ super._tearDown(options);
+ };
// #endregion
// #region Actions
diff --git a/module/Apps/popovers/AmmoTracker.mjs b/module/Apps/popovers/AmmoTracker.mjs
new file mode 100644
index 0000000..458e783
--- /dev/null
+++ b/module/Apps/popovers/AmmoTracker.mjs
@@ -0,0 +1,96 @@
+import { filePath } from "../../consts.mjs";
+import { GenericPopoverMixin } from "./GenericPopoverMixin.mjs";
+import { ItemFlags } from "../../flags/item.mjs";
+import { localizer } from "../../utils/Localizer.mjs";
+import { Logger } from "../../utils/Logger.mjs";
+
+const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
+
+export class AmmoTracker extends GenericPopoverMixin(HandlebarsApplicationMixin(ApplicationV2)) {
+ // #region Options
+ static DEFAULT_OPTIONS = {
+ classes: [
+ `ripcrypt`,
+ ],
+ window: {
+ title: `RipCrypt.app-titles.AmmoTracker`,
+ contentClasses: [
+ `ripcrypt--AmmoTracker`,
+ ],
+ },
+ actions: {
+ favourite: this.#favourite,
+ unfavourite: this.#unfavourite,
+ },
+ };
+
+ static PARTS = {
+ ammoList: {
+ template: filePath(`templates/Apps/popovers/AmmoTracker/ammoList.hbs`),
+ },
+ };
+ // #endregion
+
+ // #region Instance Data
+ _favouriteCount = 0;
+ // #endregion
+
+ // #region Lifecycle
+ async _preparePartContext(partId, data) {
+ const ctx = {
+ meta: { idp: this.id },
+ partId,
+ };
+
+ let favouriteCount = 0;
+ ctx.ammos = data.ammos.map(ammo => {
+ const favourite = ammo.getFlag(game.system.id, ItemFlags.FAVOURITE) ?? false;
+ if (favourite) { favouriteCount++ };
+
+ return {
+ ammo,
+ favourite,
+ };
+ });
+
+ this._favouriteCount = favouriteCount;
+ ctx.atFavouriteLimit = favouriteCount >= 3;
+ return ctx;
+ };
+ // #endregion
+
+ // #region Actions
+ static async #favourite(_, el) {
+ const targetEl = el.closest(`[data-item-id]`);
+ if (!targetEl) {
+ Logger.warn(`Cannot find a parent element with data-item-id`);
+ return;
+ };
+
+ if (this._favouriteCount > 3) {
+ ui.notifications.error(localizer(`RipCrypt.notifs.error.at-favourite-limit`));
+ return;
+ };
+
+ const data = targetEl.dataset;
+ const item = await fromUuid(data.itemId);
+ if (!item) { return };
+
+ item.setFlag(game.system.id, ItemFlags.FAVOURITE, true);
+ };
+
+ static async #unfavourite(_, el) {
+ const targetEl = el.closest(`[data-item-id]`);
+ if (!targetEl) {
+ Logger.warn(`Cannot find a parent element with data-item-id`);
+ return;
+ };
+
+ const data = targetEl.dataset;
+ const item = await fromUuid(data.itemId);
+ if (!item) { return };
+
+ item.unsetFlag(game.system.id, ItemFlags.FAVOURITE);
+ };
+ // #endregion
+};
diff --git a/module/Apps/popovers/GenericPopoverMixin.mjs b/module/Apps/popovers/GenericPopoverMixin.mjs
new file mode 100644
index 0000000..271bb9a
--- /dev/null
+++ b/module/Apps/popovers/GenericPopoverMixin.mjs
@@ -0,0 +1,188 @@
+import { updateForeignDocumentFromEvent } from "../utils.mjs";
+
+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. This also implements a _preparePartContext
+ * in order to allow the parent application passing new data into the popover
+ * whenever it rerenders; how the popover handles this data is up to the
+ * specific implementation.
+ */
+export function GenericPopoverMixin(HandlebarsApp) {
+ class GenericRipCryptPopover extends HandlebarsApp {
+ static DEFAULT_OPTIONS = {
+ id: `popover-{id}`,
+ classes: [
+ `popover`,
+ ],
+ window: {
+ frame: false,
+ positioned: true,
+ resizable: false,
+ minimizable: false,
+ },
+ actions: {},
+ };
+
+ popover = {};
+ constructor({ popover, ...options}) {
+
+ // For when the caller doesn't provide anything, we want this to behave
+ // like a normal Application instance.
+ popover.framed ??= 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 _onRender(...args) {
+ await super._onRender(...args);
+
+ /*
+ Foreign update listeners so that we can easily update items that may not
+ be this document itself, but are useful to be able to be edited from this
+ sheet. Primarily useful for editing the Actors' Item collection, or an Items'
+ ActiveEffect collection.
+ */
+ this.element.querySelectorAll(`input[data-foreign-update-on]`).forEach(el => {
+ const events = el.dataset.foreignUpdateOn.split(`,`);
+ for (const event of events) {
+ el.addEventListener(event, updateForeignDocumentFromEvent);
+ };
+ });
+ };
+
+ async close(options = {}) {
+ // prevent locked popovers from being closed
+ if (this.popover.locked && !options.force) { return };
+
+ if (!this.popover.framed) {
+ options.animate = false;
+ };
+ return super.close(options);
+ };
+
+ /**
+ * @override
+ * Custom implementation in order to make it show up approximately where I
+ * want it to when being created.
+ *
+ * Most of this implementation is identical to the ApplicationV2
+ * implementation, the biggest difference is how targetLeft and targetTop
+ * are calculated.
+ */
+ _updatePosition(position) {
+ if (!this.element) { return position };
+ if (this.popover.framed) { return super._updatePosition(position) };
+
+ const el = this.element;
+ let {width, height, left, top, scale} = position;
+ scale ??= 1.0;
+ const computedStyle = getComputedStyle(el);
+ let minWidth = ApplicationV2.parseCSSDimension(computedStyle.minWidth, el.parentElement.offsetWidth) || 0;
+ let maxWidth = ApplicationV2.parseCSSDimension(computedStyle.maxWidth, el.parentElement.offsetWidth) || Infinity;
+ let minHeight = ApplicationV2.parseCSSDimension(computedStyle.minHeight, el.parentElement.offsetHeight) || 0;
+ let maxHeight = ApplicationV2.parseCSSDimension(computedStyle.maxHeight, el.parentElement.offsetHeight) || Infinity;
+ let bounds = el.getBoundingClientRect();
+ const {clientWidth, clientHeight} = document.documentElement;
+
+ // Explicit width
+ const autoWidth = width === `auto`;
+ if ( !autoWidth ) {
+ const targetWidth = Number(width || bounds.width);
+ minWidth = parseInt(minWidth) || 0;
+ maxWidth = parseInt(maxWidth) || (clientWidth / scale);
+ width = Math.clamp(targetWidth, minWidth, maxWidth);
+ }
+
+ // Explicit height
+ const autoHeight = height === `auto`;
+ if ( !autoHeight ) {
+ const targetHeight = Number(height || bounds.height);
+ minHeight = parseInt(minHeight) || 0;
+ maxHeight = parseInt(maxHeight) || (clientHeight / scale);
+ height = Math.clamp(targetHeight, minHeight, maxHeight);
+ }
+
+ // Implicit height
+ if ( autoHeight ) {
+ Object.assign(el.style, {width: `${width}px`, height: ``});
+ bounds = el.getBoundingClientRect();
+ height = bounds.height;
+ }
+
+ // Implicit width
+ if ( autoWidth ) {
+ Object.assign(el.style, {height: `${height}px`, width: ``});
+ bounds = el.getBoundingClientRect();
+ width = bounds.width;
+ }
+
+ // Left Offset
+ const scaledWidth = width * scale;
+ const targetLeft = left ?? (this.popover.x - Math.floor( scaledWidth / 2 ));
+ const maxLeft = Math.max(clientWidth - scaledWidth, 0);
+ left = Math.clamp(targetLeft, 0, maxLeft);
+
+ // Top Offset
+ const scaledHeight = height * scale;
+ const targetTop = top ?? (this.popover.y - scaledHeight);
+ const maxTop = Math.max(clientHeight - scaledHeight, 0);
+ top = Math.clamp(targetTop, 0, maxTop);
+
+ // Scale
+ scale ??= 1.0;
+ return {
+ width: autoWidth ? `auto` : width,
+ height: autoHeight ? `auto` : height,
+ left,
+ top,
+ scale,
+ };
+ };
+
+ /**
+ * This is here in order allow things that are not this Application
+ * to provide / augment the context data for the lifecycle of the app.
+ */
+ async _prepareContext(_partId, _context, options) {
+ const context = {};
+ Hooks.callAll(`prepare${this.constructor.name}Context`, context, options);
+ Hooks.callAll(`prepare${this.popover.managerId}Context`, context, options);
+ return context;
+ };
+ };
+ return GenericRipCryptPopover;
+};
diff --git a/module/Apps/utils.mjs b/module/Apps/utils.mjs
index 1baee18..7c55ec4 100644
--- a/module/Apps/utils.mjs
+++ b/module/Apps/utils.mjs
@@ -42,3 +42,24 @@ export async function deleteItemFromElement(target) {
const item = await fromUuid(itemId);
item.delete();
};
+
+/**
+ * Updates a document using the UUID, expects there to be the following
+ * dataset attributes:
+ * - "data-foreign-uuid" : The UUID of the document to update
+ * - "data-foreign-name" : The dot-separated path of the value to update
+ *
+ * @param {Event} event
+ */
+export async function updateForeignDocumentFromEvent(event) {
+ const target = event.currentTarget;
+ const data = target.dataset;
+ const document = await fromUuid(data.foreignUuid);
+
+ let value = target.value;
+ switch (target.type) {
+ case `checkbox`: value = target.checked; break;
+ };
+
+ await document?.update({ [data.foreignName]: value });
+};
diff --git a/module/api.mjs b/module/api.mjs
index a56f7b1..389ca3e 100644
--- a/module/api.mjs
+++ b/module/api.mjs
@@ -1,4 +1,5 @@
// App imports
+import { AmmoTracker } from "./Apps/popovers/AmmoTracker.mjs";
import { CombinedHeroSheet } from "./Apps/ActorSheets/CombinedHeroSheet.mjs";
import { DicePool } from "./Apps/DicePool.mjs";
import { HeroSkillsCardV1 } from "./Apps/ActorSheets/HeroSkillsCardV1.mjs";
@@ -10,6 +11,9 @@ import { distanceBetweenFates, nextFate, previousFate } from "./utils/fates.mjs"
import { documentSorter } from "./consts.mjs";
import { rankToInteger } from "./utils/rank.mjs";
+// Misc Imports
+import { ItemFlags } from "./flags/item.mjs";
+
const { deepFreeze } = foundry.utils;
Object.defineProperty(
@@ -18,6 +22,7 @@ Object.defineProperty(
{
value: deepFreeze({
Apps: {
+ AmmoTracker,
DicePool,
CombinedHeroSheet,
HeroSummaryCardV1,
@@ -31,6 +36,7 @@ Object.defineProperty(
previousFate,
rankToInteger,
},
+ ItemFlags,
}),
writable: false,
},
diff --git a/module/consts.mjs b/module/consts.mjs
index 84d8740..405707e 100644
--- a/module/consts.mjs
+++ b/module/consts.mjs
@@ -54,3 +54,14 @@ export function documentSorter(a, b) {
};
return Math.sign(a.name.localeCompare(b.name));
};
+
+// MARK: getTooltipDelay
+/**
+ * Retrieves the configured minimum delay between the user hovering an element
+ * and a tooltip showing up. Used for the pseudo-tooltip Applications that I use.
+ *
+ * @returns The number of milliseconds for the timeout
+ */
+export function getTooltipDelay() {
+ return game.tooltip.constructor.TOOLTIP_ACTIVATION_MS;
+};
diff --git a/module/flags/item.mjs b/module/flags/item.mjs
new file mode 100644
index 0000000..de53d3b
--- /dev/null
+++ b/module/flags/item.mjs
@@ -0,0 +1,4 @@
+export const ItemFlags = Object.freeze({
+ /** The boolean value to indicate if an item is considered favourited/starred or not */
+ FAVOURITE: `favourited`,
+});
diff --git a/module/gameTerms.mjs b/module/gameTerms.mjs
index 5bcbe71..86507ad 100644
--- a/module/gameTerms.mjs
+++ b/module/gameTerms.mjs
@@ -37,6 +37,7 @@ export const gameTerms = Object.preventExtensions({
}),
/** The types of items that contribute to the gear limit */
gearItemTypes: new Set([
+ `ammo`,
`armour`,
`weapon`,
`shield`,
diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs
index 48c3171..d829eb4 100644
--- a/module/hooks/init.mjs
+++ b/module/hooks/init.mjs
@@ -35,6 +35,9 @@ import { registerMetaSettings } from "../settings/metaSettings.mjs";
import { registerUserSettings } from "../settings/userSettings.mjs";
import { registerWorldSettings } from "../settings/worldSettings.mjs";
+const { Items, Actors } = foundry.documents.collections;
+const { ItemSheet, ActorSheet } = foundry.appv1.sheets;
+
Hooks.once(`init`, () => {
Logger.log(`Initializing`);
@@ -70,10 +73,8 @@ Hooks.once(`init`, () => {
// #region Sheets
// Unregister core sheets
- /* eslint-disable no-undef */
Items.unregisterSheet(`core`, ItemSheet);
Actors.unregisterSheet(`core`, ActorSheet);
- /* eslint-enabled no-undef */
// #region Actors
Actors.registerSheet(game.system.id, CombinedHeroSheet, {
diff --git a/module/utils/PopoverEventManager.mjs b/module/utils/PopoverEventManager.mjs
new file mode 100644
index 0000000..07e4793
--- /dev/null
+++ b/module/utils/PopoverEventManager.mjs
@@ -0,0 +1,184 @@
+import { getTooltipDelay } from "../consts.mjs";
+import { Logger } from "./Logger.mjs";
+
+export class PopoverEventManager {
+ #options;
+ #id;
+
+ get id() {
+ return this.#id;
+ };
+
+ /** @type {Map} */
+ static #existing = new Map();
+
+ /**
+ * @param {HTMLElement} element The element to attach the listeners to.
+ * @param {GenericPopoverMixin} popoverClass The class reference that represents the popover app
+ */
+ constructor(id, element, popoverClass, options = {}) {
+ id = `${id}-${popoverClass.name}`;
+ this.#id = id;
+
+ if (PopoverEventManager.#existing.has(id)) {
+ const manager = PopoverEventManager.#existing.get(id);
+ manager.#addListeners(element);
+ return manager;
+ };
+
+ options.managerId = id;
+ options.locked ??= false;
+ options.lockable ??= true;
+
+ this.#options = options;
+ this.#element = element;
+ this.#class = popoverClass;
+
+ this.#addListeners(element);
+ PopoverEventManager.#existing.set(id, this);
+ };
+
+ /**
+ * @param {HTMLElement} element
+ */
+ #addListeners(element) {
+ element.addEventListener(`pointerenter`, this.#pointerEnterHandler.bind(this));
+ element.addEventListener(`pointerout`, this.#pointerOutHandler.bind(this));
+ element.addEventListener(`click`, this.#clickHandler.bind(this));
+
+ if (this.#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;
+ }
+ };
+
+ get rendered() {
+ return Boolean(this.#frameless?.rendered || this.#framed?.rendered);
+ };
+
+ render(options) {
+ if (this.#framed?.rendered) {
+ this.#framed.render(options);
+ };
+ if (this.#frameless?.rendered) {
+ this.#frameless.render(options);
+ };
+ };
+
+ #element;
+ #class;
+ #openTimeout = null;
+ #closeTimeout = null;
+
+ #frameless;
+ #framed;
+
+ #construct(options) {
+ options.popover ??= {};
+ options.popover.managerId = this.#id;
+
+ return new this.#class(options);
+ };
+
+ #clickHandler() {
+ Logger.debug(`click event handler`);
+ // Cleanup for the frameless lifecycle
+ this.#stopOpen();
+ this.#stopClose();
+ this.#frameless?.close({ force: true });
+
+ if (!this.#framed) {
+ this.#framed = this.#construct({ popover: { ...this.#options, framed: true } });
+ }
+ 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;
+ };
+
+ // When the frameless is already rendered, we should just move it to the
+ // new location instead of spawning a new one
+ if (this.#frameless?.rendered) {
+ const { width, height } = this.#frameless.element.getBoundingClientRect();
+ const top = y - height;
+ const left = x - Math.floor(width / 2);
+ this.#frameless.setPosition({ left, top });
+ return;
+ }
+
+ this.#frameless = this.#construct({
+ 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) {
+ if (event.button !== 1 || !this.#frameless?.rendered || Tour.tourInProgress) { return };
+ event.preventDefault();
+ this.#frameless.toggleLock();
+ };
+};
diff --git a/system.json b/system.json
index 68d0f25..06aecb9 100644
--- a/system.json
+++ b/system.json
@@ -5,7 +5,7 @@
"version": "0.0.1",
"compatibility": {
"minimum": 13,
- "verified": 13,
+ "verified": 13.339,
"maximum": 13
},
"authors": [
diff --git a/templates/Apps/DelveDiceHUD/tour/next.hbs b/templates/Apps/DelveDiceHUD/tour/next.hbs
index d81c879..5e102f0 100644
--- a/templates/Apps/DelveDiceHUD/tour/next.hbs
+++ b/templates/Apps/DelveDiceHUD/tour/next.hbs
@@ -1,7 +1,4 @@
- {{!-- This is here to prevent height collapsing --}}
- ​
-
{{#if meta.editable}}
+ {{else}}
+ {{!-- This is here to prevent height collapsing --}}
+ ​
{{/if}}
- {{!-- This is here to prevent height collapsing --}}
- ​
-
{{#if meta.editable}}
+ {{else}}
+ {{!-- This is here to prevent height collapsing --}}
+ ​
{{/if}}