diff --git a/lang/en.json b/lang/en.json index 9ad306488f..6464bc7265 100644 --- a/lang/en.json +++ b/lang/en.json @@ -3458,6 +3458,11 @@ "DND5E.SpellUsage": "Spell Usage", "DND5E.Spellbook": "Spellbook", "DND5E.Spent": "Spent", +"DND5E.SplitStack": { + "Action": "Split", + "Title": "Split Stack" +}, + "DND5E.StartingEquipment": { "Title": "Starting Equipment", "Action": { diff --git a/less/elements.less b/less/elements.less index fa837079f5..e8bcb65bc2 100644 --- a/less/elements.less +++ b/less/elements.less @@ -28,6 +28,23 @@ copyable-text { } } +/* ---------------------------------- */ +/* Double Range Picker */ +/* ---------------------------------- */ + +double-range-picker { + display: flex; + align-items: center; + gap: 0.5rem; + > input[type="range"] { flex: 1; } + > input[type="number"] { + flex: 0 0 40px; + text-align: center; + padding: 0; + font-size: 0.8em; + } +} + /* ---------------------------------- */ /* Filter State */ /* ---------------------------------- */ diff --git a/less/v2/forms.less b/less/v2/forms.less index e3e2a51c6d..17df8b0e0e 100644 --- a/less/v2/forms.less +++ b/less/v2/forms.less @@ -465,7 +465,7 @@ } } - range-picker { + range-picker, double-range-picker { input[type="range"] { --range-thumb-background-color: var(--dnd5e-color-card); --range-thumb-border-color: var(--dnd5e-color-gold); diff --git a/module/applications/components/_module.mjs b/module/applications/components/_module.mjs index 9952898d9a..d2d3a1c7b2 100644 --- a/module/applications/components/_module.mjs +++ b/module/applications/components/_module.mjs @@ -2,6 +2,7 @@ import AdoptedStyleSheetMixin from "./adopted-stylesheet-mixin.mjs"; import CheckboxElement from "./checkbox.mjs"; import CopyableTextElement from "./copyable-text.mjs"; import DamageApplicationElement from "./damage-application.mjs"; +import DoubleRangePickerElement from "./double-range-picker.mjs"; import EffectApplicationElement from "./effect-application.mjs"; import EffectsElement from "./effects.mjs"; import EnchantmentApplicationElement from "./enchantment-application.mjs"; @@ -19,6 +20,7 @@ window.customElements.define("dnd5e-checkbox", CheckboxElement); window.customElements.define("dnd5e-effects", EffectsElement); window.customElements.define("dnd5e-icon", IconElement); window.customElements.define("dnd5e-inventory", InventoryElement); +window.customElements.define("double-range-picker", DoubleRangePickerElement); window.customElements.define("effect-application", EffectApplicationElement); window.customElements.define("enchantment-application", EnchantmentApplicationElement); window.customElements.define("filigree-box", FiligreeBoxElement); @@ -28,7 +30,7 @@ window.customElements.define("proficiency-cycle", ProficiencyCycleElement); window.customElements.define("slide-toggle", SlideToggleElement); export { - AdoptedStyleSheetMixin, CopyableTextElement, CheckboxElement, DamageApplicationElement, EffectApplicationElement, - EffectsElement, EnchantmentApplicationElement, FiligreeBoxElement, FilterStateElement, IconElement, - InventoryElement, ItemListControlsElement, ProficiencyCycleElement, SlideToggleElement + AdoptedStyleSheetMixin, CopyableTextElement, CheckboxElement, DamageApplicationElement, DoubleRangePickerElement, + EffectApplicationElement, EffectsElement, EnchantmentApplicationElement, FiligreeBoxElement, FilterStateElement, + IconElement, InventoryElement, ItemListControlsElement, ProficiencyCycleElement, SlideToggleElement }; diff --git a/module/applications/components/double-range-picker.mjs b/module/applications/components/double-range-picker.mjs new file mode 100644 index 0000000000..89b545eb35 --- /dev/null +++ b/module/applications/components/double-range-picker.mjs @@ -0,0 +1,102 @@ +/** + * Version of the default range picker that has number inputs on both sides. + */ +export default class DoubleRangePickerElement extends foundry.applications.elements.HTMLRangePickerElement { + constructor() { + super(); + this.#min = Number(this.getAttribute("min")) ?? 0; + this.#max = Number(this.getAttribute("max")) ?? 1; + } + + /** @override */ + static tagName = "double-range-picker"; + + /** + * The range input. + * @type {HTMLInputElement} + */ + #rangeInput; + + /** + * The left number input. + * @type {HTMLInputElement} + */ + #leftInput; + + /** + * The right number input. + * @type {HTMLInputElement} + */ + #rightInput; + + /** + * The minimum allowed value for the range. + * @type {number} + */ + #min; + + /** + * The maximum allowed value for the range. + * @type {number} + */ + #max; + + /* -------------------------------------------- */ + + /** @inheritDoc */ + _buildElements() { + const [range, right] = super._buildElements(); + this.#rangeInput = range; + this.#rightInput = right; + this.#leftInput = this.#rightInput.cloneNode(); + return [this.#leftInput, this.#rangeInput, this.#rightInput]; + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + _refresh() { + super._refresh(); + if ( !this.#rangeInput ) return; + this.#leftInput.valueAsNumber = this.#max - this.#rightInput.valueAsNumber + this.#min; + } + + /* -------------------------------------------- */ + + /** @inheritDoc */ + _activateListeners() { + super._activateListeners(); + this.#rangeInput.addEventListener("input", this.#onDragSlider.bind(this)); + this.#leftInput.addEventListener("change", this.#onChangeInput.bind(this)); + } + + /* -------------------------------------------- */ + + /** + * Update display of the number input as the range slider is actively changed. + * @param {InputEvent} event The originating input event + */ + #onDragSlider(event) { + event.preventDefault(); + this.#leftInput.valueAsNumber = this.#max - this.#rangeInput.valueAsNumber + this.#min; + } + + /* -------------------------------------------- */ + + /** + * Handle changes to the left input. + * @param {InputEvent} event The originating input change event + */ + #onChangeInput(event) { + event.stopPropagation(); + this.value = this.#max - event.currentTarget.valueAsNumber + this.#min; + } + + /* -------------------------------------------- */ + + /** @override */ + _toggleDisabled(disabled) { + super._toggleDisabled(disabled); + this.#leftInput.toggleAttribute("disabled", disabled); + } +} diff --git a/module/applications/components/inventory.mjs b/module/applications/components/inventory.mjs index 7759749234..158ff4290d 100644 --- a/module/applications/components/inventory.mjs +++ b/module/applications/components/inventory.mjs @@ -3,6 +3,7 @@ import {parseInputDelta} from "../../utils.mjs"; import CurrencyManager from "../currency-manager.mjs"; import ContextMenu5e from "../context-menu.mjs"; import ItemSheet5e2 from "../item/item-sheet-2.mjs"; +import SplitStackDialog from "../item/split-stack-dialog.mjs"; /** * Custom element that handles displaying actor & container inventories. @@ -231,6 +232,13 @@ export default class InventoryElement extends HTMLElement { && !this.actor?.[game.release.generation < 13 ? "compendium" : "collection"]?.locked, group: "action" }, + { + name: "DND5E.SplitStack.Title", + icon: '', + callback: () => new SplitStackDialog({ document: item }).render({ force: true }), + condition: () => item.isOwner && !compendiumLocked && ((item.system.quantity ?? 0) > 1), + group: "action" + }, { name: "DND5E.ConcentrationBreak", icon: '', diff --git a/module/applications/item/split-stack-dialog.mjs b/module/applications/item/split-stack-dialog.mjs new file mode 100644 index 0000000000..50699794eb --- /dev/null +++ b/module/applications/item/split-stack-dialog.mjs @@ -0,0 +1,69 @@ +import Dialog5e from "../api/dialog.mjs"; + +/** + * Small dialog for splitting a stack of items into two. + */ +export default class SplitStackDialog extends Dialog5e { + /** @override */ + static DEFAULT_OPTIONS = { + buttons: [{ + id: "split", + label: "DND5E.SplitStack.Action", + icon: "fa-solid fa-arrows-split-up-and-left" + }], + classes: ["split-stack"], + document: null, + form: { + handler: SplitStackDialog.#handleFormSubmission + }, + position: { + width: 400 + }, + window: { + title: "DND5E.SplitStack.Title" + } + }; + + /* -------------------------------------------- */ + + /** @inheritDoc */ + static PARTS = { + ...super.PARTS, + content: { + template: "systems/dnd5e/templates/apps/split-stack-dialog.hbs" + } + }; + + /* -------------------------------------------- */ + /* Rendering */ + /* -------------------------------------------- */ + + /** @override */ + async _prepareContentContext(context, options) { + const total = this.options.document.system.quantity ?? 1; + context.max = Math.max(1, total - 1); + context.left = Math.ceil(total / 2); + context.right = total - context.left; + return context; + } + + /* -------------------------------------------- */ + /* Form Handling */ + /* -------------------------------------------- */ + + /** + * Handle submission of the dialog. + * @this {SplitStackDialog} + * @param {Event|SubmitEvent} event The form submission event. + * @param {HTMLFormElement} form The submitted form. + * @param {FormDataExtended} formData Data from the dialog. + */ + static async #handleFormSubmission(event, form, formData) { + const right = formData.object.right ?? 0; + const left = (this.options.document.system.quantity ?? 1) - right; + if ( left === this.options.document.system.quantity ) return; + await this.options.document.update({ "system.quantity": left }, { render: false }); + await this.options.document.clone({ "system.quantity": right }, { addSource: true, save: true }); + this.close(); + } +} diff --git a/templates/apps/split-stack-dialog.hbs b/templates/apps/split-stack-dialog.hbs new file mode 100644 index 0000000000..5729c4610d --- /dev/null +++ b/templates/apps/split-stack-dialog.hbs @@ -0,0 +1,3 @@ +
+ +