feat: Implement a movable and detachable Daggerheart countdown tracker with UI and module integration.

This commit is contained in:
CPTN Cosmo 2025-12-19 18:32:32 +01:00
commit 8da4684488
4 changed files with 302 additions and 0 deletions

33
module.json Normal file
View file

@ -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": ""
}

View file

@ -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 <a> tag pattern like system
if (!header.querySelector('[data-action="close"]')) {
const closeTitle = game.i18n.localize("CLOSE");
// Use <a> with <i> 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 });
}
}
};
}

101
scripts/module.js Normal file
View file

@ -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 <a> for header controls
detachBtn.className = 'header-control';
detachBtn.dataset.tooltip = detachTooltip;
detachBtn.dataset.action = 'detach-countdowns';
detachBtn.innerHTML = '<i class="fa-solid fa-up-right-from-square"></i>';
// 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;
}
}

24
styles/module.css Normal file
View file

@ -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. */