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}}
diff --git a/templates/Apps/DelveDiceHUD/tour/previous.hbs b/templates/Apps/DelveDiceHUD/tour/previous.hbs index 1120e8f..b8c59b7 100644 --- a/templates/Apps/DelveDiceHUD/tour/previous.hbs +++ b/templates/Apps/DelveDiceHUD/tour/previous.hbs @@ -1,7 +1,4 @@
- {{!-- This is here to prevent height collapsing --}} - ​ - {{#if meta.editable}} + {{else}} + {{!-- This is here to prevent height collapsing --}} + ​ {{/if}}
diff --git a/templates/Apps/HeroSkillsCardV1/content.hbs b/templates/Apps/HeroSkillsCardV1/content.hbs index eee8d4a..a4a7a56 100644 --- a/templates/Apps/HeroSkillsCardV1/content.hbs +++ b/templates/Apps/HeroSkillsCardV1/content.hbs @@ -104,7 +104,13 @@ {{/each}} -
+
+
{{ rc-i18n "RipCrypt.common.ammo"}}
@@ -112,10 +118,35 @@ {{ ammo }}
+ {{#each favouriteAmmo as | data |}} + {{#if data}} +
+
+ {{data.name}} +
+ +
+ {{else}} +
+ {{ rc-i18n "RipCrypt.Apps.starred-ammo-placeholder" }} +
+ {{/if}} + {{/each}} {{!-- * Currencies --}}
-
+
@@ -126,7 +157,7 @@ value="0" >
-
+
@@ -137,7 +168,7 @@ value="0" >
-
+
diff --git a/templates/Apps/HeroSkillsCardV1/style.css b/templates/Apps/HeroSkillsCardV1/style.css index 9c092ca..8c654b0 100644 --- a/templates/Apps/HeroSkillsCardV1/style.css +++ b/templates/Apps/HeroSkillsCardV1/style.css @@ -8,6 +8,7 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(13, minmax(0, 1fr)); column-gap: var(--col-gap); + row-gap: var(--row-gap); background: var(--base-background); color: var(--base-text); @@ -35,6 +36,7 @@ display: flex; justify-content: space-between; align-items: center; + border-radius: 999px; } .skill-list { display: grid; @@ -106,18 +108,30 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } - .half-pill { + .pill { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); align-items: center; background: var(--section-header-background); - border-radius: 0 999px 999px 0; + border-radius: 999px; color: var(--section-header-text); + padding: 2px 0 2px 4px; --input-background: var(--base-background); --input-text: var(--base-text); - .input { - margin: 2px; + &.with-icon { + grid-template-columns: min-content minmax(0, 1.5fr) minmax(0, 1fr); + gap: 4px; + } + + label, .label { + padding: 0; + white-space: nowrap; + text-overflow: ellipsis; + } + + input, .input { + margin: 0 2px 0 0; border-radius: 999px; text-align: center; } 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/ammoList.hbs b/templates/Apps/popovers/AmmoTracker/ammoList.hbs new file mode 100644 index 0000000..a9ed916 --- /dev/null +++ b/templates/Apps/popovers/AmmoTracker/ammoList.hbs @@ -0,0 +1,53 @@ +
+ {{log @root}} + {{#if ammos}} +
    + {{#each ammos as | data |}} +
  • + {{ data.ammo.name }} + + {{#if data.favourite}} + + {{else}} + + {{/if}} +
  • + {{/each}} +
+ {{else}} + + {{ rc-i18n "RipCrypt.Apps.no-ammo" }} + + {{/if}} +
diff --git a/templates/Apps/popovers/AmmoTracker/style.css b/templates/Apps/popovers/AmmoTracker/style.css new file mode 100644 index 0000000..2425a5a --- /dev/null +++ b/templates/Apps/popovers/AmmoTracker/style.css @@ -0,0 +1,47 @@ +.ripcrypt--AmmoTracker.ripcrypt--AmmoTracker.ripcrypt--AmmoTracker { + color: var(--popover-text); + background: var(--popover-background); + padding: 4px; + + --row-gap: 4px; + --button-text: var(--header-text); + --button-background: var(--header-background); + + ul { + display: flex; + flex-direction: column; + row-gap: var(--row-gap); + + > li { + padding: 4px 8px; + border-radius: 999px; + + &:nth-child(even) { + color: var(--popover-alt-row-text); + background: var(--popover-alt-row-background); + --input-text: var(--popover-text); + --input-background: var(--popover-background); + --button-text: unset; + --button-background: unset; + } + } + } + + .ammo { + display: grid; + grid-template-columns: 130px 50px min-content; + grid-template-rows: min-content; + align-items: center; + gap: 8px; + + .name { + flex-grow: 1; + justify-self: left; + } + } + + input { + text-align: center; + border-radius: 999px; + } +} diff --git a/templates/css/common.css b/templates/css/common.css index cb8c9f0..1b2758e 100644 --- a/templates/css/common.css +++ b/templates/css/common.css @@ -2,6 +2,8 @@ @import url("./vars.css"); +@import url("./popover.css"); + @import url("./elements/button.css"); @import url("./elements/input.css"); @import url("./elements/lists.css"); @@ -27,6 +29,7 @@ /* height: 270px; */ width: 680px; --col-gap: 2px; + --row-gap: 4px; } label, input, select { diff --git a/templates/css/elements/button.css b/templates/css/elements/button.css index 6eee9d8..62431e7 100644 --- a/templates/css/elements/button.css +++ b/templates/css/elements/button.css @@ -1,6 +1,9 @@ -.ripcrypt.hud button, +.ripcrypt:where(.popover.frameless, .hud) button, .ripcrypt > .window-content button { all: revert; + display: flex; + justify-content: center; + align-items: center; outline: none; border: none; padding: 2px 4px; diff --git a/templates/css/elements/input.css b/templates/css/elements/input.css index 3121505..bdf6c7f 100644 --- a/templates/css/elements/input.css +++ b/templates/css/elements/input.css @@ -1,3 +1,4 @@ +.ripcrypt.popover.frameless, .ripcrypt.hud, .ripcrypt > .window-content { input, .input { diff --git a/templates/css/elements/lists.css b/templates/css/elements/lists.css index b55f34f..24aa561 100644 --- a/templates/css/elements/lists.css +++ b/templates/css/elements/lists.css @@ -1,3 +1,4 @@ +.ripcrypt.popover.frameless, .ripcrypt.hud, .ripcrypt > .window-content { ol { @@ -35,6 +36,9 @@ } ul { + margin: 0; + padding: 0; + > li { margin: 0; } diff --git a/templates/css/elements/p.css b/templates/css/elements/p.css index fcf243a..91c29a1 100644 --- a/templates/css/elements/p.css +++ b/templates/css/elements/p.css @@ -1,3 +1,4 @@ +.ripcrypt.popover.frameless p, .ripcrypt.hud p, .ripcrypt > .window-content p { &.warning { diff --git a/templates/css/elements/pill-bar.css b/templates/css/elements/pill-bar.css index 425499c..ec85807 100644 --- a/templates/css/elements/pill-bar.css +++ b/templates/css/elements/pill-bar.css @@ -1,3 +1,4 @@ +.ripcrypt.popover.frameless .pill-bar, .ripcrypt.hud .pill-bar, .ripcrypt > .window-content .pill-bar { display: flex; diff --git a/templates/css/elements/select.css b/templates/css/elements/select.css index 0940786..be51dbb 100644 --- a/templates/css/elements/select.css +++ b/templates/css/elements/select.css @@ -1,3 +1,4 @@ +.ripcrypt.popover.frameless select, .ripcrypt.hud select, .ripcrypt > .window-content select { all: revert; diff --git a/templates/css/elements/span.css b/templates/css/elements/span.css index 85a099b..ef8bfa4 100644 --- a/templates/css/elements/span.css +++ b/templates/css/elements/span.css @@ -1,3 +1,4 @@ +.ripcrypt.popover.frameless span, .ripcrypt.hud span, .ripcrypt > .window-content span { &.small { diff --git a/templates/css/elements/table.css b/templates/css/elements/table.css index 44abd0b..e06668c 100644 --- a/templates/css/elements/table.css +++ b/templates/css/elements/table.css @@ -1,3 +1,4 @@ +.ripcrypt.popover.frameless table, .ripcrypt.hud table, .ripcrypt > .window-content table { all: revert; diff --git a/templates/css/popover.css b/templates/css/popover.css new file mode 100644 index 0000000..73cfe4a --- /dev/null +++ b/templates/css/popover.css @@ -0,0 +1,17 @@ +.ripcrypt.popover { + box-sizing: border-box; + + &.frameless { + border-width: 2px; + border-style: solid; + border-color: transparent; + border-radius: 4px; + position: absolute; + z-index: calc(var(--z-index-tooltip) - 5); + transform-origin: top left; + + &.locked { + border-color: var(--accent-3); + } + } +} diff --git a/templates/css/themes/dark.css b/templates/css/themes/dark.css index aa4a8a4..b454025 100644 --- a/templates/css/themes/dark.css +++ b/templates/css/themes/dark.css @@ -27,6 +27,16 @@ --col-gap: 2px; --row-gap: 0px; + /* Popover Variables */ + --popover-text: var(--base-text); + --popover-background: var(--base-background); + + --popover-alt-row-text: var(--alt-row-text); + --popover-alt-row-background: var(--alt-row-background); + + --popover-header-text: var(--header-text); + --popover-header-background: var(--header-background); + /* Additional Variables */ --string-tags-border: inherit; --string-tags-tag-background: inherit;