diff --git a/.editorconfig b/.editorconfig index ba4931db..12b53f83 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,6 +8,6 @@ root = true end_of_line = lf insert_final_newline = true indent_style = space -indent_size = 4 +indent_size = 2 charset = utf-8 trim_trailing_whitespace = true diff --git a/lang/de.json b/lang/de.json index 7d49ef08..c5176813 100644 --- a/lang/de.json +++ b/lang/de.json @@ -3,9 +3,11 @@ "DS4.UserInteractionAddItemTitle": "Item Erstellen", "DS4.UserInteractionEditItemTitle": "Item Bearbeiten", "DS4.UserInteractionDeleteItemTitle": "Item Löschen", + "DS4.UserInteractionDeleteItemContent": "Sind Sie sicher, dass Sie {item} löschen möchten?", "DS4.UserInteractionAddEffectTitle": "Effekt Erstellen", "DS4.UserInteractionEditEffectTitle": "Effekt Bearbeiten", "DS4.UserInteractionDeleteEffectTitle": "Effekt Löschen", + "DS4.UserInteractionDeleteEffectContent": "Sind Sie sicher, dass Sie {effect} löschen möchten?", "DS4.DocumentImageAltText": "Bild von {name}", "DS4.RollableImageRollableTitle": "Für {name} würfeln", "DS4.DiceOverlayImageAltText": "Bild eines W20", @@ -182,7 +184,9 @@ "DS4.EffectFactor": "Faktor (wie oft der Effekt angewendet wird)", "DS4.EffectFactorAbbr": "F", "DS4.ActorName": "Name", + "DS4.ActorSheet": "Aktorbogen", "DS4.ActorImageAltText": "Bild des Aktors", + "DS4.ItemSheet": "Itembogen", "DS4.ActorTypeCharacter": "Charakter", "DS4.ActorTypeCreature": "Kreatur", "DS4.Attribute": "Attribut", diff --git a/lang/en.json b/lang/en.json index 847f0aea..8b032343 100644 --- a/lang/en.json +++ b/lang/en.json @@ -3,9 +3,11 @@ "DS4.UserInteractionAddItemTitle": "Create Item", "DS4.UserInteractionEditItemTitle": "Edit Item", "DS4.UserInteractionDeleteItemTitle": "Delete Item", + "DS4.UserInteractionDeleteItemContent": "Are you sure you want to delete {item}?", "DS4.UserInteractionAddEffectTitle": "Create Effect", "DS4.UserInteractionEditEffectTitle": "Edit Effect", "DS4.UserInteractionDeleteEffectTitle": "Delete Effect", + "DS4.UserInteractionDeleteEffectContent": "Are you sure you want to delete {effect}?", "DS4.DocumentImageAltText": "Image of {name}", "DS4.RollableImageRollableTitle": "Roll for {name}", "DS4.DiceOverlayImageAltText": "Image of a d20", @@ -182,7 +184,9 @@ "DS4.EffectFactor": "Factor (the number of times the effect is being applied)", "DS4.EffectFactorAbbr": "F", "DS4.ActorName": "Name", + "DS4.ActorSheet": "Actor Sheet", "DS4.ActorImageAltText": "Image of the Actor", + "DS4.ItemSheet": "Item Sheet", "DS4.ActorTypeCharacter": "Character", "DS4.ActorTypeCreature": "Creature", "DS4.Attribute": "Attribute", diff --git a/scss/components/actor/_actor_header.scss b/scss/components/actor/_actor_header.scss index 068c0f1a..ac6142fe 100644 --- a/scss/components/actor/_actor_header.scss +++ b/scss/components/actor/_actor_header.scss @@ -42,6 +42,7 @@ align-items: center; border-bottom: 0; margin: 0; + container-type: inline-size; } &__name-input[type="text"] { @@ -49,7 +50,10 @@ background-color: transparent; border: none; flex: 1; - font-size: 1.25em; + font-size: clamp(0.75em, 8cqi, 1.25em); height: auto; + + // Use lighter font weight for better readability + font-weight: 300; } } diff --git a/scss/components/actor/_actor_progression.scss b/scss/components/actor/_actor_progression.scss index 15ac258c..c31a0c52 100644 --- a/scss/components/actor/_actor_progression.scss +++ b/scss/components/actor/_actor_progression.scss @@ -29,10 +29,11 @@ margin: 0; padding: 0; text-align: right; + font-weight: 300; } &__input { - flex: 0 0 5ch; + flex: 0 0 8ch; &--slayer-points { &::-webkit-inner-spin-button, diff --git a/scss/components/actor/_actor_properties.scss b/scss/components/actor/_actor_properties.scss index 78edeb28..cfa631ad 100644 --- a/scss/components/actor/_actor_properties.scss +++ b/scss/components/actor/_actor_properties.scss @@ -26,7 +26,7 @@ &__property-select { width: 100%; - height: var(--form-field-height); + height: var(--input-height); } &__property-multi-input { diff --git a/scss/components/actor/_biography.scss b/scss/components/actor/_biography.scss index cf033a71..fb6b22ec 100644 --- a/scss/components/actor/_biography.scss +++ b/scss/components/actor/_biography.scss @@ -8,4 +8,10 @@ display: grid; grid-template-columns: 1fr 3fr; column-gap: 1em; + height: 100%; +} + +.ds4-biography { + height: 100%; + overflow: hidden; } diff --git a/scss/components/actor/_combat_value.scss b/scss/components/actor/_combat_value.scss index 184aa31e..ca5369c9 100644 --- a/scss/components/actor/_combat_value.scss +++ b/scss/components/actor/_combat_value.scss @@ -71,3 +71,13 @@ flex: 1 1 4em; } } + +// Dark mode filter for combat value backgrounds when in dark theme +.theme-dark .ds4-combat-value__value { + filter: brightness(0) invert(1) brightness(0.8); + + // Counter-invert the text to keep it normal + .ds4-combat-value__text { + filter: brightness(1.25) invert(1) brightness(0.8); + } +} diff --git a/scss/components/actor/_core_value.scss b/scss/components/actor/_core_value.scss index 140ca89e..dfd576ad 100644 --- a/scss/components/actor/_core_value.scss +++ b/scss/components/actor/_core_value.scss @@ -35,7 +35,7 @@ &--trait { .ds4-core-value__label { - -webkit-text-stroke: 1px colors.$c-black; + -webkit-text-stroke: 1px light-dark(colors.$c-black, colors.$c-white); color: transparent; } } diff --git a/scss/components/item/_item_header.scss b/scss/components/item/_item_header.scss index 2e122a36..1a22436f 100644 --- a/scss/components/item/_item_header.scss +++ b/scss/components/item/_item_header.scss @@ -33,6 +33,7 @@ &__name { border: none; margin: 0; + container-type: inline-size; } &__name-label { @@ -43,9 +44,10 @@ @include mixins.font-heading-upper; background-color: transparent; border: none; - font-size: 1.25em; + font-size: clamp(0.75em, 8cqi, 1.25em); height: auto; padding-left: 0; padding-right: 0; + font-weight: 300; } } diff --git a/scss/components/shared/_editor.scss b/scss/components/shared/_editor.scss index dead15ce..76c8cebf 100644 --- a/scss/components/shared/_editor.scss +++ b/scss/components/shared/_editor.scss @@ -16,3 +16,38 @@ } } } + +// General ProseMirror editor styles +prose-mirror { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + + // Edit mode with editor container + &.active { + .editor-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + + .editor-content { + flex: 1; + overflow-y: auto; + min-height: 0; + } + } + } + + // View mode - direct editor-content + &.inactive { + .editor-content { + position: relative; + flex: 1; + overflow-y: auto; + min-height: 0; + inset: auto; + } + } +} diff --git a/scss/components/shared/_embedded_document_list.scss b/scss/components/shared/_embedded_document_list.scss index 50b8d5e1..c24dcbce 100644 --- a/scss/components/shared/_embedded_document_list.scss +++ b/scss/components/shared/_embedded_document_list.scss @@ -150,3 +150,8 @@ margin-top: 1em; padding-left: 1em; } + +// Dark mode filter for embedded document list images when in dark theme +.theme-dark .ds4-embedded-document-list__image { + filter: brightness(0) invert(1) brightness(0.8); +} diff --git a/scss/components/shared/_form_group.scss b/scss/components/shared/_form_group.scss index 12687e48..2a3032b3 100644 --- a/scss/components/shared/_form_group.scss +++ b/scss/components/shared/_form_group.scss @@ -22,6 +22,6 @@ &__label { flex: 2; - line-height: var(--form-field-height); + line-height: var(--input-height); } } diff --git a/scss/components/shared/_ruler.scss b/scss/components/shared/_ruler.scss new file mode 100644 index 00000000..ebbb188d --- /dev/null +++ b/scss/components/shared/_ruler.scss @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2025 Alexander Minges + * + * SPDX-License-Identifier: MIT + */ + +/* Token Ruler Waypoint Styling with Color-Coded Movement Ranges */ + +.system-ds4 .waypoint-label { + font-size: 1.5em; + + // Main icon styling for movement types + &.move-range > i.fa-person-walking { + color: var(--color-level-success); + } + + &.dash-range > i.fa-person-running { + color: var(--color-level-warning); + } + + &.out-of-range > i.fa-person-rays { + color: var(--color-level-error); + animation: pulse 2s infinite; + } + + .distance { + &.move-range { + color: var(--color-level-success); + } + + &.dash-range { + color: var(--color-level-warning); + font-weight: bold; + } + + &.out-of-range { + color: var(--color-level-error); + font-weight: bold; + animation: pulse 2s infinite; + } + } + + .delta { + opacity: 0.8; + font-size: 0.9em; + } + + .elevation { + font-style: italic; + opacity: 0.9; + } +} + +/* Icon animations */ +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} diff --git a/scss/components/shared/_sheet_body.scss b/scss/components/shared/_sheet_body.scss index 3b19d5ca..0718c6a6 100644 --- a/scss/components/shared/_sheet_body.scss +++ b/scss/components/shared/_sheet_body.scss @@ -7,4 +7,9 @@ .ds4-sheet-body { height: 100%; overflow-y: auto; + + // Prevent double scrollbars on biography tab + .ds4-sheet-tab.tab.biography.active { + overflow: hidden; + } } diff --git a/scss/ds4.scss b/scss/ds4.scss index 4f533640..53760d31 100644 --- a/scss/ds4.scss +++ b/scss/ds4.scss @@ -19,6 +19,7 @@ @import "components/shared/embedded_document_list"; @import "components/shared/form_group"; @import "components/shared/rollable_image"; +@import "components/shared/ruler"; @import "components/shared/sheet_body"; @import "components/shared/sheet_form"; @import "components/shared/sheet_tab_nav"; diff --git a/scss/global/_fonts.scss b/scss/global/_fonts.scss index 4e79e4c0..d5e7adb4 100644 --- a/scss/global/_fonts.scss +++ b/scss/global/_fonts.scss @@ -58,3 +58,21 @@ --ds4-font-primary: Lora, serif; --ds4-font-heading: "Wood Stamp", sans-serif; } + +// Apply Wood Stamp font only to DS4 sheet-specific elements +.ds4-actor-sheet h1, +.ds4-actor-sheet h2, +.ds4-actor-sheet h4, +.ds4-actor-sheet h5, +.ds4-actor-sheet h6, +.ds4-item-sheet h1, +.ds4-item-sheet h2, +.ds4-item-sheet h4, +.ds4-item-sheet h5, +.ds4-item-sheet h6, +.ds4-currency-title, +.ds4-embedded-document-list-title { + font-family: var(--ds4-font-heading) !important; + text-transform: uppercase; + font-weight: 100 !important; +} diff --git a/src/apps/active-effect-config.js b/src/apps/active-effect-config.js index e0acae02..4e620336 100644 --- a/src/apps/active-effect-config.js +++ b/src/apps/active-effect-config.js @@ -2,31 +2,55 @@ // // SPDX-License-Identifier: MIT -export class DS4ActiveEffectConfig extends ActiveEffectConfig { +/** + * DS4 Active Effect Configuration Sheet + */ +export class DS4ActiveEffectConfig extends foundry.applications.sheets.ActiveEffectConfig { + static DEFAULT_OPTIONS = { + ...super.DEFAULT_OPTIONS, + classes: ["sheet", "ds4-active-effect-config", "themed"], + }; + /** @override */ - static get defaultOptions() { - return foundry.utils.mergeObject(super.defaultOptions, { - template: "systems/ds4/templates/sheets/active-effect/active-effect-config.hbs", - }); + get template() { + return "systems/ds4/templates/sheets/active-effect/active-effect-config.hbs"; } - /** - * @override - * @param {JQuery} html - */ - activateListeners(html) { - super.activateListeners(html); - const checkbox = html[0]?.querySelector('input[name="flags.ds4.itemEffectConfig.applyToItems"]'); - checkbox?.addEventListener("change", () => this.#toggleItemEffectConfig(checkbox.checked)); + /** @override */ + async _prepareContext(options) { + const context = await super._prepareContext(options); + + // Add DS4-specific context + context.itemEffectConfig = this.document.flags?.ds4?.itemEffectConfig || {}; + context.applyToItems = context.itemEffectConfig.applyToItems || false; + + return context; + } + + /** @override */ + async _onRender(context, options) { + await super._onRender(context, options); + + // Set up initial visibility of item effect config section + const applyToItems = this.document.flags?.ds4?.itemEffectConfig?.applyToItems || false; + this._toggleItemEffectConfigVisibility(applyToItems); + + // Add event listener for the checkbox + const checkbox = this.element.querySelector('input[name="flags.ds4.itemEffectConfig.applyToItems"]'); + if (checkbox) { + checkbox.addEventListener("change", (event) => { + this._toggleItemEffectConfigVisibility(event.target.checked); + }); + } } /** * Toggle the visibility of the item effect config section - * @param {boolean} active The target state + * @param {boolean} active - The target state */ - #toggleItemEffectConfig(active) { - const elements = this.element[0]?.querySelectorAll(".ds4-item-effect-config"); - elements?.forEach((element) => { + _toggleItemEffectConfigVisibility(active) { + const elements = this.element.querySelectorAll(".ds4-item-effect-config"); + elements.forEach((element) => { if (active) { element.classList.remove("ds4-hidden"); } else { diff --git a/src/apps/actor/base-sheet.js b/src/apps/actor/base-sheet.js index 75a74b11..285a023a 100644 --- a/src/apps/actor/base-sheet.js +++ b/src/apps/actor/base-sheet.js @@ -2,495 +2,617 @@ // SPDX-FileCopyrightText: 2021 Oliver Rümpelein // SPDX-FileCopyrightText: 2021 Gesina Schwalbe // SPDX-FileCopyrightText: 2021 Siegfried Krug +// SPDX-FileCopyrightText: 2025 Alexander Minges // // SPDX-License-Identifier: MIT -import { DS4 } from "../../config"; -import { DS4ActiveEffect } from "../../documents/active-effect"; -import { isCheck } from "../../documents/actor/actor-data-properties-base"; -import { getDS4Settings } from "../../settings"; -import { notifications } from "../../ui/notifications"; -import { enforce, getCanvas, getGame } from "../../utils/utils"; -import { disableOverriddenFields } from "../sheet-helpers"; - /** - * The base sheet class for all {@link DS4Actor}s. + * The base sheet class for DS4 Actor Sheets */ -export class DS4ActorSheet extends ActorSheet { - /** @override */ - static get defaultOptions() { - return foundry.utils.mergeObject(super.defaultOptions, { - classes: ["sheet", "ds4-actor-sheet"], - height: 645, - scrollY: [".ds4-sheet-body"], - tabs: [{ navSelector: ".ds4-sheet-tab-nav", contentSelector: ".ds4-sheet-body", initial: "values" }], - dragDrop: [ - { dragSelector: ".item-list .item", dropSelector: null }, - { dragSelector: ".effect-list .effect", dropSelector: null }, - { dragSelector: ".ds4-check", dropSelector: null }, - ], - width: 650, - }); +export class DS4ActorSheet extends foundry.applications.api.HandlebarsApplicationMixin( + foundry.applications.sheets.ActorSheetV2, +) { + static DEFAULT_OPTIONS = { + classes: ["sheet", "ds4-actor-sheet", "themed"], + tag: "form", + form: { + submitOnChange: true, + closeOnSubmit: false, + }, + position: { + width: 780, + height: 680, + }, + window: { + resizable: true, + }, + actions: { + rollCheck: DS4ActorSheet.prototype._onRollCheck, + rollItem: DS4ActorSheet.prototype._onRollItem, + controlItem: DS4ActorSheet.prototype._onControlItem, + createItem: DS4ActorSheet.prototype._onCreateItem, + editItem: DS4ActorSheet.prototype._onEditItem, + deleteItem: DS4ActorSheet.prototype._onDeleteItem, + changeItem: DS4ActorSheet.prototype._onChangeItem, + controlEffect: DS4ActorSheet.prototype._onControlEffect, + createEffect: DS4ActorSheet.prototype._onCreateEffect, + editEffect: DS4ActorSheet.prototype._onEditEffect, + deleteEffect: DS4ActorSheet.prototype._onDeleteEffect, + changeEffect: DS4ActorSheet.prototype._onChangeEffect, + sortItems: DS4ActorSheet.prototype._onSortItems, + changeTab: DS4ActorSheet.prototype._onChangeTab, + editImage: DS4ActorSheet.prototype._onEditImage, + }, + }; + + static TABS = {}; + + constructor(options = {}) { + super(options); + this.activeTab = "values"; // Default active tab + } + + get title() { + return `${this.document.name} [${game.i18n.localize("DS4.ActorSheet")}]`; } /** @override */ get template() { - const basePath = "systems/ds4/templates/sheets/actor"; - if (!getGame().user?.isGM && this.actor.limited) return `${basePath}/limited-sheet.hbs`; - return `${basePath}/${this.actor.type}-sheet.hbs`; + const templatePath = + !game.user?.isGM && this.document.limited + ? "systems/ds4/templates/sheets/actor/limited-sheet.hbs" + : `systems/ds4/templates/sheets/actor/${this.document.type}-sheet.hbs`; + + return templatePath; } /** @override */ - async getData(options = {}) { - const itemsByType = Object.fromEntries( - Object.entries(this.actor.itemTypes).map(([itemType, items]) => { - return [itemType, [...items].sort((a, b) => (a.sort || 0) - (b.sort || 0))]; - }), - ); + async _renderHTML(context) { + return await foundry.applications.handlebars.renderTemplate(this.template, context); + } - const enrichedEffects = [...this.actor.allApplicableEffects()].map((effect) => { - return { - ...effect.toObject(), - sourceName: effect.parent instanceof Item ? effect.parent.name : effect.sourceName, - factor: effect.factor, - active: effect.active, - uuid: effect.uuid, - }; - }); + /** @override */ + _replaceHTML(result, content) { + content.innerHTML = result; + } - const context = { - ...this.addTooltipsToData(await super.getData(options)), - config: DS4, - itemsByType, - enrichedEffects, - settings: getDS4Settings(), + /** @override */ + async _prepareContext(options) { + const context = await super._prepareContext(options); + + // Validate document exists + if (!this.document) { + throw new Error("Document not available for sheet rendering"); + } + + // Add document data + context.data = this.document; + context.system = this.document.system; + context.source = this.document.toObject(); + context.cssClass = this.document.constructor.name.toLowerCase(); + context.editable = this.isEditable; + context.owner = this.document.isOwner; + context.limited = this.document.limited; + + // Add configuration + context.config = CONFIG.DS4; + context.settings = { + showSlayerPoints: game.settings.get("ds4", "showSlayerPoints") || false, }; + + // Add items organized by type + context.itemsByType = {}; + if (this.document.items && this.document.items.size > 0) { + for (const item of this.document.items) { + const type = item.type; + if (!context.itemsByType[type]) context.itemsByType[type] = []; + context.itemsByType[type].push(item); + } + } + + // Add enriched effects + context.enrichedEffects = []; + if (this.document.effects && this.document.effects.size > 0) { + for (const effect of this.document.effects) { + const enrichedEffect = effect.toObject(); + enrichedEffect.id = effect.id; + enrichedEffect.uuid = effect.uuid; + enrichedEffect.sourceName = effect.sourceName; + context.enrichedEffects.push(enrichedEffect); + } + } + + // Add tooltips to data + this.addTooltipsToData(context); + return context; } /** - * Adds tooltips to the attributes, traits, and combatValues of the given context object. - * @param {object} context - * @protected + * Add tooltips to the given context data */ addTooltipsToData(context) { - const valueGroups = [context.data.system.attributes, context.data.system.traits, context.data.system.combatValues]; + const data = context.data; - valueGroups.forEach((valueGroup) => { - Object.values(valueGroup).forEach((attribute) => { - attribute.tooltip = this.getTooltipForValue(attribute); - }); - }); - return context; + // Add tooltips to attributes + if (data.system.attributes) { + for (const value of Object.values(data.system.attributes)) { + value.tooltip = this.getTooltipForValue(value); + } + } + + // Add tooltips to traits + if (data.system.traits) { + for (const value of Object.values(data.system.traits)) { + value.tooltip = this.getTooltipForValue(value); + } + } + + // Add tooltips to combat values + if (data.system.combatValues) { + for (const value of Object.values(data.system.combatValues)) { + value.tooltip = this.getTooltipForValue(value); + } + } } /** - * Generates a tooltip for a given attribute, trait, or combatValue. - * @param {import("../../documents/common/common-data").ModifiableDataBaseTotal} value The value to get a tooltip for - * @returns {string} The tooltip - * @protected + * Get a tooltip for a value + * @param {object} value - The value to get a tooltip for + * @returns {string} The tooltip string */ getTooltipForValue(value) { - return `${value.base} (${getGame().i18n.localize("DS4.TooltipBaseValue")}) + ${ - value.mod - } (${getGame().i18n.localize("DS4.TooltipModifier")}) ➞ ${getGame().i18n.localize("DS4.TooltipEffects")} ➞ ${ - value.total - }`; - } + const base = value.base ?? 0; + const modifier = value.mod ?? 0; + const effects = value.effects ?? 0; - /** - * @param {JQuery} html - * @override - */ - activateListeners(html) { - super.activateListeners(html); - - if (!this.options.editable) return; - - html.find(".control-item").on("click", this.onControlItem.bind(this)); - html.find(".change-item").on("change", this.onChangeItem.bind(this)); - - html.find(".control-effect").on("click", this.onControlEffect.bind(this)); - html.find(".change-effect").on("change", this.onChangeEffect.bind(this)); - - html.find(".rollable-item").on("click", this.onRollItem.bind(this)); - html.find(".rollable-check").on("click", this.onRollCheck.bind(this)); - - html.find(".sort-items").on("click", this.onSortItems.bind(this)); - - disableOverriddenFields(this.form, this.actor.overrides, (key) => `[name="${key}"]`); - for (const item of this.actor.items) { - disableOverriddenFields( - this.form, - item.overrides, - (key) => `[data-item-uuid="${item.uuid}"] .change-item[data-property="${key}"]`, - ); - } - } - - /** - * Handles a click on an element of this sheet to control an embedded item of the actor corresponding to this sheet. - * - * @param {JQuery.ClickEvent} event The originating click event - * @protected - */ - onControlItem(event) { - event.preventDefault(); - const a = event.currentTarget; - switch (a.dataset["action"]) { - case "create": - return this.onCreateItem(event); - case "edit": - return this.onEditItem(event); - case "delete": - return this.onDeleteItem(event); - } - } - - /** - * Creates a new embedded item using the initial data defined in the HTML dataset of the clicked element. - * - * @param {JQuery.ClickEvent} event The originating click event - * @protected - */ - onCreateItem(event) { - const { type } = foundry.utils.deepClone(event.currentTarget.dataset); - const name = getGame().i18n.localize(`DS4.New${type.capitalize()}Name`); - const itemData = { name, type }; - Item.create(itemData, { parent: this.actor }); - } - - /** - * Opens the sheet of the embedded item corresponding to the clicked element. - * - * @param {JQuery.ClickEvent} event The originating click event - * @protected - */ - async onEditItem(event) { - const li = event.currentTarget.closest(embeddedDocumentListEntryProperties.Item.selector); - const uuid = li.dataset[embeddedDocumentListEntryProperties.Item.uuidDataAttribute]; - const item = await fromUuid(uuid); - enforce( - item && item.parent === this.actor, - getGame().i18n.format("DS4.ErrorActorDoesNotHaveItem", { uuid, actor: this.actor.name }), - ); - item.sheet?.render(true); - } - - /** - * Deletes the embedded item corresponding to the clicked element. - * - * @param {JQuery.ClickEvent} event The originating click event - * @protected - */ - async onDeleteItem(event) { - const li = event.currentTarget.closest(embeddedDocumentListEntryProperties.Item.selector); - const uuid = li.dataset[embeddedDocumentListEntryProperties.Item.uuidDataAttribute]; - const item = await fromUuid(uuid); - enforce( - item && item.parent === this.actor, - getGame().i18n.format("DS4.ErrorActorDoesNotHaveItem", { uuid, actor: this.actor.name }), - ); - item.delete(); - $(li).slideUp(200, () => this.render(false)); - } - - /** - * Applies a change to a property of an embedded item depending on the `data-property` attribute of the - * {@link HTMLInputElement} that has been changed and its new value. - * - * @param {JQuery.ClickEvent} event The originating click event - * @protected - */ - onChangeItem(event) { - return this.onChangeEmbeddedDocument(event, "Item"); - } - - /** - * Handles a click on an element of this sheet to control an embedded effect of the actor corresponding to this - * sheet. - * - * @param {JQuery.ClickEvent} event The originating click event - * @protected - */ - onControlEffect(event) { - event.preventDefault(); - const a = event.currentTarget; - switch (a.dataset["action"]) { - case "create": - return this.onCreateEffect(); - case "edit": - return this.onEditEffect(event); - case "delete": - return this.onDeleteEffect(event); - } - } - - /** - * Creates a new embedded effect. - * - * @param {JQuery.ClickEvent} event The originating click event - * @protected - */ - onCreateEffect() { - DS4ActiveEffect.createDefault(this.actor); - } - - /** - * Opens the sheet of the embedded effect corresponding to the clicked element. - * - * @param {JQuery.ClickEvent} event The originating click event - * @protected - */ - async onEditEffect(event) { - const li = event.currentTarget.closest(embeddedDocumentListEntryProperties.ActiveEffect.selector); - const uuid = li.dataset[embeddedDocumentListEntryProperties.ActiveEffect.uuidDataAttribute]; - const effect = await fromUuid(uuid); - enforce( - effect && (effect.parent === this.actor || effect.parent.parent === this.actor), - getGame().i18n.format("DS4.ErrorActorDoesNotHaveEffect", { uuid, actor: this.actor.name }), - ); - effect.sheet?.render(true); - } - - /** - * Deletes the embedded item corresponding to the clicked element. - * - * @param {JQuery.ClickEvent} event The originating click event - * @protected - */ - async onDeleteEffect(event) { - const li = event.currentTarget.closest(embeddedDocumentListEntryProperties.ActiveEffect.selector); - const uuid = li.dataset[embeddedDocumentListEntryProperties.ActiveEffect.uuidDataAttribute]; - const effect = await fromUuid(uuid); - enforce( - effect && (effect.parent === this.actor || effect.parent.parent === this.actor), - getGame().i18n.format("DS4.ErrorActorDoesNotHaveEffect", { uuid, actor: this.actor.name }), - ); - effect.delete(); - $(li).slideUp(200, () => this.render(false)); - } - - /** - * Applies a change to a property of an embedded effect depending on the `data-property` attribute of the - * {@link HTMLInputElement} that has been changed and its new value. - * - * @param {JQuery.ClickEvent} event The originating click event - * @protected - */ - onChangeEffect(event) { - return this.onChangeEmbeddedDocument(event, "ActiveEffect"); - } - - /** - * Applies a change to a property of an embedded document of the actor belonging to this sheet. The change depends - * on the `data-property` attribute of the {@link HTMLInputElement} that has been changed and its new value. - * - * @param {JQuery.ChangeEvent} event The originating click event - * @param {"Item" | "ActiveEffect"} documentName The name of the embedded document to be changed. - * @protected - */ - async onChangeEmbeddedDocument(event, documentName) { - event.preventDefault(); - const element = $(event.currentTarget).get(0); - if (element.disabled) return; - - const documentElement = element.closest(embeddedDocumentListEntryProperties[documentName].selector); - const uuid = documentElement.dataset[embeddedDocumentListEntryProperties[documentName].uuidDataAttribute]; - const property = element.dataset["property"]; - enforce(property !== undefined, TypeError("HTML element does not provide 'data-property' attribute")); - - const newValue = this.parseValue(element); - - const document = await fromUuid(uuid); - - if (documentName === "Item") { - enforce( - document && document.parent === this.actor, - getGame().i18n.format("DS4.ErrorActorDoesNotHaveItem", { uuid, actor: this.actor.name }), - ); + if (effects === 0) { + return game.i18n.format("DS4.TooltipWithoutEffects", { base, modifier }); } else { - enforce( - document && (document.parent === this.actor || document.parent.parent === this.actor), - getGame().i18n.format("DS4.ErrorActorDoesNotHaveEffect", { uuid, actor: this.actor.name }), - ); - } - document.update({ [property]: newValue }); - } - - /** - * Parses the value of the given {@link HTMLInputElement} depending on the element's type - * The value is parsed to: - * - checkbox: `boolean`, if the attribute `data-inverted` is set to a truthy value, the parsed value is inverted - * - text input: `string` - * - number: `number` - * - * @param {HTMLInputElement} element The input element to parse the value from - * @returns {boolean | string | number} The parsed data - * @protected - */ - parseValue(element) { - switch (element.type) { - case "checkbox": { - const inverted = Boolean(element.dataset["inverted"]); - const value = element.checked; - return inverted ? !value : value; - } - case "text": { - const value = element.value; - return value; - } - case "number": { - const value = Number(element.value.trim()); - return value; - } - default: { - throw new TypeError("Binding of item property to this type of HTML element not supported; given: " + element); - } + return game.i18n.format("DS4.TooltipWithEffects", { base, modifier, effects }); } } /** - * Handle clickable item rolls. - * @param {JQuery.ClickEvent} event The originating click event - * @protected + * Process form data for submission + * @param {Event} event - The form submission event + * @param {HTMLFormElement} form - The form element + * @param {FormDataExtended} formData - The form data + * @returns {object} The processed form data */ - async onRollItem(event) { - event.preventDefault(); - const li = event.currentTarget.closest(embeddedDocumentListEntryProperties.Item.selector); - const uuid = li.dataset[embeddedDocumentListEntryProperties.Item.uuidDataAttribute]; - const item = await fromUuid(uuid); - enforce( - item && item.parent === this.actor, - getGame().i18n.format("DS4.ErrorActorDoesNotHaveItem", { uuid, actor: this.actor.name }), - ); - item.roll().catch((e) => notifications.error(e, { log: true })); + _processFormData(event, form, formData) { + const submitData = foundry.utils.expandObject(formData.object); + + // Parse values for specific fields + for (const [key, value] of Object.entries(formData.object)) { + if (key.includes("system.") && typeof value === "string") { + foundry.utils.setProperty(submitData, key, this.parseValue(value)); + } + } + + return submitData; + } + + /** @override */ + async _onChangeForm(formConfig, event) { + const target = event.target; + + // Handle embedded document changes (items and effects) + if (target.dataset.action === "changeItem") { + await this._onChangeItem(event, target); + return; // Don't call super to avoid double updates + } + + if (target.dataset.action === "changeEffect") { + await this._onChangeEffect(event, target); + return; // Don't call super to avoid double updates + } + + // Let the default form handling process other changes + return super._onChangeForm(formConfig, event); } /** - * Handle clickable check rolls. - * @param {JQuery.ClickEvent} event The originating click event - * @protected + * Parse a value from a form field + * @param {string} value - The value to parse + * @returns {*} The parsed value */ - onRollCheck(event) { - event.preventDefault(); - event.currentTarget.blur(); - const check = event.currentTarget.dataset["check"]; - this.actor.rollCheck(check).catch((e) => notifications.error(e, { log: true })); + parseValue(value) { + if (value === "") return null; + + const numericValue = Number(value); + if (!isNaN(numericValue)) return numericValue; + + // Try to parse as a formula + try { + const roll = new Roll(value); + return roll.evaluateSync().total; + } catch { + return value; + } } /** - * @param {DragEvent} event - * @override + * Handle rolling a check + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element */ - _onDragStart(event) { - const target = event.currentTarget; - if (!(target instanceof HTMLElement)) return super._onDragStart(event); + async _onRollCheck(event, target) { + const checkKey = target.dataset.check; + if (!checkKey) return; - const check = target.dataset.check; - if (!check) return super._onDragStart(event); + const actor = this.document; + await actor.rollCheck(checkKey); + } - enforce(isCheck(check), getGame().i18n.format("DS4.ErrorCannotDragMissingCheck", { check })); + /** + * Handle rolling an item + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onRollItem(event, target) { + const itemId = target.closest("[data-item-id]")?.dataset.itemId; + if (!itemId) return; - const dragData = { - actorId: this.actor.id, - sceneId: this.actor.isToken ? getCanvas().scene?.id : null, - tokenId: this.actor.isToken ? this.actor.token?.id : null, - type: "Check", - data: check, + const item = this.document.items.get(itemId); + if (item?.system.rollable) { + await item.roll(); + } + } + + /** + * Handle item control actions + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onControlItem(_event, target) { + const action = target.dataset.action; + const itemId = target.closest("[data-item-id]")?.dataset.itemId; + + if (!itemId) return; + + const item = this.document.items.get(itemId); + if (!item) return; + + switch (action) { + case "edit": + await this._onEditItem(event, target); + break; + case "delete": + await this._onDeleteItem(event, target); + break; + } + } + + /** + * Handle creating a new item + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onCreateItem(event, target) { + const itemType = target.dataset.type; + if (!itemType) return; + + const itemData = { + name: game.i18n.localize(`DS4.ItemType${itemType.capitalize()}`), + type: itemType, }; - event.dataTransfer?.setData("text/plain", JSON.stringify(dragData)); + await this.document.createEmbeddedDocuments("Item", [itemData]); } /** - * Sort items according to the item list header that has been clicked. - * @param {JQuery.ClickEvent} event The originating click event - * @protected + * Handle editing an item + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element */ - onSortItems(event) { - event.preventDefault(); - const target = event.currentTarget; - const type = target.parentElement?.dataset["type"]; - enforce(type !== undefined, `Could not find property 'type' in the dataset of the parent of ${target}`); - const dataPath = target.dataset["dataPath"]; - enforce(dataPath !== undefined, `Could not find property 'dataPath' in the dataset of ${target}`); - const dataPath2 = target.dataset["dataPath2"]; - /** @type {import("../../documents/item/item").DS4Item[]}*/ - const items = this.actor.items.filter((item) => item.type === type); - items.sort((a, b) => a.sort - b.sort); + async _onEditItem(event, target) { + const itemId = target.closest("[data-item-id]")?.dataset.itemId; + if (!itemId) return; - /** - * @param {boolean} invert Whether or not to inverse the sort order - * @returns {(a: import("../../documents/item/item").DS4Item, b: import("../../documents/item/item").DS4Item) => number} A function for sorting items - */ - const sortFunction = (invert) => (a, b) => { - const propertyA = getProperty(a, dataPath); - const propertyB = getProperty(b, dataPath); - const comparison = - typeof propertyA === "string" || typeof propertyB === "string" - ? compareAsStrings(propertyA, propertyB, invert) - : compareAsNumbers(propertyA, propertyB, invert); + const item = this.document.items.get(itemId); + if (item) { + await item.sheet.render(true); + } + } - if (comparison === 0 && dataPath2 !== undefined) { - const propertyA = getProperty(a, dataPath); - const propertyB = getProperty(b, dataPath); - return typeof propertyA === "string" || typeof propertyB === "string" - ? compareAsStrings(propertyA, propertyB, invert) - : compareAsNumbers(propertyA, propertyB, invert); + /** + * Handle deleting an item + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onDeleteItem(event, target) { + const itemId = target.closest("[data-item-id]")?.dataset.itemId; + if (!itemId) return; + + const item = this.document.items.get(itemId); + if (!item) return; + + const confirmed = await foundry.applications.api.DialogV2.confirm({ + window: { title: game.i18n.localize("DS4.UserInteractionDeleteItemTitle") }, + content: game.i18n.format("DS4.UserInteractionDeleteItemContent", { item: item.name }), + defaultYes: false, + }); + + if (confirmed) { + await item.delete(); + } + } + + /** + * Handle changing an item property + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onChangeItem(event, target) { + const itemId = target.closest("[data-item-id]")?.dataset.itemId; + const property = target.dataset.property; + + if (!itemId || !property) return; + + const item = this.document.items.get(itemId); + if (!item) return; + + let value = target.value; + + // Handle different input types + if (target.type === "checkbox") { + value = target.checked; + if (target.dataset.inverted === "true") { + value = !value; } - - return comparison; - }; - - const sortedItems = [...items].sort(sortFunction(false)); - const wasSortedAlready = !sortedItems.find((item, index) => item !== items[index]); - - if (wasSortedAlready) { - sortedItems.sort(sortFunction(true)); + } else if (target.dataset.dtype === "Number") { + value = Number(value); } - const updates = sortedItems.map((item, i) => ({ + await item.update({ [property]: value }); + } + + /** + * Handle effect control actions + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onControlEffect(event, target) { + const action = target.dataset.action; + const effectId = target.closest("[data-effect-id]")?.dataset.effectId; + + if (!effectId) return; + + const effect = this.document.effects.get(effectId); + if (!effect) return; + + switch (action) { + case "edit": + await this._onEditEffect(event, target); + break; + case "delete": + await this._onDeleteEffect(event, target); + break; + } + } + + /** + * Handle creating a new effect + */ + async _onCreateEffect() { + const effectData = { + name: game.i18n.localize("DS4.NewEffectName"), + icon: "icons/svg/aura.svg", + }; + + await this.document.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + + /** + * Handle editing an effect + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onEditEffect(event, target) { + const effectId = target.closest("[data-effect-id]")?.dataset.effectId; + if (!effectId) return; + + const effect = this.document.effects.get(effectId); + if (effect) { + await effect.sheet.render(true); + } + } + /** + * Handle changing an effect property + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onChangeEffect(event, target) { + const effectId = target.closest("[data-effect-id]")?.dataset.effectId; + const property = target.dataset.property; + + if (!effectId || !property) return; + + const effect = this.document.effects.get(effectId); + if (!effect) return; + + let value = target.value; + + // Handle different input types + if (target.type === "checkbox") { + value = target.checked; + if (target.dataset.inverted === "true") { + value = !value; + } + } else if (target.dataset.dtype === "Number") { + value = Number(value); + } + + await effect.update({ [property]: value }); + } + + /** + * Handle deleting an effect + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onDeleteEffect(event, target) { + const effectId = target.closest("[data-effect-id]")?.dataset.effectId; + if (!effectId) return; + + const effect = this.document.effects.get(effectId); + if (!effect) return; + + const confirmed = await foundry.applications.api.DialogV2.confirm({ + window: { title: game.i18n.localize("DS4.UserInteractionDeleteEffectTitle") }, + content: game.i18n.format("DS4.UserInteractionDeleteEffectContent", { effect: effect.name }), + defaultYes: false, + }); + + if (confirmed) { + await effect.delete(); + } + } + + /** + * Handle sorting items + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onSortItems(event, target) { + const dataPath = target.dataset.dataPath; + const itemType = target.closest("[data-type]")?.dataset.type; + + if (!dataPath || !itemType) return; + + const items = this.document.items.filter((item) => item.type === itemType); + const sortedItems = this.sortItems(items, dataPath); + + const updates = sortedItems.map((item, index) => ({ _id: item.id, - sort: (i + 1) * CONST.SORT_INTEGER_DENSITY, + sort: index * 100, })); - this.actor.updateEmbeddedDocuments("Item", updates); + await this.document.updateEmbeddedDocuments("Item", updates); + } + + /** + * Sort items by a given property path + * @param {Item[]} items - The items to sort + * @param {string} dataPath - The property path to sort by + * @returns {Item[]} The sorted items + */ + sortItems(items, dataPath) { + return items.sort((a, b) => { + const aValue = foundry.utils.getProperty(a, dataPath); + const bValue = foundry.utils.getProperty(b, dataPath); + + if (typeof aValue === "string" && typeof bValue === "string") { + return aValue.localeCompare(bValue); + } else if (typeof aValue === "number" && typeof bValue === "number") { + return aValue - bValue; + } else { + return 0; + } + }); + } + + /** + * Handle tab changes manually for custom tab behavior + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onChangeTab(event, target) { + event.preventDefault(); + const tab = target.dataset.tab; + if (!tab) return; + + // Store the active tab + this.activeTab = tab; + + // Find tab navigation elements + const nav = target.closest(".ds4-sheet-tab-nav"); + const sheet = this.element.querySelector(".ds4-sheet-body"); + + if (!nav || !sheet) return; + + // Update navigation active state + nav.querySelectorAll(".ds4-sheet-tab-nav__item").forEach((item) => { + item.classList.remove("active"); + }); + target.classList.add("active"); + + // Update tab content visibility + sheet.querySelectorAll(".ds4-sheet-tab").forEach((tabContent) => { + tabContent.classList.remove("active"); + }); + + const activeTab = sheet.querySelector(`.ds4-sheet-tab[data-tab="${tab}"]`); + if (activeTab) { + activeTab.classList.add("active"); + } + } + + /** @override */ + async _onRender(context, options) { + await super._onRender(context, options); + + // Initialize first tab as active + this._initializeTabs(); + } + + /** + * Initialize tab state - show first tab, hide others + */ + _initializeTabs() { + const nav = this.element.querySelector(".ds4-sheet-tab-nav"); + const sheet = this.element.querySelector(".ds4-sheet-body"); + + if (!nav || !sheet) { + return; + } + + // Get all tab navigation items and tab content + const navItems = nav.querySelectorAll(".ds4-sheet-tab-nav__item"); + const tabContents = sheet.querySelectorAll(".ds4-sheet-tab"); + + // Remove active class from all items first + navItems.forEach((item) => item.classList.remove("active")); + tabContents.forEach((content) => content.classList.remove("active")); + + // Find the currently active tab or default to first + let targetTab = this.activeTab; + let targetNavItem = nav.querySelector(`[data-tab="${targetTab}"]`); + + // If stored tab doesn't exist, fall back to first tab + if (!targetNavItem) { + targetNavItem = navItems[0]; + targetTab = targetNavItem?.dataset.tab; + } + + // Set target tab navigation as active + if (targetNavItem && targetTab) { + targetNavItem.classList.add("active"); + + // Set corresponding tab content as active + const activeTabContent = sheet.querySelector(`.ds4-sheet-tab[data-tab="${targetTab}"]`); + if (activeTabContent) { + activeTabContent.classList.add("active"); + } + } + } + + /** + * Handle editing the actor's portrait image + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + static async _onEditImage(event, target) { + const field = target.dataset.field || "img"; + const current = foundry.utils.getProperty(this.document, field); + + const fp = new foundry.applications.apps.FilePicker({ + type: "image", + current: current, + callback: (path) => this.document.update({ [field]: path }), + }); + + return fp.browse(); } } - -/** - * This object contains information about specific properties embedded document list entries for each different type. - */ -const embeddedDocumentListEntryProperties = Object.freeze({ - ActiveEffect: { - selector: ".effect", - uuidDataAttribute: "effectUuid", - }, - Item: { - selector: ".item", - uuidDataAttribute: "itemUuid", - }, -}); - -/** - * Compare two stringifiables as strings. - * @param {{ toString(): string }} a The thing to compare with - * @param {{ toString(): string }} b The thing to compare - * @param {boolean} invert Should the comparison be inverted? - * @return {number} A number that indicates the result of the comparison - */ -const compareAsStrings = (a, b, invert) => { - return invert ? b.toString().localeCompare(a.toString()) : a.toString().localeCompare(b.toString()); -}; - -/** - * Compare two number. - * @param {number} a The number to compare with - * @param {number} b The number to compare - * @param {boolean} invert Should the comparison be inverted? - * @return {number} A number that indicates the result of the comparison - */ -const compareAsNumbers = (a, b, invert) => { - return invert ? b - a : a - b; -}; diff --git a/src/apps/actor/character-sheet.js b/src/apps/actor/character-sheet.js index 368fe176..715b345d 100644 --- a/src/apps/actor/character-sheet.js +++ b/src/apps/actor/character-sheet.js @@ -1,25 +1,34 @@ // SPDX-FileCopyrightText: 2021 Johannes Loher +// SPDX-FileCopyrightText: 2025 Alexander Minges // // SPDX-License-Identifier: MIT -import { DS4ActorSheet } from "./base-sheet"; +import { DS4ActorSheet } from "./base-sheet.js"; /** * The Sheet class for DS4 Character Actors */ export class DS4CharacterActorSheet extends DS4ActorSheet { - static get defaultOptions() { - return foundry.utils.mergeObject(super.defaultOptions, { - classes: ["sheet", "ds4-actor-sheet", "ds4-character-sheet"], - }); - } + static DEFAULT_OPTIONS = { + ...super.DEFAULT_OPTIONS, + classes: ["sheet", "ds4-actor-sheet", "ds4-character-sheet", "themed"], + }; /** @override */ - async getData(options = {}) { - const context = await super.getData(options); - context.data.system.profile.biography = await TextEditor.enrichHTML(context.data.system.profile.biography, { - async: true, - }); + async _prepareContext(options) { + const context = await super._prepareContext(options); + + // Enrich biography HTML content + if (context.data.system.profile.biography) { + context.data.system.profile.biography = await foundry.applications.ux.TextEditor.implementation.enrichHTML( + context.data.system.profile.biography, + { + async: true, + relativeTo: this.document, + }, + ); + } + return context; } } diff --git a/src/apps/actor/creature-sheet.js b/src/apps/actor/creature-sheet.js index fe89dec0..3c68de8e 100644 --- a/src/apps/actor/creature-sheet.js +++ b/src/apps/actor/creature-sheet.js @@ -1,25 +1,34 @@ // SPDX-FileCopyrightText: 2021 Johannes Loher +// SPDX-FileCopyrightText: 2025 Alexander Minges // // SPDX-License-Identifier: MIT -import { DS4ActorSheet } from "./base-sheet"; +import { DS4ActorSheet } from "./base-sheet.js"; /** * The Sheet class for DS4 Creature Actors */ export class DS4CreatureActorSheet extends DS4ActorSheet { - static get defaultOptions() { - return foundry.utils.mergeObject(super.defaultOptions, { - classes: ["sheet", "ds4-actor-sheet", "ds4-creature-sheet"], - }); - } + static DEFAULT_OPTIONS = { + ...super.DEFAULT_OPTIONS, + classes: ["sheet", "ds4-actor-sheet", "ds4-creature-sheet", "themed"], + }; /** @override */ - async getData(options = {}) { - const context = await super.getData(options); - context.data.system.baseInfo.description = await TextEditor.enrichHTML(context.data.system.baseInfo.description, { - async: true, - }); + async _prepareContext(options) { + const context = await super._prepareContext(options); + + // Enrich description HTML content + if (context.data.system.baseInfo.description) { + context.data.system.baseInfo.description = await foundry.applications.ux.TextEditor.implementation.enrichHTML( + context.data.system.baseInfo.description, + { + async: true, + relativeTo: this.document, + }, + ); + } + return context; } } diff --git a/src/apps/dialog-with-listeners.js b/src/apps/dialog-with-listeners.js index c9a3f2a2..b495b867 100644 --- a/src/apps/dialog-with-listeners.js +++ b/src/apps/dialog-with-listeners.js @@ -3,19 +3,129 @@ // SPDX-License-Identifier: MIT /** - * @typedef {DialogOptions} DialogWithListenersOptions - * @property {(html: JQuery, app: DialogWithListeners) => void} [activateAdditionalListeners] An optional function to attach additional listeners to the dialog + * A simple extension to the DialogV2 class that allows attaching additional listeners. */ +export class DialogWithListeners extends foundry.applications.api.DialogV2 { + constructor(options = {}) { + super(options); + this.activateAdditionalListeners = options.activateAdditionalListeners; + } + + static DEFAULT_OPTIONS = { + ...super.DEFAULT_OPTIONS, + classes: ["dialog", "dialog-with-listeners"], + tag: "dialog", + window: { + resizable: true, + }, + }; -/** - * A simple extension to the {@link Dialog} class that allows attaching additional listeners. - */ -export class DialogWithListeners extends Dialog { /** @override */ - activateListeners(html) { - super.activateListeners(html); - if (this.options.activateAdditionalListeners !== undefined) { - this.options.activateAdditionalListeners(html, this); + async _onRender(context, options) { + await super._onRender(context, options); + + // Attach additional listeners if provided + if (this.activateAdditionalListeners && typeof this.activateAdditionalListeners === "function") { + this.activateAdditionalListeners(this.element, this); } } + + /** + * Create a confirmation dialog using the V2 framework with additional listeners support + * @param {object} options - Dialog options + * @param {string} options.title - Dialog title (deprecated, use window.title) + * @param {object} options.window - Window configuration + * @param {string} options.window.title - Dialog title + * @param {string} options.content - Dialog content HTML + * @param {boolean} options.defaultYes - Whether "Yes" is the default button + * @param {Function} options.activateAdditionalListeners - Function to attach additional listeners + * @returns {Promise} True if confirmed, false if cancelled + */ + static async confirm(options = {}) { + const { title, window = {}, content, defaultYes = true, activateAdditionalListeners, ...rest } = options; + + // Handle backward compatibility with title parameter + if (title && !window.title) { + window.title = title; + } + + return new Promise((resolve) => { + const dialog = new DialogWithListeners({ + window: { + title: window.title || "Confirm", + ...window, + }, + content, + activateAdditionalListeners, + buttons: [ + { + action: "yes", + label: game.i18n.localize("Yes"), + icon: "fas fa-check", + default: defaultYes, + callback: () => resolve(true), + }, + { + action: "no", + label: game.i18n.localize("No"), + icon: "fas fa-times", + default: !defaultYes, + callback: () => resolve(false), + }, + ], + close: () => resolve(false), + ...rest, + }); + + dialog.render(true); + }); + } + + /** + * Create a prompt dialog using the V2 framework with additional listeners support + * @param {object} options - Dialog options + * @param {string} options.title - Dialog title (deprecated, use window.title) + * @param {object} options.window - Window configuration + * @param {string} options.window.title - Dialog title + * @param {string} options.content - Dialog content HTML + * @param {object} options.buttons - Button configuration + * @param {Function} options.activateAdditionalListeners - Function to attach additional listeners + * @returns {Promise} Promise that resolves with the result + */ + static async prompt(options = {}) { + const { title, window = {}, content, buttons = {}, activateAdditionalListeners, ...rest } = options; + + // Handle backward compatibility with title parameter + if (title && !window.title) { + window.title = title; + } + + return new Promise((resolve) => { + // Convert V1 button format to V2 format + const v2Buttons = Object.entries(buttons).map(([key, button]) => ({ + action: key, + label: button.label || key, + icon: button.icon || "", + default: button.default || false, + callback: (event) => { + const result = button.callback ? button.callback(event) : key; + resolve(result); + }, + })); + + const dialog = new DialogWithListeners({ + window: { + title: window.title || "Dialog", + ...window, + }, + content, + activateAdditionalListeners, + buttons: v2Buttons, + close: () => resolve(null), + ...rest, + }); + + dialog.render(true); + }); + } } diff --git a/src/apps/item-sheet.js b/src/apps/item-sheet.js index 1fc6f0aa..8ff85ee7 100644 --- a/src/apps/item-sheet.js +++ b/src/apps/item-sheet.js @@ -1,27 +1,49 @@ // SPDX-FileCopyrightText: 2021 Johannes Loher // SPDX-FileCopyrightText: 2021 Oliver Rümpelein // SPDX-FileCopyrightText: 2021 Gesina Schwalbe +// SPDX-FileCopyrightText: 2025 Alexander Minges // // SPDX-License-Identifier: MIT -import { DS4 } from "../config"; -import { DS4ActiveEffect } from "../documents/active-effect"; -import { enforce, getGame } from "../utils/utils"; -import { disableOverriddenFields } from "./sheet-helpers"; - /** * The Sheet class for DS4 Items */ -export class DS4ItemSheet extends ItemSheet { - /** @override */ - static get defaultOptions() { - return foundry.utils.mergeObject(super.defaultOptions, { - classes: ["sheet", "ds4-item-sheet"], +export class DS4ItemSheet extends foundry.applications.api.HandlebarsApplicationMixin( + foundry.applications.sheets.ItemSheetV2, +) { + static DEFAULT_OPTIONS = { + classes: ["sheet", "ds4-item-sheet", "themed"], + tag: "form", + form: { + submitOnChange: true, + closeOnSubmit: false, + }, + position: { + width: 560, height: 400, - scrollY: [".ds4-sheet-body"], - tabs: [{ navSelector: ".ds4-sheet-tab-nav", contentSelector: ".ds4-sheet-body", initial: "description" }], - width: 540, - }); + }, + window: { + resizable: true, + }, + actions: { + controlEffect: DS4ItemSheet.prototype._onControlEffect, + createEffect: DS4ItemSheet.prototype._onCreateEffect, + editEffect: DS4ItemSheet.prototype._onEditEffect, + deleteEffect: DS4ItemSheet.prototype._onDeleteEffect, + changeTab: DS4ItemSheet.prototype._onChangeTab, + editImage: DS4ItemSheet.prototype._onEditImage, + }, + }; + + static TABS = {}; + + constructor(options = {}) { + super(options); + this.activeTab = "description"; // Default active tab + } + + get title() { + return `${this.item.name} [${game.i18n.localize("DS4.ItemSheet")}]`; } /** @override */ @@ -31,120 +53,270 @@ export class DS4ItemSheet extends ItemSheet { } /** @override */ - async getData(options = {}) { - const superContext = await super.getData(options); - superContext.data.system.description = await TextEditor.enrichHTML(superContext.data.system.description, { - async: true, - }); - const context = { - ...superContext, - config: DS4, - isOwned: this.item.isOwned, - actor: this.item.actor, - }; + async _renderHTML(context) { + return await foundry.applications.handlebars.renderTemplate(this.template, context); + } + + /** @override */ + _replaceHTML(result, content) { + content.innerHTML = result; + } + + /** @override */ + async _prepareContext(options) { + const context = await super._prepareContext(options); + + // Validate document exists + if (!this.item) { + throw new Error("Item not available for sheet rendering"); + } + + // Add document data + context.data = this.item; + context.system = this.item.system; + context.source = this.item.toObject(); + context.cssClass = this.item.constructor.name.toLowerCase(); + context.editable = this.isEditable; + context.owner = this.item.isOwner; + context.isOwned = this.item.isOwned; + context.actor = this.actor; + + // Add configuration + context.config = CONFIG.DS4; + + // Add enriched effects + context.enrichedEffects = []; + if (this.item.effects && this.item.effects.size > 0) { + for (const effect of this.item.effects) { + const enrichedEffect = effect.toObject(); + enrichedEffect.id = effect.id; + enrichedEffect.uuid = effect.uuid; + enrichedEffect.sourceName = effect.sourceName; + context.enrichedEffects.push(enrichedEffect); + } + } + + // Enrich description content for display + if (this.item.system.description) { + context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML( + this.item.system.description, + { + secrets: this.item.isOwner, + relativeTo: this.item, + }, + ); + } else { + context.enrichedDescription = ""; + } + return context; } - /** @override */ - _getSubmitData(updateData = {}) { - const data = super._getSubmitData(updateData); + /** + * Process form data for submission + * @param {Event} event - The form submission event + * @param {HTMLFormElement} form - The form element + * @param {FormDataExtended} formData - The form data + * @returns {object} The processed form data + */ + _processFormData(event, form, formData) { + const submitData = foundry.utils.expandObject(formData.object); + // Prevent submitting overridden values const overrides = foundry.utils.flattenObject(this.item.overrides); for (const k of Object.keys(overrides)) { - delete data[k]; + foundry.utils.setProperty(submitData, k, undefined); + delete submitData[k]; } - return data; + + return submitData; + } + + /** + * Handle effect control actions + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onControlEffect(event, target) { + const action = target.dataset.action; + const effectId = target.closest("[data-effect-id]")?.dataset.effectId; + + if (!effectId) return; + + const effect = this.item.effects.get(effectId); + if (!effect) return; + + switch (action) { + case "edit": + await this._onEditEffect(event, target); + break; + case "delete": + await this._onDeleteEffect(event, target); + break; + } + } + + /** + * Handle creating a new effect + */ + async _onCreateEffect() { + const effectData = { + name: game.i18n.localize("DS4.NewEffectName"), + icon: "icons/svg/aura.svg", + }; + + await this.item.createEmbeddedDocuments("ActiveEffect", [effectData]); + } + + /** + * Handle editing an effect + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onEditEffect(event, target) { + const effectId = target.closest("[data-effect-id]")?.dataset.effectId; + if (!effectId) return; + + const effect = this.item.effects.get(effectId); + if (!effect) { + throw new Error( + game.i18n.format("DS4.ErrorItemDoesNotHaveEffect", { + id: effectId, + item: this.item.name, + }), + ); + } + + await effect.sheet.render(true); + } + + /** + * Handle deleting an effect + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onDeleteEffect(event, target) { + const effectId = target.closest("[data-effect-id]")?.dataset.effectId; + if (!effectId) return; + + const effect = this.item.effects.get(effectId); + if (!effect) return; + + const confirmed = await foundry.applications.api.DialogV2.confirm({ + window: { title: game.i18n.localize("DS4.UserInteractionDeleteEffectTitle") }, + content: game.i18n.format("DS4.UserInteractionDeleteEffectContent", { effect: effect.name }), + defaultYes: false, + }); + + if (confirmed) { + await effect.delete(); + } + } + + /** + * Handle tab changes manually for custom tab behavior + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onChangeTab(event, target) { + event.preventDefault(); + const tab = target.dataset.tab; + if (!tab) return; + + // Store the active tab + this.activeTab = tab; + + // Find tab navigation elements + const nav = target.closest(".ds4-sheet-tab-nav"); + const sheet = this.element.querySelector(".ds4-sheet-body"); + + if (!nav || !sheet) return; + + // Update navigation active state + nav.querySelectorAll(".ds4-sheet-tab-nav__item").forEach((item) => { + item.classList.remove("active"); + }); + target.classList.add("active"); + + // Update tab content visibility + sheet.querySelectorAll(".ds4-sheet-tab").forEach((tabContent) => { + tabContent.classList.remove("active"); + }); + + const activeTab = sheet.querySelector(`.ds4-sheet-tab[data-tab="${tab}"]`); + if (activeTab) { + activeTab.classList.add("active"); + } + } + + /** + * Handle editing the items's image + * @param {Event} event - The triggering event + * @param {HTMLElement} target - The target element + */ + async _onEditImage(event, target) { + const field = target.dataset.field || "img"; + const current = foundry.utils.getProperty(this.item, field); + + const fp = new foundry.applications.apps.FilePicker({ + type: "image", + current: current, + callback: (path) => this.item.update({ [field]: path }), + }); + return fp.browse(); } /** @override */ - setPosition(options = {}) { - const position = super.setPosition(options); - if (position) { - const sheetBody = this.element.find(".sheet-body"); - const bodyHeight = position.height - 192; - sheetBody.css("height", bodyHeight); + async _onRender(context, options) { + await super._onRender(context, options); + + // Initialize first tab as active + this._initializeTabs(); + } + + /** @override */ + async _onClose(options) { + await super._onClose(options); + } + + /** + * Initialize tab state - show first tab, hide others + */ + _initializeTabs() { + const nav = this.element.querySelector(".ds4-sheet-tab-nav"); + const sheet = this.element.querySelector(".ds4-sheet-body"); + + if (!nav || !sheet) { + return; } - return position; - } + // Get all tab navigation items and tab content + const navItems = nav.querySelectorAll(".ds4-sheet-tab-nav__item"); + const tabContents = sheet.querySelectorAll(".ds4-sheet-tab"); - /** - * @override - * @param {JQuery} html - */ - activateListeners(html) { - super.activateListeners(html); + // Remove active class from all items first + navItems.forEach((item) => item.classList.remove("active")); + tabContents.forEach((content) => content.classList.remove("active")); - if (!this.options.editable) return; + // Find the currently active tab or default to first + let targetTab = this.activeTab; + let targetNavItem = nav.querySelector(`[data-tab="${targetTab}"]`); - html.find(".control-effect").on("click", this.onControlEffect.bind(this)); - - disableOverriddenFields(this.form, this.item.overrides, (key) => `[name="${key}"]`); - } - - /** - * Handles a click on an element of this sheet to control an embedded effect of the item corresponding to this - * sheet. - * - * @param {JQuery.ClickEvent} event The originating click event - * @protected - */ - onControlEffect(event) { - event.preventDefault(); - const a = event.currentTarget; - switch (a.dataset["action"]) { - case "create": - return this.onCreateEffect(); - case "edit": - return this.onEditEffect(event); - case "delete": - return this.onDeleteEffect(event); + // If stored tab doesn't exist, fall back to first tab + if (!targetNavItem) { + targetNavItem = navItems[0]; + targetTab = targetNavItem?.dataset.tab; } - } - /** - * Creates a new embedded effect. - * @protected - */ - onCreateEffect() { - DS4ActiveEffect.createDefault(this.item); - } + // Set target tab navigation as active + if (targetNavItem && targetTab) { + targetNavItem.classList.add("active"); - /** - * Opens the sheet of the embedded effect corresponding to the clicked element. - * - * @param {JQuery.ClickEvent} event The originating click event - * @porotected - */ - onEditEffect(event) { - const id = $(event.currentTarget) - .parents(embeddedDocumentListEntryProperties.ActiveEffect.selector) - .data(embeddedDocumentListEntryProperties.ActiveEffect.idDataAttribute); - const effect = this.item.effects.get(id); - enforce(effect, getGame().i18n.format("DS4.ErrorItemDoesNotHaveEffect", { id, item: this.item.name })); - effect.sheet?.render(true); - } - - /** - * Deletes the embedded item corresponding to the clicked element. - * - * @param {JQuery.ClickEvent} event The originating click event - * @protected - */ - onDeleteEffect(event) { - const li = $(event.currentTarget).parents(embeddedDocumentListEntryProperties.ActiveEffect.selector); - const id = li.data(embeddedDocumentListEntryProperties.ActiveEffect.idDataAttribute); - this.item.deleteEmbeddedDocuments("ActiveEffect", [id]); - li.slideUp(200, () => this.render(false)); + // Set corresponding tab content as active + const activeTabContent = sheet.querySelector(`.ds4-sheet-tab[data-tab="${targetTab}"]`); + if (activeTabContent) { + activeTabContent.classList.add("active"); + } + } } } - -/** - * This object contains information about specific properties embedded document list entries for each different type. - */ -const embeddedDocumentListEntryProperties = Object.freeze({ - ActiveEffect: { - selector: ".effect", - idDataAttribute: "effectId", - }, -}); diff --git a/src/apps/ruler/token-ruler.js b/src/apps/ruler/token-ruler.js new file mode 100644 index 00000000..467ffa0d --- /dev/null +++ b/src/apps/ruler/token-ruler.js @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2025 Alexander Minges +// +// SPDX-License-Identifier: MIT + +/** + * DS4 Token Ruler implementation with color-coded movement ranges + * Based on actor movement combat value + */ +export class DS4TokenRuler extends foundry.canvas.placeables.tokens.TokenRuler { + static WAYPOINT_LABEL_TEMPLATE = "systems/ds4/templates/partials/waypoint-label.hbs"; + + /** + * Enhance waypoint label context with movement range information + * @param {object} waypoint - The waypoint data + * @param {object} state - The current ruler state + * @returns {object} Enhanced context with range class + */ + _getWaypointLabelContext(waypoint, state) { + const context = super._getWaypointLabelContext(waypoint, state); + + // Only apply movement coloring for distance measurements in meters + if (context?.cost?.units === "m" || context?.distance?.units === "m") { + const movement = this.token?.actor?.system?.combatValues?.movement?.total; + + if (movement) { + const total = Number(context.cost?.total || context.distance?.total || 0); + + // DS4 movement rules: + // - Normal movement: up to movement value + // - Dash: up to 2x movement value (requires action) + // - Beyond 2x: impossible in single turn + if (total > 2 * movement) { + context.rangeClass = "out-of-range"; + } else if (total <= movement) { + context.rangeClass = "move-range"; + } else { + context.rangeClass = "dash-range"; + } + } + } + + return context; + } +} diff --git a/src/dice/check-factory.js b/src/dice/check-factory.js index 6063ca98..250dcd83 100644 --- a/src/dice/check-factory.js +++ b/src/dice/check-factory.js @@ -111,6 +111,11 @@ export async function createCheckRoll(checkTargetNumber, options = {}) { // Ask for additional required data; const interactiveRollData = await askForInteractiveRollData(checkTargetNumber, options); + // Handle cancelled dialog + if (!interactiveRollData) { + return undefined; + } + const newTargetValue = interactiveRollData.checkTargetNumber ?? checkTargetNumber; const checkModifier = interactiveRollData.checkModifier ?? 0; /** @type {Partial} */ @@ -166,63 +171,69 @@ async function askForInteractiveRollData(checkTargetNumber, options = {}, { temp }), id, }; - const renderedHtml = await renderTemplate(usedTemplate, templateData); + const renderedHtml = await foundry.applications.handlebars.renderTemplate(usedTemplate, templateData); const dialogPromise = new Promise((resolve) => { - new DialogWithListeners( - { + new DialogWithListeners({ + window: { title: usedTitle, - content: renderedHtml, - buttons: { - ok: { - icon: '', - label: getGame().i18n.localize("DS4.GenericOkButton"), - callback: (html) => { - if (!("jquery" in html)) { - throw new Error( - getGame().i18n.format("DS4.ErrorUnexpectedHtmlType", { - exType: "JQuery", - realType: "HTMLElement", - }), - ); - } else { - const innerForm = html[0]?.querySelector("form"); - if (!innerForm) { - throw new Error( - getGame().i18n.format("DS4.ErrorCouldNotFindHtmlElement", { - htmlElement: "form", - }), - ); - } - resolve(innerForm); - } - }, - }, - cancel: { - icon: '', - label: getGame().i18n.localize("DS4.GenericCancelButton"), + }, + content: renderedHtml, + buttons: [ + { + action: "ok", + icon: "fas fa-check", + label: getGame().i18n.localize("DS4.GenericOkButton"), + default: true, + callback: (event) => { + const dialog = event.target.closest("dialog"); + const innerForm = dialog?.querySelector("form"); + if (!innerForm) { + throw new Error( + getGame().i18n.format("DS4.ErrorCouldNotFindHtmlElement", { + htmlElement: "form", + }), + ); + } + resolve(innerForm); }, }, - default: "ok", - }, - { - activateAdditionalListeners: (html, app) => { - const checkModifierCustomFormGroup = html.find(`#check-modifier-custom-${id}`).parent(".form-group"); - html.find(`#check-modifier-${id}`).on("change", (event) => { - if (event.currentTarget.value === "custom" && checkModifierCustomFormGroup.hasClass("ds4-hidden")) { - checkModifierCustomFormGroup.removeClass("ds4-hidden"); + { + action: "cancel", + icon: "fas fa-times", + label: getGame().i18n.localize("DS4.GenericCancelButton"), + callback: () => resolve(null), + }, + ], + close: () => resolve(null), + activateAdditionalListeners: (html, app) => { + const checkModifierCustomFormGroup = html.querySelector(`#check-modifier-custom-${id}`)?.closest(".form-group"); + const checkModifierSelect = html.querySelector(`#check-modifier-${id}`); + + if (checkModifierSelect) { + checkModifierSelect.addEventListener("change", (event) => { + if ( + event.currentTarget.value === "custom" && + checkModifierCustomFormGroup?.classList.contains("ds4-hidden") + ) { + checkModifierCustomFormGroup.classList.remove("ds4-hidden"); app.setPosition({ height: "auto" }); - } else if (!checkModifierCustomFormGroup.hasClass("ds4-hidden")) { - checkModifierCustomFormGroup.addClass("ds4-hidden"); + } else if (checkModifierCustomFormGroup && !checkModifierCustomFormGroup.classList.contains("ds4-hidden")) { + checkModifierCustomFormGroup.classList.add("ds4-hidden"); app.setPosition({ height: "auto" }); } }); - }, - id, + } }, - ).render(true); + }).render(true); }); const dialogForm = await dialogPromise; + + // Handle cancelled dialog + if (!dialogForm) { + return null; + } + return parseDialogFormData(dialogForm); } @@ -232,6 +243,11 @@ async function askForInteractiveRollData(checkTargetNumber, options = {}, { temp * @returns {Partial} */ function parseDialogFormData(formData) { + // Handle cancelled dialog + if (!formData) { + return {}; + } + const chosenCheckTargetNumber = parseInt(formData["check-target-number"]?.value); const chosenCheckModifier = formData["check-modifier"]?.value; diff --git a/src/dice/roll.js b/src/dice/roll.js index bc632a07..833f6b19 100644 --- a/src/dice/roll.js +++ b/src/dice/roll.js @@ -29,6 +29,6 @@ export class DS4Roll extends Roll { isCoup: isPrivate ? null : isCoup, isFumble: isPrivate ? null : isFumble, }; - return renderTemplate(template, chatData); + return foundry.applications.handlebars.renderTemplate(template, chatData); } } diff --git a/src/documents/actor/actor.js b/src/documents/actor/actor.js index c1f4d256..0725a444 100644 --- a/src/documents/actor/actor.js +++ b/src/documents/actor/actor.js @@ -1,5 +1,6 @@ // SPDX-FileCopyrightText: 2021 Johannes Loher // SPDX-FileCopyrightText: 2021 Oliver RÜmpelein +// SPDX-FileCopyrightText: 2025 Alexander Minges // // SPDX-License-Identifier: MIT @@ -463,60 +464,65 @@ export class DS4Actor extends Actor { async selectAttributeAndTrait() { const attributeIdentifier = "attribute-trait-selection-attribute"; const traitIdentifier = "attribute-trait-selection-trait"; - return Dialog.prompt({ - title: getGame().i18n.localize("DS4.DialogAttributeTraitSelection"), - content: await renderTemplate("systems/ds4/templates/dialogs/simple-select-form.hbs", { - selects: [ - { - label: getGame().i18n.localize("DS4.Attribute"), - identifier: attributeIdentifier, - options: Object.fromEntries( - Object.entries(DS4.i18n.attributes).map(([attribute, translation]) => [ - attribute, - `${translation} (${this.system.attributes[attribute].total})`, - ]), - ), - }, - { - label: getGame().i18n.localize("DS4.Trait"), - identifier: traitIdentifier, - options: Object.fromEntries( - Object.entries(DS4.i18n.traits).map(([trait, translation]) => [ - trait, - `${translation} (${this.system.traits[trait].total})`, - ]), - ), - }, - ], - }), - label: getGame().i18n.localize("DS4.GenericOkButton"), - callback: (html) => { - const selectedAttribute = html.find(`#${attributeIdentifier}`).val(); - if (!isAttribute(selectedAttribute)) { - throw new Error( - getGame().i18n.format("DS4.ErrorUnexpectedAttribute", { - actualAttribute: selectedAttribute, - expectedTypes: Object.keys(DS4.i18n.attributes) - .map((attribute) => `'${attribute}'`) - .join(", "), - }), - ); - } - const selectedTrait = html.find(`#${traitIdentifier}`).val(); - if (!isTrait(selectedTrait)) { - throw new Error( - getGame().i18n.format("DS4.ErrorUnexpectedTrait", { - actualTrait: selectedTrait, - expectedTypes: Object.keys(DS4.i18n.traits) - .map((attribute) => `'${attribute}'`) - .join(", "), - }), - ); - } - return { - attribute: selectedAttribute, - trait: selectedTrait, - }; + return foundry.applications.api.DialogV2.prompt({ + window: { title: getGame().i18n.localize("DS4.DialogAttributeTraitSelection") }, + content: await foundry.applications.handlebars.renderTemplate( + "systems/ds4/templates/dialogs/simple-select-form.hbs", + { + selects: [ + { + label: getGame().i18n.localize("DS4.Attribute"), + identifier: attributeIdentifier, + options: Object.fromEntries( + Object.entries(DS4.i18n.attributes).map(([attribute, translation]) => [ + attribute, + `${translation} (${this.system.attributes[attribute].total})`, + ]), + ), + }, + { + label: getGame().i18n.localize("DS4.Trait"), + identifier: traitIdentifier, + options: Object.fromEntries( + Object.entries(DS4.i18n.traits).map(([trait, translation]) => [ + trait, + `${translation} (${this.system.traits[trait].total})`, + ]), + ), + }, + ], + }, + ), + ok: { + label: getGame().i18n.localize("DS4.GenericOkButton"), + callback: (_event, button) => { + const selectedAttribute = button.form.elements[attributeIdentifier].value; + if (!isAttribute(selectedAttribute)) { + throw new Error( + getGame().i18n.format("DS4.ErrorUnexpectedAttribute", { + actualAttribute: selectedAttribute, + expectedTypes: Object.keys(DS4.i18n.attributes) + .map((attribute) => `'${attribute}'`) + .join(", "), + }), + ); + } + const selectedTrait = button.form.elements[traitIdentifier].value; + if (!isTrait(selectedTrait)) { + throw new Error( + getGame().i18n.format("DS4.ErrorUnexpectedTrait", { + actualTrait: selectedTrait, + expectedTypes: Object.keys(DS4.i18n.traits) + .map((attribute) => `'${attribute}'`) + .join(", "), + }), + ); + } + return { + attribute: selectedAttribute, + trait: selectedTrait, + }; + }, }, rejectClose: false, }); diff --git a/src/documents/item/weapon/weapon.js b/src/documents/item/weapon/weapon.js index 09a734d0..3a96ddac 100644 --- a/src/documents/item/weapon/weapon.js +++ b/src/documents/item/weapon/weapon.js @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2022 Johannes Loher +// SPDX-FileCopyrightText: 2025 Alexander Minges // // SPDX-License-Identifier: MIT @@ -83,29 +84,34 @@ export class DS4Weapon extends DS4Item { const { melee, ranged } = { ...DS4.i18n.attackTypes }; const identifier = `attack-type-selection-${foundry.utils.randomID()}`; - return Dialog.prompt({ - title: getGame().i18n.localize("DS4.DialogAttackTypeSelection"), - content: await renderTemplate("systems/ds4/templates/dialogs/simple-select-form.hbs", { - selects: [ - { - label: getGame().i18n.localize("DS4.AttackType"), - identifier, - options: { melee, ranged }, - }, - ], - }), - label: getGame().i18n.localize("DS4.GenericOkButton"), - callback: (html) => { - const selectedAttackType = html.find(`#${identifier}`).val(); - if (selectedAttackType !== "melee" && selectedAttackType !== "ranged") { - throw new Error( - getGame().i18n.format("DS4.ErrorUnexpectedAttackType", { - actualType: selectedAttackType, - expectedTypes: "'melee', 'ranged'", - }), - ); - } - return selectedAttackType; + return foundry.applications.api.DialogV2.prompt({ + window: { title: getGame().i18n.localize("DS4.DialogAttackTypeSelection") }, + content: await foundry.applications.handlebars.renderTemplate( + "systems/ds4/templates/dialogs/simple-select-form.hbs", + { + selects: [ + { + label: getGame().i18n.localize("DS4.AttackType"), + identifier, + options: { melee, ranged }, + }, + ], + }, + ), + ok: { + label: getGame().i18n.localize("DS4.GenericOkButton"), + callback: (_event, button) => { + const selectedAttackType = button.form.elements[identifier].value; + if (selectedAttackType !== "melee" && selectedAttackType !== "ranged") { + throw new Error( + getGame().i18n.format("DS4.ErrorUnexpectedAttackType", { + actualType: selectedAttackType, + expectedTypes: "'melee', 'ranged'", + }), + ); + } + return selectedAttackType; + }, }, }); } diff --git a/src/handlebars/handlebars-helpers.ts b/src/handlebars/handlebars-helpers.ts index 015064a4..5f9dcbc9 100644 --- a/src/handlebars/handlebars-helpers.ts +++ b/src/handlebars/handlebars-helpers.ts @@ -14,6 +14,11 @@ const helpers = { isEmpty: (input: Array | null | undefined): boolean => (input?.length ?? 0) === 0, + capitalize: (str: string): string => { + if (typeof str !== "string") return ""; + return str.charAt(0).toUpperCase() + str.slice(1); + }, + toRomanNumerals, }; diff --git a/src/handlebars/handlebars-partials.js b/src/handlebars/handlebars-partials.js index 747c13c4..0e7aec6e 100644 --- a/src/handlebars/handlebars-partials.js +++ b/src/handlebars/handlebars-partials.js @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2021 Johannes Loher // SPDX-FileCopyrightText: 2021 Oliver Rümpelein // SPDX-FileCopyrightText: 2021 Gesina Schwalbe +// SPDX-FileCopyrightText: 2025 Alexander Minges // // SPDX-License-Identifier: MIT @@ -55,6 +56,7 @@ export async function registerHandlebarsPartials() { "systems/ds4/templates/sheets/shared/components/add-button.hbs", "systems/ds4/templates/sheets/shared/components/control-button-group.hbs", "systems/ds4/templates/sheets/shared/components/rollable-image.hbs", + "systems/ds4/templates/partials/waypoint-label.hbs", ]; - await loadTemplates(templatePaths); + await foundry.applications.handlebars.loadTemplates(templatePaths); } diff --git a/src/hooks/init.js b/src/hooks/init.js index 199b32bd..a063a9f5 100644 --- a/src/hooks/init.js +++ b/src/hooks/init.js @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2021 Johannes Loher // SPDX-FileCopyrightText: 2021 Oliver Rümpelein // SPDX-FileCopyrightText: 2021 Gesina Schwalbe +// SPDX-FileCopyrightText: 2025 Alexander Minges // // SPDX-License-Identifier: MIT @@ -8,6 +9,7 @@ import { DS4ActiveEffectConfig } from "../apps/active-effect-config"; import { DS4CharacterActorSheet } from "../apps/actor/character-sheet"; import { DS4CreatureActorSheet } from "../apps/actor/creature-sheet"; import { DS4ItemSheet } from "../apps/item-sheet"; +import { DS4TokenRuler } from "../apps/ruler/token-ruler"; import { DS4 } from "../config"; import { DS4Check } from "../dice/check"; import { createCheckRoll } from "../dice/check-factory"; @@ -51,6 +53,8 @@ async function init() { CONFIG.ChatMessage.documentClass = DS4ChatMessage; CONFIG.Token.documentClass = DS4TokenDocument; + CONFIG.Token.rulerClass = DS4TokenRuler; + CONFIG.ActiveEffect.legacyTransferral = false; CONFIG.Actor.typeLabels = DS4.i18n.actorTypes; @@ -65,16 +69,23 @@ async function init() { registerSystemSettings(); - DocumentSheetConfig.unregisterSheet(Actor, "core", ActorSheet); - DocumentSheetConfig.registerSheet(Actor, "ds4", DS4CharacterActorSheet, { + foundry.applications.apps.DocumentSheetConfig.registerSheet(Actor, "ds4", DS4CharacterActorSheet, { types: ["character"], makeDefault: true, }); - DocumentSheetConfig.registerSheet(Actor, "ds4", DS4CreatureActorSheet, { types: ["creature"], makeDefault: true }); - DocumentSheetConfig.unregisterSheet(Item, "core", ItemSheet); - DocumentSheetConfig.registerSheet(Item, "ds4", DS4ItemSheet, { makeDefault: true }); - DocumentSheetConfig.unregisterSheet(ActiveEffect, "core", ActiveEffectConfig); - DocumentSheetConfig.registerSheet(ActiveEffect, "ds4", DS4ActiveEffectConfig, { makeDefault: true }); + foundry.applications.apps.DocumentSheetConfig.registerSheet(Actor, "ds4", DS4CreatureActorSheet, { + types: ["creature"], + makeDefault: true, + }); + foundry.applications.apps.DocumentSheetConfig.registerSheet(Item, "ds4", DS4ItemSheet, { makeDefault: true }); + foundry.applications.apps.DocumentSheetConfig.unregisterSheet( + ActiveEffect, + "core", + foundry.applications.sheets.ActiveEffectConfig, + ); + foundry.applications.apps.DocumentSheetConfig.registerSheet(ActiveEffect, "ds4", DS4ActiveEffectConfig, { + makeDefault: true, + }); preloadFonts(); await registerHandlebarsPartials(); diff --git a/src/hooks/render.js b/src/hooks/render.js index c8028dbf..ad5224c3 100644 --- a/src/hooks/render.js +++ b/src/hooks/render.js @@ -17,10 +17,18 @@ export function registerForRenderHooks() { * Select the text of input elements in given application when focused via an on focus listener. * * @param {Application} app The application in which to activate the listener. - * @param {JQuery} html The {@link JQuery} representing the HTML of the application. + * @param {HTMLElement} element The HTML element representing the HTML of the application. */ -function selectTargetInputOnFocus(app, html) { - html.find("input").on("focus", (ev) => { - ev.currentTarget.select(); +function selectTargetInputOnFocus(app, element) { + // V13: element is always a plain DOM element + if (!element || typeof element.querySelectorAll !== "function") { + return; + } + + const inputs = element.querySelectorAll("input"); + inputs.forEach((input) => { + input.addEventListener("focus", (ev) => { + ev.currentTarget.select(); + }); }); } diff --git a/src/utils/utils.js b/src/utils/utils.js index f7fec6cb..b7979b64 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -30,7 +30,10 @@ export function enforce(value, message) { * @returns {Canvas} */ export function getCanvas() { - enforce(canvas instanceof Canvas && canvas.ready, getGame().i18n.localize("DS4.ErrorCanvasIsNotInitialized")); + enforce( + canvas instanceof foundry.canvas.Canvas && canvas.ready, + getGame().i18n.localize("DS4.ErrorCanvasIsNotInitialized"), + ); return canvas; } @@ -40,7 +43,7 @@ export function getCanvas() { * @returns {Game} */ export function getGame() { - enforce(game instanceof Game, "Game is not initialized yet."); + enforce(game instanceof foundry.Game, "Game is not initialized yet."); return game; } diff --git a/system.json b/system.json index 36245e6f..8751cb40 100644 --- a/system.json +++ b/system.json @@ -32,7 +32,7 @@ "readme": "https://git.f3l.de/dungeonslayers/ds4/raw/tag/2.0.5/README.md", "bugs": "https://git.f3l.de/dungeonslayers/ds4/issues", "changelog": "https://git.f3l.de/dungeonslayers/ds4/releases/tag/2.0.5", - "version": "2.0.5", + "version": "3.0.0", "flags": { "hotReload": { "extensions": ["css", "hbs", "json"], @@ -40,8 +40,8 @@ } }, "compatibility": { - "minimum": "12.331", - "verified": "12" + "minimum": "13", + "verified": "13.346" }, "esmodules": ["ds4.js"], "styles": ["css/ds4.css"], diff --git a/templates/dice/roll.hbs b/templates/dice/roll.hbs index 8afcbd10..9b9fc28f 100644 --- a/templates/dice/roll.hbs +++ b/templates/dice/roll.hbs @@ -4,7 +4,7 @@ SPDX-FileCopyrightText: 2021 Johannes Loher SPDX-License-Identifier: MIT --}} -
+
{{#if flavor}}
{{flavor}}
{{/if}} diff --git a/templates/partials/waypoint-label.hbs b/templates/partials/waypoint-label.hbs new file mode 100644 index 00000000..90639703 --- /dev/null +++ b/templates/partials/waypoint-label.hbs @@ -0,0 +1,43 @@ +{{!-- +SPDX-FileCopyrightText: 2025 Alexander Minges + +SPDX-License-Identifier: MIT +--}} + +{{!-- +!-- Waypoint label template with color-coded movement ranges +!-- Based on DS4 movement combat values +--}} +
+ {{#if (eq rangeClass "dash-range")}} + + {{else if (eq rangeClass "out-of-range")}} + + {{else if action.icon}} + + {{else if action.label}} + {{localize action.label}} + {{/if}} + {{#if cost}} + {{cost.total}} {{cost.units}} + {{#if cost.delta}} + ({{cost.delta}}) + {{/if}} + {{else}} + {{distance.total}} {{units}} + {{#if distance.delta}} + ({{distance.delta}}) + {{/if}} + {{/if}} + {{#if (and elevation (not elevation.hidden))}} + + {{elevation.total}} {{units}} + {{#if elevation.delta}} + ({{elevation.delta}}) + {{/if}} + + {{/if}} + {{#if secret}} + + {{/if}} +
diff --git a/templates/sheets/active-effect/active-effect-config.hbs b/templates/sheets/active-effect/active-effect-config.hbs index 5a52247a..cccde11a 100644 --- a/templates/sheets/active-effect/active-effect-config.hbs +++ b/templates/sheets/active-effect/active-effect-config.hbs @@ -32,8 +32,18 @@ SPDX-License-Identifier: MIT
- {{editor descriptionHTML target="description" button=false editable=editable engine="prosemirror" - collaborate=false}} + {{#if editable}} + + {{{descriptionHTML}}} + + {{else}} + {{{descriptionHTML}}} + {{/if}}
diff --git a/templates/sheets/actor/character-sheet.hbs b/templates/sheets/actor/character-sheet.hbs index 556b4c1e..33322d79 100644 --- a/templates/sheets/actor/character-sheet.hbs +++ b/templates/sheets/actor/character-sheet.hbs @@ -14,12 +14,12 @@ SPDX-License-Identifier: MIT {{!-- Sheet Tab Navigation --}} diff --git a/templates/sheets/actor/components/actor-header.hbs b/templates/sheets/actor/components/actor-header.hbs index c6b13c78..8fc9d602 100644 --- a/templates/sheets/actor/components/actor-header.hbs +++ b/templates/sheets/actor/components/actor-header.hbs @@ -11,7 +11,7 @@ SPDX-License-Identifier: MIT !-- @param @partial-block: Properties to render in the second header row. --}}
- {{localize 'DS4.ActorImageAltText'}}
diff --git a/templates/sheets/actor/components/biography.hbs b/templates/sheets/actor/components/biography.hbs index b8e1befb..e98bf0a8 100644 --- a/templates/sheets/actor/components/biography.hbs +++ b/templates/sheets/actor/components/biography.hbs @@ -5,6 +5,16 @@ SPDX-License-Identifier: MIT --}}
- {{editor data.system.profile.biography target="system.profile.biography" button=true owner=owner - editable=editable engine="prosemirror"}} + {{#if editable}} + + {{{data.system.profile.biography}}} + + {{else}} + {{{data.system.profile.biography}}} + {{/if}}
diff --git a/templates/sheets/actor/components/check.hbs b/templates/sheets/actor/components/check.hbs index 44e50266..ecd8669c 100644 --- a/templates/sheets/actor/components/check.hbs +++ b/templates/sheets/actor/components/check.hbs @@ -12,7 +12,7 @@ SPDX-License-Identifier: MIT !-- @param check-label: The label for the check --}} -