From f2c169c07790adbd600083a9f4dc090c278ffc7b Mon Sep 17 00:00:00 2001 From: Oliver-Akins Date: Sat, 13 Apr 2024 12:18:36 -0600 Subject: [PATCH] Make a mixin for loading a stylesheet for components, including HMR auto-update support (only in devMode) --- module/components/icon.mjs | 66 ++++++++++++------------ module/components/incrementer.mjs | 26 ++-------- module/components/mixins/Styles.mjs | 80 +++++++++++++++++++++++++++++ module/hooks/hotReload.mjs | 4 ++ 4 files changed, 121 insertions(+), 55 deletions(-) create mode 100644 module/components/mixins/Styles.mjs diff --git a/module/components/icon.mjs b/module/components/icon.mjs index 9dd9ea1..8c70d40 100644 --- a/module/components/icon.mjs +++ b/module/components/icon.mjs @@ -1,34 +1,39 @@ +import { StyledShadowElement } from "./mixins/Styles.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 DotDungeonIcon extends HTMLElement { +export class DotDungeonIcon extends StyledShadowElement(HTMLElement) { static elementName = `dd-icon`; static formAssociated = false; - #shadow; + /* Stuff for the mixin to use */ + static _stylePath = `v3/components/icon.css`; + - static #styles = ``; static _cache = new Map(); - #style; #container; /** @type {null | string} */ _name; /** @type {null | string} */ _path; + /* Stored IDs for all of the hooks that are in this component */ + #svgHmr; + constructor() { super(); - this.#shadow = this.attachShadow({ mode: `open`, delegatesFocus: true }); - if (DotDungeonIcon.#styles) this.#embedStyles(); + // this._shadow = this.attachShadow({ mode: `open`, delegatesFocus: true }); this.#container = document.createElement(`div`); - this.#shadow.appendChild(this.#container); + this._shadow.appendChild(this.#container); }; _mounted = false; async connectedCallback() { + super.connectedCallback(); if (this._mounted) return; this._name = this.getAttribute(`name`); @@ -45,18 +50,6 @@ export class DotDungeonIcon extends HTMLElement { }; }; - /* - Style fetching if we haven't gotten them yet - */ - if (!DotDungeonIcon.#styles) { - fetch(`./systems/dotdungeon/.styles/v3/components/icon.css`) - .then(r => r.text()) - .then(t => { - DotDungeonIcon.#styles = t; - this.#embedStyles(); - }); - }; - /* 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 @@ -78,28 +71,33 @@ export class DotDungeonIcon extends HTMLElement { This is so that when we get an HMR event from Foundry we can appropriately handle it using our logic to update the component and the icon cache. */ - Hooks.on(`dd-hmr:svg`, (iconName, data) => { - if (this._name === iconName || this._path?.endsWith(data.path)) { - const svg = this.#parseSVG(data.content); - DotDungeonIcon._cache.set(iconName, svg); - this.#container.replaceChildren(svg.cloneNode(true)); - }; - }); + if (game.settings.get(`dotdungeon`, `devMode`)) { + this.#svgHmr = Hooks.on(`dd-hmr:svg`, (iconName, data) => { + if (this._name === iconName || this._path?.endsWith(data.path)) { + const svg = this.#parseSVG(data.content); + this.constructor._cache.set(iconName, svg); + this.#container.replaceChildren(svg.cloneNode(true)); + }; + }); + }; this._mounted = true; }; - #embedStyles() { - this.#style = document.createElement(`style`); - this.#style.innerHTML = DotDungeonIcon.#styles; - this.#shadow.appendChild(this.#style); + disconnectedCallback() { + super.disconnectedCallback(); + if (!this._mounted) return; + + Hooks.off(`dd-hmr:svg`, this.#svgHmr); + + this._mounted = false; }; async #getIcon(path) { // Cache hit! - if (DotDungeonIcon._cache.has(path)) { + if (this.constructor._cache.has(path)) { console.debug(`.dungeon | Icon ${path} cache hit`); - return DotDungeonIcon._cache.get(path); + return this.constructor._cache.get(path); }; const r = await fetch(path); @@ -114,7 +112,7 @@ export class DotDungeonIcon extends HTMLElement { console.debug(`.dungeon | Adding icon ${path} to the cache`); const svg = this.#parseSVG(await r.text()); - DotDungeonIcon._cache.set(path, svg); + this.constructor._cache.set(path, svg); return svg; }; @@ -123,5 +121,5 @@ export class DotDungeonIcon extends HTMLElement { const temp = document.createElement(`div`); temp.innerHTML = content; return temp.querySelector(`svg`); - } + }; }; diff --git a/module/components/incrementer.mjs b/module/components/incrementer.mjs index b572803..26069ee 100644 --- a/module/components/incrementer.mjs +++ b/module/components/incrementer.mjs @@ -1,4 +1,5 @@ import { DotDungeonIcon } from "./icon.mjs"; +import { StyledShadowElement } from "./mixins/Styles.mjs"; /** Attributes: @@ -13,15 +14,14 @@ Styling: - `--height`: Controls the height of the element + the width of the buttons (default: 1.25rem) - `--width`: Controls the width of the number input (default 50px) */ -export class DotDungeonIncrementer extends HTMLElement { +export class DotDungeonIncrementer extends StyledShadowElement(HTMLElement) { static elementName = `dd-incrementer`; static formAssociated = true; - static #styles = ``; + static _stylePath = `v3/components/incrementer.css`; + _internals; - _shadow; #input; - #style; _min; _max; @@ -30,8 +30,6 @@ export class DotDungeonIncrementer extends HTMLElement { constructor() { super(); - this._shadow = this.attachShadow({ mode: `open`, delegatesFocus: true }); - if (DotDungeonIncrementer.#styles) this.#embedStyles(); // Form internals this._internals = this.attachInternals(); @@ -61,6 +59,7 @@ export class DotDungeonIncrementer extends HTMLElement { } connectedCallback() { + super.connectedCallback(); this.replaceChildren(); // Attribute parsing / registration @@ -119,21 +118,6 @@ export class DotDungeonIncrementer extends HTMLElement { this.style.setProperty(`--` + prop, attrVar.value); }; }; - - if (!DotDungeonIncrementer.#styles) { - fetch(`./systems/dotdungeon/.styles/v3/components/incrementer.css`) - .then(r => r.text()) - .then(t => { - DotDungeonIncrementer.#styles = t; - this.#embedStyles(); - }); - }; - }; - - #embedStyles() { - this.#style = document.createElement(`style`); - this.#style.innerHTML = DotDungeonIncrementer.#styles; - this._shadow.appendChild(this.#style); }; #updateValue() { diff --git a/module/components/mixins/Styles.mjs b/module/components/mixins/Styles.mjs new file mode 100644 index 0000000..33d5eb5 --- /dev/null +++ b/module/components/mixins/Styles.mjs @@ -0,0 +1,80 @@ +/** + * @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 {string} + */ + static _styles; + + /** + * The HTML element of the stylesheet + * @type {HTMLStyleElement} + */ + _style; + + /** @type {ShadowRoot} */ + _shadow; + + /** + * The hook ID for this element's CSS hot reload + * @type {number} + */ + #cssHmr; + + 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(); + + if (game.settings.get(`dotdungeon`, `devMode`)) { + this.#cssHmr = Hooks.on(`dd-hmr:css`, (data) => { + if (data.path.endsWith(this.constructor._stylePath)) { + this._style.innerHTML = data.content; + }; + }); + }; + + this.#mounted = true; + }; + + disconnectedCallback() { + if (!this.#mounted) return; + if (this.#cssHmr != null) { + Hooks.off(`dd-hmr:css`, this.#cssHmr); + this.#cssHmr = null; + }; + this.#mounted = false; + }; + + _getStyles() { + if (this.constructor._styles) { + this._style.innerHTML = this.constructor._styles; + } else { + fetch(`./systems/dotdungeon/.styles/${this.constructor._stylePath}`) + .then(r => r.text()) + .then(t => { + this.constructor._styles = t; + this._style.innerHTML = t; + }); + } + }; + }; +}; diff --git a/module/hooks/hotReload.mjs b/module/hooks/hotReload.mjs index befd014..8c44a46 100644 --- a/module/hooks/hotReload.mjs +++ b/module/hooks/hotReload.mjs @@ -35,6 +35,10 @@ const loaders = { }, js() {window.location.reload()}, mjs() {window.location.reload()}, + css(data) { + console.debug(`.dungeon | Hot-reloading CSS: ${data.path}`); + Hooks.call(`dd-hmr:css`, data); + }, }; Hooks.on(`hotReload`, async (data) => {