diff --git a/daggerheart.d.ts b/daggerheart.d.ts index 891a3a2a..02abf063 100644 --- a/daggerheart.d.ts +++ b/daggerheart.d.ts @@ -11,6 +11,9 @@ import * as documents from './module/documents/_module.mjs'; import { macros } from './module/_module.mjs'; import * as dice from './module/dice/_module.mjs'; import * as fields from './module/data/fields/_module.mjs'; +import { gameSettings } from './module/config/settingsConfig.mjs'; +import DhCountdowns from './module/data/countdowns.mjs'; +import DhAutomation from './module/data/settings/Automation.mjs'; // Foundry's use of `Object.assign(globalThis) means many globally available objects are not read as such @@ -103,3 +106,13 @@ declare module '@client/packages/system.mjs' { }; } } + +declare module '@client/helpers/client-settings.mjs' { + // Add explicit typed overrides for auto complete. These require /** @type {"string"} on the vars themselves to work */ + export default interface ClientSettings { + get(namespace: 'daggerheart', key: typeof gameSettings.Automation): DhAutomation; + get(namespace: 'daggerheart', key: typeof gameSettings.Homebrew): DhHomebrew; + get(namespace: 'daggerheart', key: typeof gameSettings.Countdowns): DhCountdowns; + get(namespace: 'daggerheart', key: string): unknown; + } +} \ No newline at end of file diff --git a/module/applications/ui/countdownEdit.mjs b/module/applications/ui/countdownEdit.mjs index 1dbc56be..af1af46b 100644 --- a/module/applications/ui/countdownEdit.mjs +++ b/module/applications/ui/countdownEdit.mjs @@ -153,7 +153,6 @@ export default class CountdownEdit extends HandlebarsApplicationMixin(Applicatio action: socketEvent.Refresh, data: { refreshType: RefreshType.Countdown } }); - Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.Countdown }); } static #addCountdown() { diff --git a/module/applications/ui/countdowns.mjs b/module/applications/ui/countdowns.mjs index 5cf79100..996a46ea 100644 --- a/module/applications/ui/countdowns.mjs +++ b/module/applications/ui/countdowns.mjs @@ -12,14 +12,11 @@ const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; export default class DhCountdowns extends HandlebarsApplicationMixin(ApplicationV2) { previousCountdownData = null; - changedCountdownsForAnimation = new Set(); constructor(options = {}) { super(options); - this.previousCountdownData = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns).countdowns; - this.setupHooks(); } /** @inheritDoc */ @@ -80,7 +77,15 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application return frame; } + /** @inheritdoc */ + async _onFirstRender(context, options) { + await super._onFirstRender(context, options); + this._createContextMenu(this._getCountdownContextOptions, '.countdown-container[data-countdown]', { + parentClassHooks: false, fixed: true + }); + } + /** @inheritdoc */ async _onRender(context, options) { await super._onRender(context, options); @@ -95,7 +100,7 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application /* Handle animations to draw attention to countdown values changing */ const typesToAnimate = new Set(); - for (const countdownKey of this.changedCountdownsForAnimation) { + for (const countdownKey of options.animate ?? []) { const shimmerAnimation = [ { backgroundPositionX: '98%' }, { backgroundPositionX: '0%' } @@ -127,8 +132,6 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application const element = this.element.querySelector(`.header-type-toggles .header-type[data-type="${type}"]`); element?.animate(pulseAnimation, pulseTiming); } - - this.changedCountdownsForAnimation.clear(); } /** Returns countdown data filtered by ownership */ @@ -137,17 +140,15 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application const values = Object.entries(setting.countdowns).map(([key, countdown]) => ({ key, countdown, - ownership: DhCountdowns.#getPlayerOwnership(game.user, setting, countdown) + ownership: countdown.getUserLevel(game.user) })); return values.filter(v => v.ownership !== CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE); } _getCountdownData() { - const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns); - return this.#getCountdowns().reduce((acc, { key, countdown, ownership }) => { const playersWithAccess = game.users.reduce((acc, user) => { - const ownership = DhCountdowns.#getPlayerOwnership(user, setting, countdown); + const ownership = countdown.getUserLevel(user); if (!user.isGM && ownership && ownership !== CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE) { acc.push(user); } @@ -213,19 +214,6 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application return context; } - static #getPlayerOwnership(user, setting, countdown) { - if (user.isGM) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER; - - const playerOwnership = countdown.ownership[user.id]; - return playerOwnership === undefined || playerOwnership === CONST.DOCUMENT_OWNERSHIP_LEVELS.INHERIT - ? setting.defaultOwnership - : playerOwnership; - } - - cooldownRefresh = ({ refreshType }) => { - if (refreshType === RefreshType.Countdown) this.render(); - }; - static canPerformEdit() { if (game.user.isGM) return true; @@ -323,18 +311,12 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application action: socketEvent.Refresh, data: { refreshType: RefreshType.Countdown } }); - Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.Countdown }); - } - - setupHooks() { - Hooks.on(socketEvent.Refresh, this.cooldownRefresh.bind()); } async close(options) { /* Opt out of Foundry's standard behavior of closing all application windows marked as UI when Escape is pressed */ if (options.closeKey) return; - Hooks.off(socketEvent.Refresh, this.cooldownRefresh); return super.close(options); } @@ -377,4 +359,30 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application refreshType: RefreshType.Countdown }); } + + /** + * @returns {import('@client/applications/ux/context-menu.mjs').ContextMenuEntry[]} + */ + _getCountdownContextOptions() { + /** @param {HTMLElement} element */ + const getCountdownFromElement = element => { + const id = element.closest('[data-countdown]').dataset.countdown; + if (!id) return null; + const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns); + return setting.countdowns[id ?? '']; + } + + return [ + { + label: 'CONTROLS.CommonDelete', + icon: 'fa-solid fa-trash', + visible: element => { + return getCountdownFromElement(element)?.isOwner; + }, + onClick: (_, target) => { + getCountdownFromElement(target)?.delete(); + } + } + ]; + } } diff --git a/module/config/settingsConfig.mjs b/module/config/settingsConfig.mjs index 50841084..7aa1b0f7 100644 --- a/module/config/settingsConfig.mjs +++ b/module/config/settingsConfig.mjs @@ -27,8 +27,10 @@ export const menu = { }; export const gameSettings = { + /** @type {'Automation'} */ Automation: 'Automation', Metagaming: 'Metagaming', + /** @type {'Homebrew'} */ Homebrew: 'Homebrew', appearance: 'Appearance', GlobalOverrides: 'GlobalOverrides', @@ -37,6 +39,7 @@ export const gameSettings = { Fear: 'ResourcesFear' }, LevelTiers: 'LevelTiers', + /** @type {'Countdowns'} */ Countdowns: 'Countdowns', LastMigrationVersion: 'LastMigrationVersion', SpotlightRequestQueue: 'SpotlightRequestQueue', diff --git a/module/config/system.mjs b/module/config/system.mjs index 31dba518..de6630ec 100644 --- a/module/config/system.mjs +++ b/module/config/system.mjs @@ -12,6 +12,7 @@ import * as HOOKS from './hooksConfig.mjs'; import * as TRIGGER from './triggerConfig.mjs'; import * as ITEMBROWSER from './itemBrowserConfig.mjs'; +/** @type {"daggerheart"} */ export const SYSTEM_ID = 'daggerheart'; export const SYSTEM = { diff --git a/module/data/_types.d.ts b/module/data/_types.d.ts new file mode 100644 index 00000000..f0e2ade5 --- /dev/null +++ b/module/data/_types.d.ts @@ -0,0 +1,7 @@ +import { DhCountdown } from './countdowns.mjs' + +declare module './countdowns.mjs' { + export default interface DhCountdowns { + countdowns: Record; + } +} \ No newline at end of file diff --git a/module/data/countdowns.mjs b/module/data/countdowns.mjs index ffe4d26b..5fe01e20 100644 --- a/module/data/countdowns.mjs +++ b/module/data/countdowns.mjs @@ -1,3 +1,4 @@ +import { RefreshType, socketEvent } from '../systemRegistration/socket.mjs'; import FormulaField from './fields/formulaField.mjs'; export default class DhCountdowns extends foundry.abstract.DataModel { @@ -14,19 +15,36 @@ export default class DhCountdowns extends foundry.abstract.DataModel { }; } - handleChange() { + /** @inheritdoc */ + _initialize(options) { + super._initialize(options); + for (const [id, countdown] of Object.entries(this.countdowns)) { + countdown.id = id; + } + } + + async handleChange() { const previousCountdowns = foundry.ui.countdowns.previousCountdownData; const changedCountdowns = Object.entries(this.countdowns).reduce((acc, [key, countdown]) => { - const previousCountdown = previousCountdowns[key]; - if (!previousCountdown || (previousCountdown.progress.current !== countdown.progress.current)) { + const previous = previousCountdowns[key]; + const currentChanged = !previous || (previous.progress.current !== countdown.progress.current); + if (currentChanged && previous?.progress.start === countdown.progress.start) { acc.push(key); } - return acc; }, []); - for (const countdownKey of changedCountdowns) - foundry.ui.countdowns.changedCountdownsForAnimation.add(countdownKey); + // Re-render countdowns applications. When the change is due to an actual update, resync the editor + if (!foundry.utils.equals(previousCountdowns, this.countdowns)) { + await foundry.ui.countdowns.render({ animate: changedCountdowns }); + for (const instance of game.system.api.applications.ui.CountdownEdit.instances()) { + instance.data = this; + await instance.render(); + } + } + + // Inform modules of updates + Hooks.callAll(socketEvent.Refresh, { refreshType: RefreshType.Countdown }); } static migrateData(source) { @@ -160,6 +178,14 @@ export class DhCountdown extends foundry.abstract.DataModel { }, {}); } + /** + * A boolean indicator for whether the current game User has ownership rights for this countdown + * @returns {boolean} + */ + get isOwner() { + return this.getUserLevel(game.user) === CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER; + } + /** @inheritDoc */ static migrateData(source) { if (source.progress.max) { @@ -170,4 +196,29 @@ export class DhCountdown extends foundry.abstract.DataModel { return super.migrateData(source); } + + /** + * Get the explicit permission level that a User has over this Document, a value in CONST.DOCUMENT_OWNERSHIP_LEVELS. + * Compendium content ignores the ownership field in favor of User role-based ownership. Otherwise, Documents use + * granular per-User ownership definitions and Embedded Documents defer to their parent ownership. + * + * @param {BaseUser} [user=game.user] The User being tested + * @returns {DocumentOwnershipNumber} A numeric permission level from {@link CONST.DOCUMENT_OWNERSHIP_LEVELS} + */ + getUserLevel(user) { + if (user.isGM) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER; + + const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns); + const playerOwnership = this.ownership[user.id]; + return playerOwnership === undefined || playerOwnership === CONST.DOCUMENT_OWNERSHIP_LEVELS.INHERIT + ? setting.defaultOwnership + : playerOwnership; + } + + async delete() { + const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns); + const data = foundry.utils.deepClone(setting._source); + delete data.countdowns[this.id]; + await game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns, data); + } } diff --git a/system.json b/system.json index 214ab2ee..0aafe3f5 100644 --- a/system.json +++ b/system.json @@ -5,7 +5,7 @@ "version": "2.5.4", "compatibility": { "minimum": "14.364", - "verified": "14.364", + "verified": "14.365", "maximum": "14" }, "url": "https://github.com/Foundryborne/daggerheart",