Merge pull request 'Add ability to select custom status icons' (#44) from feature/custom-status-icons into main

Reviewed-on: #44
This commit is contained in:
Oliver 2026-02-13 04:20:19 +00:00
commit c86a7b13cd
17 changed files with 562 additions and 7 deletions

1
assets/icons/save.svg Normal file
View file

@ -0,0 +1 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m56.133 34.207 8.082-.004a2.414 2.414 0 0 0 2.41-2.41l-.012-16.227A2.406 2.406 0 0 0 64.2 13.16l-8.078.004a2.41 2.41 0 0 0-2.41 2.41l.012 16.23a2.41 2.41 0 0 0 2.41 2.402z"/><path d="M92.758 22.266c-.004-1.555-.375-2.832-1.379-3.828-.644-.645-7.7-7.691-11.328-11.316C78.774 5.852 76.371 5 74.582 5s-60.258.039-60.258.039A7.13 7.13 0 0 0 7.2 12.173l.051 75.703A7.13 7.13 0 0 0 14.383 95l71.289-.047a7.126 7.126 0 0 0 7.125-7.133c.004 0-.04-64.594-.04-65.555zM71.02 9.414l.016 24.371c0 2.117-2.18 4.148-4.297 4.148l-30.977.02c-2.117 0-4.106-2.027-4.106-4.145l-.015-24.367zm9.781 78.281H19.2V53.652c0-2.562 1.992-4.644 4.45-4.644h52.7c2.461 0 4.45 2.078 4.45 4.644z"/></svg>

After

Width:  |  Height:  |  Size: 742 B

View file

@ -62,6 +62,11 @@
}
},
"menu": {
"customStatusIcons": {
"name": "Custom Status Icons",
"hint": "(v13+) Modify your token status effect icons without needing a custom module or a world script to do so! Pick any image on your server for the icons!",
"label": "Configure Icons"
},
"devSettings": {
"name": "Developer Settings",
"hint": "Tweaks that are relevant if you are developing something within Foundry, but are rarely useful outside of that context.",
@ -81,7 +86,13 @@
},
"apps": {
"no-settings-to-display": "No settings to display",
"make-global-reference": "Make Global Reference"
"make-global-reference": "Make Global Reference",
"StatusEffectIconConfig": {
"title": "Configure Status Effect Icons",
"no-status-effects": "No status effects detected, this is most likely due to your game system or other modules.",
"remove-override": "Remove custom override",
"select-using-image-tagger": "Select using Image Tagger"
}
},
"notifs": {
"toggleMouseBroadcast": {

View file

@ -0,0 +1,131 @@
import { __ID__, filePath } from "../consts.mjs";
import { key as customStatusIconsKey } from "../tweaks/customStatusIcons.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
const { SettingsConfig } = foundry.applications.settings;
export class StatusEffectIconConfig extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
tag: `form`,
classes: [
__ID__,
`StatusEffectIconConfig`,
],
window: {
title: `OFT.apps.StatusEffectIconConfig.title`,
},
position: {
width: 550,
},
form: {
handler: this.#onSubmit,
closeOnSubmit: true,
submitOnChange: false,
},
actions: {
pickViaImageTagger: this.#pickViaImageTagger,
removeOverride: this.#removeOverride,
},
};
static PARTS = {
list: {
template: filePath(`templates/StatusEffectIconConfig/effects.hbs`),
scrollable: [``],
},
footer: {
template: filePath(`templates/StatusEffectIconConfig/footer.hbs`),
},
};
// #endregion Options
// #region Instance Data
#overrides = null;
#originalOverrides = null;
// #endregion Instance Data
// #region Lifecycle
async _onRender() {
const pickers = this.element.querySelectorAll(`file-picker`);
for (const picker of pickers) {
picker.addEventListener(`change`, this.#onChangeFilePicker.bind(this));
};
};
// #endregion Lifecycle
// #region Data Prep
_prepareContext() {
const ctx = {
meta: {
idp: this.id,
},
showImageTaggerButton: game.modules.get(`image-tagger`)?.active ?? false,
};
const effects = Object.values(CONFIG.statusEffects);
this.#overrides ??= game.settings.get(__ID__, customStatusIconsKey);
this.#originalOverrides ??= foundry.utils.deepClone(this.#overrides);
ctx.effects = [];
for (const effect of effects) {
let preview = this.#overrides[effect.id] ?? effect.img;
if (
this.#originalOverrides[effect.id] != null
&& this.#overrides[effect.id] === null
) {
preview = null;
}
ctx.effects.push({
id: effect.id,
preview,
name: game.i18n.localize(effect.name),
img: this.#overrides[effect.id],
hasOverride: this.#overrides[effect.id] != null,
});
};
return ctx;
};
// #endregion Data Prep
// #region Event Listeners
async #onChangeFilePicker(event) {
const target = event.currentTarget;
const id = target.closest(`[data-effect-id]`).dataset.effectId;
this.#overrides[id] = target.value || null;
await this.render();
};
/** @this {StatusEffectIconConfig} */
static async #onSubmit() {
game.settings.set(__ID__, customStatusIconsKey, this.#overrides);
SettingsConfig.reloadConfirm({ world: true });
};
// #endregion Event Listeners
// #region Actions
/** @this {StatusEffectIconConfig} */
static async #pickViaImageTagger(event, element) {
const id = element.closest(`[data-effect-id]`)?.dataset.effectId;
if (!id) { return };
const ArtBrowser = game.modules.get(`image-tagger`).api.Apps.ArtBrowser;
const newImage = await ArtBrowser.select();
if (!newImage) { return };
this.#overrides[id] = newImage;
await this.render();
};
/** @this {StatusEffectIconConfig} */
static async #removeOverride(event, element) {
const id = element.closest(`[data-effect-id]`)?.dataset.effectId;
this.#overrides[id] = null;
await this.render();
};
// #endregion Actions
};

View file

@ -0,0 +1,11 @@
import { OFTSVGLoader } from "./SVGLoader.mjs";
/**
Attributes:
@property {string} name - The name of the icon, takes precedence over the path
@property {string} path - The path of the icon file
*/
export class OFTIcon extends OFTSVGLoader {
static elementName = `oft-icon`;
static _stylePath = `icon.css`;
};

View file

@ -0,0 +1,102 @@
import { filePath } from "../../consts.mjs";
import { StyledShadowElement } from "./StyledShadowElement.mjs";
/**
Attributes:
@property {string} name - The name of the icon, takes precedence over the path
@property {string} path - The path of the icon file
*/
export class OFTSVGLoader extends StyledShadowElement(HTMLElement) {
static elementName = `oft-svg`;
static formAssociated = false;
/* Stuff for the mixin to use */
static _stylePath = `svg-loader.css`;
static _cache = new Map();
#container;
/** @type {null | string} */
_name;
/** @type {null | string} */
_path;
constructor() {
super();
this.#container = document.createElement(`div`);
this._shadow.appendChild(this.#container);
};
_mounted = false;
async connectedCallback() {
super.connectedCallback();
if (this._mounted) { return };
this._name = this.getAttribute(`name`);
this._path = this.getAttribute(`path`);
/*
This converts all of the double-dash prefixed properties on the element to
CSS variables so that they don't all need to be provided by doing style=""
*/
for (const attrVar of this.attributes) {
if (attrVar.name?.startsWith(`var:`)) {
const prop = attrVar.name.replace(`var:`, ``);
this.style.setProperty(`--` + prop, attrVar.value);
};
};
/*
Try to retrieve the icon if it isn't present, try the path then default to
the slot content, as then we can have a default per-icon usage
*/
let content;
if (this._name) {
content = await this.#getIcon(filePath(`assets/${this._name}.svg`));
};
if (this._path && !content) {
content = await this.#getIcon(this._path);
};
if (content) {
this.#container.appendChild(content.cloneNode(true));
};
this._mounted = true;
};
disconnectedCallback() {
super.disconnectedCallback();
if (!this._mounted) { return };
this._mounted = false;
};
async #getIcon(path) {
// Cache hit!
if (this.constructor._cache.has(path)) {
return this.constructor._cache.get(path);
};
const r = await fetch(path);
switch (r.status) {
case 200:
case 201:
break;
default: return;
};
const svg = this.#parseSVG(await r.text());
this.constructor._cache.set(path, svg);
return svg;
};
/** Takes an SVG string and returns it as a DOM node */
#parseSVG(content) {
const temp = document.createElement(`div`);
temp.innerHTML = content;
return temp.querySelector(`svg`);
};
};

View file

@ -0,0 +1,66 @@
import { filePath } from "../../consts.mjs";
/**
* @param {HTMLElement} Base
*/
export function StyledShadowElement(Base) {
return class extends Base {
/**
* The path to the CSS that is loaded
* @type {string}
*/
static _stylePath;
/**
* The stringified CSS to use
* @type {Map<string, string>}
*/
static _styles = new Map();
/**
* The HTML element of the stylesheet
* @type {HTMLStyleElement}
*/
_style;
/** @type {ShadowRoot} */
_shadow;
constructor() {
super();
this._shadow = this.attachShadow({ mode: `open` });
this._style = document.createElement(`style`);
this._shadow.appendChild(this._style);
};
#mounted = false;
connectedCallback() {
if (this.#mounted) { return };
this._getStyles();
this.#mounted = true;
};
disconnectedCallback() {
if (!this.#mounted) { return };
this.#mounted = false;
};
_getStyles() {
// TODO: Cache the CSS content in a more sane way that doesn't break
const stylePath = this.constructor._stylePath;
if (this.constructor._styles.has(stylePath)) {
this._style.innerHTML = this.constructor._styles.get(stylePath);
} else {
fetch(filePath(`styles/components/${stylePath}`))
.then(r => r.text())
.then(t => {
this.constructor._styles.set(stylePath, t);
this._style.innerHTML = t;
});
}
};
};
};

View file

@ -0,0 +1,22 @@
import { OFTIcon } from "./Icon.mjs";
import { OFTSVGLoader } from "./SVGLoader.mjs";
const components = [
OFTSVGLoader,
OFTIcon,
];
export function registerCustomComponents() {
(CONFIG.CACHE ??= {}).componentListeners ??= [];
for (const component of components) {
if (!window.customElements.get(component.elementName)) {
window.customElements.define(
component.elementName,
component,
);
if (component.formAssociated) {
CONFIG.CACHE.componentListeners.push(component.elementName);
}
};
}
};

View file

@ -1,9 +1,10 @@
// Settings
// Tweaks
import { preventMovementHistory } from "../tweaks/preventMovementHistory.mjs";
import { toggleMouseBroadcast } from "../tweaks/toggleMouseBroadcast.mjs";
// Utils
import { Logger } from "../utils/Logger.mjs";
import { registerCustomComponents } from "../apps/elements/_index.mjs";
/*
This is only here for setting that **require** being registered during
@ -14,6 +15,8 @@ where they ideally should be implemented.
Hooks.on(`init`, () => {
Logger.log(`Initializing`);
registerCustomComponents();
preventMovementHistory();
toggleMouseBroadcast();
});

View file

@ -4,6 +4,7 @@ import { addGlobalDocReferrer } from "../tweaks/addGlobalDocReferrer.mjs";
import { autoUnpauseOnLoad } from "../tweaks/autoUnpauseOnLoad.mjs";
import { chatImageLinks } from "../tweaks/chatImageLinks.mjs";
import { chatSidebarBackground } from "../tweaks/chatSidebarBackground.mjs";
import { customStatusIcons } from "../tweaks/customStatusIcons.mjs";
import { defaultHotbarPage } from "../tweaks/defaultHotbarPage.mjs";
import { hotbarButtonGap } from "../tweaks/hotbarButtonGap.mjs";
import { hotbarButtonSize } from "../tweaks/hotbarButtonSize.mjs";
@ -48,6 +49,7 @@ Hooks.on(`setup`, () => {
hotbarButtonGap();
repositionHotbar();
customStatusIcons();
chatImageLinks();
chatSidebarBackground();
startSidebarExpanded();

View file

@ -0,0 +1,44 @@
import { SettingStatusEnum, status } from "../utils/SettingStatus.mjs";
import { __ID__ } from "../consts.mjs";
import { Logger } from "../utils/Logger.mjs";
import { preventTweakRegistration } from "../utils/preRegisterTweak.mjs";
import { StatusEffectIconConfig } from "../apps/StatusEffectIconConfig.mjs";
export const key = `customStatusIcons`;
export function customStatusIcons() {
status[key] = SettingStatusEnum.Unknown;
if (preventTweakRegistration(key)) { return };
// #region Registration
Logger.log(`Registering tweak: ${key}`);
game.settings.registerMenu(__ID__, `${key}Menu`, {
name: `OFT.menu.${key}.name`,
hint: `OFT.menu.${key}.hint`,
label: `OFT.menu.${key}.label`,
restricted: true,
type: StatusEffectIconConfig,
});
game.settings.register(__ID__, key, {
scope: `world`,
config: false,
type: Object,
default: {},
});
// #endregion Registration
// #region Implementation
Hooks.on(`ready`, () => {
const value = game.settings.get(__ID__, key);
const effects = Object.values(CONFIG.statusEffects);
for (const effect of effects) {
if (value[effect.id] != null) {
effect.img = value[effect.id];
};
};
});
// #endregion Implementation
status[key] = SettingStatusEnum.Registered;
};

View file

@ -0,0 +1,59 @@
.oft.StatusEffectIconConfig {
> .window-content {
gap: 1rem;
}
footer {
display: flex;
flex-direction: row;
> * {
flex-grow: 1;
}
button {
display: flex;
flex-direction: row;
gap: 8px;
height: initial;
padding: 8px 16px;
}
}
.effect-list {
list-style-type: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.placeholder {
font-style: italic;
}
.effect {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
margin: 0;
.preview {
--size: 30px;
width: var(--size);
height: var(--size);
display: flex;
justify-content: center;
align-items: center;
}
h2 {
margin: 0;
font-size: 1rem;
font-family: var(--font-body);
flex-grow: 1;
}
}
}

View file

@ -0,0 +1,23 @@
:host {
display: inline-block;
}
div {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
svg {
width: var(--size, 1rem);
height: var(--size, 1rem);
fill: var(--fill);
}
path {
stroke: var(--stroke);
stroke-width: var(--stroke-width);
stroke-linejoin: var(--stroke-linejoin);
}

View file

@ -0,0 +1,22 @@
:host {
display: inline-block;
}
div {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
svg {
fill: var(--fill);
stroke: var(--stroke);
}
path {
stroke: var(--stroke);
stroke-width: var(--stroke-width);
stroke-linejoin: var(--stroke-linejoin);
}

View file

@ -1,9 +1,12 @@
@import url("./chatSidebarBackground.css");
@import url("./hotbarButtonGap.css");
@import url("./hotbarButtonSize.css");
@import url("./repositionHotbar.css");
@layer resets, elements, tweaks, apps;
@import url("./apps.css");
@import url("./chatSidebarBackground.css") layer(tweaks);
@import url("./hotbarButtonGap.css") layer(tweaks);
@import url("./hotbarButtonSize.css") layer(tweaks);
@import url("./repositionHotbar.css") layer(tweaks);
@import url("./apps/common.css") layer(apps);
@import url("./apps/StatusEffectIconConfig.css") layer(apps);
/* Make the chat sidebar the same width as all the other tabs */
.chat-sidebar:not(.sidebar-popout) { width: var(--sidebar-width); }

View file

@ -0,0 +1,44 @@
<main class="scrollable">
{{#if effects}}
<ul class="effect-list">
{{#each effects as | effect |}}
<li class="effect" data-effect-id="{{ effect.id }}">
<div class="preview">
{{#if effect.preview}}
<img
src="{{effect.preview}}"
alt=""
>
{{/if}}
</div>
<h2>{{ effect.name }}</h2>
<file-picker
value="{{effect.img}}"
></file-picker>
{{#if @root.showImageTaggerButton}}
<button
type="button"
class="icon fa-solid fa-paintbrush"
aria-label="{{localize "OFT.apps.StatusEffectIconConfig.select-using-image-tagger"}}"
data-tooltip
data-action="pickViaImageTagger"
></button>
{{/if}}
{{#if effect.hasOverride}}
<button
type="button"
class="icon fa-solid fa-xmark"
aria-label="{{localize "OFT.apps.StatusEffectIconConfig.remove-override"}}"
data-tooltip
data-action="removeOverride"
></button>
{{/if}}
</li>
{{/each}}
</ul>
{{else}}
<div class="placeholder">
{{ localize "OFT.apps.StatusEffectIconConfig.no-status-effects" }}
</div>
{{/if}}
</main>

View file

@ -0,0 +1,11 @@
<footer>
<button type="submit">
<oft-icon
name="icons/save"
aria-hidden="true"
var:fill="currentColor"
var:size="1.25rem"
></oft-icon>
{{ localize "Save Changes" }}
</button>
</footer>