diff --git a/.vscode/components.html-data.json b/.vscode/components.html-data.json new file mode 100644 index 0000000..e1c3ebd --- /dev/null +++ b/.vscode/components.html-data.json @@ -0,0 +1,27 @@ +{ + "version": 1.1, + "tags": [ + { + "name": "dd-incrementer", + "description": "A number input that allows more flexible increase/decrease buttons", + "attributes": [ + { "name": "value", "description": "The initial value to put in the input" }, + { "name": "name", "description": "The form name to use when this input is used to submit data" }, + { "name": "min", "description": "The minimum value that this input can contain" }, + { "name": "max", "description": "The maximum value that this input can contain" }, + { "name": "smallStep", "description": "The value that the input is changed by when clicking a delta button or using the up/down arrow key" }, + { "name": "largeStep", "description": "The value that the input is changed by when clicking a delta button with control held or using the page up/ page down arrow key" } + ] + }, + { + "name": "dd-icon", + "description": "Loads an icon asynchronously, caching the result for future uses", + "attributes": [ + { "name": "name", "description": "The name of the icon, this is relative to the assets folder of the dotdungeon system" }, + { "name": "path", "description": "The full path of the icon, this will only be used if `name` isn't provided or fails to fetch." } + ] + } + ], + "globalAttributes": [], + "valueSets": [] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 36a383b..9cd44b8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,8 @@ "node_modules": true, "packs": true, ".gitattributes": true, - } -} \ No newline at end of file + }, + "html.customData": [ + "./.vscode/components.html-data.json" + ] +} diff --git a/module/components/icon.mjs b/module/components/icon.mjs new file mode 100644 index 0000000..8c70d40 --- /dev/null +++ b/module/components/icon.mjs @@ -0,0 +1,125 @@ +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 StyledShadowElement(HTMLElement) { + static elementName = `dd-icon`; + static formAssociated = false; + + /* Stuff for the mixin to use */ + static _stylePath = `v3/components/icon.css`; + + + static _cache = new Map(); + #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 }); + + 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(`./systems/dotdungeon/assets/${this._name}.svg`); + }; + + if (this._path && !content) { + content = await this.#getIcon(this._path); + }; + + if (content) { + this.#container.appendChild(content.cloneNode(true)); + }; + + /* + 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. + */ + 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; + }; + + disconnectedCallback() { + super.disconnectedCallback(); + if (!this._mounted) return; + + Hooks.off(`dd-hmr:svg`, this.#svgHmr); + + this._mounted = false; + }; + + async #getIcon(path) { + // Cache hit! + if (this.constructor._cache.has(path)) { + console.debug(`.dungeon | Icon ${path} cache hit`); + return this.constructor._cache.get(path); + }; + + const r = await fetch(path); + switch (r.status) { + case 200: + case 201: + break; + default: + console.error(`.dungeon | Failed to fetch icon: ${path}`); + return; + }; + + console.debug(`.dungeon | Adding icon ${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/components/incrementer.mjs b/module/components/incrementer.mjs new file mode 100644 index 0000000..26069ee --- /dev/null +++ b/module/components/incrementer.mjs @@ -0,0 +1,152 @@ +import { DotDungeonIcon } from "./icon.mjs"; +import { StyledShadowElement } from "./mixins/Styles.mjs"; + +/** +Attributes: +@property {string} name - The path to the value to update +@property {number} value - The actual value of the input +@property {number} min - The minimum value of the input +@property {number} max - The maximum value of the input +@property {number?} smallStep - The step size used for the buttons and arrow keys +@property {number?} largeStep - The step size used for the buttons + Ctrl and page up / down + +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 StyledShadowElement(HTMLElement) { + static elementName = `dd-incrementer`; + static formAssociated = true; + + static _stylePath = `v3/components/incrementer.css`; + + _internals; + #input; + + _min; + _max; + _smallStep; + _largeStep; + + constructor() { + super(); + + // Form internals + this._internals = this.attachInternals(); + this._internals.role = `spinbutton`; + }; + + get form() { + return this._internals.form; + } + + get name() { + return this.getAttribute(`name`); + } + set name(value) { + this.setAttribute(`name`, value); + } + + get value() { + return this.getAttribute(`value`); + }; + set value(value) { + this.setAttribute(`value`, value); + }; + + get type() { + return `number`; + } + + connectedCallback() { + super.connectedCallback(); + this.replaceChildren(); + + // Attribute parsing / registration + const value = this.getAttribute(`value`); + this._min = parseInt(this.getAttribute(`min`) ?? 0); + this._max = parseInt(this.getAttribute(`max`) ?? 0); + this._smallStep = parseInt(this.getAttribute(`smallStep`) ?? 1); + this._largeStep = parseInt(this.getAttribute(`largeStep`) ?? 5); + + this._internals.ariaValueMin = this._min; + this._internals.ariaValueMax = this._max; + + const container = document.createElement(`div`); + + // The input that the user can see / modify + const input = document.createElement(`input`); + this.#input = input; + input.type = `number`; + input.ariaHidden = true; + input.min = this.getAttribute(`min`); + input.max = this.getAttribute(`max`); + input.addEventListener(`change`, this.#updateValue.bind(this)); + input.value = value; + + // plus button + const increment = document.createElement("dd-icon"); + increment.setAttribute(`name`, `create`); + increment.setAttribute(`var:size`, `0.75rem`); + increment.setAttribute(`var:fill`, `currentColor`); + increment.ariaHidden = true; + increment.classList.value = `increment`; + increment.addEventListener(`mousedown`, this.#increment.bind(this)); + + // minus button + const decrement = document.createElement(DotDungeonIcon.elementName); + decrement.setAttribute(`name`, `minus`); + decrement.setAttribute(`var:size`, `0.75rem`); + decrement.setAttribute(`var:fill`, `currentColor`); + decrement.ariaHidden = true; + decrement.classList.value = `decrement`; + decrement.addEventListener(`mousedown`, this.#decrement.bind(this)); + + // Construct the DOM + container.appendChild(decrement); + container.appendChild(input); + container.appendChild(increment); + this._shadow.appendChild(container); + + /* + This converts all of the namespace 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); + }; + }; + }; + + #updateValue() { + let value = parseInt(this.#input.value); + if (this.getAttribute(`min`)) value = Math.max(this._min, value); + if (this.getAttribute(`max`)) value = Math.min(this._max, value); + this.#input.value = value; + this.value = value; + this.dispatchEvent(new Event(`change`, { bubbles: true })); + + // NOTE: This may be really annoying, in that case, remove it later + this.blur(); + }; + + /** @param {Event} $e */ + #increment($e) { + $e.preventDefault(); + let value = parseInt(this.#input.value); + value += $e.ctrlKey ? this._largeStep : this._smallStep; + this.#input.value = value; + this.#updateValue(); + }; + + /** @param {Event} $e */ + #decrement($e) { + $e.preventDefault(); + let value = parseInt(this.#input.value); + value -= $e.ctrlKey ? this._largeStep : this._smallStep; + this.#input.value = value; + this.#updateValue(); + }; +}; diff --git a/module/components/index.mjs b/module/components/index.mjs new file mode 100644 index 0000000..f4d39e9 --- /dev/null +++ b/module/components/index.mjs @@ -0,0 +1,23 @@ +import { DotDungeonIncrementer } from "./incrementer.mjs"; +import { DotDungeonIcon } from "./icon.mjs"; + +const components = [ + DotDungeonIcon, + DotDungeonIncrementer, +]; + +export function registerCustomComponents() { + (CONFIG.CACHE ??= {}).componentListeners ??= []; + for (const component of components) { + if (!window.customElements.get(component.elementName)) { + console.debug(`.dungeon | Registering component "${component.elementName}"`); + window.customElements.define( + component.elementName, + component + ); + if (component.formAssociated) { + CONFIG.CACHE.componentListeners.push(component.elementName); + } + }; + } +}; 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/dotdungeon.mjs b/module/dotdungeon.mjs index 4dba8f4..09f4d39 100644 --- a/module/dotdungeon.mjs +++ b/module/dotdungeon.mjs @@ -32,6 +32,7 @@ import * as hbs from "./handlebars.mjs"; import "./hooks/hotReload.mjs"; // Misc Imports +import { registerCustomComponents } from "./components/index.mjs"; import loadSettings from "./settings/index.mjs"; import { devInit } from "./hooks/devInit.mjs"; import DOTDUNGEON from "./config.mjs"; @@ -108,8 +109,9 @@ Hooks.once(`init`, async () => { hbs.registerHandlebarsHelpers(); hbs.preloadHandlebarsTemplates(); + registerCustomComponents(); - CONFIG.CACHE = {}; + CONFIG.CACHE ??= {}; CONFIG.CACHE.icons = await hbs.preloadIcons(); }); diff --git a/module/hooks/hotReload.mjs b/module/hooks/hotReload.mjs index b183196..8c44a46 100644 --- a/module/hooks/hotReload.mjs +++ b/module/hooks/hotReload.mjs @@ -3,8 +3,8 @@ import * as hbs from "../handlebars.mjs"; const loaders = { svg(data) { const iconName = data.path.split(`/`).slice(-1)[0].slice(0, -4); - console.log(`.dungeon | hot-reloading icon: ${iconName}`); - CONFIG.CACHE.icons[iconName] = data.content; + console.debug(`.dungeon | hot-reloading icon: ${iconName}`); + Hooks.call(`dd-hmr:svg`, iconName, data); }, hbs(data) { if (!hbs.partials.some(p => data.path.endsWith(p))) { @@ -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) => { diff --git a/module/sheets/Actors/PC/PlayerSheetV2.mjs b/module/sheets/Actors/PC/PlayerSheetV2.mjs index 75e169d..9dd14ac 100644 --- a/module/sheets/Actors/PC/PlayerSheetV2.mjs +++ b/module/sheets/Actors/PC/PlayerSheetV2.mjs @@ -167,9 +167,4 @@ export class PlayerSheetv2 extends GenericActorSheet { max: this.actor.system.inventory_slots, }; }; - - _updateObject(...args) { - console.log(args) - super._updateObject(...args); - }; -} \ No newline at end of file +} diff --git a/module/sheets/GenericActorSheet.mjs b/module/sheets/GenericActorSheet.mjs index d4d38b0..1f39bf3 100644 --- a/module/sheets/GenericActorSheet.mjs +++ b/module/sheets/GenericActorSheet.mjs @@ -52,6 +52,17 @@ export class GenericActorSheet extends ActorSheet { if (!this.isEditable) return; console.debug(`.dungeon | Generic sheet adding listeners`); + /* + Custom element event listeners because Foundry doesn't listen to them by + default. + */ + html.find( + CONFIG.CACHE.componentListeners.join(`,`) + ).on(`change`, this._onChangeInput.bind(this)); + + /* + Utility event listeners that apply + */ html.find(`[data-collapse-id]`).on(`click`, this._handleSummaryToggle.bind(this)); html.find(`[data-roll-formula]`).on(`click`, this._handleRoll.bind(this)); html.find(`[data-embedded-update-on="change"]`) diff --git a/styles/sheets/actor/char-sheet/v2/pages/inventory.scss b/styles/sheets/actor/char-sheet/v2/pages/inventory.scss index 4bc9018..457f9ee 100644 --- a/styles/sheets/actor/char-sheet/v2/pages/inventory.scss +++ b/styles/sheets/actor/char-sheet/v2/pages/inventory.scss @@ -68,7 +68,7 @@ .bytes-panel { display: grid; - grid-template-columns: 1fr min-content 50px min-content; + grid-template-columns: 1fr auto; gap: 8px; align-items: center; diff --git a/styles/v3/components/common.scss b/styles/v3/components/common.scss new file mode 100644 index 0000000..59f812d --- /dev/null +++ b/styles/v3/components/common.scss @@ -0,0 +1,7 @@ +// Disclaimer: This CSS is used by a custom web component and is scoped to JUST +// the corresponding web component. This should only be imported by web component +// style files. + +:host { + display: inline-block; +} diff --git a/styles/v3/components/icon.scss b/styles/v3/components/icon.scss new file mode 100644 index 0000000..012593f --- /dev/null +++ b/styles/v3/components/icon.scss @@ -0,0 +1,23 @@ +/* +Disclaimer: This CSS is used by a custom web component and is scoped to JUST +the corresponding web component. Importing this into other files is forbidden +*/ + +$default-size: 1rem; + +@use "./common.scss"; + +div { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +} + +svg { + width: var(--size, $default-size); + height: var(--size, $default-size); + fill: var(--fill); + stroke: var(--stroke); +} diff --git a/styles/v3/components/incrementer.scss b/styles/v3/components/incrementer.scss new file mode 100644 index 0000000..abe9478 --- /dev/null +++ b/styles/v3/components/incrementer.scss @@ -0,0 +1,63 @@ +/* +Disclaimer: This CSS is used by a custom web component and is scoped to JUST +the corresponding web component. Importing this into other files is forbidden +*/ + +$default-border-radius: 4px; +$default-height: 1.5rem; + +@use "../mixins/material"; +@use "./common.scss"; + +div { + display: grid; + grid-template-columns: var(--height, $default-height) var(--width, 50px) var(--height, $default-height); + grid-template-rows: var(--height, 1fr); + border-radius: var(--border-radius, $default-border-radius); + @include material.elevate(2); + + &:hover { + @include material.elevate(4); + } + + &:focus-within { + @include material.elevate(6); + } +} + +span, input { + border: none; + outline: none; + background: none; + color: inherit; +} + +input { + font-family: var(--font-family, inherit); + text-align: center; + font-size: var(--font-size, inherit); + padding: 2px 4px; + + &::-webkit-inner-spin-button, &::-webkit-outer-spin-button { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + margin: 0 + } +} + +.increment, .decrement { + aspect-ratio: 1 / 1; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +} + +.increment { + border-radius: 0 var(--border-radius, $default-border-radius) var(--border-radius, 4px) 0; +} +.decrement { + border-radius: var(--border-radius, $default-border-radius) 0 0 var(--border-radius, $default-border-radius); +} diff --git a/templates/actors/char-sheet/v2/partials/inventory/player.v2.pc.hbs b/templates/actors/char-sheet/v2/partials/inventory/player.v2.pc.hbs index 1dd14c2..900af1f 100644 --- a/templates/actors/char-sheet/v2/partials/inventory/player.v2.pc.hbs +++ b/templates/actors/char-sheet/v2/partials/inventory/player.v2.pc.hbs @@ -11,35 +11,17 @@