// SPDX-FileCopyrightText: 2021 Johannes Loher // SPDX-FileCopyrightText: 2021 Oliver Rümpelein // SPDX-FileCopyrightText: 2021 Gesina Schwalbe // SPDX-FileCopyrightText: 2021 Siegfried Krug // // SPDX-License-Identifier: MIT import { DS4ActiveEffect } from "../active-effect/active-effect"; import { disableOverriddenFields } from "../apps/sheet-helpers"; import { DS4 } from "../config"; import { getCanvas, getGame } from "../helpers"; import { getDS4Settings } from "../settings"; import notifications from "../ui/notifications"; import { enforce } from "../utils"; import { isCheck } from "./actor-data-properties-base"; import type { ModifiableDataBaseTotal } from "../common/common-data"; import type { DS4Settings } from "../settings"; import type { DS4Item } from "../item/item"; /** * The base sheet class for all {@link DS4Actor}s. */ export class DS4ActorSheet extends ActorSheet<ActorSheet.Options, DS4ActorSheetData> { static override get defaultOptions(): ActorSheet.Options { return foundry.utils.mergeObject(super.defaultOptions, { classes: ["sheet", "ds4-actor-sheet"], height: 625, 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(): string { const basePath = "systems/ds4/templates/sheets/actor"; return `${basePath}/${this.actor.data.type}-sheet.hbs`; } override async getData(): Promise<DS4ActorSheetData> { const itemsByType = Object.fromEntries( Object.entries(this.actor.itemTypes).map(([itemType, items]) => { return [itemType, items.map((item) => item.data).sort((a, b) => (a.sort || 0) - (b.sort || 0))]; }), ); const enrichedEffectPromises = this.actor.effects.map(async (effect) => { return { ...effect.toObject(), sourceName: await effect.getCurrentSourceName(), factor: effect.factor, isEffectivelyEnabled: !effect.data.disabled && !effect.isSurpressed, }; }); const enrichedEffects = await Promise.all(enrichedEffectPromises); const data = { ...this.addTooltipsToData(await super.getData()), config: DS4, itemsByType, enrichedEffects, settings: getDS4Settings(), }; return data; } /** * Adds tooltips to the attributes, traits, and combatValues of the actor data of the given {@link ActorSheet.Data}. */ protected addTooltipsToData(data: ActorSheet.Data): ActorSheet.Data { const valueGroups = [data.data.data.attributes, data.data.data.traits, data.data.data.combatValues]; valueGroups.forEach((valueGroup) => { Object.values(valueGroup).forEach((attribute: ModifiableDataBaseTotal<number> & { tooltip?: string }) => { attribute.tooltip = this.getTooltipForValue(attribute); }); }); return data; } /** * Generates a tooltip for a given attribute, trait, or combatValue. */ protected getTooltipForValue(value: ModifiableDataBaseTotal<number>): string { return `${value.base} (${getGame().i18n.localize("DS4.TooltipBaseValue")}) + ${ value.mod } (${getGame().i18n.localize("DS4.TooltipModifier")}) ➞ ${getGame().i18n.localize("DS4.TooltipEffects")} ➞ ${ value.total }`; } override activateListeners(html: JQuery): void { 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.call(this); } /** * Handles a click on an element of this sheet to control an embedded item of the actor corresponding to this sheet. * * @param event - The originating click event */ protected onControlItem(event: JQuery.ClickEvent): void { 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 event - The originating click event */ protected onCreateItem(event: JQuery.ClickEvent): void { const { type, ...data } = foundry.utils.deepClone(event.currentTarget.dataset); const name = getGame().i18n.localize(`DS4.New${type.capitalize()}Name`); const itemData = { name: name, type: type, data: data, }; Item.create(itemData, { parent: this.actor, pack: this.actor.pack ?? undefined }); } /** * Opens the sheet of the embedded item corresponding to the clicked element. * * @param event - The originating click event */ protected onEditItem(event: JQuery.ClickEvent): void { const id = $(event.currentTarget) .parents(embeddedDocumentListEntryProperties.Item.selector) .data(embeddedDocumentListEntryProperties.Item.idDataAttribute); const item = this.actor.items.get(id); enforce(item, getGame().i18n.format("DS4.ErrorActorDoesNotHaveItem", { id, actor: this.actor.name })); enforce(item.sheet); item.sheet.render(true); } /** * Deletes the embedded item corresponding to the clicked element. * * @param event - The originating click event */ protected onDeleteItem(event: JQuery.ClickEvent): void { const li = $(event.currentTarget).parents(embeddedDocumentListEntryProperties.Item.selector); this.actor.deleteEmbeddedDocuments("Item", [li.data(embeddedDocumentListEntryProperties.Item.idDataAttribute)]); 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 event - The originating change event */ protected onChangeItem(event: JQuery.ChangeEvent): void { 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 event - The originating click event */ protected onControlEffect(event: JQuery.ClickEvent): void { 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 event - The originating click event */ protected onCreateEffect(): void { DS4ActiveEffect.createDefault(this.actor); } /** * Opens the sheet of the embedded effect corresponding to the clicked element. * * @param event - The originating click event */ protected onEditEffect(event: JQuery.ClickEvent): void { const id = $(event.currentTarget) .parents(embeddedDocumentListEntryProperties.ActiveEffect.selector) .data(embeddedDocumentListEntryProperties.ActiveEffect.idDataAttribute); const effect = this.actor.effects.get(id); enforce(effect, getGame().i18n.format("DS4.ErrorActorDoesNotHaveEffect", { id, actor: this.actor.name })); effect.sheet?.render(true); } /** * Deletes the embedded item corresponding to the clicked element. * * @param event - The originating click event */ protected onDeleteEffect(event: JQuery.ClickEvent): void { const li = $(event.currentTarget).parents(embeddedDocumentListEntryProperties.ActiveEffect.selector); const id = li.data(embeddedDocumentListEntryProperties.ActiveEffect.idDataAttribute); this.actor.deleteEmbeddedDocuments("ActiveEffect", [id]); 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 event - The originating change event */ protected onChangeEffect(event: JQuery.ChangeEvent): void { 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 event - The originating change event * @param documentName - The name of the embedded document to be changed. */ protected onChangeEmbeddedDocument(event: JQuery.ChangeEvent, documentName: "Item" | "ActiveEffect"): void { event.preventDefault(); const element = $(event.currentTarget).get(0); enforce(element instanceof HTMLInputElement); if (element.disabled) return; const documentElement = element.closest(embeddedDocumentListEntryProperties[documentName].selector); enforce(documentElement instanceof HTMLElement); const id = documentElement.dataset[embeddedDocumentListEntryProperties[documentName].idDataAttribute]; const property = element.dataset["property"]; enforce(property !== undefined, TypeError("HTML element does not provide 'data-property' attribute")); const newValue = this.parseValue(element); this.actor.updateEmbeddedDocuments(documentName, [{ _id: id, [property]: newValue }]); } /** * Parses the value of the given {@link HTMLInputElement} depending on the element's type * The value is parsed to: * - checkbox: `boolean`, if the attribute `data-inverted` is set to a truthy value, the parsed value is inverted * - text input: `string` * - number: `number` * * @param element - The input element to parse the value from */ protected parseValue(element: HTMLInputElement): boolean | string | number { switch (element.type) { case "checkbox": { const inverted = Boolean(element.dataset["inverted"]); const value: boolean = element.checked; return inverted ? !value : value; } case "text": { const value: string = 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, ); } } } /** * Handle clickable item rolls. * @param event - The originating click event */ protected onRollItem(event: JQuery.ClickEvent): void { event.preventDefault(); const id = $(event.currentTarget) .parents(embeddedDocumentListEntryProperties.Item.selector) .data(embeddedDocumentListEntryProperties.Item.idDataAttribute); const item = this.actor.items.get(id); enforce(item, getGame().i18n.format("DS4.ErrorActorDoesNotHaveItem", { id, actor: this.actor.name })); item.roll().catch((e) => notifications.error(e, { log: true })); } /** * Handle clickable check rolls. * @param event - The originating click event */ protected onRollCheck(event: JQuery.ClickEvent): void { event.preventDefault(); const check = event.currentTarget.dataset["check"]; this.actor.rollCheck(check).catch((e) => notifications.error(e, { log: true })); } override _onDragStart(event: DragEvent): void { const target = event.currentTarget as HTMLElement; if (!(target instanceof HTMLElement)) return super._onDragStart(event); const check = target.dataset.check; if (!check) return super._onDragStart(event); enforce(isCheck(check), getGame().i18n.format("DS4.ErrorCannotDragMissingCheck", { check })); 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, }; event.dataTransfer?.setData("text/plain", JSON.stringify(dragData)); } /** * Sort items according to the item list header that has been clicked. * @param event - The originating click event */ protected onSortItems(event: JQuery.ClickEvent<unknown, unknown, HTMLElement>): void { 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 items = this.actor.items.filter((item) => item.type === type); items.sort((a, b) => a.data.sort - b.data.sort); const sortFunction = (invert: boolean) => (a: DS4Item, b: DS4Item): number => { const propertyA = getProperty(a.data, dataPath); const propertyB = getProperty(b.data, dataPath); if (typeof propertyA === "string" || typeof propertyB === "string") { return invert ? (propertyB ?? "").localeCompare(propertyA ?? "") : (propertyA ?? "").localeCompare(propertyB ?? ""); } else { return invert ? propertyB - propertyA : propertyA - propertyB; } }; const sortedItems = [...items].sort(sortFunction(false)); const wasSortedAlready = !sortedItems.find((item, index) => item !== items[index]); if (wasSortedAlready) { sortedItems.sort(sortFunction(true)); } const updates = sortedItems.map((item, i) => ({ _id: item.id, sort: (i + 1) * CONST.SORT_INTEGER_DENSITY, })); this.actor.updateEmbeddedDocuments("Item", updates); } protected override async _onDropItem(event: DragEvent, data: ActorSheet.DropData.Item): Promise<unknown> { const item = await Item.fromDropData(data); if (item && !this.actor.canOwnItemType(item.data.type)) { notifications.warn( getGame().i18n.format("DS4.WarningActorCannotOwnItem", { actorName: this.actor.name, actorType: this.actor.data.type, itemName: item.name, itemType: item.data.type, }), ); return false; } return super._onDropItem(event, data); } } interface DS4ActorSheetData extends ActorSheet.Data { config: typeof DS4; itemsByType: Record<string, foundry.data.ItemData[]>; enrichedEffects: EnrichedActiveEffectDataSource[]; settings: DS4Settings; } type ActiveEffectDataSource = foundry.data.ActiveEffectData["_source"]; interface EnrichedActiveEffectDataSource extends ActiveEffectDataSource { sourceName: string; } /** * This object contains information about specific properties embedded document list entries for each different type. */ const embeddedDocumentListEntryProperties = Object.freeze({ ActiveEffect: { selector: ".effect", idDataAttribute: "effectId", }, Item: { selector: ".item", idDataAttribute: "itemId", }, });