taf/module/apps/PlayerSheet.mjs

497 lines
13 KiB
JavaScript

import { __ID__, filePath } from "../consts.mjs";
import { deleteItemFromElement, editItemFromElement } from "./utils.mjs";
import { config } from "../config.mjs";
import { Logger } from "../utils/Logger.mjs";
import { TAFDocumentSheetConfig } from "./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]`,
[
{
label: _loc(`taf.misc.edit`),
condition: (el) => {
const itemUuid = el.dataset.itemUuid;
const itemExists = itemUuid != null && itemUuid !== ``;
return this.isEditable && itemExists;
},
onClick: editItemFromElement,
},
{
label: _loc(`taf.misc.delete`),
condition: (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
};