commit 8da4684488eb790bfd6b193ff732a3f76c339bda Author: CPTN Cosmo Date: Fri Dec 19 18:32:32 2025 +0100 feat: Implement a movable and detachable Daggerheart countdown tracker with UI and module integration. diff --git a/module.json b/module.json new file mode 100644 index 0000000..d85be43 --- /dev/null +++ b/module.json @@ -0,0 +1,33 @@ +{ + "id": "dh-countdownsplus", + "title": "Daggerheart Countdowns Plus", + "description": "A module to allow the Daggerheart countdown tracker to be detached and moved freely.", + "version": "1.0.0", + "compatibility": { + "minimum": "12", + "verified": "12" + }, + "authors": [ + { + "name": "Antigravity" + } + ], + "relationships": { + "systems": [ + { + "id": "daggerheart", + "type": "system", + "compatibility": {} + } + ] + }, + "esmodules": [ + "scripts/module.js" + ], + "styles": [ + "styles/module.css" + ], + "url": "", + "manifest": "", + "download": "" +} \ No newline at end of file diff --git a/scripts/MovableCountdowns.js b/scripts/MovableCountdowns.js new file mode 100644 index 0000000..8d651b7 --- /dev/null +++ b/scripts/MovableCountdowns.js @@ -0,0 +1,144 @@ +/** + * Creates the MovableCountdowns class extending the provided base class. + * @param {class} BaseCountdowns The DhCountdowns class from Daggerheart system. + * @returns {class} The MovableCountdowns class. + */ +export function createMovableCountdownsClass(BaseCountdowns) { + return class MovableCountdowns extends BaseCountdowns { + constructor(options = {}) { + super(options); + } + + /** @inheritDoc */ + static DEFAULT_OPTIONS = { + id: 'movable-countdowns', + tag: 'div', // AppV2 + classes: ['daggerheart', 'dh-style', 'countdowns', 'movable-countdowns'], + window: { + icon: 'fa-solid fa-clock-rotate-left', + frame: true, + title: 'DAGGERHEART.UI.Countdowns.title', + positioned: true, + resizable: true, + minimizable: true + }, + actions: { + // Override toggleViewMode to instance method + toggleViewMode: MovableCountdowns.prototype._toggleViewMode, + + // Inherited static actions + editCountdowns: BaseCountdowns.DEFAULT_OPTIONS.actions.editCountdowns, + loopCountdown: BaseCountdowns.DEFAULT_OPTIONS.actions.loopCountdown, + decreaseCountdown: BaseCountdowns.DEFAULT_OPTIONS.actions.decreaseCountdown, + increaseCountdown: BaseCountdowns.DEFAULT_OPTIONS.actions.increaseCountdown, + + reattach: MovableCountdowns.reattach + }, + position: { + width: 320, + height: 'auto', + top: 100, + left: 100 + } + }; + + /** @override */ + static PARTS = { + resources: { + root: true, + template: 'systems/daggerheart/templates/ui/countdowns.hbs' + } + }; + + get element() { + return document.getElementById(this.id); + } + + /** + * Re-implementation of toggleViewMode that works on this instance. + */ + async _toggleViewMode() { + // Need access to system CONFIG constants. + // CONFIG.DH is global. + const currentMode = game.user.getFlag(CONFIG.DH.id, CONFIG.DH.FLAGS.userFlags.countdownMode); + const appMode = CONFIG.DH.GENERAL.countdownAppMode; + const newMode = currentMode === appMode.textIcon ? appMode.iconOnly : appMode.textIcon; + + await game.user.setFlag(CONFIG.DH.id, CONFIG.DH.FLAGS.userFlags.countdownMode, newMode); + + // Update this instance directly + if (newMode === appMode.iconOnly) this.element.classList.add('icon-only'); + else this.element.classList.remove('icon-only'); + + this.render(); + + // Note: System tracker won't update automatically here if it's hidden, + // but next time it renders it will check flag. + } + + /**@inheritdoc */ + /**@inheritdoc */ + async _renderFrame(options) { + const frame = await super._renderFrame(options); + + const header = frame.querySelector('.window-header'); + if (!header) return frame; + + // Re-add Close Button if missing + // dh-countdownsplus: Fix invisible close button by using tag pattern like system + if (!header.querySelector('[data-action="close"]')) { + const closeTitle = game.i18n.localize("CLOSE"); + // Use with child to match system header control styling + const closeBtn = document.createElement('a'); + closeBtn.className = 'header-control'; + closeBtn.setAttribute('data-action', 'close'); + closeBtn.setAttribute('data-tooltip', closeTitle); + closeBtn.setAttribute('aria-label', closeTitle); + + const icon = document.createElement('i'); + icon.className = 'fa-solid fa-xmark'; + closeBtn.appendChild(icon); + + header.appendChild(closeBtn); + } + + return frame; + } + + /** @override */ + async _onRender(context, options) { + // Call super to handle template rendering + await super._onRender(context, options); + + // Workaround system pinning: Move back to body if pinned + if (this.element && this.element.parentElement && this.element.parentElement.id === 'ui-right-column-1') { + document.body.appendChild(this.element); + } + + // Ensure visibility + this.element.hidden = false; + } + + static async reattach() { + const movableApp = Object.values(ui.windows).find(w => w instanceof MovableCountdowns); + if (movableApp) await movableApp.close(); + + // Unsuppress system tracker + if (ui.countdowns) { + ui.countdowns._suppressed = false; + await ui.countdowns.render({ force: true }); + } + } + + /** @override */ + /** @override */ + async close(options = {}) { + await super.close(options); + // Re-attach on close + if (ui.countdowns) { + ui.countdowns._suppressed = false; + await ui.countdowns.render({ force: true }); + } + } + }; +} diff --git a/scripts/module.js b/scripts/module.js new file mode 100644 index 0000000..af0ae8e --- /dev/null +++ b/scripts/module.js @@ -0,0 +1,101 @@ +import { createMovableCountdownsClass } from './MovableCountdowns.js'; + +const MODULE_ID = 'dh-countdownsplus'; +let MovableCountdowns; + +Hooks.once('init', () => { + console.log(`${MODULE_ID} | Initializing Daggerheart Countdowns Plus`); + + // Register settings or other init logic if needed +}); + +Hooks.on('ready', () => { + // Ensure the system's countdown class exists + if (!CONFIG.ui.countdowns) { + console.error(`${MODULE_ID} | CONFIG.ui.countdowns not found. Is Daggerheart system initialized?`); + return; + } + + // Create our class extending the system's class + MovableCountdowns = createMovableCountdownsClass(CONFIG.ui.countdowns); + + // Initial check to see if we should inject button + hookSystemCountdowns(); +}); + +function hookSystemCountdowns() { + // We want to hook into the render of the system's countdown app. + // The system app is likely a singleton at ui.countdowns. + + // Hook on renderDhCountdowns (name of the class in system is DhCountdowns) + Hooks.on('renderDhCountdowns', (app, html, data) => { + // Only inject if this is the SYSTEM one, not our movable one (ours inherits, so it might trigger this hook too?) + // DhCountdowns extends ApplicationV2. AppV2 emits 'renderApplicationV2' and 'renderMyClass'. + // So 'renderDhCountdowns' will fire for both. + + if (app instanceof MovableCountdowns) return; + + // Find the header to inject button + // html is the HTMLElement in AppV2 hooks + const header = html.querySelector('.window-header'); + if (!header) return; + + // Check if button already exists + if (header.querySelector('[data-action="detach-countdowns"]')) return; + + // Create Detach Button + const detachTooltip = "Detach"; // TODO: Localize + const detachBtn = document.createElement('a'); // System uses for header controls + detachBtn.className = 'header-control'; + detachBtn.dataset.tooltip = detachTooltip; + detachBtn.dataset.action = 'detach-countdowns'; + detachBtn.innerHTML = ''; + + // Add listener + detachBtn.addEventListener('click', async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + // Open Movable + // We assume singleton pattern for movable too + let movable = Object.values(ui.windows).find(w => w instanceof MovableCountdowns); + if (!movable) { + movable = new MovableCountdowns(); + } + await movable.render({ force: true }); + + // Close/Hide System App + // Suppress future renders + if (app) { + app._suppressed = true; + await app.close(); + } + }); + + // Insert before the "Close" button? System one doesn't have close button. + // Insert at end or specific position? + // System header controls: Edit, Toggle Mode. + // Let's prepend or append. + header.insertAdjacentElement('beforeend', detachBtn); + }); + + // Patch system render to respect suppression + if (ui.countdowns && !ui.countdowns._patched) { + const originalRender = ui.countdowns.render.bind(ui.countdowns); + ui.countdowns.render = function (options, ...args) { + if (this._suppressed) { + // Update internal state but don't show + // If data update, we might need to update OUR instance? + // But socket hooks call render() on ui.countdowns. + // We should notify Movable if it's open. + + const movable = Object.values(ui.windows).find(w => w instanceof MovableCountdowns); + if (movable) movable.render(); + + return this; + } + return originalRender(options, ...args); + }; + ui.countdowns._patched = true; + } +} diff --git a/styles/module.css b/styles/module.css new file mode 100644 index 0000000..12923a5 --- /dev/null +++ b/styles/module.css @@ -0,0 +1,24 @@ +/* Movable Countdowns Styling */ + +/* Override system positioning constraints */ +.movable-countdowns.daggerheart.dh-style.countdowns { + position: fixed !important; + /* Managed by AppV2 */ + /* Reset system specific "docked" positioning */ + right: auto !important; + align-self: auto !important; + margin: 0 !important; + transition: none !important; + /* Disable system transition for sliding */ + box-shadow: 0 0 10px #000; + /* Add shadow for popping out */ +} + +/* Ensure window header is visible/draggable */ +.movable-countdowns .window-header { + cursor: grab !important; +} + +/* Ensure background image works if it relied on parent classes */ +/* System uses .theme-dark .daggerheart.dh-style.countdowns */ +/* If the body has theme-dark, this should still work. */ \ No newline at end of file