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 @@
+