import React, { ReactNode, useContext } from "react"; import { Checkbox, Collapsible, ComponentConstants, Damage, DamageTypeIcon, DataOriginName, InfusionPreview, } from "@dndbeyond/character-components/es"; import { Action, ActionUtils, CharacterTheme, CharacterUtils, Constants, Container, ContainerUtils, CoreUtils, DataOrigin, DiceUtils, EntityValueLookup, FormatUtils, Infusion, InfusionChoiceLookup, InventoryManager, Item, ItemUtils, PartyInfo, RuleData, RuleDataUtils, SnippetData, SourceMappingContract, Spell, SpellUtils, ValueUtils, WeaponSpellDamageGroup, } from "@dndbeyond/character-rules-engine/es"; import StarIcon from "@dndbeyond/fontawesome-cache/svgs/solid/star.svg"; import { HtmlContent } from "~/components/HtmlContent"; import { InfoItem } from "~/components/InfoItem"; import { NumberDisplay } from "~/components/NumberDisplay"; import { Reference } from "~/components/Reference"; import { TagGroup } from "~/components/TagGroup"; import { useCharacterEngine } from "~/hooks/useCharacterEngine"; import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types"; import ItemDetailAbilities from "../../containers/ItemDetailAbilities"; import ItemDetailActions from "../../containers/ItemDetailActions"; import { InventoryManagerContext } from "../../managers/InventoryManagerContext"; import CustomizeDataEditor from "../CustomizeDataEditor"; import EditorBox from "../EditorBox"; import ValueEditor from "../ValueEditor"; import { RemoveButton } from "../common/Button"; import styles from "./styles.module.css"; const infoItemProps = { role: "listitem", inline: true }; interface Props { item: Item; weaponSpellDamageGroups: Array; ruleData: RuleData; snippetData: SnippetData; className: string; showAbilities: boolean; showCustomize: boolean; showActions: boolean; showIntro: boolean; showImage: boolean; onCustomDataUpdate?: ( adjustmentType: Constants.AdjustmentTypeEnum, value: any ) => void; onCustomizationsRemove?: () => void; onCustomItemEdit?: (data: Record) => void; onDataOriginClick?: (dataOrigin: DataOrigin) => void; onSpellClick?: (spell: Spell) => void; onInfusionClick?: (infusion: Infusion) => void; onMasteryActionClick?: (action: Action) => void; entityValueLookup: EntityValueLookup; infusionChoiceLookup: InfusionChoiceLookup; isReadonly: boolean; proficiencyBonus: number; theme: CharacterTheme; container: Container | null; onPostRemoveNavigation?: PaneComponentEnum; partyInfo: PartyInfo; inventoryManager: InventoryManager; isCustomizeClosed?: boolean; onCustomizeClick?: (isCollapsed: boolean) => void; actions: Array; } export class ItemDetail extends React.PureComponent { static defaultProps = { weaponSpellDamageGroups: [], className: "", showCustomize: true, showAbilities: true, showActions: true, showIntro: true, showImage: true, snippetData: null, entityValueLookup: {}, infusionChoiceLookup: {}, isReadonly: false, container: null, }; handleSpellClick = (spell: Spell, evt: React.MouseEvent): void => { const { onSpellClick } = this.props; if (onSpellClick) { evt.stopPropagation(); evt.nativeEvent.stopImmediatePropagation(); onSpellClick(spell); } }; handleInfusionClick = () => { const { item, onInfusionClick } = this.props; const infusion = ItemUtils.getInfusion(item); if (onInfusionClick && infusion) { onInfusionClick(infusion); } }; handleMasteryActionClick = (evt: React.MouseEvent) => { const { item, onMasteryActionClick, actions } = this.props; const masteryAction = ItemUtils.getMasteryAction(item, actions); if (masteryAction && onMasteryActionClick) { onMasteryActionClick(masteryAction); } }; handleReplaceAbilityStatChange = ( enabled: boolean, statId: Constants.AbilityStatEnum ) => { const { onCustomDataUpdate } = this.props; const adjustmentType = CoreUtils.getAdjustmentTypeByStatId(statId); if (onCustomDataUpdate && adjustmentType) { onCustomDataUpdate(adjustmentType, enabled); } }; handleHexWeaponChange = (enabled) => { const { onCustomDataUpdate } = this.props; if (onCustomDataUpdate) { onCustomDataUpdate(Constants.AdjustmentTypeEnum.IS_HEXBLADE, enabled); } }; handleDedicatedWeaponChange = (enabled) => { const { onCustomDataUpdate } = this.props; if (onCustomDataUpdate) { onCustomDataUpdate( Constants.AdjustmentTypeEnum.IS_DEDICATED_WEAPON, enabled ); } }; handlePactWeaponChange = (enabled) => { const { onCustomDataUpdate, item } = this.props; if (onCustomDataUpdate) { const isHexWeapon = ItemUtils.isHexWeapon(item); if ( !enabled && isHexWeapon && !this.shouldShowDependentHexWeapons(enabled) ) { onCustomDataUpdate(Constants.AdjustmentTypeEnum.IS_HEXBLADE, null); } onCustomDataUpdate(Constants.AdjustmentTypeEnum.IS_PACT_WEAPON, enabled); } }; handleRemoveCustomizations = () => { const { onCustomizationsRemove } = this.props; if (onCustomizationsRemove) { onCustomizationsRemove(); } }; shouldShowDependentHexWeapons = (isPactWeapon) => { const { item } = this.props; return ( isPactWeapon || (!isPactWeapon && !ItemUtils.hasWeaponProperty( item, Constants.WeaponPropertyEnum.TWO_HANDED )) ); }; shouldShowHexWeapon = () => { const { item } = this.props; const canHexWeapon = ItemUtils.canHexWeapon(item); const isPactWeapon = ItemUtils.isPactWeapon(item); const canPactWeapon = ItemUtils.canPactWeapon(item); const hexWeaponEnabled = ItemUtils.hexWeaponEnabled(item); if (!hexWeaponEnabled) { return false; } let showHexWeapon = false; if (canPactWeapon) { showHexWeapon = this.shouldShowDependentHexWeapons(isPactWeapon); } else { showHexWeapon = canHexWeapon; } return showHexWeapon; }; getSourceReference = (item: Item): ReactNode => { const { theme, ruleData } = this.props; let sourceId: number | null = null; let sourcePage: number | null = null; let filteredSources = ItemUtils.getSources(item).filter( CharacterUtils.isPrimarySource ); if (filteredSources.length) { let primarySource: SourceMappingContract = filteredSources[0]; sourceId = primarySource.sourceId; sourcePage = primarySource.pageNumber; } if (sourceId === null) { return null; } return ( ); }; renderIntro = () => { const { item, container, partyInfo, inventoryManager } = this.props; let type = ItemUtils.getType(item); const subType = ItemUtils.getSubType(item); const baseArmorName = ItemUtils.getBaseArmorName(item); const rarity = ItemUtils.getRarity(item); const canAttune = ItemUtils.canAttune(item); const equippedEntityId = ItemUtils.getEquippedEntityId(item); const attunementDescription = ItemUtils.getAttunementDescription(item); const isCustom = ItemUtils.isCustom(item); if (ItemUtils.isWeaponContract(item)) { if (ItemUtils.isMagic(item)) { type = `Weapon (${type})`; } else { type = "Weapon"; } } else if (ItemUtils.isArmorContract(item)) { if (ItemUtils.isMagic(item)) { type = `Armor (${baseArmorName})`; } else { type = "Armor"; } } else if (ItemUtils.isGearContract(item)) { type = subType ? subType : type; } else if (isCustom) { type = "Custom"; } return ( <>
{ItemUtils.isLegacy(item) && "Legacy • "} {type} {!isCustom && rarity ? `, ${rarity}` : ""} {canAttune && ` (requires attunement${ attunementDescription ? ` by a ${attunementDescription}` : "" })`} {ItemUtils.isContainer(item) && ", container"}
{container && (
{" "} {!ItemUtils.isContainer(item) ? ` In ${ContainerUtils.getName(container)}` : ` In ${ ContainerUtils.isShared(container) ? "Party " : "" }Inventory`}
{partyInfo && equippedEntityId && `${ inventoryManager.isEquippedToCurrentCharacter(item) ? "Equipped" : `Equipped by ${inventoryManager.getEquippedCharacterName( item )}` }`}
)} ); }; renderAbilities = () => { const { item } = this.props; return ; }; renderClassCustomize = () => { const { item, ruleData } = this.props; const isHexWeapon = ItemUtils.isHexWeapon(item); const canHexWeapon = ItemUtils.canHexWeapon(item); const isPactWeapon = ItemUtils.isPactWeapon(item); const canPactWeapon = ItemUtils.canPactWeapon(item); const canBeDedicatedWeapon = ItemUtils.canBeDedicatedWeapon(item); const isDedicatedWeapon = ItemUtils.isDedicatedWeapon(item); const replacementWeaponStats = ItemUtils.getReplacementWeaponStats(item); const appliedWeaponReplacementStats = ItemUtils.getAppliedWeaponReplacementStats(item); if ( !canHexWeapon && !canPactWeapon && !canBeDedicatedWeapon && replacementWeaponStats.length === 0 ) { return null; } return (
{canPactWeapon && (
)} {this.shouldShowHexWeapon() && (
)} {canBeDedicatedWeapon && (
)} {replacementWeaponStats.map((statId) => (
this.handleReplaceAbilityStatChange(enabled, statId) } label={`Use ${RuleDataUtils.getStatNameById( statId, ruleData, true )}`} />
))}
); }; renderCustomItemEdit = (): React.ReactNode => { const { item, onCustomItemEdit } = this.props; let originalContract = ItemUtils.getOriginalContract(item); if (!originalContract || !onCustomItemEdit) { return null; } const { id, quantity, ...itemData } = originalContract; return ( ); }; renderCustomize = (): React.ReactNode => { const { item, ruleData, onCustomDataUpdate, entityValueLookup, isReadonly, } = this.props; if (isReadonly) { return; } const isWeaponLike = ItemUtils.validateIsWeaponLike(item); let customizationValues: Array = []; if (isWeaponLike) { customizationValues = [ ...customizationValues, Constants.AdjustmentTypeEnum.TO_HIT_OVERRIDE, Constants.AdjustmentTypeEnum.TO_HIT_BONUS, Constants.AdjustmentTypeEnum.FIXED_VALUE_BONUS, ]; } customizationValues.push(Constants.AdjustmentTypeEnum.COST_OVERRIDE); customizationValues.push(Constants.AdjustmentTypeEnum.WEIGHT_OVERRIDE); if (ItemUtils.isContainer(item)) { customizationValues.push( Constants.AdjustmentTypeEnum.CAPACITY_WEIGHT_OVERRIDE ); } if (ItemUtils.canOffhand(item)) { customizationValues.push(Constants.AdjustmentTypeEnum.IS_OFFHAND); } if (ItemUtils.isWeaponContract(item)) { customizationValues = [ ...customizationValues, Constants.AdjustmentTypeEnum.IS_SILVER, Constants.AdjustmentTypeEnum.IS_ADAMANTINE, ]; } if (isWeaponLike) { customizationValues.push(Constants.AdjustmentTypeEnum.DISPLAY_AS_ATTACK); } customizationValues.push(Constants.AdjustmentTypeEnum.NAME_OVERRIDE); customizationValues.push(Constants.AdjustmentTypeEnum.NOTES); let customizationLabelOverrides: Partial< Record > = { [Constants.AdjustmentTypeEnum.NAME_OVERRIDE]: "Name", [Constants.AdjustmentTypeEnum.IS_SILVER]: "Silvered", [Constants.AdjustmentTypeEnum.FIXED_VALUE_BONUS]: "Damage Bonus", [Constants.AdjustmentTypeEnum.IS_ADAMANTINE]: "Adamantine", }; let customizationDefaults: Partial< Record > = { [Constants.AdjustmentTypeEnum.DISPLAY_AS_ATTACK]: ItemUtils.isDisplayAsAttack(item), }; const isCustomized = ItemUtils.isCustomized(item); return ( {isCustomized ? "Remove" : "No"} Customizations ); //` }; renderCustomProperties = (): React.ReactNode => { const { item } = this.props; const weight = ItemUtils.getWeight(item); const cost = ItemUtils.getCost(item); const notes = ItemUtils.getNotes(item); return (
{weight ? : "--"} {cost ? `${FormatUtils.renderLocaleNumber(cost)} gp` : "--"} {notes && ( {notes} )}
); //` }; renderGearProperties = (): React.ReactNode => { const { item, theme } = this.props; const bundleSize = ItemUtils.getBundleSize(item); const isStackable = ItemUtils.isStackable(item); const version = ItemUtils.getVersion(item); const weight = ItemUtils.getWeight(item); const capacityWeight = ItemUtils.getCapacityWeight(item); const notes = ItemUtils.getNotes(item); const cost = ItemUtils.getCost(item); return (
{weight ? : "--"} {ItemUtils.isContainer(item) && ( {capacityWeight ? ( ) : ( "--" )} )} {cost ? `${FormatUtils.renderLocaleNumber(cost)} gp` : "--"} {isStackable && bundleSize > 1 && ( {bundleSize} )} {version !== null && ( {version} )} {notes && ( {notes} )} {this.getSourceReference(item)}
); //` }; renderWeaponProperties = (): React.ReactNode => { const { item, theme } = this.props; const bundleSize = ItemUtils.getBundleSize(item); const isStackable = ItemUtils.isStackable(item); const version = ItemUtils.getVersion(item); const weight = ItemUtils.getWeight(item); const reach = ItemUtils.getReach(item); const range = ItemUtils.getRange(item); const longRange = ItemUtils.getLongRange(item); const versatileDamage = ItemUtils.getVersatileDamage(item); const additionalDamages = ItemUtils.getAdditionalDamages(item); const attackType = ItemUtils.getAttackType(item); const attackTypeName: string = attackType === null ? "" : RuleDataUtils.getAttackTypeRangeName(attackType); const damageType = ItemUtils.getDamageType(item); const damage = ItemUtils.getDamage(item); const properties = ItemUtils.getProperties(item); const proficiency = ItemUtils.hasProficiency(item); const notes = ItemUtils.getNotes(item); const cost = ItemUtils.getCost(item); let versatileDamageNode: React.ReactNode; if (versatileDamage) { versatileDamageNode = ( ({DiceUtils.renderDie(versatileDamage)}) ); } let damageDisplay: React.ReactNode = "--"; if (damage !== null) { damageDisplay = ; } const showRange: boolean = attackType === Constants.AttackTypeRangeEnum.RANGED || ItemUtils.hasWeaponProperty(item, Constants.WeaponPropertyEnum.THROWN) || ItemUtils.hasWeaponProperty(item, Constants.WeaponPropertyEnum.RANGE); return (
{proficiency && ( Yes )} {attackType === null ? "--" : attackTypeName} {reach && ( )} {showRange && ( / )}
{damageDisplay} {versatileDamageNode}
{additionalDamages.map((additionalDamage, idx) => (
{typeof additionalDamage.damage === "number" ? additionalDamage.damage : DiceUtils.renderDie(additionalDamage.damage)} {additionalDamage.damageType !== null && ( )} {additionalDamage.info && ( {additionalDamage.info} )}
))}
{damageType === null ? ( "--" ) : ( {" "} {damageType} )} {weight ? : "--"} {cost ? `${FormatUtils.renderLocaleNumber(cost)} gp` : "--"} {isStackable && bundleSize > 1 && ( {bundleSize} )} {properties && properties.length > 0 && ( {properties .map((prop) => prop.name + (prop.notes ? ` (${prop.notes})` : "")) .join(", ")} )} {version !== null && ( {version} )} {notes && ( {notes} )} {this.getSourceReference(item)}
); //` }; renderArmorProperties = (): React.ReactNode => { const { item, theme } = this.props; const bundleSize = ItemUtils.getBundleSize(item); const isStackable = ItemUtils.isStackable(item); const version = ItemUtils.getVersion(item); const armorClass = ItemUtils.getArmorClass(item); const weight = ItemUtils.getWeight(item); const cost = ItemUtils.getCost(item); const notes = ItemUtils.getNotes(item); let armorClassDisplay: React.ReactNode = armorClass; if (ItemUtils.isShield(item)) { armorClassDisplay = ( ); } return (
{armorClassDisplay} {weight ? : "--"} {cost ? `${FormatUtils.renderLocaleNumber(cost)} gp` : "--"} {isStackable && bundleSize > 1 && ( {bundleSize} )} {version !== null && ( {version} )} {notes && ( {notes} )} {this.getSourceReference(item)}
); //` }; renderWeaponSpellDamage = (): React.ReactNode => { const { item, weaponSpellDamageGroups, onDataOriginClick, theme } = this.props; if (!ItemUtils.validateIsWeaponLike(item)) { return null; } let filteredSpellDamage = ItemUtils.getApplicableWeaponSpellDamageGroups( item, weaponSpellDamageGroups ); if (!filteredSpellDamage.length) { return null; } return (
{filteredSpellDamage.map((spellDamageGroup, groupIdx) => (
{SpellUtils.getName(spellDamageGroup.spell)} ( )
{spellDamageGroup.damageDice.map((damage, itemIdx) => (
{damage.restriction && ( ({damage.restriction}) )}
))}
))}
); //` }; renderProperties = (): React.ReactNode => { const { item } = this.props; if (ItemUtils.isWeaponContract(item)) { return this.renderWeaponProperties(); } else if (ItemUtils.isArmorContract(item)) { return this.renderArmorProperties(); } else if (ItemUtils.isGearContract(item)) { return this.renderGearProperties(); } else if (ItemUtils.isCustom(item)) { return this.renderCustomProperties(); } return null; }; renderTags = (): React.ReactNode => { const { item } = this.props; const tags = ItemUtils.getTags(item); if (!tags.length) { return null; } return ; }; renderImage = (): React.ReactNode => { const { item } = this.props; const largeImageUrl = ItemUtils.getLargeImageUrl(item); if (!largeImageUrl) { return null; } return (
); }; renderDescription = (): React.ReactNode => { const { item } = this.props; const description = ItemUtils.getDescription(item); if (!description) { return null; } if (ItemUtils.isCustom(item)) { return (
{description}
); } else { return ( ); } }; renderInfusionPreview = (): React.ReactNode => { const { item, snippetData, ruleData, infusionChoiceLookup, proficiencyBonus, } = this.props; const infusion = ItemUtils.getInfusion(item); if (infusion && snippetData && infusionChoiceLookup) { return (
); } return null; }; render() { const { item, className, showActions, showAbilities, showCustomize, showIntro, showImage, onPostRemoveNavigation, isCustomizeClosed, onCustomizeClick, actions, theme, } = this.props; const isCustomized = ItemUtils.isCustomized(item) && !ItemUtils.isCustom(item); const masteryAction = ItemUtils.getMasteryAction(item, actions); return (
{showIntro && this.renderIntro()} {showAbilities && this.renderAbilities()} {showCustomize && (
{ItemUtils.isCustom(item) ? this.renderCustomItemEdit() : this.renderCustomize()}
)} {showCustomize && this.renderClassCustomize()} {this.renderProperties()} {this.renderWeaponSpellDamage()} {this.renderDescription()} {this.renderTags()} {masteryAction && (
Mastery:{" "} {ActionUtils.getName(masteryAction)}
)} {this.renderInfusionPreview()} {showImage && this.renderImage()} {showActions && ( )}
); } } const ItemDetailContainer = (props) => { const { inventoryManager } = useContext(InventoryManagerContext); const { actions } = useCharacterEngine(); return ( ); }; export default ItemDetailContainer;