diff --git a/module/apps/elements/Icon.mjs b/module/apps/elements/Icon.mjs new file mode 100644 index 0000000..50e1d5b --- /dev/null +++ b/module/apps/elements/Icon.mjs @@ -0,0 +1,11 @@ +import { TafSVGLoader } 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 TafIcon extends TafSVGLoader { + static elementName = `taf-icon`; + static _stylePath = `icon.css`; +}; 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..dce2b24 --- /dev/null +++ b/module/apps/elements/_index.mjs @@ -0,0 +1,24 @@ +import { Logger } from "../../utils/Logger.mjs"; +import { TafIcon } from "./Icon.mjs"; +import { TafSVGLoader } from "./svgLoader.mjs"; + +const components = [ + TafSVGLoader, + TafIcon, +]; + +export function registerCustomComponents() { + (CONFIG.CACHE ??= {}).componentListeners ??= []; + for (const component of components) { + if (!window.customElements.get(component.elementName)) { + Logger.debug(`Registering component "${component.elementName}"`); + window.customElements.define( + component.elementName, + component, + ); + if (component.formAssociated) { + CONFIG.CACHE.componentListeners.push(component.elementName); + } + }; + } +}; diff --git a/module/apps/elements/svgLoader.mjs b/module/apps/elements/svgLoader.mjs new file mode 100644 index 0000000..a209ccd --- /dev/null +++ b/module/apps/elements/svgLoader.mjs @@ -0,0 +1,107 @@ +import { filePath } from "../../consts.mjs"; +import { Logger } from "../../utils/Logger.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 TafSVGLoader extends StyledShadowElement(HTMLElement) { + static elementName = `taf-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)) { + Logger.debug(`Image ${path} cache hit`); + return this.constructor._cache.get(path); + }; + + const r = await fetch(path); + switch (r.status) { + case 200: + case 201: + break; + default: + Logger.error(`Failed to fetch icon: ${path}`); + return; + }; + + Logger.debug(`Adding image ${path} to the cache`); + 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/hooks/init.mjs b/module/hooks/init.mjs index 442d916..6f1c54e 100644 --- a/module/hooks/init.mjs +++ b/module/hooks/init.mjs @@ -14,6 +14,7 @@ import { registerWorldSettings } from "../settings/world.mjs"; // Utils import { __ID__ } from "../consts.mjs"; import { Logger } from "../utils/Logger.mjs"; +import { registerCustomComponents } from "../apps/elements/_index.mjs"; Hooks.on(`init`, () => { Logger.debug(`Initializing`); @@ -32,5 +33,6 @@ Hooks.on(`init`, () => { }, ); + registerCustomComponents(); registerWorldSettings(); }); 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); +}