import { __ID__, filePath } from "../consts.mjs"; import { createContextMenuOption, deleteItemFromElement, editItemFromElement } from "./utils.mjs"; import { config } from "../config.mjs"; import { Logger } from "../utils/Logger.mjs"; import { TAFDocumentSheetConfig } from "./overrides/TAFDocumentSheetConfig.mjs"; import { TAFDocumentSheetMixin } from "./mixins/TAFDocumentSheetMixin.mjs"; import { TAFActor } from "../documents/Actor.mjs"; const { HandlebarsApplicationMixin } = foundry.applications.api; const { ActorSheetV2 } = foundry.applications.sheets; const { getProperty } = foundry.utils; const { ContextMenu, TextEditor } = foundry.applications.ux; export class PlayerSheet extends TAFDocumentSheetMixin( HandlebarsApplicationMixin( ActorSheetV2, )) { // #region Options static DEFAULT_OPTIONS = { classes: [ __ID__, `PlayerSheet`, ], position: { width: 575, height: 740, }, window: { resizable: true, }, form: { submitOnChange: true, closeOnSubmit: false, }, actions: { createEmbeddedItem: this.#createEmbeddedItem, configureSheet: this.#configureSheet, toggleExpand: this.#toggleExpand, executeTrigger: this.#executeTrigger, saveDefaultAttrs: this.#saveDefaultAttrs, }, }; static PARTS = { header: { template: filePath(`templates/PlayerSheet/header.hbs`) }, primaryAttributes: { template: filePath(`templates/PlayerSheet/primary-attributes.hbs`) }, tabs: { template: filePath(`templates/generic/tabs.hbs`) }, content: { template: filePath(`templates/PlayerSheet/content.hbs`) }, attributeTab: { template: filePath(`templates/PlayerSheet/tabs/attributes/lists.hbs`), scrollable: [``], templates: [ filePath(`templates/PlayerSheet/tabs/attributes/attribute.hbs`), ], }, items: { template: filePath(`templates/PlayerSheet/tabs/items/lists.hbs`), scrollable: [``], templates: [ filePath(`templates/PlayerSheet/tabs/items/item.hbs`), ], }, }; /** * This tells the Application's TAFDocumentSheetMixin how to rerender this app * when specific properties get changed on the actor, so that it doesn't need * to full-app rendering if we can do a partial rerender instead. */ static PROPERTY_TO_PARTIAL = { "name": [`header`], "img": [`header`], "system.attr": [`attributes`], "system.attr.value": [`attributes`, `content`], "system.attr.max": [`attributes`, `content`], "system.content": [`content`], "system.carryCapacity": [`items`], }; static TABS = { primary: { initial: `content`, labelPrefix: `taf.Apps.PlayerSheet.tab-names`, tabs: [ { id: `content` }, { id: `attributes` }, { id: `items` }, ], }, }; // #endregion Options // #region Instance Data /** * This Set is used to keep track of which items have had their full * details expanded so that it can be persisted across rerenders as * they occur. */ #expandedItems = new Set(); /** * This method is used in order to ensure that when we hide specific * tabs due to programmatic logic (e.g. having no items), that the tab * doesn't stay selected in the app if the logic for it being visible * no longer holds true. */ _assertSelectedTabs() { const initial = this.constructor.TABS.primary.initial; if (this.tabGroups.primary === `items` && !this.hasItemsTab) { Logger.debug(`Asserting app "${this.id}" from tab "items" to "${initial}"`); this.tabGroups.primary = initial; }; if (this.tabGroups.primary === `attributes` && !this.hasAttributesTab) { Logger.debug(`Asserting app "${this.id}" from tab "attributes" to "${initial}"`); this.tabGroups.primary = initial; }; }; /** * A helper method that allows a shortcut to determine if a tab is visible * solely based on it's ID. This usually redirects to the relevant getter in * the class, but if the tab ID doesn't exist it always returns false. * * @param {string} tabID The ID of the relevant tab * @returns Whether or not the tab is visible */ hasTab(tabID) { switch (tabID) { case `content`: return this.hasContentTab; case `items`: return this.hasItemsTab; case `attributes`: return this.hasAttributesTab; }; return false; }; get hasContentTab() { return true; }; get hasAttributesTab() { return this.actor.itemTypes .attribute ?.filter(attr => !attr.system.aboveTheFold) .length > 0; }; get hasItemsTab() { return this.actor.items .filter(item => item.type !== `attribute`) .length > 0; }; // #endregion Instance Data // #region Lifecycle _initializeApplicationOptions(options) { const sizing = getProperty(options.document, `flags.${__ID__}.PlayerSheet.size`) ?? {}; const setting = { width: game.settings.get(__ID__, `sheetDefaultWidth`), height: game.settings.get(__ID__, `sheetDefaultHeight`), resizable: game.settings.get(__ID__, `sheetDefaultResizable`), }; options.window ??= {}; if (sizing.resizable !== ``) { options.window.resizable ??= sizing.resizable === `true`; } else if (setting.resizable !== ``) { options.window.resizable ??= setting.resizable === `true`; }; options.position ??= {}; // Set width if (sizing.width) { options.position.width ??= sizing.width; } else if (setting.width) { options.position.width ??= setting.width; }; // Set height if (sizing.height) { options.position.height ??= sizing.height; } else if (setting.height) { options.position.height ??= setting.height; }; return super._initializeApplicationOptions(options); }; _getHeaderControls() { const controls = super._getHeaderControls(); controls.push( { icon: `fa-solid fa-globe`, label: `taf.Apps.PlayerSheet.save-attributes-as-defaults`, action: `saveDefaultAttrs`, visible: () => game.user.isGM, }, { icon: `fa-solid fa-suitcase`, label: `taf.Apps.PlayerSheet.create-item`, action: `createEmbeddedItem`, visible: () => { return this.isEditable; }, }, ); return controls; }; async _preRender(ctx, options) { this._assertSelectedTabs(); return super._preRender(ctx, options); }; async _onRender(ctx, options) { await super._onRender(ctx, options); new ContextMenu.implementation( this.element, `[data-item-uuid]`, [ createContextMenuOption({ label: _loc(`taf.misc.edit`), visible: (el) => { const itemUuid = el.dataset.itemUuid; const itemExists = itemUuid != null && itemUuid !== ``; return this.isEditable && itemExists; }, onClick: editItemFromElement, }), createContextMenuOption({ label: _loc(`taf.misc.delete`), visible: (el) => { const itemUuid = el.dataset.itemUuid; const itemExists = itemUuid != null && itemUuid !== ``; return this.isEditable && itemExists; }, onClick: deleteItemFromElement, }), ], { jQuery: false, fixed: true }, ); }; // #endregion Lifecycle // #region Data Prep async _prepareContext() { return { meta: { idp: this.id, editable: this.isEditable, }, actor: this.actor, system: this.actor.system, editable: this.isEditable, }; }; async _preparePartContext(partID, ctx) { switch (partID) { case `primaryAttributes`: { await this._preparePrimaryAttributes(ctx); break; }; case `attributeTab`: { await this._prepareAttributesTab(ctx); break; }; case `tabs`: { await this._prepareTabList(ctx); break; }; case `content`: { await this._prepareContent(ctx); break; }; case `items`: { await this._prepareItems(ctx); break; }; }; return ctx; }; async _preparePrimaryAttributes(ctx) { const attrs = this.actor.itemTypes.attribute ?? []; const filtered = attrs.filter(attr => attr.system.aboveTheFold); ctx.hasAttributes = filtered.length > 0; ctx.attrs = filtered; }; async _prepareAttributesTab(ctx) { ctx.tabActive = this.tabGroups.primary === `attributes`; const groups = new Map(); const attrs = (this.actor.itemTypes.attribute ?? []) .toSorted((a, b) => a.name.localeCompare(b.name)); for (const attr of attrs) { if (attr.system.aboveTheFold) { continue }; const groupName = attr.system.group ?? `Attributes`; if (!groups.has(groupName)) { groups.set(groupName, { name: groupName.titleCase(), attrs: [], collapsed: false, }); }; const group = groups.get(groupName); group.attrs.push(attr); }; ctx.attrGroups = [...groups.values()].toSorted((a, b) => a.name.localeCompare(b.name)); }; async _prepareTabList(ctx) { ctx.tabs = await this._prepareTabs(`primary`); let amountVisible = 0; for (const tabID in ctx.tabs) { const visible = this.hasTab(tabID); ctx.tabs[tabID].visible = visible; if (visible) { amountVisible++ }; }; ctx.hideTabs = amountVisible <= 1; }; async _prepareContent(ctx) { // Whether or not the prose-mirror is toggled or always-edit ctx.toggled = true; ctx.tabActive = this.tabGroups.primary === `content`; ctx.enriched = { system: { content: await TextEditor.implementation.enrichHTML(this.actor.system.content), }, }; }; async _prepareItems(ctx) { ctx.tabActive = this.tabGroups.primary === `items`; let totalWeight = 0; ctx.itemGroups = []; for (const [groupName, items] of Object.entries(this.actor.itemTypes)) { // We don't care about attribute items here if (groupName === `attribute`) { continue }; const preparedItems = []; let summedWeight = 0; for (const item of items) { summedWeight += item.system.quantifiedWeight ?? 0; const data = await this._prepareItem(item); if (data) { preparedItems.push(data) }; }; totalWeight += summedWeight; ctx.itemGroups.push({ name: groupName.titleCase(), items: preparedItems, weight: config.weightFormatter(summedWeight), }); }; ctx.totalWeight = config.weightFormatter(totalWeight); ctx.hasCarryingCapacity = this.actor.system.carryCapacity != null; ctx.carryCapacityPercent = Math.round(totalWeight / this.actor.system.carryCapacity * 100); }; async _prepareItem(item) { if (item.type !== `generic`) { return }; const ctx = { uuid: item.uuid, img: item.img, name: item.name, equipped: item.system.equipped, quantity: item.system.quantity, weight: config.weightFormatter(item.system.quantifiedWeight), isExpanded: this.#expandedItems.has(item.uuid), canExpand: item.system.description.length > 0, trigger: item.system.trigger, }; ctx.description = ``; if (item.system.description.length > 0) { ctx.description = await TextEditor.implementation.enrichHTML(item.system.description); }; return ctx; }; // #endregion Data Prep // #region Actions /** * This action overrides the default Foundry action in order to tell * it to open my custom DocumentSheetConfig application instead of * opening the non-customized sheet config app. * * @this {PlayerSheet} */ static async #configureSheet(event) { event.stopPropagation(); if ( event.detail > 1 ) { return } new TAFDocumentSheetConfig({ document: this.document, position: { top: this.position.top + 40, left: this.position.left + ((this.position.width - 60) / 2), }, }).render({ force: true, window: { windowId: this.window.windowId }, }); }; /** * This action is used by the item lists in order to expand/collapse * the descriptions while maintaining that state across renders. * * @this {PlayerSheet} */ static async #toggleExpand(event, target) { const element = target.closest(`[data-item-uuid]`); const { itemUuid } = element?.dataset ?? {}; if (!itemUuid) { return }; const expanded = this.#expandedItems.has(itemUuid); const newExpanded = !expanded; this.#expandedItems[newExpanded ? `add` : `delete`]?.(itemUuid); target.dataset.expanded = newExpanded; const collapses = element.querySelectorAll(`[data-expanded]`); collapses.forEach(el => { el.dataset.expanded = newExpanded; }); }; /** * Used by the sheet in order to create embedded items without needing to have * equivalent World Items or Compendiums initially. * * @this {PlayerSheet} */ static async #createEmbeddedItem(event, target) { let { itemGroup } = target.dataset ?? {}; if (itemGroup === `Items`) { itemGroup = undefined }; const data = { name: Item.defaultName({ type: `generic`, parent: this.actor, }), type: `generic`, system: { group: itemGroup, }, }; const item = await Item.create(data, { parent: this.actor }); item?.sheet?.render({ force: true }); }; /** * Executes an embedded item's triggering Macro if it has one attached to it. * * @this {PlayerSheet} */ static async #executeTrigger(event, target) { const { itemUuid } = target.closest(`[data-item-uuid]`)?.dataset ?? {}; const item = await fromUuid(itemUuid); await item?.system.execute?.(); }; /** * Saves the Actor's current attribute items into the world setting for newly * created Actors to have the same attribute list. * * @this {PlayerSheet} */ static async #saveDefaultAttrs() { TAFActor.setDefaultAttributes(this.actor); }; // #endregion Actions };