diff --git a/assets/icons/save.svg b/assets/icons/save.svg new file mode 100644 index 0000000..da68b4f --- /dev/null +++ b/assets/icons/save.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/langs/en-ca.json b/langs/en-ca.json index b744059..a64ce8d 100644 --- a/langs/en-ca.json +++ b/langs/en-ca.json @@ -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": { diff --git a/module/apps/StatusEffectIconConfig.mjs b/module/apps/StatusEffectIconConfig.mjs new file mode 100644 index 0000000..1c93e38 --- /dev/null +++ b/module/apps/StatusEffectIconConfig.mjs @@ -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 +}; diff --git a/module/apps/elements/Icon.mjs b/module/apps/elements/Icon.mjs new file mode 100644 index 0000000..e7861c9 --- /dev/null +++ b/module/apps/elements/Icon.mjs @@ -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`; +}; diff --git a/module/apps/elements/SVGLoader.mjs b/module/apps/elements/SVGLoader.mjs new file mode 100644 index 0000000..323bc42 --- /dev/null +++ b/module/apps/elements/SVGLoader.mjs @@ -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`); + }; +}; diff --git a/module/apps/elements/StyledShadowElement.mjs b/module/apps/elements/StyledShadowElement.mjs new file mode 100644 index 0000000..4929868 --- /dev/null +++ b/module/apps/elements/StyledShadowElement.mjs @@ -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} + */ + 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; + }); + } + }; + }; +}; diff --git a/module/apps/elements/_index.mjs b/module/apps/elements/_index.mjs new file mode 100644 index 0000000..9ec5bcc --- /dev/null +++ b/module/apps/elements/_index.mjs @@ -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); + } + }; + } +}; diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs index 729a004..01bc44c 100644 --- a/module/hooks/init.mjs +++ b/module/hooks/init.mjs @@ -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(); }); diff --git a/module/hooks/setup.mjs b/module/hooks/setup.mjs index d693714..b3d5fbf 100644 --- a/module/hooks/setup.mjs +++ b/module/hooks/setup.mjs @@ -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(); diff --git a/module/tweaks/customStatusIcons.mjs b/module/tweaks/customStatusIcons.mjs new file mode 100644 index 0000000..00a325b --- /dev/null +++ b/module/tweaks/customStatusIcons.mjs @@ -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; +}; diff --git a/styles/apps/StatusEffectIconConfig.css b/styles/apps/StatusEffectIconConfig.css new file mode 100644 index 0000000..0a247dc --- /dev/null +++ b/styles/apps/StatusEffectIconConfig.css @@ -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; + } + } +} diff --git a/styles/apps.css b/styles/apps/common.css similarity index 100% rename from styles/apps.css rename to styles/apps/common.css diff --git a/styles/components/icon.css b/styles/components/icon.css new file mode 100644 index 0000000..31ddda5 --- /dev/null +++ b/styles/components/icon.css @@ -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); +} diff --git a/styles/components/svg-loader.css b/styles/components/svg-loader.css new file mode 100644 index 0000000..b843bc5 --- /dev/null +++ b/styles/components/svg-loader.css @@ -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); +} diff --git a/styles/main.css b/styles/main.css index 1d0fec5..63794a5 100644 --- a/styles/main.css +++ b/styles/main.css @@ -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); } diff --git a/templates/StatusEffectIconConfig/effects.hbs b/templates/StatusEffectIconConfig/effects.hbs new file mode 100644 index 0000000..46946ef --- /dev/null +++ b/templates/StatusEffectIconConfig/effects.hbs @@ -0,0 +1,44 @@ +
+ {{#if effects}} + + {{else}} +
+ {{ localize "OFT.apps.StatusEffectIconConfig.no-status-effects" }} +
+ {{/if}} +
diff --git a/templates/StatusEffectIconConfig/footer.hbs b/templates/StatusEffectIconConfig/footer.hbs new file mode 100644 index 0000000..54f5fff --- /dev/null +++ b/templates/StatusEffectIconConfig/footer.hbs @@ -0,0 +1,11 @@ +