diff --git a/.editorconfig b/.editorconfig index 12b53f83..ba4931db 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,6 +8,6 @@ root = true end_of_line = lf insert_final_newline = true indent_style = space -indent_size = 2 +indent_size = 4 charset = utf-8 trim_trailing_whitespace = true diff --git a/lang/de.json b/lang/de.json index c5176813..7d49ef08 100644 --- a/lang/de.json +++ b/lang/de.json @@ -3,11 +3,9 @@ "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", @@ -184,9 +182,7 @@ "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 8b032343..847f0aea 100644 --- a/lang/en.json +++ b/lang/en.json @@ -3,11 +3,9 @@ "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", @@ -184,9 +182,7 @@ "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 ac6142fe..068c0f1a 100644 --- a/scss/components/actor/_actor_header.scss +++ b/scss/components/actor/_actor_header.scss @@ -42,7 +42,6 @@ align-items: center; border-bottom: 0; margin: 0; - container-type: inline-size; } &__name-input[type="text"] { @@ -50,10 +49,7 @@ background-color: transparent; border: none; flex: 1; - font-size: clamp(0.75em, 8cqi, 1.25em); + font-size: 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 c31a0c52..15ac258c 100644 --- a/scss/components/actor/_actor_progression.scss +++ b/scss/components/actor/_actor_progression.scss @@ -29,11 +29,10 @@ margin: 0; padding: 0; text-align: right; - font-weight: 300; } &__input { - flex: 0 0 8ch; + flex: 0 0 5ch; &--slayer-points { &::-webkit-inner-spin-button, diff --git a/scss/components/actor/_actor_properties.scss b/scss/components/actor/_actor_properties.scss index cfa631ad..78edeb28 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(--input-height); + height: var(--form-field-height); } &__property-multi-input { diff --git a/scss/components/actor/_biography.scss b/scss/components/actor/_biography.scss index fb6b22ec..cf033a71 100644 --- a/scss/components/actor/_biography.scss +++ b/scss/components/actor/_biography.scss @@ -8,10 +8,4 @@ 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 ca5369c9..184aa31e 100644 --- a/scss/components/actor/_combat_value.scss +++ b/scss/components/actor/_combat_value.scss @@ -71,13 +71,3 @@ 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 dfd576ad..140ca89e 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 light-dark(colors.$c-black, colors.$c-white); + -webkit-text-stroke: 1px colors.$c-black; color: transparent; } } diff --git a/scss/components/item/_item_header.scss b/scss/components/item/_item_header.scss index 1a22436f..2e122a36 100644 --- a/scss/components/item/_item_header.scss +++ b/scss/components/item/_item_header.scss @@ -33,7 +33,6 @@ &__name { border: none; margin: 0; - container-type: inline-size; } &__name-label { @@ -44,10 +43,9 @@ @include mixins.font-heading-upper; background-color: transparent; border: none; - font-size: clamp(0.75em, 8cqi, 1.25em); + font-size: 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 76c8cebf..dead15ce 100644 --- a/scss/components/shared/_editor.scss +++ b/scss/components/shared/_editor.scss @@ -16,38 +16,3 @@ } } } - -// 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 c24dcbce..50b8d5e1 100644 --- a/scss/components/shared/_embedded_document_list.scss +++ b/scss/components/shared/_embedded_document_list.scss @@ -150,8 +150,3 @@ 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 85f70230..12687e48 100644 --- a/scss/components/shared/_form_group.scss +++ b/scss/components/shared/_form_group.scss @@ -4,13 +4,12 @@ * SPDX-License-Identifier: MIT */ -.ds4-form-group, -.ds4-item-sheet .form-group { +.ds4-form-group { clear: both; display: flex; flex-direction: row; flex-wrap: wrap; - margin: 8px 0; + margin: 3px 0; align-items: center; &--start { @@ -23,107 +22,6 @@ &__label { flex: 2; - line-height: var(--input-height); - } - - // Add spacing between form groups - & + & { - margin-top: 12px; - } - - // Style for slim form groups (input + select combinations) - &.slim { - margin: 6px 0; - - .form-fields { - display: flex; - align-items: center; - gap: 4px; - - input { - flex: 1; - min-width: 60px; - } - - select { - flex: 0 0 auto; - min-width: 120px; - width: auto !important; - } - - label { - flex: 0 0 auto; - margin-right: 8px; - font-weight: bold; - } - } + line-height: var(--form-field-height); } } - -// Style standard Foundry form-fields containers -.ds4-item-sheet .form-fields { - display: flex; - align-items: center; - gap: 6px; - flex: 1; - - input, select, textarea { - background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--color-border); - border-radius: 3px; - padding: 4px 8px; - color: var(--color-text-primary); - - &:focus { - border-color: var(--color-text-accent); - box-shadow: 0 0 3px var(--color-text-accent); - } - } - - select { - background: rgba(255, 255, 255, 0.05); - appearance: none; - background-image: url("data:image/svg+xml;charset=US-ASCII,"); - background-repeat: no-repeat; - background-position: right 8px center; - background-size: 8px; - padding-right: 24px; - width: auto !important; - - - } -} - -// Dark mode: try to achieve dark background for select options -.theme-dark .ds4-item-sheet .form-fields select { - color-scheme: dark; - - option { - background-color: var(--color-cool-4) !important; - background: var(--color-cool-4) !important; - color: var(--color-light-2) !important; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - } - - option:checked { - background-color: var(--color-cool-3) !important; - background: var(--color-cool-3) !important; - color: var(--color-light-1) !important; - } - - option:hover { - background-color: var(--color-cool-3) !important; - background: var(--color-cool-3) !important; - } -} - -// Improve label styling -.ds4-item-sheet .form-group > label { - flex: 0 0 160px; - font-weight: bold; - color: var(--color-form-label); - line-height: var(--input-height); - margin-right: 12px; -} diff --git a/scss/components/shared/_ruler.scss b/scss/components/shared/_ruler.scss deleted file mode 100644 index ebbb188d..00000000 --- a/scss/components/shared/_ruler.scss +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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 d0bcdd50..3b19d5ca 100644 --- a/scss/components/shared/_sheet_body.scss +++ b/scss/components/shared/_sheet_body.scss @@ -4,14 +4,7 @@ * SPDX-License-Identifier: MIT */ -.ds4-sheet-body, -.sheet-body { +.ds4-sheet-body { height: 100%; overflow-y: auto; - - // Prevent double scrollbars on biography tab - .ds4-sheet-tab.tab.biography.active, - .tab[data-tab="biography"].active { - overflow: hidden; - } } diff --git a/scss/components/shared/_sheet_tab.scss b/scss/components/shared/_sheet_tab.scss index 3074f121..ea1d13fc 100644 --- a/scss/components/shared/_sheet_tab.scss +++ b/scss/components/shared/_sheet_tab.scss @@ -4,8 +4,7 @@ * SPDX-License-Identifier: MIT */ -.ds4-sheet-tab, -.tab { +.ds4-sheet-tab { flex-direction: column; flex-wrap: nowrap; height: 100%; diff --git a/scss/components/shared/_sheet_tab_nav.scss b/scss/components/shared/_sheet_tab_nav.scss index 1a432568..676fea60 100644 --- a/scss/components/shared/_sheet_tab_nav.scss +++ b/scss/components/shared/_sheet_tab_nav.scss @@ -6,31 +6,20 @@ @use "../../utils/variables"; -.ds4-sheet-tab-nav, -nav.tabs { +.ds4-sheet-tab-nav { border-bottom: variables.$border-groove; border-top: variables.$border-groove; display: flex; flex-wrap: nowrap; - height: calc(2.5 * var(--line-height-16)); + height: calc(2 * var(--line-height-16)); justify-content: space-around; + line-height: calc(2 * var(--line-height-16)); margin: variables.$margin-sm 0; - .ds4-sheet-tab-nav__item, - .item { + &__item { flex: 0 1 auto !important; // necessary to override the styling from lang-de, see https://gitlab.com/henry4k/foundryvtt-lang-de/-/issues/9 font-weight: bold; white-space: nowrap; - display: flex; - align-items: center; - justify-content: center; - gap: 0.25rem; - padding: 0.5rem 0.75rem; - line-height: 1; - - i { - font-size: 0.875rem; - } &.active { text-shadow: 0 0 variables.$padding-md var(--color-shadow-primary); diff --git a/scss/ds4.scss b/scss/ds4.scss index 53760d31..4f533640 100644 --- a/scss/ds4.scss +++ b/scss/ds4.scss @@ -19,7 +19,6 @@ @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 574bc4cb..4e79e4c0 100644 --- a/scss/global/_fonts.scss +++ b/scss/global/_fonts.scss @@ -58,27 +58,3 @@ --ds4-font-primary: Lora, serif; --ds4-font-heading: "Wood Stamp", sans-serif; } - -// Apply Wood Stamp font only to DS4 sheet-specific elements (excluding window titles) -.ds4-actor-sheet h2, -.ds4-actor-sheet h4, -.ds4-actor-sheet h5, -.ds4-actor-sheet h6, -.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; -} - -// Keep window titles readable with standard font -.ds4-actor-sheet .window-title, -.ds4-item-sheet .window-title { - font-family: var(--font-sans) !important; - text-transform: none !important; - font-weight: normal !important; -} diff --git a/src/apps/active-effect-config.js b/src/apps/active-effect-config.js index 4e620336..e0acae02 100644 --- a/src/apps/active-effect-config.js +++ b/src/apps/active-effect-config.js @@ -2,55 +2,31 @@ // // SPDX-License-Identifier: MIT -/** - * 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"], - }; - +export class DS4ActiveEffectConfig extends ActiveEffectConfig { /** @override */ - get template() { - return "systems/ds4/templates/sheets/active-effect/active-effect-config.hbs"; + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + template: "systems/ds4/templates/sheets/active-effect/active-effect-config.hbs", + }); } - /** @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); - }); - } + /** + * @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)); } /** * Toggle the visibility of the item effect config section - * @param {boolean} active - The target state + * @param {boolean} active The target state */ - _toggleItemEffectConfigVisibility(active) { - const elements = this.element.querySelectorAll(".ds4-item-effect-config"); - elements.forEach((element) => { + #toggleItemEffectConfig(active) { + const elements = this.element[0]?.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 0aec8f18..75a74b11 100644 --- a/src/apps/actor/base-sheet.js +++ b/src/apps/actor/base-sheet.js @@ -2,699 +2,495 @@ // 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 DS4 Actor Sheets + * The base sheet class for all {@link DS4Actor}s. */ -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, - - editImage: DS4ActorSheet.prototype._onEditImage, - }, - }; - - static TABS = { - primary: { - initial: "values", - tabs: [ - { id: "values", label: "DS4.HeadingValues", icon: "fas fa-chart-bar" }, - { id: "inventory", label: "DS4.HeadingInventory", icon: "fas fa-backpack" }, - { id: "spells", label: "DS4.HeadingSpells", icon: "fas fa-magic" }, - { id: "abilities", label: "DS4.HeadingAbilities", icon: "fas fa-fist-raised" }, - { id: "effects", label: "DS4.HeadingEffects", icon: "fas fa-sparkles" }, - { id: "biography", label: "DS4.HeadingBiography", icon: "fas fa-book" } - ] - } - }; - - constructor(options = {}) { - super(options); - - // Initialize tabGroups with default values - if (!this.tabGroups) { - this.tabGroups = {}; - } - // Set default tab for primary group - if (!this.tabGroups.primary) { - this.tabGroups.primary = this.constructor.TABS.primary?.initial || "values"; - } - - } - - get title() { - return `${this.document.name} [${game.i18n.localize("DS4.ActorSheet")}]`; +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, + }); } /** @override */ get template() { - 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; + 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`; } /** @override */ - async _renderHTML(context) { - return await foundry.applications.handlebars.renderTemplate(this.template, context); - } + 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))]; + }), + ); - /** @override */ - _replaceHTML(result, content) { - content.innerHTML = result; - } + 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 */ - 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, + const context = { + ...this.addTooltipsToData(await super.getData(options)), + config: DS4, + itemsByType, + enrichedEffects, + settings: getDS4Settings(), }; - - // 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); - - // Add tabs configuration using ApplicationTab typedef - context.tabs = this._prepareTabs("primary"); - return context; } /** - * Add tooltips to the given context data + * Adds tooltips to the attributes, traits, and combatValues of the given context object. + * @param {object} context + * @protected */ addTooltipsToData(context) { - const data = context.data; + const valueGroups = [context.data.system.attributes, context.data.system.traits, context.data.system.combatValues]; - // 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); - } - } + valueGroups.forEach((valueGroup) => { + Object.values(valueGroup).forEach((attribute) => { + attribute.tooltip = this.getTooltipForValue(attribute); + }); + }); + return context; } /** - * Get a tooltip for a value - * @param {object} value - The value to get a tooltip for - * @returns {string} The tooltip string + * 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 */ getTooltipForValue(value) { - const base = value.base ?? 0; - const modifier = value.mod ?? 0; - const effects = value.effects ?? 0; + return `${value.base} (${getGame().i18n.localize("DS4.TooltipBaseValue")}) + ${ + value.mod + } (${getGame().i18n.localize("DS4.TooltipModifier")}) ➞ ${getGame().i18n.localize("DS4.TooltipEffects")} ➞ ${ + value.total + }`; + } - if (effects === 0) { - return game.i18n.format("DS4.TooltipWithoutEffects", { base, modifier }); + /** + * @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 }), + ); } else { - return game.i18n.format("DS4.TooltipWithEffects", { base, modifier, effects }); + 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 }); } /** - * 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 + * 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 */ - _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)); + 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 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); } /** - * Parse a value from a form field - * @param {string} value - The value to parse - * @returns {*} The parsed value + * Handle clickable item rolls. + * @param {JQuery.ClickEvent} event The originating click event + * @protected */ - 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; - } + 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 })); } /** - * Handle rolling a check - * @param {Event} event - The triggering event - * @param {HTMLElement} target - The target element + * Handle clickable check rolls. + * @param {JQuery.ClickEvent} event The originating click event + * @protected */ - async _onRollCheck(event, target) { - const checkKey = target.dataset.check; - if (!checkKey) return; - - const actor = this.document; - await actor.rollCheck(checkKey); + onRollCheck(event) { + event.preventDefault(); + event.currentTarget.blur(); + const check = event.currentTarget.dataset["check"]; + this.actor.rollCheck(check).catch((e) => notifications.error(e, { log: true })); } /** - * Handle rolling an item - * @param {Event} event - The triggering event - * @param {HTMLElement} target - The target element + * @param {DragEvent} event + * @override */ - async _onRollItem(event, target) { - const itemId = target.closest("[data-item-id]")?.dataset.itemId; - if (!itemId) return; + _onDragStart(event) { + const target = event.currentTarget; + if (!(target instanceof HTMLElement)) return super._onDragStart(event); - const item = this.document.items.get(itemId); - if (item?.system.rollable) { - await item.roll(); - } - } + const check = target.dataset.check; + if (!check) return super._onDragStart(event); - /** - * 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; + enforce(isCheck(check), getGame().i18n.format("DS4.ErrorCannotDragMissingCheck", { check })); - 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, + 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, }; - await this.document.createEmbeddedDocuments("Item", [itemData]); + event.dataTransfer?.setData("text/plain", JSON.stringify(dragData)); } /** - * Handle editing an item - * @param {Event} event - The triggering event - * @param {HTMLElement} target - The target element + * Sort items according to the item list header that has been clicked. + * @param {JQuery.ClickEvent} event The originating click event + * @protected */ - async _onEditItem(event, target) { - const itemId = target.closest("[data-item-id]")?.dataset.itemId; - if (!itemId) return; + 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); - const item = this.document.items.get(itemId); - if (item) { - await item.sheet.render(true); - } - } + /** + * @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); - /** - * 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; + 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); } - } else if (target.dataset.dtype === "Number") { - value = Number(value); - } - 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", + return comparison; }; - await this.document.createEmbeddedDocuments("ActiveEffect", [effectData]); - } + const sortedItems = [...items].sort(sortFunction(false)); + const wasSortedAlready = !sortedItems.find((item, index) => item !== items[index]); - /** - * 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); + if (wasSortedAlready) { + sortedItems.sort(sortFunction(true)); } - 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) => ({ + const updates = sortedItems.map((item, i) => ({ _id: item.id, - sort: index * 100, + sort: (i + 1) * CONST.SORT_INTEGER_DENSITY, })); - 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 - */ - - /** - * Prepare tabs for a given group using ApplicationTab typedef - * @param {string} group - The tab group identifier - * @returns {Record} Prepared tab data - */ - _prepareTabs(group) { - - const config = this.constructor.TABS[group]; - if (!config) return {}; - - // Ensure tabGroups is initialized - if (!this.tabGroups[group]) { - this.tabGroups[group] = config.initial; - - } - - const tabs = {}; - for (const tabConfig of config.tabs) { - const isActive = this.tabGroups[group] === tabConfig.id; - - tabs[tabConfig.id] = { - id: tabConfig.id, - group: group, - icon: tabConfig.icon, - label: game.i18n.localize(tabConfig.label), - active: isActive, - cssClass: isActive ? "active" : "" - }; - } - - - return tabs; - } - - /** @override */ - async _onRender(context, options) { - - await super._onRender(context, options); - this._initializeTabHandlers(); - } - - /** - * Initialize tab event handlers manually since ApplicationV2 doesn't do this automatically - */ - _initializeTabHandlers() { - const tabNavigation = this.element.querySelector('.tabs[data-group="primary"]'); - if (!tabNavigation) return; - - // Remove existing event listeners to prevent memory leaks - this._removeTabHandlers(); - - // Store tab navigation reference for cleanup - this._tabNavigation = tabNavigation; - - // Create bound handler function for later removal - this._tabClickHandler = (event) => { - event.preventDefault(); - const item = event.target.closest('.item[data-tab]'); - if (!item) return; - - const tabId = item.dataset.tab; - const groupId = item.dataset.group; - if (tabId && groupId) { - this.changeTab(tabId, groupId); - } - }; - - // Add single delegated event listener to the navigation container - tabNavigation.addEventListener('click', this._tabClickHandler); - } - - /** - * Remove tab event handlers to prevent memory leaks - */ - _removeTabHandlers() { - if (this._tabNavigation && this._tabClickHandler) { - this._tabNavigation.removeEventListener('click', this._tabClickHandler); - } - } - - /** - * Override changeTab to ensure proper tab switching without forced re-render - */ - changeTab(tab, group, options = {}) { - // Call parent changeTab method - const result = super.changeTab(tab, group, options); - - // Update tab display without full re-render - this._updateTabDisplay(tab, group); - - return result; - } - - /** - * Update tab display without full re-render for better performance - */ - _updateTabDisplay(activeTab, group) { - const tabNavigation = this.element.querySelector(`.tabs[data-group="${group}"]`); - const tabContent = this.element.querySelector('.sheet-body'); - - if (!tabNavigation || !tabContent) return; - - // Update navigation active states - const navItems = tabNavigation.querySelectorAll('.item[data-tab]'); - navItems.forEach(item => { - if (item.dataset.tab === activeTab) { - item.classList.add('active'); - } else { - item.classList.remove('active'); - } - }); - - // Update content tab visibility - const contentTabs = tabContent.querySelectorAll('.tab[data-tab]'); - contentTabs.forEach(tab => { - if (tab.dataset.tab === activeTab) { - tab.classList.add('active'); - } else { - tab.classList.remove('active'); - } - }); - } - - /** - * Override _onClose to cleanup event listeners - */ - async _onClose(options) { - this._removeTabHandlers(); - return super._onClose(options); - } - - - - - - - - - /** - * Handle editing the actor'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.document, field); - - const fp = new foundry.applications.apps.FilePicker({ - type: "image", - current: current, - callback: (path) => this.document.update({ [field]: path }), - }); - return fp.browse(); + this.actor.updateEmbeddedDocuments("Item", updates); } } + +/** + * 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 715b345d..368fe176 100644 --- a/src/apps/actor/character-sheet.js +++ b/src/apps/actor/character-sheet.js @@ -1,34 +1,25 @@ // SPDX-FileCopyrightText: 2021 Johannes Loher -// SPDX-FileCopyrightText: 2025 Alexander Minges // // SPDX-License-Identifier: MIT -import { DS4ActorSheet } from "./base-sheet.js"; +import { DS4ActorSheet } from "./base-sheet"; /** * The Sheet class for DS4 Character Actors */ export class DS4CharacterActorSheet extends DS4ActorSheet { - static DEFAULT_OPTIONS = { - ...super.DEFAULT_OPTIONS, - classes: ["sheet", "ds4-actor-sheet", "ds4-character-sheet", "themed"], - }; + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["sheet", "ds4-actor-sheet", "ds4-character-sheet"], + }); + } /** @override */ - 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, - }, - ); - } - + async getData(options = {}) { + const context = await super.getData(options); + context.data.system.profile.biography = await TextEditor.enrichHTML(context.data.system.profile.biography, { + async: true, + }); return context; } } diff --git a/src/apps/actor/creature-sheet.js b/src/apps/actor/creature-sheet.js index 09fd917d..fe89dec0 100644 --- a/src/apps/actor/creature-sheet.js +++ b/src/apps/actor/creature-sheet.js @@ -1,51 +1,25 @@ // SPDX-FileCopyrightText: 2021 Johannes Loher -// SPDX-FileCopyrightText: 2025 Alexander Minges // // SPDX-License-Identifier: MIT -import { DS4ActorSheet } from "./base-sheet.js"; +import { DS4ActorSheet } from "./base-sheet"; /** * The Sheet class for DS4 Creature Actors */ export class DS4CreatureActorSheet extends DS4ActorSheet { - static DEFAULT_OPTIONS = { - ...super.DEFAULT_OPTIONS, - classes: ["sheet", "ds4-actor-sheet", "ds4-creature-sheet", "themed"], - }; - - static TABS = { - primary: { - initial: "values", - tabs: [ - { id: "values", label: "DS4.HeadingValues", icon: "fas fa-chart-bar" }, - { id: "inventory", label: "DS4.HeadingInventory", icon: "fas fa-backpack" }, - { id: "spells", label: "DS4.HeadingSpells", icon: "fas fa-magic" }, - { id: "abilities", label: "DS4.HeadingAbilities", icon: "fas fa-fist-raised" }, - { id: "effects", label: "DS4.HeadingEffects", icon: "fas fa-sparkles" }, - { id: "description", label: "DS4.HeadingDescription", icon: "fas fa-file-text" } - ] - } - }; + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["sheet", "ds4-actor-sheet", "ds4-creature-sheet"], + }); + } /** @override */ - 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, - }, - ); - } - - // Add tabs configuration using ApplicationTab typedef - context.tabs = this._prepareTabs("primary"); - + async getData(options = {}) { + const context = await super.getData(options); + context.data.system.baseInfo.description = await TextEditor.enrichHTML(context.data.system.baseInfo.description, { + async: true, + }); return context; } } diff --git a/src/apps/dialog-with-listeners.js b/src/apps/dialog-with-listeners.js index b495b867..c9a3f2a2 100644 --- a/src/apps/dialog-with-listeners.js +++ b/src/apps/dialog-with-listeners.js @@ -3,129 +3,19 @@ // SPDX-License-Identifier: MIT /** - * A simple extension to the DialogV2 class that allows attaching additional listeners. + * @typedef {DialogOptions} DialogWithListenersOptions + * @property {(html: JQuery, app: DialogWithListeners) => void} [activateAdditionalListeners] An optional function to attach additional listeners to the dialog */ -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 */ - 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); + activateListeners(html) { + super.activateListeners(html); + if (this.options.activateAdditionalListeners !== undefined) { + this.options.activateAdditionalListeners(html, 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 76efdabb..1fc6f0aa 100644 --- a/src/apps/item-sheet.js +++ b/src/apps/item-sheet.js @@ -1,63 +1,27 @@ // 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 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, +export class DS4ItemSheet extends ItemSheet { + /** @override */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["sheet", "ds4-item-sheet"], height: 400, - }, - window: { - resizable: true, - }, - actions: { - controlEffect: DS4ItemSheet.prototype._onControlEffect, - createEffect: DS4ItemSheet.prototype._onCreateEffect, - editEffect: DS4ItemSheet.prototype._onEditEffect, - deleteEffect: DS4ItemSheet.prototype._onDeleteEffect, - editImage: DS4ItemSheet.prototype._onEditImage, - }, - }; - - static TABS = { - primary: { - initial: "description", - tabs: [ - { id: "description", label: "DS4.HeadingDescription", icon: "fas fa-book" }, - { id: "effects", label: "DS4.HeadingEffects", icon: "fas fa-sparkles" } - ] - } - }; - - constructor(options = {}) { - super(options); - // Initialize tabGroups with default values - if (!this.tabGroups) { - this.tabGroups = {}; - } - // Set default tab for primary group - if (!this.tabGroups.primary) { - this.tabGroups.primary = this.constructor.TABS.primary?.initial || "description"; - } - } - - get title() { - return `${this.item.name} [${game.i18n.localize("DS4.ItemSheet")}]`; + scrollY: [".ds4-sheet-body"], + tabs: [{ navSelector: ".ds4-sheet-tab-nav", contentSelector: ".ds4-sheet-body", initial: "description" }], + width: 540, + }); } /** @override */ @@ -67,358 +31,120 @@ export class DS4ItemSheet extends foundry.applications.api.HandlebarsApplication } /** @override */ - 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 tabs configuration using ApplicationTab typedef - context.tabs = this._prepareTabs("primary"); - - // 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 = ""; - } - + 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, + }; return context; } - /** - * 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); - + /** @override */ + _getSubmitData(updateData = {}) { + const data = super._getSubmitData(updateData); // Prevent submitting overridden values const overrides = foundry.utils.flattenObject(this.item.overrides); for (const k of Object.keys(overrides)) { - foundry.utils.setProperty(submitData, k, undefined); - delete submitData[k]; + delete data[k]; } - - 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 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(); + return data; } /** @override */ - async _onRender(context, options) { - await super._onRender(context, options); - this._initializeTabHandlers(); - } - - /** - * Initialize tab event handlers manually since ApplicationV2 doesn't do this automatically - */ - _initializeTabHandlers() { - const tabNavigation = this.element.querySelector('.tabs[data-group="primary"]'); - if (!tabNavigation) { - return; + 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); } - // Remove existing event listeners to prevent memory leaks - this._removeTabHandlers(); - - // Store tab navigation reference for cleanup - this._tabNavigation = tabNavigation; - - // Create bound handler function for later removal - this._tabClickHandler = (event) => { - event.preventDefault(); - const item = event.target.closest('.item[data-tab]'); - if (!item) { - return; - } - - const tabId = item.dataset.tab; - const groupId = item.dataset.group; - if (tabId && groupId) { - this.changeTab(tabId, groupId); - } - }; - - // Add single delegated event listener to the navigation container - tabNavigation.addEventListener('click', this._tabClickHandler); + return position; } /** - * Override form submission to prevent re-rendering + * @override + * @param {JQuery} html */ - async _processSubmitData(event, form, submitData, options = {}) { - // Always prevent re-rendering on form updates - options.render = false; - return super._processSubmitData(event, form, submitData, options); + activateListeners(html) { + super.activateListeners(html); + + if (!this.options.editable) return; + + html.find(".control-effect").on("click", this.onControlEffect.bind(this)); + + disableOverriddenFields(this.form, this.item.overrides, (key) => `[name="${key}"]`); } /** - * Remove tab event handlers to prevent memory leaks + * 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 */ - _removeTabHandlers() { - if (this._tabNavigation && this._tabClickHandler) { - this._tabNavigation.removeEventListener('click', this._tabClickHandler); + 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); } } /** - * Override changeTab to ensure proper tab switching without forced re-render + * Creates a new embedded effect. + * @protected */ - changeTab(tab, group, options = {}) { - // Call parent changeTab method - const result = super.changeTab(tab, group, options); - - // Update tab display without full re-render - this._updateTabDisplay(tab, group); - - return result; + onCreateEffect() { + DS4ActiveEffect.createDefault(this.item); } /** - * Update tab display without full re-render for better performance + * Opens the sheet of the embedded effect corresponding to the clicked element. + * + * @param {JQuery.ClickEvent} event The originating click event + * @porotected */ - _updateTabDisplay(activeTab, group) { - const tabNavigation = this.element.querySelector(`.tabs[data-group="${group}"]`); - const tabContent = this.element.querySelector('.sheet-body'); - - if (!tabNavigation || !tabContent) return; - - // Update navigation active states - const navItems = tabNavigation.querySelectorAll('.item[data-tab]'); - navItems.forEach(item => { - if (item.dataset.tab === activeTab) { - item.classList.add('active'); - } else { - item.classList.remove('active'); - } - }); - - // Update content tab visibility - const contentTabs = tabContent.querySelectorAll('.tab[data-tab]'); - contentTabs.forEach(tab => { - if (tab.dataset.tab === activeTab) { - tab.classList.add('active'); - } else { - tab.classList.remove('active'); - } - }); + 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); } /** - * Override _onClose to cleanup event listeners + * Deletes the embedded item corresponding to the clicked element. + * + * @param {JQuery.ClickEvent} event The originating click event + * @protected */ - async _onClose(options) { - this._removeTabHandlers(); - return super._onClose(options); + 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)); } - - /** - * Prepare tabs for a given group using ApplicationTab typedef - * @param {string} group - The tab group identifier - * @returns {Record} Prepared tab data - */ - _prepareTabs(group) { - const config = this._getTabsConfigForItemType(); - if (!config) return {}; - - // Ensure tabGroups is initialized - if (!this.tabGroups[group]) { - this.tabGroups[group] = config.initial || "description"; - } - - const tabs = {}; - for (const tabConfig of config.tabs) { - const isActive = this.tabGroups[group] === tabConfig.id; - tabs[tabConfig.id] = { - id: tabConfig.id, - group: group, - icon: tabConfig.icon, - label: game.i18n.localize(tabConfig.label), - active: isActive, - cssClass: isActive ? "active" : "" - }; - } - - return tabs; - } - - /** - * Get tab configuration based on item type - * @returns {Object} Tab configuration for this item type - */ - _getTabsConfigForItemType() { - const tabs = [ - { id: "description", label: "DS4.HeadingDescription", icon: "fas fa-book" } - ]; - - // Item types that have properties tabs - const itemsWithProperties = [ - "weapon", "armor", "shield", "equipment", "loot", - "spell", "talent", "specialCreatureAbility" - ]; - - if (itemsWithProperties.includes(this.item.type)) { - tabs.push({ - id: "properties", - label: "DS4.HeadingProperties", - icon: "fas fa-cogs" - }); - } - - // All items can have effects - tabs.push({ - id: "effects", - label: "DS4.HeadingEffects", - icon: "fas fa-sparkles" - }); - - return { tabs, initial: "description" }; - } - } + +/** + * 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 deleted file mode 100644 index 467ffa0d..00000000 --- a/src/apps/ruler/token-ruler.js +++ /dev/null @@ -1,44 +0,0 @@ -// 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 250dcd83..6063ca98 100644 --- a/src/dice/check-factory.js +++ b/src/dice/check-factory.js @@ -111,11 +111,6 @@ 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} */ @@ -171,69 +166,63 @@ async function askForInteractiveRollData(checkTargetNumber, options = {}, { temp }), id, }; - const renderedHtml = await foundry.applications.handlebars.renderTemplate(usedTemplate, templateData); + const renderedHtml = await renderTemplate(usedTemplate, templateData); const dialogPromise = new Promise((resolve) => { - new DialogWithListeners({ - window: { + new DialogWithListeners( + { title: usedTitle, - }, - 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); + 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"), }, }, - { - 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"); + 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"); app.setPosition({ height: "auto" }); - } else if (checkModifierCustomFormGroup && !checkModifierCustomFormGroup.classList.contains("ds4-hidden")) { - checkModifierCustomFormGroup.classList.add("ds4-hidden"); + } else if (!checkModifierCustomFormGroup.hasClass("ds4-hidden")) { + checkModifierCustomFormGroup.addClass("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); } @@ -243,11 +232,6 @@ 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 833f6b19..bc632a07 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 foundry.applications.handlebars.renderTemplate(template, chatData); + return renderTemplate(template, chatData); } } diff --git a/src/documents/actor/actor.js b/src/documents/actor/actor.js index 0725a444..c1f4d256 100644 --- a/src/documents/actor/actor.js +++ b/src/documents/actor/actor.js @@ -1,6 +1,5 @@ // SPDX-FileCopyrightText: 2021 Johannes Loher // SPDX-FileCopyrightText: 2021 Oliver RÜmpelein -// SPDX-FileCopyrightText: 2025 Alexander Minges // // SPDX-License-Identifier: MIT @@ -464,65 +463,60 @@ export class DS4Actor extends Actor { async selectAttributeAndTrait() { const attributeIdentifier = "attribute-trait-selection-attribute"; const traitIdentifier = "attribute-trait-selection-trait"; - 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, - }; - }, + 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, + }; }, rejectClose: false, }); diff --git a/src/documents/item/weapon/weapon.js b/src/documents/item/weapon/weapon.js index 3a96ddac..09a734d0 100644 --- a/src/documents/item/weapon/weapon.js +++ b/src/documents/item/weapon/weapon.js @@ -1,5 +1,4 @@ // SPDX-FileCopyrightText: 2022 Johannes Loher -// SPDX-FileCopyrightText: 2025 Alexander Minges // // SPDX-License-Identifier: MIT @@ -84,34 +83,29 @@ export class DS4Weapon extends DS4Item { const { melee, ranged } = { ...DS4.i18n.attackTypes }; const identifier = `attack-type-selection-${foundry.utils.randomID()}`; - 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; - }, + 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; }, }); } diff --git a/src/handlebars/handlebars-helpers.ts b/src/handlebars/handlebars-helpers.ts index 5f9dcbc9..015064a4 100644 --- a/src/handlebars/handlebars-helpers.ts +++ b/src/handlebars/handlebars-helpers.ts @@ -14,11 +14,6 @@ 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 0e7aec6e..747c13c4 100644 --- a/src/handlebars/handlebars-partials.js +++ b/src/handlebars/handlebars-partials.js @@ -1,7 +1,6 @@ // 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 @@ -56,7 +55,6 @@ 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 foundry.applications.handlebars.loadTemplates(templatePaths); + await loadTemplates(templatePaths); } diff --git a/src/hooks/init.js b/src/hooks/init.js index a063a9f5..199b32bd 100644 --- a/src/hooks/init.js +++ b/src/hooks/init.js @@ -1,7 +1,6 @@ // 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 @@ -9,7 +8,6 @@ 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"; @@ -53,8 +51,6 @@ 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; @@ -69,23 +65,16 @@ async function init() { registerSystemSettings(); - foundry.applications.apps.DocumentSheetConfig.registerSheet(Actor, "ds4", DS4CharacterActorSheet, { + DocumentSheetConfig.unregisterSheet(Actor, "core", ActorSheet); + DocumentSheetConfig.registerSheet(Actor, "ds4", DS4CharacterActorSheet, { types: ["character"], 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, - }); + 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 }); preloadFonts(); await registerHandlebarsPartials(); diff --git a/src/hooks/render.js b/src/hooks/render.js index ad5224c3..c8028dbf 100644 --- a/src/hooks/render.js +++ b/src/hooks/render.js @@ -17,18 +17,10 @@ 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 {HTMLElement} element The HTML element representing the HTML of the application. + * @param {JQuery} html The {@link JQuery} representing the HTML of the application. */ -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(); - }); +function selectTargetInputOnFocus(app, html) { + html.find("input").on("focus", (ev) => { + ev.currentTarget.select(); }); } diff --git a/src/utils/utils.js b/src/utils/utils.js index b7979b64..f7fec6cb 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -30,10 +30,7 @@ export function enforce(value, message) { * @returns {Canvas} */ export function getCanvas() { - enforce( - canvas instanceof foundry.canvas.Canvas && canvas.ready, - getGame().i18n.localize("DS4.ErrorCanvasIsNotInitialized"), - ); + enforce(canvas instanceof Canvas && canvas.ready, getGame().i18n.localize("DS4.ErrorCanvasIsNotInitialized")); return canvas; } @@ -43,7 +40,7 @@ export function getCanvas() { * @returns {Game} */ export function getGame() { - enforce(game instanceof foundry.Game, "Game is not initialized yet."); + enforce(game instanceof Game, "Game is not initialized yet."); return game; } diff --git a/system.json b/system.json index 8751cb40..36245e6f 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": "3.0.0", + "version": "2.0.5", "flags": { "hotReload": { "extensions": ["css", "hbs", "json"], @@ -40,8 +40,8 @@ } }, "compatibility": { - "minimum": "13", - "verified": "13.346" + "minimum": "12.331", + "verified": "12" }, "esmodules": ["ds4.js"], "styles": ["css/ds4.css"], diff --git a/templates/dice/roll.hbs b/templates/dice/roll.hbs index 9b9fc28f..8afcbd10 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 deleted file mode 100644 index 90639703..00000000 --- a/templates/partials/waypoint-label.hbs +++ /dev/null @@ -1,43 +0,0 @@ -{{!-- -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 cccde11a..5a52247a 100644 --- a/templates/sheets/active-effect/active-effect-config.hbs +++ b/templates/sheets/active-effect/active-effect-config.hbs @@ -32,18 +32,8 @@ SPDX-License-Identifier: MIT
- {{#if editable}} - - {{{descriptionHTML}}} - - {{else}} - {{{descriptionHTML}}} - {{/if}} + {{editor descriptionHTML target="description" button=false editable=editable engine="prosemirror" + collaborate=false}}
diff --git a/templates/sheets/actor/character-sheet.hbs b/templates/sheets/actor/character-sheet.hbs index d725c796..556b4c1e 100644 --- a/templates/sheets/actor/character-sheet.hbs +++ b/templates/sheets/actor/character-sheet.hbs @@ -13,47 +13,38 @@ SPDX-License-Identifier: MIT {{/systems/ds4/templates/sheets/actor/components/actor-header.hbs}} {{!-- Sheet Tab Navigation --}} -