Grabbed dndbeyond's source code

```
~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js
```
This commit is contained in:
2025-05-28 11:50:03 -07:00
commit 8df9031d27
3592 changed files with 319051 additions and 0 deletions
@@ -0,0 +1,83 @@
import { FC, HTMLAttributes, ReactNode } from "react";
import { useSelector } from "react-redux";
import { AbilityScoreManager } from "~/components/AbilityScoreManager";
import { HtmlContent } from "~/components/HtmlContent";
import { NumberDisplay } from "~/components/NumberDisplay";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useAbilities } from "~/hooks/useAbilities";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { PaneInitFailureContent } from "~/subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { AbilityIcon } from "~/tools/js/smartComponents/Icons";
import { PaneIdentifiersAbility } from "../../types";
import styles from "./styles.module.css";
/*
AbilityPane is the specific Sidebar for each Ability on a character
(Strength, Dexterity, Constitution, Wisdom, Intelligence, Charisma)
*/
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersAbility | null;
}
export const AbilityPane: FC<Props> = ({ identifiers, ...props }) => {
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const { isDarkMode } = useCharacterTheme();
const abilities = useAbilities();
const ability =
(identifiers &&
abilities.find((a) => a.getId() === identifiers?.ability.getId())) ||
identifiers?.ability;
if (!ability) {
return <PaneInitFailureContent />;
}
const statId = ability.getId();
let sidebarPreviewNode: ReactNode = (
<Preview>
<AbilityIcon
className={styles.icon}
statId={statId}
themeMode={isDarkMode ? "light" : "dark"}
/>
</Preview>
);
const modifier = ability.getModifier();
const abilityCompendiumText = ability.getCompendiumText();
return (
<div key={statId} {...props}>
<Header preview={sidebarPreviewNode}>
{ability.getLabel()} {ability.getTotalScore()}
{modifier !== null && (
<span className={styles.modifier}>
(
<NumberDisplay
type="signed"
number={modifier}
className={styles.signedNumber}
/>
)
</span>
)}
</Header>
<AbilityScoreManager
ability={ability}
showHeader={false}
isReadonly={isReadonly}
/>
{abilityCompendiumText && (
<HtmlContent
className={styles.description}
html={abilityCompendiumText}
withoutTooltips
/>
)}
</div>
);
};
@@ -0,0 +1,214 @@
import { ComponentType, FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
AbilityIcon,
Collapsible,
DarkModeNegativeBonusNegativeSvg,
DarkModePositiveBonusPositiveSvg,
NegativeBonusNegativeSvg,
PositiveBonusPositiveSvg,
} from "@dndbeyond/character-components/es";
import {
AbilityUtils,
characterActions,
Constants,
FormatUtils,
ModifierUtils,
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { NumberDisplay } from "~/components/NumberDisplay";
import { RuleKeyEnum } from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import { Ability, SituationalSavingThrowInfo, StatDataContract } from "~/types";
import EditorBox from "../../../../../../tools/js/Shared/components/EditorBox";
import ValueEditor from "../../../../../../tools/js/Shared/components/ValueEditor";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import {
PaneComponentEnum,
PaneIdentifiersAbilitySavingThrow,
} from "../../types";
import styles from "./styles.module.css";
/*
AbilitySavingThrowsPane is the specific Sidebar for each Ability Saving Throw on a character - (Strength, Dexterity, Constitution, Wisdom, Intelligence, Charisma) - it displays the details, modifiers, any customizations and overrides, as well as the situational bonuses.
*/
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersAbilitySavingThrow | null;
}
export const AbilitySavingThrowsPane: FC<Props> = ({
identifiers,
...props
}) => {
const dispatch = useDispatch();
const {
ruleData,
situationalBonusSavingThrowsLookup,
abilityLookup,
entityValueLookup,
} = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
const { isDarkMode } = useCharacterTheme();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const {
pane: { paneHistoryPush },
} = useSidebar();
const abilityDataLookup = ruleDataUtils.getStatsLookup(ruleData);
const ability: Ability | null = identifiers && abilityLookup[identifiers.id];
const abilityData: StatDataContract | null =
identifiers && abilityDataLookup[identifiers.id];
const situationalBonusSavingThrowsInfo: SituationalSavingThrowInfo[] | null =
identifiers && situationalBonusSavingThrowsLookup[identifiers.id];
const rulesText = ruleDataUtils.getRule(RuleKeyEnum.SAVING_THROWS, ruleData);
const handleCustomDataUpdate = (
key: number,
value: any,
source: string
): void => {
if (!abilityData) return;
dispatch(
characterActions.valueSet(
key,
value,
source,
ValueUtils.hack__toString(abilityData.id),
ValueUtils.hack__toString(abilityData.entityTypeId)
)
);
};
const handleParentClick = (): void => {
paneHistoryPush(PaneComponentEnum.SAVING_THROWS);
};
if (!ability || !abilityData) {
return <PaneInitFailureContent />;
}
const statId = AbilityUtils.getId(ability);
let sidebarPreviewNode: ReactNode = (
<Preview>
<AbilityIcon
className={styles.icon}
statId={statId}
themeMode={isDarkMode ? "light" : "dark"}
/>
</Preview>
);
return (
<div key={statId} {...props}>
<Header
preview={sidebarPreviewNode}
parent="Saving Throws"
onClick={handleParentClick}
>
{abilityData.name} Saving Throw
<span className={styles.modifier}>
<NumberDisplay type="signed" number={AbilityUtils.getSave(ability)} />
</span>
</Header>
{!isReadonly && (
<Collapsible
layoutType={"minimal"}
header="Customize"
className={styles.customize}
>
<EditorBox className={styles.editorBox}>
<ValueEditor
dataLookup={ValueUtils.getEntityData(
entityValueLookup,
ValueUtils.hack__toString(abilityData.id),
ValueUtils.hack__toString(abilityData.entityTypeId)
)}
onDataUpdate={handleCustomDataUpdate}
valueEditors={[
Constants.AdjustmentTypeEnum.SAVING_THROW_OVERRIDE,
Constants.AdjustmentTypeEnum.SAVING_THROW_MAGIC_BONUS,
Constants.AdjustmentTypeEnum.SAVING_THROW_MISC_BONUS,
Constants.AdjustmentTypeEnum.SAVING_THROW_PROFICIENCY_LEVEL,
]}
ruleData={ruleData}
/>
</EditorBox>
</Collapsible>
)}
{situationalBonusSavingThrowsInfo && (
<div className={styles.situational}>
{situationalBonusSavingThrowsInfo.map((situationalBonus) => {
let abilityNode: ReactNode;
if (
situationalBonus.type ===
Constants.SituationalBonusSavingThrowTypeEnum.MODIFIER
) {
let restriction = ModifierUtils.getRestriction(
situationalBonus.extra
);
if (restriction) {
restriction = restriction.trim();
restriction = FormatUtils.lowerCaseLetters(restriction, 0);
abilityNode = <>{restriction}</>;
}
}
if (!abilityNode) {
abilityNode = <>on saves</>;
}
const saveAmount = situationalBonus.value;
let IconComponent: ComponentType<any>;
if (situationalBonus.value >= 0) {
IconComponent = isDarkMode
? DarkModePositiveBonusPositiveSvg
: PositiveBonusPositiveSvg;
} else {
IconComponent = isDarkMode
? DarkModeNegativeBonusNegativeSvg
: NegativeBonusNegativeSvg;
}
return (
<div
className={styles.situationalBonus}
key={situationalBonus.key}
>
<IconComponent className={styles.situationalBonusIcon} />
<span className={styles.situationalBonusValue}>
{saveAmount}
</span>
<span className={styles.situationalBonusRestriction}>
{abilityNode}
</span>
</div>
);
})}
</div>
)}
{rulesText && rulesText.description && (
<HtmlContent
className={styles.description}
html={rulesText.description}
withoutTooltips
/>
)}
</div>
);
};
@@ -0,0 +1,195 @@
import { FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch, useSelector } from "react-redux";
import { ActionName } from "@dndbeyond/character-components/es";
import {
ActionUtils,
characterActions,
InfusionUtils,
HelperUtils,
ItemUtils,
EntityUtils,
ItemManager,
} from "@dndbeyond/character-rules-engine/es";
import { ItemName } from "~/components/ItemName";
import { DataOriginTypeEnum } from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { BaseInventoryContract } from "~/types";
import { ActionDetail } from "../../../../../../tools/js/Shared/components/ActionDetail";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { getDataOriginComponentInfo } from "../../helpers/paneUtils";
import { PaneComponentEnum, PaneIdentifiersAction } from "../../types";
/*
ActionPane is the Sidebar content that displays the details and usages of actions. If the Action is directly represented as an Item in the Actions section - it will display the ItemPane in the Sidebar. Some Items contain actions (reactions or bonus actions) when attuned - These will display seperately from the Item and when clicked they use the ActionPane with special data depending on the DataOrigin.
*/
interface ActionPaneProps extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersAction | null;
}
export const ActionPane: FC<ActionPaneProps> = ({ identifiers, ...props }) => {
const {
actions,
abilityLookup,
ruleData,
entityValueLookup,
proficiencyBonus,
inventoryLookup,
characterTheme,
} = useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const dispatch = useDispatch();
const {
pane: { paneHistoryPush },
} = useSidebar();
const action = actions.find(
(action) =>
identifiers?.id === ActionUtils.getMappingId(action) &&
identifiers?.entityTypeId === ActionUtils.getMappingEntityTypeId(action)
);
const handleCustomDataUpdate = (
key: number,
value: string,
source: string
): void => {
if (action) {
dispatch(
characterActions.valueSet(
key,
value,
source,
ActionUtils.getMappingId(action),
ActionUtils.getMappingEntityTypeId(action)
)
);
}
};
const handleRemoveCustomizations = (): void => {
if (action) {
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
dispatch(
characterActions.actionCustomizationsDelete(
mappingId,
mappingEntityTypeId
)
);
}
}
};
const handleLimitedUseSet = (uses: number): void => {
if (action) {
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId === null || mappingEntityTypeId === null) {
return;
}
let dataOriginType = ActionUtils.getDataOriginType(action);
if (dataOriginType === DataOriginTypeEnum.ITEM) {
const dataOrigin = ActionUtils.getDataOrigin(action);
const itemData = dataOrigin.primary as BaseInventoryContract;
const item = ItemManager.getItem(ItemUtils.getMappingId(itemData));
item.handleItemLimitedUseSet(uses);
} else {
dispatch(
characterActions.actionUseSet(
mappingId,
mappingEntityTypeId,
uses,
dataOriginType
)
);
}
}
};
const handleParentClick = (): void => {
if (action) {
let dataOrigin = ActionUtils.getDataOrigin(action);
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
}
};
if (!action) {
return <PaneInitFailureContent />;
}
//We could do the dataOrigin stuff better, this could be refactored
//Check ItemPane and InfusionPanes for similar usages of DataOrigin
const dataOrigin = ActionUtils.getDataOrigin(action);
const dataOriginType = ActionUtils.getDataOriginType(action);
let name: ReactNode = null;
if (ActionUtils.getName(action) !== "") {
name = <ActionName theme={characterTheme} action={action} />;
}
let parentName: string | null = null;
let parentClick: (() => void) | undefined = undefined;
if (dataOriginType === DataOriginTypeEnum.ITEM) {
const itemContract = dataOrigin.primary as BaseInventoryContract;
const itemMappingId = ItemUtils.getMappingId(itemContract);
const item = HelperUtils.lookupDataOrFallback(
inventoryLookup,
itemMappingId
);
if (item !== null) {
let infusion = ItemUtils.getInfusion(item);
if (infusion) {
parentName = `Infusion: ${InfusionUtils.getName(infusion)}`;
parentClick = handleParentClick;
}
if (name === null) {
name = <ItemName item={item} onClick={handleParentClick} />;
}
}
} else {
if (dataOrigin.primary) {
parentName = EntityUtils.getDataOriginName(dataOrigin);
parentClick = handleParentClick;
}
}
return (
<div key={ActionUtils.getUniqueKey(action)} {...props}>
<Header parent={parentName} onClick={parentClick}>
{name}
</Header>
<ActionDetail
theme={characterTheme}
action={action}
abilityLookup={abilityLookup}
showCustomize={dataOriginType !== DataOriginTypeEnum.ITEM}
onCustomDataUpdate={handleCustomDataUpdate}
onCustomizationsRemove={handleRemoveCustomizations}
ruleData={ruleData}
entityValueLookup={entityValueLookup}
inventoryLookup={inventoryLookup}
isReadonly={isReadonly}
onLimitedUseSet={handleLimitedUseSet}
proficiencyBonus={proficiencyBonus}
/>
</div>
);
};
@@ -0,0 +1,137 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { DataOriginName } from "@dndbeyond/character-components/es";
import {
FormatUtils,
ItemUtils,
ModifierUtils,
} from "@dndbeyond/character-rules-engine";
import { ItemName } from "~/components/ItemName";
import { NumberDisplay } from "~/components/NumberDisplay";
import { ArmorClassExtraTypeEnum, ArmorClassTypeEnum } from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import { Item, DataOrigin, Modifier } from "~/types";
import { getDataOriginComponentInfo } from "../../../helpers/paneUtils";
import { PaneComponentEnum } from "../../../types";
import styles from "./styles.module.css";
export interface ArmorClassDetailProps extends HTMLAttributes<HTMLDivElement> {}
export const ArmorClassDetail: FC<ArmorClassDetailProps> = ({
className,
...props
}) => {
const {
pane: { paneHistoryPush },
} = useSidebar();
const { acAdjustments, acSuppliers, characterTheme } = useCharacterEngine();
let hasOverrideAcAdjustment =
acAdjustments.overrideAc && acAdjustments.overrideAc.value !== null;
const handleItemDetailShow = (item: Item): void => {
paneHistoryPush(
PaneComponentEnum.ITEM_DETAIL,
PaneIdentifierUtils.generateItem(ItemUtils.getMappingId(item))
);
};
const handleDataOriginShowDetail = (dataOrigin: DataOrigin): void => {
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
};
return (
<div
className={clsx([
styles.items,
hasOverrideAcAdjustment && styles.itemsOverridden,
])}
>
{acSuppliers.map((armorSupplier, idx) => {
const { type, amount, extra, extraType } = armorSupplier;
if (!amount) {
return null;
}
let amountDisplay: ReactNode;
switch (type) {
case ArmorClassTypeEnum.ARMOR:
case ArmorClassTypeEnum.OVERRIDE_BASE_ARMOR:
amountDisplay = amount;
break;
default:
amountDisplay = <NumberDisplay type="signed" number={amount} />;
}
let extraDisplay: ReactNode;
switch (type) {
case ArmorClassTypeEnum.DEX_BONUS:
extraDisplay = extra === null ? "" : `Max ${extra}`;
break;
default:
switch (extraType) {
case ArmorClassExtraTypeEnum.MODIFIER:
extraDisplay = (
<DataOriginName
dataOrigin={ModifierUtils.getDataOrigin(extra as Modifier)}
onClick={handleDataOriginShowDetail}
theme={characterTheme}
/>
);
break;
case ArmorClassExtraTypeEnum.ITEM:
if (extra) {
extraDisplay = (
<ItemName
item={extra as Item}
onClick={() => handleItemDetailShow(extra as Item)}
/>
);
} else {
extraDisplay = "None";
}
break;
case ArmorClassExtraTypeEnum.NUMBER:
extraDisplay = extra
? FormatUtils.renderLocaleNumber(extra as number)
: "";
break;
case ArmorClassExtraTypeEnum.STRING:
default:
extraDisplay = extra;
}
break;
}
return (
<div
className={styles.item}
key={idx}
{...props}
data-testid="armor-class-detail"
>
<div className={styles.value}>{amountDisplay}</div>
<div className={styles.label}>
<span>{type}</span>
{extraDisplay && (
<span className={styles.source}>({extraDisplay})</span>
)}
</div>
</div>
);
})}
</div>
);
};
@@ -0,0 +1,106 @@
import { FC, HTMLAttributes } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Collapsible } from "@dndbeyond/character-components/es";
import {
characterActions,
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { AdjustmentTypeEnum, RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import EditorBox from "~/tools/js/Shared/components/EditorBox";
import ValueEditor from "~/tools/js/Shared/components/ValueEditor";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { ArmorClassDetail } from "./ArmorClassDetail";
import styles from "./styles.module.css";
/*
This is the sidebar information for Armor Class. It displays the sources of any Armor Class bonuses and you can provide customizations and overrides here.
*/
interface ArmorManagePaneProps extends HTMLAttributes<HTMLDivElement> {}
export const ArmorManagePane: FC<ArmorManagePaneProps> = ({ ...props }) => {
const dispatch = useDispatch();
const { acAdjustments, acTotal, valueLookup, ruleData } =
useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const { ruleDataUtils } = useRuleData();
const acRule =
ruleDataUtils.getRule(RuleKeyEnum.ARMOR_CLASS, ruleData)?.description ??
null;
const customizationValues = [
AdjustmentTypeEnum.OVERRIDE_AC,
AdjustmentTypeEnum.OVERRIDE_BASE_ARMOR,
AdjustmentTypeEnum.MAGIC_BONUS_AC,
AdjustmentTypeEnum.MISC_BONUS_AC,
];
let hasOverrideAcAdjustment: boolean =
acAdjustments.overrideAc !== null &&
acAdjustments.overrideAc.value !== null;
let overrideAcAdjustmentSource: string | null =
hasOverrideAcAdjustment && acAdjustments.overrideAc
? acAdjustments.overrideAc.notes
: "";
const handleCustomDataUpdate = (
key: number,
value: string,
source: string
): void => {
dispatch(characterActions.valueSet(key, value, source));
};
return (
<div {...props}>
<Header>
Armor Class: {acTotal}
{hasOverrideAcAdjustment ? "*" : ""}
{hasOverrideAcAdjustment && (
<span className={styles.headingOverride}>
(
{overrideAcAdjustmentSource
? overrideAcAdjustmentSource
: "Override"}
)
</span>
)}
</Header>
<ArmorClassDetail />
{/* AC CUSTOMIZATION */}
{!isReadonly && (
<Collapsible
layoutType={"minimal"}
header="Customize"
className={styles.customize}
>
<EditorBox className={styles.editorBox}>
<ValueEditor
dataLookup={ValueUtils.getDataLookup(
valueLookup,
customizationValues
)}
onDataUpdate={handleCustomDataUpdate}
valueEditors={customizationValues}
ruleData={ruleData}
/>
</EditorBox>
</Collapsible>
)}
{/* AC Description */}
{acRule && (
<HtmlContent
className={styles.description}
html={acRule}
withoutTooltips
/>
)}
</div>
);
};
@@ -0,0 +1,75 @@
import { FC, HTMLAttributes } from "react";
import { BackgroundUtils } from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
import { PaneInitFailureContent } from "~/subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
import { FeatureSnippetChoices } from "../../../../../../tools/js/CharacterSheet/components/FeatureSnippet";
import styles from "./styles.module.css";
/*
This is the Sidebar display for Background. You can access this Pane by clicking on the Background data displayed in the Description tab section of the Character Sheet.
*/
interface BackgroundPaneProps extends HTMLAttributes<HTMLDivElement> {}
export const BackgroundPane: FC<BackgroundPaneProps> = ({ ...props }) => {
const {
ruleData,
abilityLookup,
backgroundInfo,
snippetData,
originRef,
proficiencyBonus,
characterTheme,
} = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
if (!backgroundInfo) {
return <PaneInitFailureContent />;
}
const hasCustomBackground =
BackgroundUtils.getHasCustomBackground(backgroundInfo);
const description = BackgroundUtils.getDescription(backgroundInfo);
const featureName = BackgroundUtils.getFeatureName(backgroundInfo);
return (
<div {...props}>
<Header>{BackgroundUtils.getName(backgroundInfo)}</Header>
<div className={styles.data}>
<div className={styles.choices}>
<FeatureSnippetChoices
choices={BackgroundUtils.getChoices(backgroundInfo)}
ruleData={ruleData}
abilityLookup={abilityLookup}
snippetData={snippetData}
sourceDataLookup={ruleDataUtils.getSourceDataLookup(ruleData)}
dataOriginRefData={originRef}
proficiencyBonus={proficiencyBonus}
theme={characterTheme}
/>
</div>
</div>
{hasCustomBackground ? (
<div className={styles.customDescription}>{description}</div>
) : (
<HtmlContent html={description ? description : ""} withoutTooltips />
)}
{hasCustomBackground && featureName && (
<div>
<Heading>Feature: {featureName}</Heading>
<HtmlContent
html={BackgroundUtils.getFeatureDescription(backgroundInfo) ?? ""}
withoutTooltips
/>
</div>
)}
</div>
);
};
@@ -0,0 +1,41 @@
import { FC, HTMLAttributes } from "react";
import { HtmlContent } from "~/components/HtmlContent";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { PaneIdentifiersBasicAction } from "../../types";
/*
This is the Sidebar display for Basic Actions. You can see this Pane by clicking on the Basic Actions (actions, bonus actions, reactions, etc.) displayed in the Actions section of the Character Sheet. Examples of Basic Actions include Attack, Dash, Disengage, Dodge, Help, Hide, Ready, Search, Use an Object, etc.
*/
interface BasicActionPaneProps extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersBasicAction | null;
}
export const BasicActionPane: FC<BasicActionPaneProps> = ({
identifiers,
...props
}) => {
const { ruleData } = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
const action =
identifiers && ruleDataUtils.getBasicAction(identifiers.id, ruleData);
if (action === null) {
return <PaneInitFailureContent />;
}
return (
<div {...props}>
<Header>{action.name}</Header>
{action.description !== null && (
<HtmlContent html={action.description} withoutTooltips />
)}
</div>
);
};
@@ -0,0 +1,69 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { useSelector } from "react-redux";
import { CharacterAvatarPortrait } from "@dndbeyond/character-components/es";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import styles from "./styles.module.css";
/*
This is a single character in the CampaignPane. It displays the character's avatar, name, and user name.
*/
export interface CampaignCharacterProps extends HTMLAttributes<HTMLDivElement> {
characterId: number;
avatarUrl: string;
characterName: string;
userName: string;
}
export const CampaignCharacter: FC<CampaignCharacterProps> = ({
characterId,
avatarUrl,
characterName,
userName,
className,
...props
}) => {
const { isDarkMode } = useCharacterTheme();
const currentCharacterId = useSelector(appEnvSelectors.getCharacterId);
const isCurrentCharacter = currentCharacterId === characterId;
return (
<div
className={clsx([
styles.character,
isCurrentCharacter && styles.current,
className,
])}
key={characterId}
{...props}
>
<CharacterAvatarPortrait
className={styles.preview}
avatarUrl={avatarUrl}
/>
<div className={styles.content}>
<div className={styles.name}>
{isCurrentCharacter ? (
<span>{characterName}</span>
) : (
<a
href={`/characters/${characterId}`}
className={clsx([styles.link, isDarkMode && styles.darkMode])}
target="_blank"
rel="noreferrer"
>
{characterName}
</a>
)}
</div>
<div className={styles.user}>{userName}</div>
</div>
</div>
);
};
@@ -0,0 +1,100 @@
import { FC, HTMLAttributes } from "react";
import { useSelector } from "react-redux";
import {
DarkPlayButtonSvg,
LightPlayButtonSvg,
} from "@dndbeyond/character-components/es";
import { Button } from "~/components/Button/Button";
import { HtmlContent } from "~/components/HtmlContent";
import { PreferencePrivacyTypeEnum } from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { PaneInitFailureContent } from "~/subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { NavigationUtils } from "~/tools/js/Shared/utils";
import { CampaignCharacterContract } from "~/types";
import { CampaignCharacter } from "./CampaignCharacter";
import styles from "./styles.module.css";
/*
This is the Sidebar for a Campaign. If the character has joined a Campaign, you can click on the campaign button in the Character Sheet header to see this Sidebar. It displays the Campaign name, maps link, DM, and all characters in the campaign. If the character is the DM, they will see all characters in the campaign. If the character is not the DM, they will only see characters that have been set to public or campaign only.
*/
interface CampaignPaneProps extends HTMLAttributes<HTMLDivElement> {}
export const CampaignPane: FC<CampaignPaneProps> = ({ ...props }) => {
const { campaign, campaignUtils } = useCharacterEngine();
const { isDarkMode } = useCharacterTheme();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
if (!campaign) {
return <PaneInitFailureContent />;
}
const characters = campaignUtils.getCharacters(campaign);
const description = campaignUtils.getDescription(campaign);
const filteredCharacters = characters
? characters.filter(
(character) =>
character.privacyType === PreferencePrivacyTypeEnum.PUBLIC ||
character.privacyType === PreferencePrivacyTypeEnum.CAMPAIGN_ONLY
)
: [];
const orderedCharacters = filteredCharacters.sort((a, b) =>
(a.characterName ?? "").localeCompare(b.characterName ?? "")
);
return (
<div {...props}>
<div className={styles.header}>
<Header>Campaign</Header>
<Button
href={NavigationUtils.getLaunchGameUrl(campaign)}
target="_blank"
size="x-small"
variant="text"
className={styles.launchButton}
themed
>
{isDarkMode ? <LightPlayButtonSvg /> : <DarkPlayButtonSvg />}
<span>Launch Game</span>
</Button>
</div>
<div className={styles.name}>
{isReadonly ? (
campaignUtils.getName(campaign)
) : (
<a
className={styles.link}
href={campaignUtils.getLink(campaign) ?? ""}
>
{campaignUtils.getName(campaign)}
</a>
)}
</div>
{!isReadonly && description !== null && (
<HtmlContent html={description} withoutTooltips />
)}
<div className={styles.dm}>
<span className={styles.dmLabel}>DM:</span>
<span>{campaignUtils.getDmUsername(campaign)}</span>
</div>
<div className={styles.characters}>
{orderedCharacters.map((character: CampaignCharacterContract) => (
<CampaignCharacter
key={character.characterId}
characterId={character.characterId}
avatarUrl={character.avatarUrl ?? ""}
characterName={character.characterName ?? ""}
userName={character.username ?? ""}
/>
))}
</div>
</div>
);
};
@@ -0,0 +1,247 @@
import { ComponentType, FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import {
LightLinkOutSvg,
ThemedBackdropSvg,
ThemedBuilderSvg,
ThemedExportSvg,
ThemedFrameSvg,
ThemedLongRestSvg,
ThemedManageLevelSvg,
ThemedManageXpSvg,
ThemedPortraitSvg,
ThemedPreferencesSvg,
ThemedShareSvg,
ThemedShortRestSvg,
ThemedThemeIconSvg,
ThemedChatBubbleSvg,
} from "@dndbeyond/character-components/es";
import D6 from "@dndbeyond/fontawesome-cache/svgs/regular/dice-d6.svg";
import { Button } from "~/components/Button";
import config from "~/config";
import {
PreferencePrivacyTypeEnum,
PreferenceProgressionTypeEnum,
} from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { sheetAppSelectors } from "~/tools/js/CharacterSheet/selectors";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { CharacterStatusSlug } from "~/types";
import {
PaneMenu,
PaneMenuGroup,
PaneMenuItem,
} from "../../components/PaneMenu";
import { PaneComponentEnum } from "../../types";
import { Overview } from "./Overview";
import styles from "./styles.module.css";
const BASE_PATHNAME = config.basePathname;
interface CharacterManagePaneProps extends HTMLAttributes<HTMLDivElement> {}
export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
...props
}) => {
const navigate = useNavigate();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const builderUrl = useSelector(sheetAppSelectors.getBuilderUrl);
const {
preferences,
decorationInfo,
helperUtils,
decorationUtils,
characterStatusSlug: characterStatus,
characterTheme,
} = useCharacterEngine();
const {
pane: { paneHistoryPush },
} = useSidebar();
const handleXpMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.XP);
};
const handleGameLogMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.GAME_LOG);
};
const handleShortRestMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.SHORT_REST);
};
const handleLongRestMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.LONG_REST);
};
const handlePreferencesMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.PREFERENCES);
};
const handleShareMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.SHARE_URL);
};
const handlePdfClick = (): void => {
paneHistoryPush(PaneComponentEnum.EXPORT_PDF);
};
const handleBuilderClick = (): void => {
navigate(builderUrl);
};
//USE A LOOKUP TO GET EACH MENU ICON
const getMenuIconNode = (menuKey: string): ReactNode => {
const menuIconLookup: Record<string, ComponentType> = {
builder: ThemedBuilderSvg,
shortrest: ThemedShortRestSvg,
longrest: ThemedLongRestSvg,
share: ThemedShareSvg,
managelevel: ThemedManageLevelSvg,
managexp: ThemedManageXpSvg,
gamelog: ThemedChatBubbleSvg,
frame: ThemedFrameSvg,
backdrop: ThemedBackdropSvg,
theme: ThemedThemeIconSvg,
portrait: ThemedPortraitSvg,
preferences: ThemedPreferencesSvg,
downloadpdf: ThemedExportSvg,
};
const IconNode: ComponentType<any> | null =
menuIconLookup[menuKey] &&
helperUtils.lookupDataOrFallback(menuIconLookup, menuKey);
if (IconNode === null) {
return null;
}
if (menuKey === "gamelog") {
return (
<D6
className="ddbc-svg ddbc-svg--themed"
style={{ fill: characterTheme.themeColor }}
/>
);
}
return (
<IconNode theme={decorationUtils.getCharacterTheme(decorationInfo)} />
);
};
return (
<div
className={styles.characterManagePane}
data-testid="character-manage-pane"
{...props}
>
<Overview />
{/* Display Readonly CTA or MENU */}
{isReadonly ? (
<div className={styles.readonlyBackground}>
<div className={styles.readonlyContent}>
<div>
You are viewing a{" "}
{characterStatus === CharacterStatusSlug.PREMADE
? "premade"
: "read-only"}{" "}
Character Sheet.
</div>
<Button
className={styles.createCTA}
size="medium"
variant="solid"
target="_blank"
themed
href={`${BASE_PATHNAME}/builder`}
forceThemeMode="dark"
>
<span>Create a Character</span>
<LightLinkOutSvg />
</Button>
</div>
</div>
) : (
<PaneMenu>
<PaneMenuGroup label="My Character">
<PaneMenuItem
menukey="builder"
prefixIcon={getMenuIconNode("builder")}
suffixIcon={<LightLinkOutSvg />}
onClick={handleBuilderClick}
>
Manage Character & Levels
</PaneMenuItem>
{preferences.progressionType ===
PreferenceProgressionTypeEnum.XP && (
<PaneMenuItem
menukey="managexp"
prefixIcon={getMenuIconNode("managexp")}
onClick={handleXpMenuClick}
>
Manage Experience
</PaneMenuItem>
)}
<PaneMenuItem
menukey="preferences"
prefixIcon={getMenuIconNode("preferences")}
onClick={handlePreferencesMenuClick}
>
Character Settings
</PaneMenuItem>
</PaneMenuGroup>
<PaneMenuGroup label="Play">
<PaneMenuItem
menukey="gamelog"
prefixIcon={getMenuIconNode("gamelog")}
onClick={handleGameLogMenuClick}
>
Game Log
</PaneMenuItem>
<PaneMenuItem
menukey="shortrest"
prefixIcon={getMenuIconNode("shortrest")}
onClick={handleShortRestMenuClick}
>
Short Rest
</PaneMenuItem>
<PaneMenuItem
menukey="longrest"
prefixIcon={getMenuIconNode("longrest")}
onClick={handleLongRestMenuClick}
>
Long Rest
</PaneMenuItem>
</PaneMenuGroup>
<PaneMenuGroup label="Share">
{preferences.privacyType === PreferencePrivacyTypeEnum.PUBLIC && (
<PaneMenuItem
menukey="share"
prefixIcon={getMenuIconNode("share")}
onClick={handleShareMenuClick}
>
Shareable Link
</PaneMenuItem>
)}
<PaneMenuItem
menukey="downloadpdf"
prefixIcon={getMenuIconNode("downloadpdf")}
onClick={handlePdfClick}
>
Export to PDF
</PaneMenuItem>
</PaneMenuGroup>
</PaneMenu>
)}
</div>
);
};
@@ -0,0 +1,280 @@
import clsx from "clsx";
import {
ChangeEvent,
FC,
HTMLAttributes,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import {
CharacterAvatarPortrait,
CharacterName,
CharacterProgressionSummary,
CharacterSummary,
LightPencilSvg,
ThemedPaintBrushSvg,
} from "@dndbeyond/character-components/es";
import { Button } from "~/components/Button/Button";
import {
CharacterNameLimitMsg,
DeathCauseEnum,
InputLimits,
} from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useMaxLengthErrorHandling } from "~/hooks/useErrorHandling/useMaxLengthErrorHandling";
import useUserId from "~/hooks/useUserId";
import { appEnvActions } from "~/tools/js/Shared/actions";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { CharacterStatusSlug } from "~/types";
import { Popover } from "../../../../../../../components/Popover";
import { PopoverContent } from "../../../../../../../components/PopoverContent";
import { PaneComponentEnum } from "../../../types";
import styles from "./styles.module.css";
export interface OverviewProps extends HTMLAttributes<HTMLDivElement> {}
export const Overview: FC<OverviewProps> = ({ className, ...props }) => {
const {
ruleData,
characterName,
characterGender: gender,
race: species,
experienceInfo: xpInfo,
deathCause,
preferences,
decorationInfo,
playerName,
playerId,
characterActions,
decorationUtils,
classUtils,
classes,
characterStatusSlug: characterStatus,
} = useCharacterEngine();
const dispatch = useDispatch();
const { isDarkMode } = useCharacterTheme();
const userId = useUserId();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const canOverrideReadOnly = useSelector(
appEnvSelectors.getCanOverrideReadOnly
);
const {
pane: { paneHistoryPush },
} = useSidebar();
const { MaxLengthErrorMessage, hideError, handleMaxLengthErrorMsg } =
useMaxLengthErrorHandling(
(characterName?.length ?? 0) >= InputLimits.characterNameMaxLength,
InputLimits.characterNameMaxLength,
CharacterNameLimitMsg
);
const isUserCharacter = playerId === Number(userId);
const isPremade = characterStatus === CharacterStatusSlug.PREMADE;
const [isNameEditorVisible, setIsNameEditorVisible] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isNameEditorVisible && inputRef.current) {
inputRef.current.focus();
}
}, [isNameEditorVisible]);
const handleNameClick = (): void => {
setIsNameEditorVisible(true);
};
const handleNameChange = (evt: ChangeEvent<HTMLInputElement>): void => {
const inputValue = evt.target.value;
if (inputValue !== characterName && inputValue !== "") {
dispatch(characterActions.nameSet(inputValue));
}
setIsNameEditorVisible(false);
hideError();
};
const handleToggleEdit = (status: boolean): void => {
dispatch(appEnvActions.dataSet({ isReadonly: status }));
};
const handleNameUpdate = (evt: ChangeEvent<HTMLInputElement>): void => {
handleMaxLengthErrorMsg(evt.target.value);
};
const handleDecorateMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.DECORATE);
};
const isEditingCharacterName = useMemo(
() => isNameEditorVisible && !isReadonly,
[isNameEditorVisible, isReadonly]
);
return (
<div
className={clsx([styles.intro, isDarkMode && styles.dark, className])}
{...props}
>
<div className={styles.summary}>
<CharacterAvatarPortrait
className={styles.avatar}
avatarUrl={decorationUtils.getAvatarInfo(decorationInfo).avatarUrl}
/>
{
<div
className={clsx([
styles.characterName,
isEditingCharacterName && styles.editName,
])}
onClick={isReadonly ? undefined : handleNameClick}
>
{isEditingCharacterName ? (
<>
<input
ref={inputRef}
type="text"
defaultValue={characterName ?? ""}
maxLength={InputLimits.characterNameMaxLength}
onBlur={handleNameChange}
onChange={handleNameUpdate}
onFocus={handleNameUpdate}
/>
<MaxLengthErrorMessage />
</>
) : (
<>
<CharacterName
name={characterName ?? ""}
isDead={deathCause !== DeathCauseEnum.NONE}
isFaceMenu={true}
/>
{!isReadonly && <LightPencilSvg />}
</>
)}
</div>
}
{/* DISPLAY PLAYER NAME IF ITS NOT A PREMADE OR YOUR CHARACTER */}
{!isUserCharacter && !isPremade && (
<div>
<span className={styles.playerNameLabel}>Player: </span>
{playerName}
</div>
)}
{/* SHOW SPECIES, GENDER AND XP/LEVEL INFO */}
<div className={styles.description}>
<CharacterSummary
className={styles.summaryText}
classes={null}
species={species}
gender={gender}
/>
<CharacterProgressionSummary
ruleData={ruleData}
progressionType={preferences.progressionType}
xpInfo={xpInfo}
/>
</div>
</div>
{/* DISPLAY CLASS LIST */}
<div className={styles.classList}>
{classes.map((charClass) => (
<div className={styles.classItem} key={classUtils.getId(charClass)}>
<div
className={styles.classImage}
style={{
backgroundImage: `url(${classUtils.getPortraitUrl(charClass)})`,
}}
>
<div className={styles.classLevel}>
{classUtils.getLevel(charClass)}
</div>
</div>
<div className={styles.classSummary}>
<div>{classUtils.getName(charClass)}</div>
<div className={styles.classMeta}>
{classUtils.getSubclassName(charClass) ?? "No Subclass"}
</div>
</div>
</div>
))}
</div>
<div className={styles.buttonGroup}>
{/* This is only for PREMADE characters */}
{canOverrideReadOnly && (
<div className={styles.featureCallout}>
{isReadonly ? (
<Popover
trigger={
<Button
size="x-small"
variant="solid"
className={clsx([styles.toggleButton])}
forceThemeMode="dark"
>
<LightPencilSvg />
<span>Enable Edit Premade</span>
</Button>
}
maxWidth={284}
className={styles.popoverToggle}
>
<PopoverContent
title="PROCEED WITH CAUTION!!!"
content="⚠️ If you enable editing you will be modifying a published character ⚠️"
confirmText="Enable"
onConfirm={() => handleToggleEdit(false)}
withCancel
/>
</Popover>
) : (
<Button
size="x-small"
variant="solid"
className={clsx([styles.toggleButton])}
forceThemeMode="dark"
onClick={() => handleToggleEdit(true)}
>
<LightPencilSvg />
<span>DISABLE Edit Premade</span>
</Button>
)}
</div>
)}
{/* DISPLAY DECORATE BUTTON AS A "FEATURE CALLOUT" */}
{!isReadonly && (
<div className={styles.featureCallout}>
<Button
size="x-small"
variant="outline"
className={styles.decorateButton}
forceThemeMode="dark"
themed
onClick={handleDecorateMenuClick}
>
<ThemedPaintBrushSvg
theme={decorationUtils.getCharacterTheme(decorationInfo)}
/>
<span>Change Sheet Appearance</span>
</Button>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,385 @@
import {
FC,
HTMLAttributes,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import {
ApiAdapterUtils,
FeatManager,
} from "@dndbeyond/character-rules-engine";
import { FeatureChoice } from "~/components/FeatureChoice";
import { BuilderChoiceTypeEnum } from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { apiCreatorSelectors } from "~/tools/js/Shared/selectors";
import { AppLoggerUtils, PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import LoadingPlaceholder from "~/tools/js/smartComponents/LoadingPlaceholder";
import {
Action,
CharClass,
ClassDefinitionContract,
ClassFeature,
Feat,
InfusionChoice,
Spell,
} from "~/types";
import { ClassFeatureSnippet } from "../../../../../../tools/js/CharacterSheet/components/FeatureSnippet";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { Heading } from "../../components/Heading";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { PaneComponentEnum, PaneIdentifiersClassFeature } from "../../types";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersClassFeature | null;
}
export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
const dispatch = useDispatch();
const {
infusionChoiceUtils,
actionUtils,
characterActions,
spellUtils,
classUtils,
classFeatureUtils,
classes,
feats,
snippetData,
ruleData,
abilityLookup,
proficiencyBonus,
originRef: dataOriginRefData,
characterTheme: theme,
choiceUtils,
featUtils,
helperUtils,
} = useCharacterEngine();
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const {
pane: { paneHistoryPush },
} = useSidebar();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const loadAvailableFeats = useSelector(
apiCreatorSelectors.makeLoadAvailableFeats
);
const loadAvailableSubclasses = useSelector(
apiCreatorSelectors.makeLoadAvailableSubclasses
);
const [subclassData, setSubclassData] = useState<
Array<ClassDefinitionContract>
>([]);
const [featsData, setFeatsData] = useState<Array<Feat>>([]);
const [featLoadingStatus, setFeatLoadingStatus] =
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
const [subclassLoadingStatus, setSubclassLoadingStatus] =
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
const currentCharClass = useRef<CharClass | null>(null);
const charClass = identifiers?.classMappingId
? classes.find(
(charClass) =>
identifiers.classMappingId === classUtils.getMappingId(charClass)
)
: null;
const classFeature =
charClass?.classFeatures.find(
(feature) => identifiers?.id === classFeatureUtils.getId(feature)
) ?? null;
const featureChoices = classFeature
? classFeatureUtils.getChoices(classFeature)
: [];
const hasFeatChoice = featureChoices.some(
(choice) =>
choiceUtils.getType(choice) === BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION
);
const hasSubclassChoice = featureChoices.some(
(choice) =>
choiceUtils.getType(choice) === BuilderChoiceTypeEnum.SUB_CLASS_OPTION
);
useEffect(() => {
const isSameClass = (
c1: CharClass | null,
c2: CharClass | null
): boolean => {
if (!c1 || !c2) return false;
return classUtils.getId(c1) === classUtils.getId(c2);
};
if (
hasFeatChoice &&
featLoadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
) {
setFeatLoadingStatus(DataLoadingStatusEnum.LOADING);
loadAvailableFeats({
// cancelToken: new axios.CancelToken((c) => {
// this.loadFeatsCanceler = c;
// }),
})
.then((response) => {
let featsData: Array<Feat> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
featsData = data.map((definition) =>
featUtils.simulateFeat(definition)
);
}
setFeatsData(featsData);
setFeatLoadingStatus(DataLoadingStatusEnum.LOADED);
// this.loadFeatsCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
if (
charClass &&
!isSameClass(charClass, currentCharClass.current) && // Only pull data if the class has changed from previous fetch
hasSubclassChoice &&
subclassLoadingStatus !== DataLoadingStatusEnum.LOADING // Check if its not already fetching data
) {
currentCharClass.current = charClass; //Update ref of fetched class
setSubclassLoadingStatus(DataLoadingStatusEnum.LOADING);
loadAvailableSubclasses(classUtils.getId(charClass), {
// cancelToken: new axios.CancelToken((c) => {
// this.loadSubclassesCanceler = c;
// }),
})
.then((response) => {
let subclassData: Array<ClassDefinitionContract> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
subclassData = data;
}
setSubclassData(subclassData);
setSubclassLoadingStatus(DataLoadingStatusEnum.LOADED);
// this.loadSubclassesCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
}, [
hasFeatChoice,
hasSubclassChoice,
subclassLoadingStatus,
featLoadingStatus,
charClass,
classUtils,
featUtils,
loadAvailableFeats,
loadAvailableSubclasses,
currentCharClass,
]);
const handleInfusionChoiceShow = (infusionChoice: InfusionChoice): void => {
const choiceKey = infusionChoiceUtils.getKey(infusionChoice);
if (choiceKey !== null) {
paneHistoryPush(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
};
const handleChoiceChange = (
id: string,
type: number,
value: any,
parentChoiceId: string | null
): void => {
if (!charClass || !classFeature) {
return;
}
dispatch(
characterActions.classFeatureChoiceSetRequest(
classUtils.getActiveId(charClass),
classFeatureUtils.getId(classFeature),
type,
id,
helperUtils.parseInputInt(value),
parentChoiceId
)
);
};
const handleActionUseSet = (action: Action, uses: number): void => {
const id = actionUtils.getId(action);
const entityTypeId = actionUtils.getEntityTypeId(action);
if (id !== null && entityTypeId !== null) {
dispatch(
characterActions.actionUseSet(
id,
entityTypeId,
uses,
actionUtils.getDataOriginType(action)
)
);
}
};
const handleSpellUseSet = (spell: Spell, uses: number): void => {
const mappingId = spellUtils.getMappingId(spell);
const mappingEntityTypeId = spellUtils.getMappingEntityTypeId(spell);
const dataOriginType = spellUtils.getDataOriginType(spell);
if (mappingId && mappingEntityTypeId && dataOriginType) {
dispatch(
characterActions.spellUseSet(
mappingId,
mappingEntityTypeId,
uses,
dataOriginType
)
);
}
};
const handleSpellDetailShow = (spell: Spell): void => {
const mappingId = spellUtils.getMappingId(spell);
if (mappingId !== null) {
paneHistoryPush(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId)
);
}
};
const handleClassFeatureShow = (
feature: ClassFeature,
charClass: CharClass
): void => {
paneHistoryPush(
PaneComponentEnum.CLASS_FEATURE_DETAIL,
PaneIdentifierUtils.generateClassFeature(
classFeatureUtils.getId(feature),
classUtils.getMappingId(charClass)
)
);
};
const handleActionShow = (action: Action): void => {
const mappingId = actionUtils.getMappingId(action);
const mappingEntityTypeId = actionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneHistoryPush(
PaneComponentEnum.ACTION,
PaneIdentifierUtils.generateAction(mappingId, mappingEntityTypeId)
);
}
};
const handleFeatShow = (feat: FeatManager): void => {
paneHistoryPush(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(feat.getId())
);
};
const isDataLoaded = (): boolean => {
if (hasSubclassChoice) {
if (subclassLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
if (hasFeatChoice) {
if (featLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
return true;
};
if (!charClass || !classFeature) {
return <PaneInitFailureContent />;
}
return (
<div
key={`${classUtils.getMappingId(charClass)}-${classFeatureUtils.getId(
classFeature
)}`}
{...props}
>
<Header
parent={classUtils.getName(charClass)}
preview={<Preview imageUrl={classUtils.getPortraitUrl(charClass)} />}
>
{classFeatureUtils.getName(classFeature)}
</Header>
<ClassFeatureSnippet
charClass={charClass}
feature={classFeature}
onActionUseSet={handleActionUseSet}
onActionClick={handleActionShow}
onSpellClick={handleSpellDetailShow}
onSpellUseSet={handleSpellUseSet}
onFeatureClick={handleClassFeatureShow}
onInfusionChoiceClick={handleInfusionChoiceShow}
showHeader={false}
showDescription={true}
feats={feats}
snippetData={snippetData}
ruleData={ruleData}
abilityLookup={abilityLookup}
dataOriginRefData={dataOriginRefData}
isReadonly={isReadonly}
proficiencyBonus={proficiencyBonus}
theme={theme}
onFeatClick={handleFeatShow}
featuresManager={characterFeaturesManager}
/>
{!isReadonly && featureChoices.length > 0 && (
<>
<Heading className={styles.heading}>{`Option${
featureChoices.length > 1 ? "s" : ""
}`}</Heading>
{isDataLoaded() ? (
featureChoices.map((choice) => (
<FeatureChoice
charClass={charClass}
choice={choice}
feature={classFeature}
featsData={featsData}
subclassData={subclassData}
className={styles.choice}
key={choiceUtils.getId(choice)}
onChoiceChange={handleChoiceChange}
collapseDescription={true}
/>
))
) : (
<LoadingPlaceholder />
)}
</>
)}
</div>
);
};
@@ -0,0 +1,606 @@
import clsx from "clsx";
import {
FC,
Fragment,
HTMLAttributes,
useCallback,
useEffect,
useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import {
Collapsible,
CollapsibleHeaderCallout,
CollapsibleHeaderContent,
CreatureName,
InfusionPreview,
} from "@dndbeyond/character-components/es";
import {
characterActions,
Constants,
Creature,
CreatureUtils,
InfusionUtils,
serviceDataActions,
SourceUtils,
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { Button } from "~/components/Button";
import { EditableName } from "~/components/EditableName";
import { HtmlContent } from "~/components/HtmlContent";
import { Reference } from "~/components/Reference";
import { TagGroup } from "~/components/TagGroup";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { CreatureBlock } from "~/subApps/sheet/components/CreatureBlock";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import {
PaneComponentEnum,
PaneIdentifiersCreature,
} from "~/subApps/sheet/components/Sidebar/types";
import CustomizeDataEditor from "../../../../../../tools/js/Shared/components/CustomizeDataEditor";
import EditorBox from "../../../../../../tools/js/Shared/components/EditorBox";
import ValueEditor from "../../../../../../tools/js/Shared/components/ValueEditor";
import { RemoveButton } from "../../../../../../tools/js/Shared/components/common/Button";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { PaneIdentifierUtils } from "../../../../../../tools/js/Shared/utils";
import { DeathSummary } from "../../../HitPointsBox/DeathSummary";
import { HitPointsSummary } from "../../../HitPointsBox/HitPointsSummary";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { QuickActions } from "../../components/QuickActions";
import { HitPointsAdjuster } from "../HitPointsManagePane/HitPointsAdjuster";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersCreature | null;
}
export const CreaturePane: FC<Props> = ({ identifiers, ...props }) => {
const { pane } = useSidebar();
const {
ruleData,
creatures,
entityValueLookup,
snippetData,
infusionChoiceLookup,
proficiencyBonus,
characterTheme: theme,
hpInfo,
deathCause,
} = useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const dispatch = useDispatch();
const getCreature = useCallback((): Creature | null => {
return (
creatures.find(
(creature) => identifiers?.id === CreatureUtils.getMappingId(creature)
) ?? null
);
}, [creatures, identifiers]);
const [creature, setCreature] = useState<Creature | null>(getCreature());
const [isCustomizeClosed, setIsCustomizeClosed] = useState(true);
useEffect(() => {
const foundCreature = getCreature();
setCreature(foundCreature);
}, [getCreature]);
const getData = (): Record<string, any> => {
if (!creature) {
return {};
}
return {
name: CreatureUtils.getName(creature),
description: creature.description,
groupId: CreatureUtils.getGroupId(creature),
};
};
const handleToggleCustomize = () => {
setIsCustomizeClosed(!isCustomizeClosed);
};
const dispatchTempHp = (value: number): void => {
if (!creature) {
return;
}
const useOwnerHp = CreatureUtils.getUseOwnerHp(creature);
const id = CreatureUtils.getMappingId(creature);
const hitPointInfo = useOwnerHp
? hpInfo
: CreatureUtils.getHitPointInfo(creature);
dispatch(
useOwnerHp
? characterActions.hitPointsSet(hitPointInfo.removedHp, value)
: characterActions.creatureHitPointsSet(
id,
hitPointInfo.removedHp,
value
)
);
};
const handleQuickTempAction = (value: number): void => {
dispatchTempHp(value);
};
const handleSaveProperties = (properties: Record<string, any>): void => {
if (!creature) {
return;
}
const data = getData();
const prevData: Record<string, any> = data || {};
const newProperties: Record<string, any> = {
...prevData,
...properties,
};
dispatch(
characterActions.creatureDataSet(
CreatureUtils.getMappingId(creature),
newProperties as any
)
);
};
const handleDataUpdate = (data: Record<string, any>): void => {
if (!data.name) {
data.name = null;
}
handleSaveProperties(data);
};
const handleCustomDataUpdate = (
key: number,
value: string,
source: string | null
): void => {
if (!creature) {
return;
}
dispatch(
characterActions.valueSet(
key,
value,
source,
ValueUtils.hack__toString(CreatureUtils.getMappingId(creature)),
ValueUtils.hack__toString(
CreatureUtils.getMappingEntityTypeId(creature)
)
)
);
};
const handleRemoveCustomizations = (): void => {
if (creature) {
dispatch(
characterActions.creatureCustomizationsDelete(
CreatureUtils.getMappingId(creature),
CreatureUtils.getMappingEntityTypeId(creature)
)
);
}
};
const handleRemove = (): void => {
if (!creature) {
return;
}
pane.paneHistoryStart(PaneComponentEnum.EXTRA_MANAGE);
dispatch(
characterActions.creatureRemove(CreatureUtils.getMappingId(creature))
);
};
const handleRemoveInfusion = (): void => {
if (!creature) {
return;
}
const infusion = CreatureUtils.getInfusion(creature);
if (infusion) {
const infusionId = InfusionUtils.getId(infusion);
if (infusionId === null) {
return;
}
const choiceKey = InfusionUtils.getChoiceKey(infusion);
if (choiceKey !== null) {
pane.paneHistoryStart(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
dispatch(
serviceDataActions.infusionMappingDestroy(
infusionId,
InfusionUtils.getInventoryMappingId(infusion)
)
);
}
};
const handleInfusionClick = (): void => {
if (!creature) {
return;
}
const infusion = CreatureUtils.getInfusion(creature);
if (infusion) {
const choiceKey = InfusionUtils.getChoiceKey(infusion);
if (choiceKey !== null) {
pane.paneHistoryPush(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
}
};
const handleDeathSummaryClick = (evt: React.MouseEvent): void => {
if (!isReadonly) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
pane.paneHistoryPush(PaneComponentEnum.HEALTH_MANAGE);
}
};
const renderHealthAdjuster = (): React.ReactNode => {
if (!creature) {
return null;
}
const creatureHpInfo = CreatureUtils.getHitPointInfo(creature);
// Key to check if the creature has useOwnerHp set to true
const creatureGroupName = CreatureUtils.getGroupInfo(creature)?.name;
const useOwnerHp = CreatureUtils.getUseOwnerHp(creature);
const initialTempHp = CreatureUtils.getInitialTempHp(creature);
const hp = useOwnerHp ? hpInfo : creatureHpInfo;
const tempHp = hp.tempHp ?? 0;
const extraNode: React.ReactNode = (
<>
<span className={clsx([tempHp > 0 && styles.hasTempHp])}>
{hp.remainingHp + tempHp}
</span>
<span className={styles.valueSep}>/</span>
<span className={clsx([tempHp > 0 && styles.hasTempHp])}>
{hp.totalHp + tempHp}
</span>
</>
);
const headerCalloutNode: React.ReactNode = (
<CollapsibleHeaderCallout extra={extraNode} value={null} />
);
const headerNode: React.ReactNode = (
<CollapsibleHeaderContent
heading="Hit Points"
callout={headerCalloutNode}
/>
);
return (
<div className={clsx([styles.adjuster, styles.hasSeparator])}>
<Collapsible header={headerNode}>
{useOwnerHp &&
(deathCause === Constants.DeathCauseEnum.CONDITION ||
hpInfo.remainingHp <= 0) ? (
<DeathSummary
className={styles.deathSummary}
onClick={handleDeathSummaryClick}
/>
) : (
<div className={styles.hitPointsContainer}>
{creatureGroupName === Constants.DB_STRING_WILDSHAPE_2024 &&
useOwnerHp && (
<p className={clsx([styles.wildshapeInfo, styles.block])}>
Wild Shape modifies your character's HP. Your sheet will
reflect these changes.
</p>
)}
<HitPointsSummary
hpInfo={hp}
creature={useOwnerHp ? undefined : creature}
showPermanentInputs
/>
{!isReadonly && (
<HitPointsAdjuster
hpInfo={hp}
creature={useOwnerHp ? undefined : creature}
/>
)}
{creatureGroupName === Constants.DB_STRING_WILDSHAPE_2024 &&
initialTempHp !== 0 && (
<p className={styles.wildshapeInfo}>
When you assume a Wild Shape form, you gain{" "}
<b>{initialTempHp}</b> Temporary HP.
</p>
)}
{!isReadonly && renderQuickActions()}
</div>
)}
</Collapsible>
</div>
);
};
const renderQuickActions = (): React.ReactNode => {
if (!creature) {
return null;
}
// If initialTempHp exists and is not 0, render a button to add the initial temp hp to the creature.
const initialTempHp = CreatureUtils.getInitialTempHp(creature);
if (!initialTempHp) {
return null;
}
const actions = [] as {
label: string;
onClick: () => void;
disabled: boolean;
}[];
actions.push({
label: `Set Temp HP to ${initialTempHp}`,
onClick: () => handleQuickTempAction(initialTempHp),
disabled: false,
});
return <QuickActions actions={actions}></QuickActions>;
};
const renderDescription = (
label: React.ReactNode,
description: string | null
): React.ReactNode => {
if (!description) {
return null;
}
return (
<div className={styles.description}>
{label && <Heading>{label}</Heading>}
<HtmlContent html={description} withoutTooltips />
</div>
);
};
const renderTags = (): React.ReactNode => {
if (!creature) {
return null;
}
const tags = CreatureUtils.getTags(creature);
const envTags = CreatureUtils.getEnvironmentTags(creature);
return (
<>
{tags.length > 0 && <TagGroup label="Tags" tags={tags} />}
{envTags.length > 0 && <TagGroup label="Habitats" tags={envTags} />}
</>
);
};
const renderCustomize = (): React.ReactNode => {
if (isReadonly || !creature) {
return null;
}
const useOwnerHp = CreatureUtils.getUseOwnerHp(creature);
const optionRestrictions: Record<number, Array<number> | null> = {};
const groupInfo = CreatureUtils.getGroupInfo(creature);
if (groupInfo?.monsterTypes?.length) {
optionRestrictions[Constants.AdjustmentTypeEnum.CREATURE_TYPE_OVERRIDE] =
groupInfo.monsterTypes;
}
const isCustomized = CreatureUtils.isCustomized(creature);
const valueEditors = [
Constants.AdjustmentTypeEnum.CREATURE_SIZE,
Constants.AdjustmentTypeEnum.CREATURE_TYPE_OVERRIDE,
Constants.AdjustmentTypeEnum.CREATURE_ALIGNMENT,
Constants.AdjustmentTypeEnum.CREATURE_AC,
Constants.AdjustmentTypeEnum.CREATURE_HIT_POINTS,
Constants.AdjustmentTypeEnum.CREATURE_NOTES,
];
// Filter out hitpoints if the creature has useOwnerHp set to true
if (useOwnerHp) {
valueEditors.splice(
valueEditors.indexOf(Constants.AdjustmentTypeEnum.CREATURE_HIT_POINTS),
1
);
}
const labelOverrides = {
[Constants.AdjustmentTypeEnum.CREATURE_AC]: "Armor Class",
[Constants.AdjustmentTypeEnum.CREATURE_ALIGNMENT]: "Alignment",
[Constants.AdjustmentTypeEnum.CREATURE_HIT_POINTS]: "Max HP",
[Constants.AdjustmentTypeEnum.CREATURE_SIZE]: "Size",
[Constants.AdjustmentTypeEnum.CREATURE_TYPE_OVERRIDE]: "Type",
[Constants.AdjustmentTypeEnum.CREATURE_NOTES]: "Notes",
};
return (
<div className={styles.hasSeparator}>
<Collapsible
layoutType={"minimal"}
header={`Customize${isCustomized ? "*" : ""}`}
collapsed={isCustomizeClosed}
onChangeHandler={handleToggleCustomize}
>
<EditorBox className={styles.editor}>
<CustomizeDataEditor
data={getData()}
enableName={true}
onDataUpdate={handleDataUpdate}
/>
<ValueEditor
dataLookup={ValueUtils.getEntityData(
entityValueLookup,
ValueUtils.hack__toString(CreatureUtils.getMappingId(creature)),
ValueUtils.hack__toString(
CreatureUtils.getMappingEntityTypeId(creature)
)
)}
onDataUpdate={handleCustomDataUpdate}
valueEditors={valueEditors}
labelOverrides={labelOverrides}
layoutType={"standard"}
optionRestrictions={optionRestrictions}
ruleData={ruleData}
/>
<RemoveButton
enableConfirm={true}
size="medium"
style="filled"
disabled={!isCustomized}
isInteractive={isCustomized}
onClick={handleRemoveCustomizations}
>
{isCustomized ? "Remove" : "No"} Customizations
</RemoveButton>
</EditorBox>
</Collapsible>
</div>
);
};
const renderInfusionPreview = (): React.ReactNode => {
if (!creature) {
return null;
}
const infusion = CreatureUtils.getInfusion(creature);
if (infusion) {
return (
<div className={styles.hasSeparator}>
<InfusionPreview
infusion={infusion}
snippetData={snippetData}
ruleData={ruleData}
infusionChoiceLookup={infusionChoiceLookup}
onClick={handleInfusionClick}
proficiencyBonus={proficiencyBonus}
/>
</div>
);
}
return null;
};
const key: string = creature ? CreatureUtils.getUniqueKey(creature) : "";
const groupInfo = creature ? CreatureUtils.getGroupInfo(creature) : null;
const largeAvatarUrl = creature
? CreatureUtils.getLargeAvatarUrl(creature)
: null;
const infusion = creature ? CreatureUtils.getInfusion(creature) : null;
return (
<div key={key} {...props}>
{!creature ? (
<>
<Header>Missing Creature</Header>
<PaneInitFailureContent />
</>
) : (
<>
<Header
parent={groupInfo?.name || null}
preview={
<Preview imageUrl={CreatureUtils.getAvatarUrl(creature)} />
}
>
<EditableName onClick={handleToggleCustomize}>
<CreatureName theme={theme} creature={creature} />
</EditableName>
</Header>
{CreatureUtils.isHomebrew(creature) ? (
<Reference isDarkMode={theme.isDarkMode} name="Homebrew" />
) : (
SourceUtils.getSourceFullNames(
CreatureUtils.getSources(creature),
ruleData
).map((source, idx) => {
return (
<Fragment key={source}>
{idx > 0 ? " /" : ""}{" "}
<Reference isDarkMode={theme.isDarkMode} name={source} />
</Fragment>
);
})
)}
{renderCustomize()}
{renderHealthAdjuster()}
<div className={styles.block}>
<CreatureBlock
variant="default"
creature={creature}
ruleData={ruleData}
/>
</div>
{largeAvatarUrl && (
<img
className={styles.img}
src={largeAvatarUrl}
alt={CreatureUtils.getDefinitionName(creature)}
/>
)}
{renderTags()}
{renderDescription(
null,
CreatureUtils.getCharacteristicsDescription(creature)
)}
{renderDescription(
"Lair",
CreatureUtils.getLairDescription(creature)
)}
{renderInfusionPreview()}
{!isReadonly && (
<div className={clsx([styles.hasSeparator, styles.removeButton])}>
<Button
variant="outline"
size="xx-small"
themed
onClick={() => {
if (infusion) {
handleRemoveInfusion();
} else {
handleRemove();
}
}}
>
{infusion ? "Remove Infusion" : "Delete"}
</Button>
</div>
)}
</>
)}
</div>
);
};
@@ -0,0 +1,187 @@
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Accordion } from "~/components/Accordion";
import { Button } from "~/components/Button";
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
import { HtmlContent } from "~/components/HtmlContent";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { FeatFeatureSnippet } from "~/tools/js/CharacterSheet/components/FeatureSnippet";
import { DetailChoiceFeat } from "~/tools/js/Shared/containers/DetailChoice";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import { DdbBadgeSvg } from "~/tools/js/smartComponents/Svg";
import { Action, Spell } from "~/types";
import { Header } from "../../components/Header";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { getDataOriginComponentInfo } from "../../helpers/paneUtils";
import { PaneComponentEnum, PaneIdentifiersFeat } from "../../types";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersFeat | null;
}
export const FeatPane: FC<Props> = ({ identifiers, ...props }) => {
const dispatch = useDispatch();
const {
actionUtils,
characterActions,
spellUtils,
snippetData,
ruleData,
abilityLookup,
proficiencyBonus,
originRef: dataOriginRefData,
characterTheme: theme,
entityUtils,
} = useCharacterEngine();
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const {
pane: { paneHistoryPush, paneHistoryStart },
} = useSidebar();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const feat = identifiers?.id
? characterFeaturesManager.getFeatById(identifiers.id)
: null;
const handleActionUseSet = (action: Action, uses: number): void => {
const id = actionUtils.getId(action);
const entityTypeId = actionUtils.getEntityTypeId(action);
if (id !== null && entityTypeId !== null) {
dispatch(
characterActions.actionUseSet(
id,
entityTypeId,
uses,
actionUtils.getDataOriginType(action)
)
);
}
};
const handleSpellUseSet = (spell: Spell, uses: number): void => {
const mappingId = spellUtils.getMappingId(spell);
const mappingEntityTypeId = spellUtils.getMappingEntityTypeId(spell);
if (mappingId && mappingEntityTypeId) {
dispatch(
characterActions.spellUseSet(
mappingId,
mappingEntityTypeId,
uses,
spellUtils.getDataOriginType(spell)
)
);
}
};
const handleSpellDetailShow = (spell: Spell): void => {
const mappingId = spellUtils.getMappingId(spell);
if (mappingId !== null) {
paneHistoryPush(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId)
);
}
};
const handleActionShow = (action: Action): void => {
const mappingId = actionUtils.getMappingId(action);
const mappingEntityTypeId = actionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneHistoryPush(
PaneComponentEnum.ACTION,
PaneIdentifierUtils.generateAction(mappingId, mappingEntityTypeId)
);
}
};
const handleParentClick = (): void => {
if (feat) {
let component = getDataOriginComponentInfo(feat.getDataOrigin());
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
}
};
if (feat === null) {
return <PaneInitFailureContent />;
}
const prerequisiteDescription = feat.getPrerequisiteDescription();
return (
<div key={feat.getId()} {...props}>
<Header
parent={entityUtils.getDataOriginName(feat.getDataOrigin(), "", true)}
onClick={handleParentClick}
>
{feat.getName()}
</Header>
<HelperTextAccordion
builderHelperText={feat.getHelperText()}
size="small"
useTheme
/>
{prerequisiteDescription && (
<div className={styles.prereq}>
Prerequisite: {prerequisiteDescription}
</div>
)}
<FeatFeatureSnippet
feat={feat}
onActionUseSet={handleActionUseSet}
onActionClick={handleActionShow}
onSpellUseSet={handleSpellUseSet}
onSpellClick={handleSpellDetailShow}
onFeatureClick={() =>
paneHistoryStart(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(feat.getId())
)
}
showHeader={false}
showDescription={true}
snippetData={snippetData}
ruleData={ruleData}
abilityLookup={abilityLookup}
dataOriginRefData={dataOriginRefData}
isReadonly={isReadonly}
proficiencyBonus={proficiencyBonus}
theme={theme}
/>
{!isReadonly && feat.getChoices().length > 0 && (
<div className={styles.choices}>
<DetailChoiceFeat featId={feat.getId()} />
</div>
)}
{!feat.isHiddenFeat() &&
!isReadonly && ( // don't show "Delete" if readonly mode, or a hidden feat
<div className={styles.footer}>
<Button
variant="outline"
size="xx-small"
themed
onClick={() => {
paneHistoryStart(PaneComponentEnum.FEATS_MANAGE);
feat.handleRemove();
}}
>
Delete
</Button>
</div>
)}
</div>
);
};
@@ -0,0 +1,105 @@
import { FC, useContext, useEffect, useState } from "react";
import { FeatureManager } from "@dndbeyond/character-rules-engine";
import { ThemeButton } from "~/tools/js/Shared/components/common/Button";
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { AppLoggerUtils, AppNotificationUtils } from "~/tools/js/Shared/utils";
import Collapsible, {
CollapsibleHeaderContent,
} from "~/tools/js/smartComponents/Collapsible";
export const BlessingShoppe: FC<{}> = () => {
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const [loadingStatus, setLoadingStatus] = useState<DataLoadingStatusEnum>(
DataLoadingStatusEnum.NOT_INITIALIZED
);
const [blessingShoppe, setBlessingShoppe] = useState<Array<FeatureManager>>(
[]
);
const [blessings, setBlessings] = useState<Array<FeatureManager>>([]);
async function fetchBlessings() {
const blessings = await characterFeaturesManager.getBlessings();
setBlessings(blessings);
}
useEffect(() => {
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
characterFeaturesManager
.getBlessingShoppe()
.then((blessingShoppe) => {
setLoadingStatus(DataLoadingStatusEnum.LOADED);
setBlessingShoppe(blessingShoppe);
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
fetchBlessings();
}, [loadingStatus, characterFeaturesManager]);
return (
<div>
<p>
A blessing is usually bestowed by a god or a godlike being. A character
might receive a blessing from a deity for doing something truly
momentous an accomplishment that catches the attention of both gods
and mortals.
</p>
<div>
{blessingShoppe.map((blessing) => {
return (
<Collapsible
key={blessing.getId()}
layoutType={"minimal"}
header={
<CollapsibleHeaderContent
heading={blessing.getName()}
callout={
characterFeaturesManager.hasBlessing(blessing) ? (
<ThemeButton
onClick={() => {
blessing.handleRemove(() => {
fetchBlessings();
// AppNotificationUtils.dispatchSuccess(
// 'Removed Blessing',
// 'You removed a blessing',
// );
});
}}
size="small"
>
Remove
</ThemeButton>
) : (
<ThemeButton
onClick={() => {
blessing.handleAdd(() => {
fetchBlessings();
AppNotificationUtils.dispatchSuccess(
"Added Blessing",
"You added a blessing"
);
});
}}
size="small"
style="outline"
>
Add
</ThemeButton>
)
}
/>
}
>
{blessing.getDescription()}
</Collapsible>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,130 @@
import clsx from "clsx";
import { FC, ReactNode } from "react";
import { FeatManager } from "@dndbeyond/character-rules-engine/es";
import { Reference } from "~/components/Reference";
import { DataOriginTypeEnum } from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import Collapsible, {
CollapsibleHeaderContent,
} from "~/tools/js/smartComponents/Collapsible";
import { TodoIcon } from "~/tools/js/smartComponents/Icons";
import PrerequisiteFailureSummary from "~/tools/js/smartComponents/PrerequisiteFailureSummary";
import { CharacterTheme } from "~/types";
import {
RemoveButton,
ThemeButton,
} from "../../../../../../../tools/js/Shared/components/common/Button";
import { FeatDetail } from "../FeatDetail";
import styles from "./styles.module.css";
interface Props {
feat: FeatManager;
enableRemove?: boolean;
enableAdd?: boolean;
showFailures?: boolean;
theme: CharacterTheme;
}
export const Feat: FC<Props> = ({
feat,
enableAdd = true,
enableRemove = true,
showFailures = false,
theme,
}) => {
const { entityUtils } = useCharacterEngine();
const { isDarkMode } = useCharacterTheme();
let missingChoiceCount: number = feat.getUnfinishedChoices().length;
let calloutNode: ReactNode = null;
let dataOrigin = feat.getDataOrigin();
let dataOriginType = feat.getDataOriginType();
switch (dataOriginType) {
case DataOriginTypeEnum.ADHOC:
if (enableRemove) {
calloutNode = (
<RemoveButton
onClick={feat.handleRemove}
style="filled"
className={styles.button}
/>
);
}
break;
case DataOriginTypeEnum.SIMULATED:
if (enableAdd) {
calloutNode = (
<ThemeButton
onClick={feat.handleAdd}
size="small"
style="outline"
className={styles.button}
>
Add
</ThemeButton>
);
}
break;
default:
const dataOriginExtra = entityUtils.getDataOriginName(
dataOrigin,
"Unknown",
true
);
calloutNode = (
<span className={styles.origin}>
<span className={styles.originLabel}>From</span>
<span className={styles.originName}>{dataOriginExtra}</span>
</span>
);
}
return (
<Collapsible
layoutType={"minimal"}
className={styles.feat}
header={
<CollapsibleHeaderContent
heading={
<div>
<div>
<span>{feat.getName()}</span>
{missingChoiceCount > 0 && (
<TodoIcon
theme={theme}
title={`Choice${
missingChoiceCount !== 1 ? "s" : ""
} Needed`}
/>
)}
</div>
<span className={styles.source}>
<Reference name={feat.getPrimarySourceName()} />
</span>
{showFailures && (
<PrerequisiteFailureSummary
failures={feat.getPrerequisiteFailures()}
className={styles.failures}
/>
)}
</div>
}
callout={calloutNode}
/>
}
>
<FeatDetail
featManager={feat}
className={clsx([styles.detail, isDarkMode && styles.dark])}
/>
</Collapsible>
);
};
@@ -0,0 +1,35 @@
import { FC, HTMLAttributes } from "react";
import { FeatManager } from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { DetailChoiceFeat } from "~/tools/js/Shared/containers/DetailChoice";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
featManager: FeatManager;
}
export const FeatDetail: FC<Props> = ({ featManager, className, ...props }) => {
const prerequisiteDescription = featManager.getPrerequisiteDescription();
const featDescription = featManager.getDescription();
return (
<div className={className} {...props}>
{prerequisiteDescription && (
<div className={styles.prereq}>
Prerequisite: {prerequisiteDescription}
</div>
)}
{featDescription && (
<HtmlContent html={featDescription} withoutTooltips />
)}
{featManager.getChoices().length > 0 && (
<div className={styles.choices}>
<DetailChoiceFeat featId={featManager.getId()} />
</div>
)}
</div>
);
};
@@ -0,0 +1,131 @@
import { FC, useContext, useEffect, useState } from "react";
import { FeatManager } from "@dndbeyond/character-rules-engine";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useSource } from "~/hooks/useSource";
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { AppLoggerUtils } from "~/tools/js/Shared/utils";
import { isNotNullOrUndefined } from "~/tools/js/Shared/utils/TypeScript";
import Collapsible, {
CollapsibleHeaderContent,
CollapsibleHeading,
} from "~/tools/js/smartComponents/Collapsible";
import LoadingPlaceholder from "~/tools/js/smartComponents/LoadingPlaceholder";
import { Feat } from "../Feat/Feat";
import styles from "../styles.module.css";
export const FeatShoppe: FC<{}> = () => {
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const {
feats: currentCharacterFeats,
characterTheme: theme,
preferences,
} = useCharacterEngine();
const { getSourceCategoryGroups } = useSource();
const [loadingStatus, setLoadingStatus] = useState<DataLoadingStatusEnum>(
DataLoadingStatusEnum.NOT_INITIALIZED
);
const [featShoppe, setFeatShoppe] = useState<Array<FeatManager>>([]);
useEffect(() => {
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
characterFeaturesManager
.getFeatShoppe()
.then((featShoppe) => {
setLoadingStatus(DataLoadingStatusEnum.LOADED);
setFeatShoppe(featShoppe);
})
.catch(AppLoggerUtils.handleAdhocApiError);
} else {
setFeatShoppe(characterFeaturesManager.updateFeatShoppe(featShoppe));
}
}, [currentCharacterFeats]);
const getFeatsInSourceCategoryGroups = (feats: FeatManager[]) => {
const featDefs = feats
.map((feat) => feat.getDefinition())
.filter(isNotNullOrUndefined);
return getSourceCategoryGroups(featDefs).map((group) => {
return {
...group,
items: characterFeaturesManager.transformLoadedFeats(group.items),
};
});
};
return loadingStatus !== DataLoadingStatusEnum.LOADED ? (
<LoadingPlaceholder />
) : (
<>
<div className={styles.featList}>
{getFeatsInSourceCategoryGroups(
characterFeaturesManager.getAvailableFeats(featShoppe)
).map((group) => (
<Collapsible
key={group.id + "available"}
layoutType="minimal"
initiallyCollapsed={false}
header={<h2>{group.name}</h2>}
>
<div>
{group.items.map((feat) => (
<Feat theme={theme} key={feat.getId()} feat={feat} />
))}
</div>
</Collapsible>
))}
</div>
{characterFeaturesManager.getUnavailableFeats(featShoppe).length > 0 && (
<Collapsible
header={
<CollapsibleHeaderContent
heading={
<CollapsibleHeading>
Unavailable Feats
<span className={styles.unavailableCallout}>
Prerequisites Not Met
</span>
</CollapsibleHeading>
}
/>
}
className={styles.featList}
>
<>
{getFeatsInSourceCategoryGroups(
characterFeaturesManager.getUnavailableFeats(featShoppe)
).map((group) => (
<Collapsible
key={group.id + "unavailable"}
layoutType="minimal"
initiallyCollapsed={false}
header={<h2>{group.name}</h2>}
>
<div>
{group.items.map((feat) => (
<Feat
theme={theme}
key={feat.getId()}
feat={feat}
enableAdd={!preferences?.enforceFeatRules}
showFailures={true}
/>
))}
</div>
</Collapsible>
))}
</>
</Collapsible>
)}
</>
);
};
@@ -0,0 +1,66 @@
import { FC } from "react";
import { useSelector } from "react-redux";
import { useFeatureFlags } from "~/contexts/FeatureFlag";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import SettingsButton from "~/tools/js/CharacterSheet/components/SettingsButton";
import { SettingsContextsEnum } from "~/tools/js/Shared/containers/panes/SettingsPane/typings";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import Collapsible, {
CollapsibleHeaderContent,
CollapsibleHeading,
} from "~/tools/js/smartComponents/Collapsible";
import { BlessingShoppe } from "./BlessingShoppe";
import { FeatShoppe } from "./FeatShoppe";
import styles from "./styles.module.css";
export const FeatsManagePane: FC<{}> = () => {
const { gfsBlessingsUiFlag } = useFeatureFlags();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
if (isReadonly) {
return null;
}
return (
<div>
<Header
callout={
<SettingsButton
context={SettingsContextsEnum.FEATURES}
isReadonly={isReadonly}
/>
}
>
Manage {gfsBlessingsUiFlag ? "Features" : "Feats"}
</Header>
{gfsBlessingsUiFlag ? (
<Collapsible
header={
<CollapsibleHeaderContent
heading={<CollapsibleHeading>Add Feats</CollapsibleHeading>}
/>
}
className={styles.featList}
>
<FeatShoppe />
</Collapsible>
) : (
<FeatShoppe />
)}
{gfsBlessingsUiFlag && (
<Collapsible
header={
<CollapsibleHeaderContent
heading={<CollapsibleHeading>Add Blessings</CollapsibleHeading>}
/>
}
className={styles.featList}
>
<BlessingShoppe />
</Collapsible>
)}
</div>
);
};
@@ -0,0 +1,61 @@
import clsx from "clsx";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Dice } from "@dndbeyond/dice";
import { GameLog } from "@dndbeyond/game-log-components";
import { appEnvActions } from "~/tools/js/Shared/actions/appEnv";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import styles from "./styles.module.css";
export const GameLogPane = () => {
const dispatch = useDispatch();
const gameLog = useSelector(appEnvSelectors.getGameLog);
const characterId = useSelector(appEnvSelectors.getCharacterId);
useEffect(
() => {
const updateOpenState = (isOpen: boolean) => {
const lastMessageTime = new Date().getTime();
dispatch(
appEnvActions.dataSet({
gameLog: {
...gameLog,
isOpen: isOpen,
lastMessageTime: lastMessageTime,
},
})
);
try {
localStorage.setItem(
`gameLog-lastMessageTime-${characterId}`,
lastMessageTime.toString()
);
} catch (e) {}
// turn the dice notifications off if the panel is open
Dice.setDiceNotificationEnabled(!isOpen);
};
updateOpenState(true);
return () => {
updateOpenState(false);
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<div className={clsx(styles.gameLogPane)} data-testid="gamelog-pane">
{characterId && <GameLog />}
</div>
);
};
export default GameLogPane;
@@ -0,0 +1,220 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
DigitalDiceWrapper,
LightDiceSvg,
} from "@dndbeyond/character-components/es";
import { RollKind, RollRequest, RollType } from "@dndbeyond/dice";
import { GameLogContext } from "@dndbeyond/game-log-components";
import { useSidebar } from "~/contexts/Sidebar";
import { isNotNullOrUndefined } from "~/helpers/validation";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { getDataOriginComponentInfo } from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import DiceAdjustmentSummary from "~/tools/js/Shared/components/DiceAdjustmentSummary";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "~/tools/js/Shared/selectors";
import { DataOrigin } from "~/types";
import { Heading } from "../../../components/Heading";
import { DeathSavesMarks } from "./DeathSavesMarks";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
damageValue: number;
}
export const DeathSavesManager: FC<Props> = ({
damageValue,
className,
...props
}) => {
const {
characterTheme,
deathSaveInfo,
ruleData,
characterActions,
ruleDataUtils,
} = useCharacterEngine();
const isDiceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
const characterRollContext = useSelector(
characterRollContextSelectors.getCharacterRollContext
);
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
useContext(GameLogContext);
const dispatch = useDispatch();
const {
pane: { paneHistoryPush },
} = useSidebar();
const oneHPRestoreType = ruleDataUtils
.getRestoreTypes(ruleData)
.find((r) => r.name === "OneHP");
const hasAdvantage = deathSaveInfo.advantageAdjustments.length > 0;
const hasDisadvantage = deathSaveInfo.disadvantageAdjustments.length > 0;
let rollKind = RollKind.None;
if (hasAdvantage && !hasDisadvantage) {
rollKind = RollKind.Advantage;
} else if (hasDisadvantage && !hasAdvantage) {
rollKind = RollKind.Disadvantage;
}
/* --- On Click Functions --- */
const onClickDataOrigin = (dataOrigin: DataOrigin): void => {
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
};
const dispatchDeathSaves = (fails: number, successes: number) => {
dispatch(
characterActions.deathSavesSet(
Math.min(ruleDataUtils.getMaxDeathsavesFail(ruleData), fails),
Math.min(ruleDataUtils.getMaxDeathsavesSuccess(ruleData), successes)
)
);
};
const onFail = (evt?: React.MouseEvent, isCritical = false) => {
const uses = isCritical ? 2 : 1;
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(
deathSaveInfo.failCount + uses,
deathSaveInfo.successCount
);
};
const onFailClear = (evt?: React.MouseEvent) => {
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(deathSaveInfo.failCount - 1, deathSaveInfo.successCount);
};
const onSuccess = (evt?: React.MouseEvent) => {
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(deathSaveInfo.failCount, deathSaveInfo.successCount + 1);
};
const onSuccessClear = (evt?: React.MouseEvent) => {
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(deathSaveInfo.failCount, deathSaveInfo.successCount - 1);
};
return (
<div className={clsx([styles.column, className])} {...props}>
<div className={clsx([styles.row, styles.heading])}>
<Heading>Death Saves</Heading>
{isDiceEnabled && (
<DigitalDiceWrapper
diceNotation={"1d20"}
onRollResults={(rollRequest: RollRequest) => {
const roll = rollRequest.rolls[0].result?.total;
if (roll) {
if (roll === 1) {
onFail(undefined, true);
} else if (roll === 20 && oneHPRestoreType) {
dispatch(characterActions.restoreLife(oneHPRestoreType.id));
} else if (roll >= 10) {
onSuccess.bind(this)();
} else {
onFail.bind(this)();
}
}
}}
rollType={RollType.Save}
rollKind={rollKind}
rollAction={"Death"}
diceEnabled={isDiceEnabled}
rollContext={characterRollContext}
rollTargetOptions={
messageTargetOptions?.entities
? Object.values(messageTargetOptions.entities).filter(
isNotNullOrUndefined
)
: undefined
}
rollTargetDefault={defaultMessageTargetOption}
userId={Number(userId)}
>
<LightDiceSvg />
<span>Roll</span>
</DigitalDiceWrapper>
)}
</div>
<div className={clsx([styles.row, styles.deathSavesGroups])}>
<DeathSavesMarks
label="Failures"
type="fails"
activeCount={deathSaveInfo.failCount}
willBeActiveCount={damageValue !== 0 ? 1 : 0}
totalCount={ruleData.maxDeathsavesFail}
onUse={onFail}
onClear={onFailClear}
/>
<DeathSavesMarks
label="Successes"
type="successes"
activeCount={deathSaveInfo.successCount}
totalCount={ruleData.maxDeathsavesSuccess}
onUse={onSuccess}
onClear={onSuccessClear}
/>
</div>
{(!!deathSaveInfo.advantageAdjustments.length ||
!!deathSaveInfo.disadvantageAdjustments.length) && (
<div className={styles.diceAdjustments}>
{deathSaveInfo.advantageAdjustments.map((diceAdjustment) => {
return (
<DiceAdjustmentSummary
key={diceAdjustment.uniqueKey}
diceAdjustment={diceAdjustment}
ruleData={ruleData}
theme={characterTheme}
onDataOriginClick={onClickDataOrigin}
/>
);
})}
{deathSaveInfo.disadvantageAdjustments.map((diceAdjustment) => {
return (
<DiceAdjustmentSummary
key={diceAdjustment.uniqueKey}
diceAdjustment={diceAdjustment}
ruleData={ruleData}
theme={characterTheme}
onDataOriginClick={onClickDataOrigin}
/>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,88 @@
import clsx from "clsx";
import { HTMLAttributes, FC } from "react";
import { CloseSvg } from "@dndbeyond/character-components/es";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
type: "successes" | "fails";
label: string;
activeCount: number;
totalCount: number;
willBeActiveCount?: number;
onUse: (evt: React.MouseEvent) => void;
onClear: (evt: React.MouseEvent) => void;
}
export const DeathSavesMarks: FC<Props> = ({
type,
label,
activeCount,
totalCount,
willBeActiveCount = 0,
onUse,
onClear,
className,
...props
}) => {
// TODO: Need to make these marks accessible - they currently cannot be interacted with with a keyboard.
let marks: Array<React.ReactNode> = [];
let availableSlots: number = totalCount;
for (let i = 0; i < activeCount; i++) {
marks.push(
<span
className={styles.mark}
key={`${type}-active-${i}`}
onClick={onClear}
>
<CloseSvg
fillColor={type === "successes" ? "#00c797" : "#c53131"}
secondaryFillColor=""
key={`${type}-active-${i}`}
className={clsx([styles.mark, styles.activeMark])}
/>
</span>
);
}
availableSlots -= activeCount;
for (let i = 0; i < Math.min(availableSlots, willBeActiveCount); i++) {
marks.push(
<span
key={`${type}-willbe-${i}`}
className={clsx([
styles.mark,
styles.willBeActive,
type === "fails" && styles.willBeFail,
])}
/>
);
}
availableSlots -= willBeActiveCount;
for (let i = 0; i < Math.min(availableSlots, totalCount); i++) {
marks.push(
<span
key={`${type}-inactive-${i}`}
className={clsx([styles.mark])}
onClick={onUse}
/>
);
}
return (
<div
className={clsx([styles.container, className])}
data-testid={`${type}-group`}
{...props}
>
<p className={styles.label}>{label}</p>
<div className={styles.marks}>{marks}</div>
</div>
);
};
@@ -0,0 +1,537 @@
import clsx from "clsx";
import { createRef, FC, HTMLAttributes, useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { DataOriginName } from "@dndbeyond/character-components/es";
import { Constants, ItemManager } from "@dndbeyond/character-rules-engine";
import { Button } from "~/components/Button";
import { Checkbox } from "~/components/Checkbox";
import { ItemName } from "~/components/ItemName";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { HP_DAMAGE_TAKEN_VALUE } from "~/subApps/sheet/constants";
import { Item, HitPointInfo, Creature } from "~/types";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
hpInfo: HitPointInfo;
creature?: Creature;
handleDamageUpdate?: (damageAmount: number) => void;
vibrationAmount?: number;
}
// TODO This hitpoints adjuster doesn't work as a generic component for vehicles yet.
export const HitPointsAdjuster: FC<Props> = ({
hpInfo,
creature,
handleDamageUpdate,
vibrationAmount = 15,
className,
...props
}) => {
const {
characterTheme,
protectionSuppliers,
deathSaveInfo,
ruleData,
characterActions,
actionUtils,
characterUtils,
helperUtils,
itemUtils,
limitedUseUtils,
modifierUtils,
ruleDataUtils,
creatureUtils,
} = useCharacterEngine();
const getInitialActiveProtectionSupplierKey = (): string | null => {
const foundSupplier = protectionSuppliers.find(
(supplier) =>
supplier.availabilityStatus ===
Constants.ProtectionAvailabilityStatusEnum.AVAILABLE
);
if (foundSupplier) {
return foundSupplier.key;
}
return null;
};
const [tickCount, setTickCount] = useState(0);
const [healingAmount, setHealingAmount] = useState<number | null>(null);
const [damageAmount, setDamageAmount] = useState<number | null>(null);
const [hpDifference, setHpDifference] = useState(
characterUtils.calculateHitPoints(hpInfo, 0)
);
const [isValueChanged, setIsValueChanged] = useState<boolean>(false);
const [activeProtectionSupplierKey, setActiveProtectionSupplierKey] =
useState<string | null>(getInitialActiveProtectionSupplierKey());
const damageInput = createRef<HTMLInputElement>();
const healingInput = createRef<HTMLInputElement>();
const dispatch = useDispatch();
/* --- Helper Functions --- */
const reset = (newTemp: number | null): void => {
if (hpInfo.tempHp !== newTemp && newTemp !== null) {
creature
? dispatch(
characterActions.creatureHitPointsSet(
creatureUtils.getMappingId(creature),
hpInfo.removedHp,
newTemp
)
)
: dispatch(characterActions.hitPointsSet(hpInfo.removedHp, newTemp));
}
setTickCount(0);
setHealingAmount(0);
setDamageAmount(0);
setIsValueChanged(false);
setActiveProtectionSupplierKey(getInitialActiveProtectionSupplierKey());
};
const onKeyUpSave = (evt: React.KeyboardEvent<HTMLInputElement>): void => {
if (evt.key === "Enter") {
onClickSave();
}
};
// onKeyDown must be used for the Escape key to reset the adjuster because the
// Sidebar uses onKeyDown to close when the Escape key is pressed.
const onKeyDownCancel = (
evt: React.KeyboardEvent<HTMLInputElement>
): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
if (evt.key === "Escape") {
reset(hpInfo.tempHp);
}
};
/* --- On Input Change Functions --- */
const onChangeHealing = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setTickCount(0);
setHealingAmount(
helperUtils.clampInt(
helperUtils.parseInputInt(evt.target.value, HP_DAMAGE_TAKEN_VALUE.MIN),
HP_DAMAGE_TAKEN_VALUE.MIN,
HP_DAMAGE_TAKEN_VALUE.MAX
)
);
setDamageAmount(0);
setIsValueChanged(true);
};
const onChangeDamage = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setTickCount(0);
setHealingAmount(0);
setDamageAmount(
helperUtils.clampInt(
helperUtils.parseInputInt(evt.target.value, HP_DAMAGE_TAKEN_VALUE.MIN),
HP_DAMAGE_TAKEN_VALUE.MIN,
HP_DAMAGE_TAKEN_VALUE.MAX
) * -1
);
setIsValueChanged(true);
};
/* --- On Click Functions --- */
const onClickIncrease = (): void => {
setHealingAmount(
healingAmount !== null && damageAmount === 0 ? healingAmount + 1 : 0
);
setDamageAmount(
damageAmount !== null && damageAmount < 0 ? damageAmount + 1 : 0
);
setIsValueChanged(true);
if (navigator.vibrate) {
navigator.vibrate(vibrationAmount);
}
};
const onClickDecrease = (): void => {
setHealingAmount(
healingAmount !== null && healingAmount > 0 ? healingAmount - 1 : 0
);
setDamageAmount(
damageAmount !== null && healingAmount === 0 ? damageAmount - 1 : 0
);
setIsValueChanged(true);
if (navigator.vibrate) {
navigator.vibrate(vibrationAmount);
}
};
const onClickSave = (): void => {
const { newTemp, startHp, newHp } = hpDifference;
let protectedHpResult: number | null = null;
if (!creature) {
if (
startHp !== 0 &&
newHp === 0 &&
activeProtectionSupplierKey !== null
) {
let foundActiveSupplier = protectionSuppliers.find(
(supplier) => supplier.key === activeProtectionSupplierKey
);
if (foundActiveSupplier) {
protectedHpResult = foundActiveSupplier.setHpValue;
switch (foundActiveSupplier.type) {
case Constants.ProtectionSupplierTypeEnum.ITEM: {
const itemData = foundActiveSupplier.data as Item;
const item = ItemManager.getItem(
itemUtils.getMappingId(itemData)
);
const numberUsed = item.getNumberUsed();
if (numberUsed !== null) {
item.handleItemLimitedUseSet(numberUsed + 1);
}
break;
}
case Constants.ProtectionSupplierTypeEnum.RACIAL_TRAIT:
case Constants.ProtectionSupplierTypeEnum.CLASS_FEATURE:
case Constants.ProtectionSupplierTypeEnum.FEAT: {
let action = foundActiveSupplier.action;
if (action !== null) {
let limitedUse = actionUtils.getLimitedUse(action);
const id = actionUtils.getId(action);
const entityTypeId = actionUtils.getEntityTypeId(action);
const dataOriginType = actionUtils.getDataOriginType(action);
if (
limitedUse !== null &&
id !== null &&
entityTypeId !== null
) {
const numberUsed = limitedUseUtils.getNumberUsed(limitedUse);
dispatch(
characterActions.actionUseSet(
id,
entityTypeId,
numberUsed + 1,
dataOriginType
)
);
}
}
break;
}
default:
// not implemented
}
}
}
}
let removedHp: number = 0;
if (protectedHpResult === null) {
removedHp = hpInfo.totalHp - newHp;
} else {
removedHp = hpInfo.totalHp - protectedHpResult;
}
creature
? dispatch(
characterActions.creatureHitPointsSet(
creatureUtils.getMappingId(creature),
removedHp,
newTemp
)
)
: dispatch(characterActions.hitPointsSet(removedHp, newTemp));
const damage: number =
damageAmount === null
? 0
: Math.min(
tickCount +
(healingAmount === null ? 0 : healingAmount) +
damageAmount,
0
);
if (startHp === 0 && !creature) {
if (newHp > 0) {
dispatch(characterActions.deathSavesSet(0, 0));
} else if (damage !== 0 && protectedHpResult === null) {
dispatch(
characterActions.deathSavesSet(
Math.min(
ruleDataUtils.getMaxDeathsavesFail(ruleData),
deathSaveInfo.failCount + 1
),
Math.min(
ruleDataUtils.getMaxDeathsavesSuccess(ruleData),
deathSaveInfo.successCount
)
)
);
reset(hpInfo.tempHp);
}
}
if (startHp === newHp) {
reset(newTemp);
}
};
const onClickCancel = (): void => {
reset(hpInfo.tempHp);
};
/* --- useEffects --- */
useEffect(() => {
const difference: number =
(healingAmount === null ? 0 : healingAmount) +
(damageAmount === null ? 0 : damageAmount) +
tickCount;
setHpDifference(characterUtils.calculateHitPoints(hpInfo, difference));
}, [healingAmount, damageAmount, tickCount, hpInfo]);
useEffect(() => {
reset(hpInfo.tempHp);
}, [hpInfo, protectionSuppliers]);
useEffect(() => {
if (handleDamageUpdate) {
const damageValue: number =
damageAmount === null
? 0
: Math.min(
tickCount +
(healingAmount === null ? 0 : healingAmount) +
damageAmount,
0
);
handleDamageUpdate(damageValue);
}
}, [tickCount, healingAmount, damageAmount]);
/* --- Render Functions --- */
const renderProtectionInfo = (): React.ReactNode => {
const { startHp, newHp } = hpDifference;
if (
!isValueChanged ||
startHp === 0 ||
newHp !== 0 ||
protectionSuppliers.length === 0
) {
return null;
}
return (
<>
{protectionSuppliers.map((protectionSupplier) => {
if (
protectionSupplier.availabilityStatus !==
Constants.ProtectionAvailabilityStatusEnum.AVAILABLE
) {
return null;
}
let dataOrigin = modifierUtils.getDataOrigin(
protectionSupplier.modifier
);
let isEnabled =
activeProtectionSupplierKey !== null &&
protectionSupplier.key === activeProtectionSupplierKey;
//TODO remove if check and ItemName => use DataOriginName when EntityDataOriginLookup is available to be passed as a prop to DataOriginName
let nameNode: React.ReactNode = null;
if (
protectionSupplier.type ===
Constants.ProtectionSupplierTypeEnum.ITEM
) {
nameNode = <ItemName item={protectionSupplier.data as Item} />;
} else {
nameNode = (
<DataOriginName dataOrigin={dataOrigin} theme={characterTheme} />
);
}
return (
<div
className={clsx([styles.row, styles.protectionNotice])}
key={protectionSupplier.key}
>
<Checkbox
checked={isEnabled}
themed
darkMode={characterTheme.isDarkMode}
id={`${protectionSupplier.key}-checkbox`}
onClick={() => {
isEnabled = !isEnabled;
setActiveProtectionSupplierKey(
isEnabled ? protectionSupplier.key : null
);
}}
/>
<label htmlFor={`${protectionSupplier.key}-checkbox`}>
Instead of dropping to 0 hit points, use{" "}
<strong>{nameNode}</strong> to set hit points to{" "}
<strong>{protectionSupplier.setHpValue}</strong>
</label>
</div>
);
})}
</>
);
};
return (
<div
className={clsx([styles.column, styles.container, className])}
{...props}
>
<div className={clsx([styles.row, styles.container])}>
<div className={clsx([styles.column, styles.inputsContainer])}>
<div
className={clsx([styles.inputContainer, styles.healingContainer])}
>
<label
htmlFor="healing-input"
className={clsx([styles.inputLabel, styles.positiveColor])}
>
Healing
</label>
<input
ref={healingInput}
type="number"
id="healing-input"
className={styles.input}
value={
healingAmount === null
? ""
: Math.max(
tickCount +
healingAmount +
(damageAmount === null ? 0 : damageAmount),
0
)
}
min={HP_DAMAGE_TAKEN_VALUE.MIN}
max={HP_DAMAGE_TAKEN_VALUE.MAX}
onChange={onChangeHealing}
onKeyUp={onKeyUpSave}
onKeyDown={onKeyDownCancel}
/>
</div>
<div className={clsx([styles.row, styles.newValues])}>
<div
className={clsx([
styles.column,
hpDifference.newHp > hpDifference.startHp &&
styles.positiveColor,
hpDifference.newHp < hpDifference.startHp &&
styles.negativeColor,
])}
>
<span className={styles.label}>New HP</span>
<span className={styles.value} data-testid="new-hp">
{hpDifference.newHp}
</span>
</div>
{hpInfo.tempHp !== null && hpInfo.tempHp > 0 && (
<div
className={clsx([
styles.column,
hpDifference.newTemp > hpInfo.tempHp && styles.positiveColor,
hpDifference.newTemp < hpInfo.tempHp && styles.negativeColor,
])}
>
<span className={styles.label}>New Temp</span>
<span className={styles.value} data-testid="new-temp-hp">
{hpDifference.newTemp}
</span>
</div>
)}
</div>
<div
className={clsx([styles.inputContainer, styles.damageContainer])}
>
<label
htmlFor="damage-input"
className={clsx([styles.inputLabel, styles.negativeColor])}
>
Damage
</label>
<input
ref={damageInput}
type="number"
id="damage-input"
className={styles.input}
value={
damageAmount === null
? ""
: Math.abs(
Math.min(
tickCount +
(healingAmount === null ? 0 : healingAmount) +
damageAmount,
0
)
)
}
min={HP_DAMAGE_TAKEN_VALUE.MIN}
max={HP_DAMAGE_TAKEN_VALUE.MAX}
onChange={onChangeDamage}
onKeyUp={onKeyUpSave}
onKeyDown={onKeyDownCancel}
/>
</div>
</div>
<div className={clsx([styles.column, styles.modifyButtons])}>
<Button
onClick={onClickIncrease}
themed
size="xx-small"
className={styles.increase}
aria-label="Increase Hit Points"
/>
<Button
onClick={onClickDecrease}
themed
size="xx-small"
className={styles.decrease}
aria-label="Decrease Hit Points"
/>
</div>
</div>
{!creature && renderProtectionInfo()}
{isValueChanged && (
<div className={clsx([styles.row, styles.applyButtons])}>
<Button onClick={onClickSave} size="xx-small" themed>
Apply Changes
</Button>
<Button
onClick={onClickCancel}
variant="outline"
size="xx-small"
themed
>
Cancel
</Button>
</div>
)}
</div>
);
};
@@ -0,0 +1,104 @@
import { FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { Constants } from "@dndbeyond/character-rules-engine";
import { HtmlContent } from "~/components/HtmlContent";
import { RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
import { toastMessageActions } from "~/tools/js/Shared/actions";
import { ShortModelInfoContract } from "~/types";
import { HitPointsSummary } from "../../../HitPointsBox/HitPointsSummary";
import { DeathSavesManager } from "./DeathSavesManager/DeathSavesManager";
import { HitPointsAdjuster } from "./HitPointsAdjuster";
import { HitPointsOverrides } from "./HitPointsOverrides";
import { RestoreLifeManager } from "./RestoreLifeManager/RestoreLifeManager";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const HitPointsManagePane: FC<Props> = ({ className, ...props }) => {
const { hpInfo, deathCause, ruleData, ruleDataUtils, characterActions } =
useCharacterEngine();
const dispatch = useDispatch();
const [damage, setDamage] = useState<number>(0);
const handleDamageUpdate = (damageValue: number) => {
setDamage(damageValue);
};
const onRestoreToLife = (restoreType: ShortModelInfoContract): void => {
const restoreChoice = restoreType.name === "Full" ? "full" : "1";
dispatch(characterActions.restoreLife(restoreType.id));
dispatch(
toastMessageActions.toastSuccess(
"Character Restored to Life",
`You have been restored to life with ${restoreChoice} HP.`
)
);
};
const renderDeathSavesRules = (): React.ReactNode => {
if (
hpInfo.remainingHp > 0 ||
(deathCause !== Constants.DeathCauseEnum.NONE &&
deathCause !== Constants.DeathCauseEnum.DEATHSAVES)
) {
return null;
}
const deathSavesRule = ruleDataUtils.getRule(
RuleKeyEnum.DEATH_SAVING_THROWS,
ruleData
);
return (
<div>
<Heading>Death Saving Throws Rules</Heading>
<HtmlContent
html={deathSavesRule?.description ? deathSavesRule.description : ""}
withoutTooltips
/>
</div>
);
};
return (
<div className={className} {...props}>
<Header>HP Management</Header>
<div className={styles.container}>
{hpInfo.remainingHp <= 0 &&
(deathCause === Constants.DeathCauseEnum.NONE ||
deathCause === Constants.DeathCauseEnum.DEATHSAVES) && (
<DeathSavesManager damageValue={damage} className={styles.border} />
)}
{deathCause !== Constants.DeathCauseEnum.NONE && (
<RestoreLifeManager
onSave={onRestoreToLife}
className={styles.border}
/>
)}
<HitPointsSummary
hpInfo={hpInfo}
showOriginalMax
showPermanentInputs
className={styles.border}
/>
<HitPointsAdjuster
hpInfo={hpInfo}
handleDamageUpdate={handleDamageUpdate}
className={styles.border}
/>
<HitPointsOverrides className={styles.border} />
{hpInfo.remainingHp <= 0 &&
(deathCause === Constants.DeathCauseEnum.NONE ||
deathCause === Constants.DeathCauseEnum.DEATHSAVES) &&
renderDeathSavesRules()}
</div>
</div>
);
};
@@ -0,0 +1,150 @@
import clsx from "clsx";
import { createRef, FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import {
HP_BONUS_VALUE,
HP_DAMAGE_TAKEN_VALUE,
HP_OVERRIDE_MAX_VALUE,
HP_TEMP_VALUE,
} from "~/subApps/sheet/constants";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const HitPointsOverrides: FC<Props> = ({ className, ...props }) => {
const { characterActions, hpInfo, ruleData, ruleDataUtils, helperUtils } =
useCharacterEngine();
const dispatch = useDispatch();
const [maxModifier, setMaxModifier] = useState<number | null>(hpInfo.bonusHp);
const [maxOverride, setMaxOverride] = useState<number | null>(
hpInfo.overrideHp
);
const maxModifierInput = createRef<HTMLInputElement>();
const maxOverrideInput = createRef<HTMLInputElement>();
/* --- Helper functions --- */
const updateDamageTaken = (maxHp: number) => {
if (hpInfo.totalHp !== null) {
let maxDiff = hpInfo.totalHp - maxHp;
if (maxDiff > 0) {
let newRemovedHp = Math.max(
HP_DAMAGE_TAKEN_VALUE.MIN,
hpInfo.removedHp - maxDiff
);
if (newRemovedHp !== hpInfo.removedHp) {
dispatch(
characterActions.hitPointsSet(
newRemovedHp,
hpInfo.tempHp ?? HP_TEMP_VALUE.MIN
)
);
}
}
}
};
const onKeyUp = (
func: any,
evt: React.KeyboardEvent<HTMLInputElement>
): void => {
if (evt.key === "Enter") {
func(evt);
}
};
/* --- Max hit point modifier functions --- */
const onChangeMaxModifier = (evt: React.ChangeEvent<HTMLInputElement>) => {
setMaxModifier(helperUtils.parseInputInt(evt.target.value));
};
const onBlurMaxModifier = (evt: React.FocusEvent<HTMLInputElement>) => {
let value = helperUtils.parseInputInt(evt.target.value);
if (value !== null) {
value = helperUtils.clampInt(
value,
HP_BONUS_VALUE.MIN,
HP_BONUS_VALUE.MAX
);
}
if (value !== null) {
updateDamageTaken(hpInfo.baseTotalHp + value);
}
if (value !== hpInfo.bonusHp) {
dispatch(characterActions.bonusHitPointsSet(value));
}
setMaxModifier(value);
};
/* --- Max hit point override functions --- */
const onChangeMaxOverride = (evt: React.ChangeEvent<HTMLInputElement>) => {
setMaxOverride(helperUtils.parseInputInt(evt.target.value));
};
const onBlurMaxOverride = (evt: React.FocusEvent<HTMLInputElement>) => {
let value = helperUtils.parseInputInt(evt.target.value);
if (value !== null) {
value = helperUtils.clampInt(
value,
ruleDataUtils.getMinimumHpTotal(ruleData),
HP_OVERRIDE_MAX_VALUE
);
}
if (value !== null) {
updateDamageTaken(value);
}
if (value !== hpInfo.overrideHp) {
//TODO fix when it can accept null
dispatch(characterActions.overrideHitPointsSet(value as number));
}
setMaxOverride(value);
};
return (
<div className={clsx([styles.row, styles.overrides, className])} {...props}>
<label className={clsx([styles.column, styles.override, styles.label])}>
Max HP Modifier
<input
ref={maxModifierInput}
className={styles.input}
type="number"
value={maxModifier ?? ""}
min={HP_BONUS_VALUE.MIN}
max={HP_BONUS_VALUE.MAX}
onChange={onChangeMaxModifier}
onBlur={onBlurMaxModifier}
onKeyUp={onKeyUp.bind(this, onBlurMaxModifier)}
placeholder="--"
/>
</label>
<label className={clsx([styles.column, styles.override, styles.label])}>
Override Max HP
<input
ref={maxOverrideInput}
className={styles.input}
type="number"
value={maxOverride ?? ""}
min={ruleDataUtils.getMinimumHpTotal(ruleData)}
max={HP_OVERRIDE_MAX_VALUE}
onChange={onChangeMaxOverride}
onBlur={onBlurMaxOverride}
onKeyUp={onKeyUp.bind(this, onBlurMaxOverride)}
placeholder="--"
/>
</label>
</div>
);
};
@@ -0,0 +1,84 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useState } from "react";
import {
ExclusiveCheckbox,
TypeScriptUtils,
} from "@dndbeyond/character-components/es";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { ShortModelInfoContract } from "~/types";
import { Heading } from "../../../components/Heading";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
onSave?: (restoreType: ShortModelInfoContract) => void;
}
export const RestoreLifeManager: FC<Props> = ({
onSave,
className,
...props
}) => {
const { characterTheme, ruleData, ruleDataUtils } = useCharacterEngine();
const [activeChoice, setActiveChoice] = useState<number | null>(null);
const onClickRestore = () => {
if (onSave && activeChoice !== null) {
let restoreTypes = ruleDataUtils.getRestoreTypes(ruleData);
onSave(restoreTypes[activeChoice]);
}
};
const onClickReset = () => {
setActiveChoice(null);
};
const onSelection = (slotIdx: number) => {
setActiveChoice(slotIdx);
};
const renderRestoreLifeChoices = (): React.ReactNode => {
const restoreLifeChoices: Array<string> = ruleDataUtils
.getRestoreTypes(ruleData)
.map((type) => type.description)
.filter(TypeScriptUtils.isNotNullOrUndefined);
return (
<ExclusiveCheckbox
theme={characterTheme}
choices={restoreLifeChoices}
activeChoice={activeChoice}
onSelection={onSelection}
/>
);
};
const renderActions = (): React.ReactNode => {
if (activeChoice === null) {
return null;
}
return (
<div className={styles.actions}>
<Button onClick={onClickRestore} themed size="xx-small">
Restore Life
</Button>
<Button onClick={onClickReset} themed size="xx-small" variant="outline">
Reset
</Button>
</div>
);
};
return (
<div className={clsx([className])} {...props}>
<Heading className={styles.heading}>Restore Life</Heading>
{renderRestoreLifeChoices()}
{renderActions()}
</div>
);
};
@@ -0,0 +1,91 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { AdvantageIcon } from "@dndbeyond/character-components/es";
import { HtmlContent } from "~/components/HtmlContent";
import { NumberDisplay } from "~/components/NumberDisplay";
import { RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const InitiativePane: FC<Props> = ({ ...props }) => {
const { ruleDataUtils, initiativeScore } = useRuleData();
const {
ruleData,
processedInitiative,
hasInitiativeAdvantage,
characterTheme,
formatUtils,
} = useCharacterEngine();
const staticTotal = processedInitiative + initiativeScore.amount;
let rule = ruleDataUtils.getRule(RuleKeyEnum.INITIATIVE, ruleData);
return (
<div {...props}>
<Header>
{hasInitiativeAdvantage && (
<AdvantageIcon
theme={characterTheme}
title={"Advantage on Initiative"}
className={styles.advantageIcon}
/>
)}
Initiative{" "}
<span className={styles.modifier}>
(
<NumberDisplay
type="signed"
number={processedInitiative}
className={styles.signedNumber}
/>
)
</span>
</Header>
<div className={styles.container}>
<p>
Initiative scores can replace rolls at your DM's discretion. Your
initiative score equals {initiativeScore.amount} plus your DEX
modifier.
</p>
<div
className={clsx([
styles.score,
hasInitiativeAdvantage && styles.isAdvantage,
])}
>
<span className={styles.label}>Initiative Score: </span>
<span className={styles.amount}>{staticTotal}</span>
</div>
<div className={clsx([hasInitiativeAdvantage && styles.isAdvantage])}>
<span className={styles.labelSecondary}>
With Advantage (
{formatUtils.renderSignedNumber(initiativeScore.advantage)}):{" "}
</span>
<span className={styles.amount}>
{staticTotal + initiativeScore.advantage}
</span>
</div>
<div>
<span className={styles.labelSecondary}>
With Disadvantage (
{formatUtils.renderSignedNumber(initiativeScore.disadvantage)}):{" "}
</span>
<span className={styles.amount}>
{staticTotal + initiativeScore.disadvantage}
</span>
</div>
</div>
<HtmlContent
html={rule && rule.description ? rule.description : ""}
withoutTooltips
/>
</div>
);
};
@@ -0,0 +1,26 @@
import { FC, HTMLAttributes } from "react";
import { HtmlContent } from "~/components/HtmlContent";
import { RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const InspirationPane: FC<Props> = ({ ...props }) => {
const { ruleData } = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
let rule = ruleDataUtils.getRule(RuleKeyEnum.INSPIRATION, ruleData);
let description: string = "";
if (rule && rule.description) {
description = rule.description;
}
return (
<div {...props}>
<Header>Heroic Inspiration</Header>
<HtmlContent html={description} withoutTooltips />
</div>
);
};
@@ -0,0 +1,61 @@
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { characterActions } from "@dndbeyond/character-rules-engine/es";
import { Button } from "~/components/Button";
import { XpManager } from "~/components/XpManager";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { toastMessageActions } from "~/tools/js/Shared/actions";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const XpPane: FC<Props> = ({ ...props }) => {
const dispatch = useDispatch();
const [newXpTotal, setNewXpTotal] = useState<number | null>(null);
const [shouldReset, setShouldReset] = useState<boolean>(false);
useEffect(() => {
setShouldReset(newXpTotal === null);
}, [newXpTotal]);
const handleReset = (): void => {
setNewXpTotal(null);
};
const handleSave = (): void => {
if (newXpTotal !== null) {
setNewXpTotal(null);
dispatch(characterActions.xpSet(newXpTotal));
dispatch(
toastMessageActions.toastSuccess(
"Experience Points Updated",
"You have successfully updated your XP"
)
);
}
};
const handleXpUpdates = (newXpTotal: number): void => {
setNewXpTotal(newXpTotal);
};
return (
<div {...props}>
<Header>Manage XP</Header>
<XpManager handleXpUpdates={handleXpUpdates} shouldReset={shouldReset} />
{newXpTotal !== null && (
<div className={styles.buttonGroup}>
<Button themed variant="solid" size="x-small" onClick={handleSave}>
Apply Changes
</Button>
<Button themed onClick={handleReset} size="x-small" variant="outline">
Cancel
</Button>
</div>
)}
</div>
);
};