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:
@@ -0,0 +1,399 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Creature,
|
||||
CreatureUtils,
|
||||
ConditionUtils,
|
||||
DamageAdjustmentContract,
|
||||
FormatUtils,
|
||||
RuleData,
|
||||
CreatureAbilityInfo,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
|
||||
import {
|
||||
AbilityStatMentalList,
|
||||
AbilityStatPhysicalList,
|
||||
DDB_MEDIA_URL,
|
||||
StatBlockTypeEnum,
|
||||
} from "../../../../constants";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
variant: "default" | "basic";
|
||||
creature: Creature;
|
||||
ruleData: RuleData;
|
||||
}
|
||||
|
||||
export const CreatureBlock: FC<Props> = ({
|
||||
variant = "default",
|
||||
creature,
|
||||
ruleData,
|
||||
className = "",
|
||||
}) => {
|
||||
const statBlockType = CreatureUtils.getStatBlockType(creature);
|
||||
const isVersion2024 = statBlockType === StatBlockTypeEnum.CORE_RULES_2024;
|
||||
|
||||
const getDamageAdjustment = (
|
||||
type: "vulnerability" | "resistance" | "immunity"
|
||||
): string | null => {
|
||||
let damageAdjustments: Array<DamageAdjustmentContract> = [];
|
||||
|
||||
switch (type) {
|
||||
case "vulnerability":
|
||||
damageAdjustments = CreatureUtils.getDamageVulnerabilities(creature);
|
||||
break;
|
||||
case "resistance":
|
||||
damageAdjustments = CreatureUtils.getDamageResistances(creature);
|
||||
break;
|
||||
case "immunity":
|
||||
damageAdjustments = CreatureUtils.getDamageImmunities(creature);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!damageAdjustments.length) {
|
||||
return null;
|
||||
} else {
|
||||
return damageAdjustments
|
||||
.map((damageAdjustment) => damageAdjustment.name)
|
||||
.join(", ");
|
||||
}
|
||||
};
|
||||
|
||||
const renderSeparator = (): ReactNode => {
|
||||
if (isVersion2024) {
|
||||
return null;
|
||||
}
|
||||
//TODO not hard-code the src url here?
|
||||
return (
|
||||
<img
|
||||
className={styles.separator}
|
||||
alt=""
|
||||
src={`${DDB_MEDIA_URL}/file-attachments/0/579/stat-block-header-bar.svg`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatTable = (
|
||||
stats: Array<CreatureAbilityInfo>,
|
||||
type: "physical" | "mental"
|
||||
): ReactNode => {
|
||||
return (
|
||||
<table className={clsx([styles.statTable, styles[type]])}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th />
|
||||
<th>Mod</th>
|
||||
<th>Save</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.map((stat) => {
|
||||
return (
|
||||
<tr key={stat.id}>
|
||||
<th>{stat.statKey}</th>
|
||||
<td>{stat.score}</td>
|
||||
<td className={styles.modifier}>
|
||||
{FormatUtils.renderSignedNumber(stat.modifier)}
|
||||
</td>
|
||||
<td className={styles.modifier}>
|
||||
{FormatUtils.renderSignedNumber(stat.saveModifier)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStats = (): ReactNode => {
|
||||
const stats = CreatureUtils.getAbilities(creature);
|
||||
|
||||
if (isVersion2024) {
|
||||
const mentalStats: Array<CreatureAbilityInfo> = [];
|
||||
const physicalStats: Array<CreatureAbilityInfo> = [];
|
||||
|
||||
stats.forEach((ability) => {
|
||||
if (AbilityStatPhysicalList.includes(ability.id)) {
|
||||
physicalStats.push(ability);
|
||||
}
|
||||
if (AbilityStatMentalList.includes(ability.id)) {
|
||||
mentalStats.push(ability);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<div className={styles.stats}>
|
||||
{renderStatTable(physicalStats, "physical")}
|
||||
{renderStatTable(mentalStats, "mental")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.stats}>
|
||||
{stats.map((stat) => {
|
||||
const statKey: string = stat.statKey ? stat.statKey : "";
|
||||
const modifier: number = stat.modifier ? stat.modifier : 0;
|
||||
const score: number | null = stat.score;
|
||||
|
||||
return (
|
||||
<div className={styles.stat} key={statKey}>
|
||||
<h2 className={styles.statHeading}>{statKey}</h2>
|
||||
<p className={styles.statScore}>{score}</p>
|
||||
<p className={styles.statModifier}>
|
||||
({FormatUtils.renderSignedNumber(modifier)})
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderActions = (): ReactNode => {
|
||||
let groupActionNode: ReactNode;
|
||||
const groupActionSnippet = CreatureUtils.getGroupActionSnippet(creature);
|
||||
if (groupActionSnippet) {
|
||||
groupActionNode = <HtmlContent html={groupActionSnippet} />;
|
||||
}
|
||||
|
||||
let actionsNode: ReactNode;
|
||||
const actionsDescription = CreatureUtils.getActionsDescription(creature);
|
||||
if (actionsDescription) {
|
||||
actionsNode = <HtmlContent html={actionsDescription} />;
|
||||
}
|
||||
|
||||
const contentNode: ReactNode = (
|
||||
<>
|
||||
{groupActionNode}
|
||||
{actionsNode}
|
||||
</>
|
||||
);
|
||||
|
||||
return renderDescription("Actions", contentNode);
|
||||
};
|
||||
|
||||
const renderLegendaryActions = (): ReactNode => {
|
||||
if (!CreatureUtils.canUseLegendaryActions(creature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return renderDescription(
|
||||
"Legendary Actions",
|
||||
CreatureUtils.getLegendaryActionsDescription(creature)
|
||||
);
|
||||
};
|
||||
|
||||
const renderSpecialDescription = (): ReactNode => {
|
||||
const groupInfo = CreatureUtils.getGroupInfo(creature);
|
||||
let groupNode: ReactNode;
|
||||
|
||||
if (groupInfo?.specialQualityText) {
|
||||
let groupTitle: ReactNode;
|
||||
if (groupInfo.specialQualityTitle) {
|
||||
groupTitle = (
|
||||
<>
|
||||
<strong>
|
||||
<em>{groupInfo.specialQualityTitle}</em>
|
||||
</strong>
|
||||
.{" "}
|
||||
</>
|
||||
);
|
||||
}
|
||||
groupNode = (
|
||||
<p>
|
||||
{groupTitle}
|
||||
{groupInfo.specialQualityText}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
let monsterNode: ReactNode;
|
||||
const monsterDescription =
|
||||
CreatureUtils.getSpecialTraitsDescription(creature);
|
||||
if (monsterDescription) {
|
||||
monsterNode = <HtmlContent html={monsterDescription} />;
|
||||
}
|
||||
|
||||
let contentNode: ReactNode = null;
|
||||
if (monsterNode || groupNode) {
|
||||
contentNode = (
|
||||
<>
|
||||
{monsterNode}
|
||||
{groupNode}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return renderDescription("Traits", contentNode);
|
||||
};
|
||||
|
||||
const renderDescription = (
|
||||
label: string | null,
|
||||
description: ReactNode
|
||||
): ReactNode => {
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let descriptionNode: ReactNode;
|
||||
if (typeof description === "string") {
|
||||
descriptionNode = (
|
||||
<HtmlContent className={styles.description} html={description} />
|
||||
);
|
||||
} else {
|
||||
descriptionNode = <div className={styles.description}>{description}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{label && <h2 className={styles.descriptionHeading}>{label}</h2>}
|
||||
{descriptionNode}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAttribute = (label: string, content: string | null) => {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={styles.attribute}>
|
||||
<h2 className={styles.attributeLabel}>{label}</h2>
|
||||
<p>{content}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderImmunities = (): ReactNode => {
|
||||
const damageImmunities = getDamageAdjustment("immunity");
|
||||
const conditionImmunities = CreatureUtils.getConditionImmunities(creature)
|
||||
.map((condition) => ConditionUtils.getName(condition))
|
||||
.join(", ");
|
||||
|
||||
if (!isVersion2024) {
|
||||
if (!damageImmunities && !conditionImmunities) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const immunityChunks: Array<string> = [];
|
||||
if (damageImmunities) {
|
||||
immunityChunks.push(damageImmunities);
|
||||
}
|
||||
if (conditionImmunities) {
|
||||
immunityChunks.push(conditionImmunities);
|
||||
}
|
||||
return renderAttribute("Immunities", immunityChunks.join("; "));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderAttribute("Damage Immunities", damageImmunities)}
|
||||
{renderAttribute("Condition Immunities", conditionImmunities)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx([
|
||||
className,
|
||||
styles.creatureBlock,
|
||||
isVersion2024 && styles.v2024,
|
||||
])}
|
||||
>
|
||||
<h1 className={styles.header}>{CreatureUtils.getName(creature)}</h1>
|
||||
<p className={styles.meta}>
|
||||
{CreatureUtils.renderMetaText(creature, ruleData, variant === "basic")}
|
||||
</p>
|
||||
{renderSeparator()}
|
||||
{renderAttribute(
|
||||
isVersion2024 ? "AC" : "Armor Class",
|
||||
CreatureUtils.renderArmorClass(creature)
|
||||
)}
|
||||
{renderAttribute(
|
||||
"Initiative",
|
||||
CreatureUtils.renderInitiativeInfo(creature)
|
||||
)}
|
||||
{renderAttribute(
|
||||
isVersion2024 ? "HP" : "Hit Points",
|
||||
CreatureUtils.renderHitPointInfo(creature)
|
||||
)}
|
||||
{renderAttribute(
|
||||
"Speed",
|
||||
CreatureUtils.renderSpeedInfo(creature, ruleData)
|
||||
)}
|
||||
{variant === "default" && (
|
||||
<>
|
||||
{renderSeparator()}
|
||||
{renderStats()}
|
||||
{renderSeparator()}
|
||||
</>
|
||||
)}
|
||||
{variant === "default" && (
|
||||
<>
|
||||
{!isVersion2024 &&
|
||||
renderAttribute(
|
||||
"Saving Throws",
|
||||
CreatureUtils.renderSavingThrows(creature)
|
||||
)}
|
||||
{renderAttribute(
|
||||
"Skills",
|
||||
CreatureUtils.renderSkills(creature, ruleData)
|
||||
)}
|
||||
{renderAttribute(
|
||||
isVersion2024 ? "Vulnerabilities" : "Damage Vulnerabilities",
|
||||
getDamageAdjustment("vulnerability")
|
||||
)}
|
||||
{renderAttribute(
|
||||
isVersion2024 ? "Resistances" : "Damage Resistances",
|
||||
getDamageAdjustment("resistance")
|
||||
)}
|
||||
{renderImmunities()}
|
||||
{renderAttribute("Gear", CreatureUtils.getGear(creature))}
|
||||
{renderAttribute(
|
||||
"Senses",
|
||||
CreatureUtils.renderSensesInfo(creature, ruleData)
|
||||
)}
|
||||
{renderAttribute(
|
||||
"Languages",
|
||||
CreatureUtils.renderLanguages(creature, ruleData)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{renderAttribute(
|
||||
isVersion2024 ? "CR" : "Challenge",
|
||||
CreatureUtils.renderChallengeRatingInfo(creature)
|
||||
)}
|
||||
{variant === "default" && (
|
||||
<>
|
||||
{!isVersion2024 &&
|
||||
renderAttribute(
|
||||
"Proficiency Bonus",
|
||||
CreatureUtils.renderProficiencyBonus(creature)
|
||||
)}
|
||||
{renderSeparator()}
|
||||
<div className={styles.descriptions}>
|
||||
{renderSpecialDescription()}
|
||||
{renderActions()}
|
||||
{renderDescription(
|
||||
"Bonus Actions",
|
||||
CreatureUtils.getBonusActionsDescription(creature)
|
||||
)}
|
||||
{renderDescription(
|
||||
"Reactions",
|
||||
CreatureUtils.getReactionsDescription(creature)
|
||||
)}
|
||||
{renderLegendaryActions()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,285 @@
|
||||
import { keyBy } from "lodash";
|
||||
import React from "react";
|
||||
|
||||
import { Snippet } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityLookup,
|
||||
BaseSpell,
|
||||
BaseFeat,
|
||||
Choice,
|
||||
ChoiceUtils,
|
||||
ClassDefinitionContract,
|
||||
Constants,
|
||||
DataOriginBaseAction,
|
||||
DataOriginRefData,
|
||||
InfusionChoice,
|
||||
LevelScaleContract,
|
||||
Option,
|
||||
OptionUtils,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
SnippetData,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { Reference } from "~/components/Reference";
|
||||
import FeatureSnippetActions from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetActions";
|
||||
import FeatureSnippetChoices from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetChoices";
|
||||
import FeatureSnippetInfusionChoices from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetInfusionChoices";
|
||||
import FeatureSnippetOption from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetOption";
|
||||
import FeatureSnippetSpells from "~/tools/js/CharacterSheet/components/FeatureSnippet/FeatureSnippetSpells";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
//TODO: tools/js/CharacterSheet/components/FeatureSnipper components still need to be gradually migrated to FC
|
||||
interface Props {
|
||||
heading: React.ReactNode;
|
||||
dataOriginExtra?: string;
|
||||
extraMeta: Array<string>;
|
||||
className: string;
|
||||
snippetData: SnippetData;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
sourceId: number | null;
|
||||
sourcePage: number | null;
|
||||
|
||||
spells: Array<BaseSpell>;
|
||||
feats: Array<BaseFeat>;
|
||||
actions: Array<DataOriginBaseAction>;
|
||||
options: Array<Option>;
|
||||
choices: Array<Choice>;
|
||||
infusionChoices?: Array<InfusionChoice>;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
|
||||
levelScale?: LevelScaleContract | null;
|
||||
classLevel?: number;
|
||||
subclass?: ClassDefinitionContract | null;
|
||||
|
||||
onActionClick?: (action: DataOriginBaseAction) => void;
|
||||
onActionUseSet?: (action: DataOriginBaseAction, uses: number) => void;
|
||||
onSpellClick?: (spell: BaseSpell) => void;
|
||||
onSpellUseSet?: (spell: BaseSpell, uses: number) => void;
|
||||
onFeatureClick: () => void;
|
||||
onInfusionChoiceClick?: (infusionChoice: InfusionChoice) => void;
|
||||
|
||||
showHeader?: boolean;
|
||||
showDescription?: boolean;
|
||||
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
|
||||
export const FeatureSnippet: React.FC<Props> = ({
|
||||
heading,
|
||||
dataOriginExtra,
|
||||
extraMeta = [],
|
||||
choices,
|
||||
actions,
|
||||
spells,
|
||||
infusionChoices = [],
|
||||
className,
|
||||
snippetData,
|
||||
sourceId,
|
||||
sourcePage,
|
||||
classLevel,
|
||||
levelScale,
|
||||
children,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
subclass,
|
||||
options,
|
||||
feats,
|
||||
dataOriginRefData,
|
||||
onActionUseSet,
|
||||
onActionClick,
|
||||
onSpellClick,
|
||||
onSpellUseSet,
|
||||
onInfusionChoiceClick,
|
||||
onFeatureClick,
|
||||
showHeader = true,
|
||||
showDescription = false,
|
||||
isReadonly,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
}) => {
|
||||
const handleFeatureClick = (evt: React.MouseEvent): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
|
||||
if (onFeatureClick) {
|
||||
onFeatureClick();
|
||||
}
|
||||
};
|
||||
|
||||
const getNonChoiceOptions = (): Array<Option> => {
|
||||
let optionLookup = keyBy(options, (option) => OptionUtils.getId(option));
|
||||
let usedOptionIds: Array<number> = [];
|
||||
|
||||
choices.forEach((choice) => {
|
||||
const type = ChoiceUtils.getType(choice);
|
||||
const optionValue = ChoiceUtils.getOptionValue(choice);
|
||||
|
||||
switch (type) {
|
||||
case Constants.BuilderChoiceTypeEnum.RACIAL_TRAIT_OPTION:
|
||||
case Constants.BuilderChoiceTypeEnum.FEAT_OPTION:
|
||||
case Constants.BuilderChoiceTypeEnum.FEATURE_OPTION:
|
||||
if (optionValue !== null && optionLookup[optionValue]) {
|
||||
usedOptionIds.push(optionValue);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return options.filter(
|
||||
(option) => !usedOptionIds.includes(OptionUtils.getId(option))
|
||||
);
|
||||
};
|
||||
|
||||
const nonChoiceOptions = getNonChoiceOptions();
|
||||
const classNames = [styles.snippet, className];
|
||||
const metaItems: Array<React.ReactNode> = [];
|
||||
|
||||
if (levelScale) {
|
||||
metaItems.push(
|
||||
<span className={styles.levelScale}>{levelScale.description}</span>
|
||||
);
|
||||
}
|
||||
|
||||
metaItems.push(...extraMeta);
|
||||
|
||||
const sourceDataLookup = RuleDataUtils.getSourceDataLookup(ruleData);
|
||||
|
||||
if (sourceId) {
|
||||
metaItems.push(
|
||||
<Reference name={sourceDataLookup[sourceId]?.name} page={sourcePage} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")} onClick={handleFeatureClick}>
|
||||
{showHeader && (
|
||||
<div
|
||||
className={`${styles.heading} ${
|
||||
theme?.isDarkMode ? styles.headingDarkMode : ""
|
||||
}`}
|
||||
>
|
||||
{heading}
|
||||
<span className={styles.meta}>
|
||||
{metaItems.map((metaItem, idx) => (
|
||||
<span
|
||||
className={`${styles.metaItem} ${
|
||||
theme?.isDarkMode ? styles.metaItemDarkMode : ""
|
||||
}`}
|
||||
key={idx}
|
||||
>
|
||||
{metaItem}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{dataOriginExtra && dataOriginExtra !== "Unknown" && (
|
||||
<div>
|
||||
<span className={styles.origin}>
|
||||
<span className={styles.originLabel}>From</span>
|
||||
<span className={styles.originName}>{dataOriginExtra}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.content}>
|
||||
<Snippet
|
||||
snippetData={snippetData}
|
||||
levelScale={levelScale}
|
||||
classLevel={classLevel}
|
||||
parseSnippet={!showDescription}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
>
|
||||
{children}
|
||||
</Snippet>
|
||||
</div>
|
||||
{(choices.length > 0 ||
|
||||
actions.length > 0 ||
|
||||
spells.length > 0 ||
|
||||
nonChoiceOptions.length > 0) && (
|
||||
<div
|
||||
className={styles.extra}
|
||||
style={
|
||||
theme?.isDarkMode ? { borderColor: theme.themeColor } : undefined
|
||||
}
|
||||
>
|
||||
<FeatureSnippetChoices
|
||||
choices={choices}
|
||||
options={options}
|
||||
onSpellClick={onSpellClick}
|
||||
onSpellUseSet={onSpellUseSet}
|
||||
onActionUseSet={onActionUseSet}
|
||||
onActionClick={onActionClick}
|
||||
ruleData={ruleData}
|
||||
abilityLookup={abilityLookup}
|
||||
subclass={subclass}
|
||||
feats={feats}
|
||||
sourceDataLookup={sourceDataLookup}
|
||||
classLevel={classLevel}
|
||||
snippetData={snippetData}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
showDescription={showDescription}
|
||||
isInteractive={!isReadonly}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
/>
|
||||
<FeatureSnippetActions
|
||||
actions={actions}
|
||||
abilityLookup={abilityLookup}
|
||||
ruleData={ruleData}
|
||||
onActionClick={onActionClick}
|
||||
onActionUseSet={onActionUseSet}
|
||||
isInteractive={!isReadonly}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
/>
|
||||
<FeatureSnippetSpells
|
||||
spells={spells}
|
||||
abilityLookup={abilityLookup}
|
||||
ruleData={ruleData}
|
||||
onSpellClick={onSpellClick}
|
||||
onSpellUseSet={onSpellUseSet}
|
||||
isInteractive={!isReadonly}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
/>
|
||||
{nonChoiceOptions.length > 0 && (
|
||||
<div className={styles.options}>
|
||||
{nonChoiceOptions.map((option) => (
|
||||
<FeatureSnippetOption
|
||||
key={OptionUtils.getId(option)}
|
||||
option={option}
|
||||
onSpellClick={onSpellClick}
|
||||
onSpellUseSet={onSpellUseSet}
|
||||
onActionUseSet={onActionUseSet}
|
||||
onActionClick={onActionClick}
|
||||
abilityLookup={abilityLookup}
|
||||
ruleData={ruleData}
|
||||
sourceDataLookup={sourceDataLookup}
|
||||
classLevel={classLevel}
|
||||
snippetData={snippetData}
|
||||
showDescription={showDescription}
|
||||
isInteractive={!isReadonly}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FeatureSnippetInfusionChoices
|
||||
infusionChoices={infusionChoices}
|
||||
onInfusionChoiceClick={onInfusionChoiceClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import { Constants } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const DeathSummary: FC<Props> = ({ className, onClick, ...props }) => {
|
||||
const { deathSaveInfo, ruleData, ruleDataUtils, deathCause, characterTheme } =
|
||||
useCharacterEngine();
|
||||
|
||||
const renderDeathSavesSummaryMarkGroup = (
|
||||
key: string,
|
||||
label: string,
|
||||
activeCount: number,
|
||||
totalCount: number,
|
||||
isDarkMode: boolean
|
||||
): React.ReactNode => {
|
||||
let marks: Array<React.ReactNode> = [];
|
||||
for (let i = 0; i < totalCount; i++) {
|
||||
marks.push(
|
||||
<span
|
||||
key={`${key}-${i}`}
|
||||
className={clsx([
|
||||
styles.mark,
|
||||
i < activeCount ? styles[`activeMark-${key}`] : styles.inactiveMark,
|
||||
isDarkMode &&
|
||||
i < activeCount &&
|
||||
styles[`activeMarkDarkMode-${key}`],
|
||||
])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.group}>
|
||||
<h2 className={styles.label}>{label}</h2>
|
||||
<span className={styles.marks}>{marks}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* If the death cause is exhaustion, render the exhaustion summary. Otherwise, render the death saves summary. */}
|
||||
{deathCause === Constants.DeathCauseEnum.CONDITION ? (
|
||||
<div
|
||||
className={clsx([styles.container, className])}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.exhaustion}>
|
||||
<div
|
||||
className={clsx([
|
||||
styles.icon,
|
||||
characterTheme.isDarkMode && styles.iconDarkMode,
|
||||
])}
|
||||
/>
|
||||
<h2 className={clsx([styles.label, styles.exhaustionLabel])}>
|
||||
Exhaustion Level 6
|
||||
</h2>
|
||||
</div>
|
||||
<h1 className={styles.deathLabel}>Death</h1>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={clsx([styles.container, className])}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.deathSaves}>
|
||||
<div
|
||||
className={clsx([
|
||||
styles.icon,
|
||||
characterTheme.isDarkMode && styles.iconDarkMode,
|
||||
])}
|
||||
/>
|
||||
<div className={styles.markGroupsContainer}>
|
||||
{renderDeathSavesSummaryMarkGroup(
|
||||
"fail",
|
||||
"Failure",
|
||||
deathSaveInfo.failCount,
|
||||
ruleDataUtils.getMaxDeathsavesFail(ruleData),
|
||||
characterTheme.isDarkMode
|
||||
)}
|
||||
{renderDeathSavesSummaryMarkGroup(
|
||||
"success",
|
||||
"Success",
|
||||
deathSaveInfo.successCount,
|
||||
ruleDataUtils.getMaxDeathsavesSuccess(ruleData),
|
||||
characterTheme.isDarkMode
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h1 className={styles.deathLabel}>Death Saves</h1>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
BeveledBoxSvg317x89,
|
||||
BoxBackground,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import { Constants } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { useSidebar } from "~/contexts/Sidebar/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
|
||||
import { PaneComponentEnum } from "../Sidebar/types";
|
||||
import { DeathSummary } from "./DeathSummary";
|
||||
import { HitPointsQuickAdjust } from "./HitPointsQuickAdjust";
|
||||
import { HitPointsSummary } from "./HitPointsSummary";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const HitPointsBox: FC<Props> = ({ className, ...props }) => {
|
||||
const {
|
||||
pane: { paneHistoryStart },
|
||||
} = useSidebar();
|
||||
|
||||
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
|
||||
|
||||
const { hpInfo, deathCause, characterTheme } = useCharacterEngine();
|
||||
|
||||
const onClick = (evt: React.MouseEvent): void => {
|
||||
if (!isReadonly) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
paneHistoryStart(PaneComponentEnum.HEALTH_MANAGE);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx([styles.hitPointsBox, className])}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
>
|
||||
<BoxBackground
|
||||
StyleComponent={BeveledBoxSvg317x89}
|
||||
theme={characterTheme}
|
||||
/>
|
||||
{deathCause === Constants.DeathCauseEnum.CONDITION ||
|
||||
hpInfo.remainingHp <= 0 ? (
|
||||
<DeathSummary />
|
||||
) : (
|
||||
<div className={styles.content}>
|
||||
<h1 className={styles.label}>Hit Points</h1>
|
||||
<HitPointsQuickAdjust />
|
||||
<HitPointsSummary hpInfo={hpInfo} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
|
||||
import { HP_DAMAGE_TAKEN_VALUE } from "../../../constants";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement | HTMLButtonElement> {}
|
||||
|
||||
export const HitPointsQuickAdjust: FC<Props> = ({ className, ...props }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { hpInfo, characterActions, characterUtils, helperUtils } =
|
||||
useCharacterEngine();
|
||||
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
|
||||
|
||||
const [adjustmentValue, setAdjustmentValue] = useState<number | null>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const adjustHitPoints = (adjustmentSign: number): void => {
|
||||
const difference = adjustmentSign * (adjustmentValue ?? 0);
|
||||
|
||||
const { newTemp, newRemovedHp } = characterUtils.calculateHitPoints(
|
||||
hpInfo,
|
||||
difference
|
||||
);
|
||||
|
||||
dispatch(characterActions.hitPointsSet(newRemovedHp, newTemp));
|
||||
|
||||
setAdjustmentValue(null);
|
||||
setIsHovered(false);
|
||||
};
|
||||
|
||||
const onHealClick = useUnpropagatedClick(() => {
|
||||
adjustHitPoints(1);
|
||||
});
|
||||
|
||||
const onDamageClick = useUnpropagatedClick(() => {
|
||||
adjustHitPoints(-1);
|
||||
});
|
||||
|
||||
const onFocus = (): void => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
const onMouseEnter = (): void => {
|
||||
setIsHovered(true);
|
||||
};
|
||||
|
||||
const onMouseLeave = (): void => {
|
||||
setIsHovered(false);
|
||||
};
|
||||
|
||||
const onBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
setAdjustmentValue(helperUtils.parseInputInt(evt.target.value));
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
const onChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setAdjustmentValue(helperUtils.parseInputInt(evt.target.value));
|
||||
};
|
||||
|
||||
const onWheel = (evt: WheelEvent): void => {
|
||||
if (isHovered || isFocused) {
|
||||
evt.preventDefault();
|
||||
let direction = evt.deltaY < 0 ? 1 : -1;
|
||||
if (!isReadonly) {
|
||||
let newValue = (adjustmentValue ?? 0) + direction;
|
||||
newValue = helperUtils.clampInt(newValue ?? 0, 0, hpInfo.totalHp * 2);
|
||||
setAdjustmentValue(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onScroll = (evt: Event): void => {
|
||||
if (isHovered || isFocused) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("wheel", onWheel, { passive: false });
|
||||
window.addEventListener("scroll", onScroll);
|
||||
return () => {
|
||||
window.removeEventListener("wheel", onWheel);
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.container, className])} {...props}>
|
||||
<Button
|
||||
size="xx-small"
|
||||
variant="outline"
|
||||
themed
|
||||
disabled={isReadonly}
|
||||
onClick={onHealClick}
|
||||
className={clsx([styles.button, styles.heal])}
|
||||
>
|
||||
Heal
|
||||
</Button>
|
||||
<input
|
||||
className={styles.input}
|
||||
data-testid="hp-adjust-input"
|
||||
type="number"
|
||||
aria-label="Hit Points Adjustment"
|
||||
value={adjustmentValue ?? ""}
|
||||
min={HP_DAMAGE_TAKEN_VALUE.MIN}
|
||||
max={HP_DAMAGE_TAKEN_VALUE.MAX}
|
||||
onClick={useUnpropagatedClick()}
|
||||
onFocus={onFocus}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
readOnly={isReadonly}
|
||||
/>
|
||||
<Button
|
||||
size="xx-small"
|
||||
variant="outline"
|
||||
themed
|
||||
disabled={isReadonly}
|
||||
onClick={onDamageClick}
|
||||
className={clsx([styles.button, styles.damage])}
|
||||
>
|
||||
Damage
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useState, useRef } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import accessibility from "~/styles/accessibility.module.css";
|
||||
import { HP_TEMP_VALUE } from "~/subApps/sheet/constants";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
import { CharacterHitPointInfo, Creature, HitPointInfo } from "~/types";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
hpInfo: CharacterHitPointInfo | HitPointInfo;
|
||||
creature?: Creature;
|
||||
showOriginalMax?: boolean;
|
||||
showPermanentInputs?: boolean;
|
||||
}
|
||||
|
||||
export const HitPointsSummary: FC<Props> = ({
|
||||
hpInfo,
|
||||
creature,
|
||||
showOriginalMax = false,
|
||||
showPermanentInputs = false,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
// TODO This hitpoints summary doesn't work as a generic component for vehicles yet. Works for creatures and characters.
|
||||
|
||||
const { characterActions, creatureUtils, helperUtils } = useCharacterEngine();
|
||||
|
||||
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
|
||||
|
||||
const [isCurrentEditorVisible, setIsCurrentEditorVisible] = useState(false);
|
||||
const [isTempEditorVisible, setIsTempEditorVisible] = useState(false);
|
||||
const [currentValue, setCurrentValue] = useState<number | null>(
|
||||
hpInfo.remainingHp
|
||||
);
|
||||
const [tempValue, setTempValue] = useState<number | null>(hpInfo.tempHp);
|
||||
const [isMaxDifferent, setIsMaxDifferent] = useState<boolean>(false);
|
||||
|
||||
const currentEditorRef = useRef<HTMLInputElement | null>(null);
|
||||
const tempEditorRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const currentId = uuidv4();
|
||||
const tempId = uuidv4();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
/* --- Current hit point functions --- */
|
||||
|
||||
const onCurrentInputChange = (
|
||||
evt: React.ChangeEvent<HTMLInputElement>
|
||||
): void => {
|
||||
setCurrentValue(helperUtils.parseInputInt(evt.target.value));
|
||||
};
|
||||
|
||||
const onCurrentInputClick = (
|
||||
evt: React.MouseEvent<HTMLInputElement>
|
||||
): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const onCurrentClick = (evt: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
if (!isReadonly) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
setIsCurrentEditorVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const dispatchCurrentHp = (value: number | null) => {
|
||||
if (value === null) {
|
||||
value = hpInfo.remainingHp;
|
||||
} else {
|
||||
let newRemovedAmount = hpInfo.totalHp - value;
|
||||
newRemovedAmount = helperUtils.clampInt(
|
||||
newRemovedAmount,
|
||||
0,
|
||||
hpInfo.totalHp
|
||||
);
|
||||
value = hpInfo.totalHp - newRemovedAmount;
|
||||
if (newRemovedAmount !== hpInfo.removedHp) {
|
||||
creature
|
||||
? dispatch(
|
||||
characterActions.creatureHitPointsSet(
|
||||
creatureUtils.getMappingId(creature),
|
||||
newRemovedAmount,
|
||||
hpInfo.tempHp ?? HP_TEMP_VALUE.MIN
|
||||
)
|
||||
)
|
||||
: dispatch(
|
||||
characterActions.hitPointsSet(
|
||||
newRemovedAmount,
|
||||
hpInfo.tempHp ?? HP_TEMP_VALUE.MIN
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
setCurrentValue(value);
|
||||
setIsCurrentEditorVisible(false);
|
||||
};
|
||||
|
||||
const onCurrentInputBlur = (
|
||||
evt: React.FocusEvent<HTMLInputElement>
|
||||
): void => {
|
||||
let current: number | null = helperUtils.parseInputInt(evt.target.value);
|
||||
dispatchCurrentHp(current);
|
||||
};
|
||||
|
||||
const onCurrentInputEnter = (
|
||||
evt: React.KeyboardEvent<HTMLInputElement>
|
||||
): void => {
|
||||
if (evt.key === "Enter") {
|
||||
let current: number | null = helperUtils.parseInputInt(
|
||||
evt.currentTarget.value
|
||||
);
|
||||
dispatchCurrentHp(current);
|
||||
}
|
||||
};
|
||||
|
||||
/* --- Temp hit point functions --- */
|
||||
|
||||
const onTempInputClick = (evt: React.MouseEvent<HTMLInputElement>): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const onTempClick = (evt: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
if (!isReadonly) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
setIsTempEditorVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onTempInputChange = (
|
||||
evt: React.ChangeEvent<HTMLInputElement>
|
||||
): void => {
|
||||
setTempValue(helperUtils.parseInputInt(evt.target.value));
|
||||
};
|
||||
|
||||
const dispatchTempHp = (value: number | null) => {
|
||||
if (value === null) {
|
||||
value = hpInfo.tempHp;
|
||||
} else {
|
||||
value = helperUtils.clampInt(value, HP_TEMP_VALUE.MIN, HP_TEMP_VALUE.MAX);
|
||||
if (value !== hpInfo.tempHp) {
|
||||
creature
|
||||
? dispatch(
|
||||
characterActions.creatureHitPointsSet(
|
||||
creatureUtils.getMappingId(creature),
|
||||
hpInfo.removedHp,
|
||||
value
|
||||
)
|
||||
)
|
||||
: dispatch(characterActions.hitPointsSet(hpInfo.removedHp, value));
|
||||
}
|
||||
}
|
||||
setTempValue(value);
|
||||
setIsTempEditorVisible(false);
|
||||
};
|
||||
|
||||
const onTempInputBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
let temp: number | null = helperUtils.parseInputInt(evt.target.value);
|
||||
dispatchTempHp(temp);
|
||||
};
|
||||
|
||||
const onTempInputEnter = (
|
||||
evt: React.KeyboardEvent<HTMLInputElement>
|
||||
): void => {
|
||||
if (evt.key === "Enter") {
|
||||
let temp: number | null = helperUtils.parseInputInt(
|
||||
evt.currentTarget.value
|
||||
);
|
||||
dispatchTempHp(temp);
|
||||
}
|
||||
};
|
||||
|
||||
/* --- useEffects --- */
|
||||
|
||||
useEffect(() => {
|
||||
if (isTempEditorVisible) {
|
||||
tempEditorRef.current?.focus();
|
||||
}
|
||||
}, [isTempEditorVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentEditorVisible) {
|
||||
currentEditorRef.current?.focus();
|
||||
}
|
||||
}, [isCurrentEditorVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentValue(hpInfo.remainingHp);
|
||||
}, [hpInfo.remainingHp]);
|
||||
|
||||
useEffect(() => {
|
||||
setTempValue(hpInfo.tempHp);
|
||||
}, [hpInfo.tempHp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showOriginalMax && (hpInfo.overrideHp || hpInfo.bonusHp)) {
|
||||
setIsMaxDifferent(true);
|
||||
} else {
|
||||
setIsMaxDifferent(false);
|
||||
}
|
||||
}, [hpInfo.overrideHp, hpInfo.bonusHp, showOriginalMax]);
|
||||
|
||||
/* --- Render Functions --- */
|
||||
|
||||
const renderTempHitPoints = (): React.ReactNode => {
|
||||
if (isTempEditorVisible || showPermanentInputs) {
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
data-testid="temp-hp-input"
|
||||
id={tempId}
|
||||
ref={tempEditorRef}
|
||||
className={styles.input}
|
||||
type="number"
|
||||
value={tempValue ?? ""}
|
||||
min={HP_TEMP_VALUE.MIN}
|
||||
max={HP_TEMP_VALUE.MAX}
|
||||
onChange={onTempInputChange}
|
||||
onBlur={onTempInputBlur}
|
||||
onClick={onTempInputClick}
|
||||
onKeyDown={onTempInputEnter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tempValue) {
|
||||
return (
|
||||
<button
|
||||
id={tempId}
|
||||
className={clsx([
|
||||
styles.valueButton,
|
||||
styles.tempEmpty,
|
||||
styles.number,
|
||||
])}
|
||||
onClick={onTempClick}
|
||||
>
|
||||
--
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
id={tempId}
|
||||
className={clsx([styles.valueButton, styles.number])}
|
||||
onClick={onTempClick}
|
||||
>
|
||||
{tempValue}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.container, className])} {...props}>
|
||||
<div className={styles.innerContainer}>
|
||||
<div className={styles.item}>
|
||||
<label
|
||||
htmlFor={currentId}
|
||||
className={styles.label}
|
||||
aria-label={`Current hit points ${currentValue}, change current hit points`}
|
||||
>
|
||||
Current
|
||||
</label>
|
||||
{isCurrentEditorVisible || showPermanentInputs ? (
|
||||
<div>
|
||||
<input
|
||||
data-testid="current-hp-input"
|
||||
id={currentId}
|
||||
ref={currentEditorRef}
|
||||
className={styles.input}
|
||||
type="number"
|
||||
value={currentValue ?? ""}
|
||||
min={0}
|
||||
max={hpInfo.totalHp}
|
||||
onChange={onCurrentInputChange}
|
||||
onBlur={onCurrentInputBlur}
|
||||
onClick={onCurrentInputClick}
|
||||
onKeyDown={onCurrentInputEnter}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
id={currentId}
|
||||
className={clsx([styles.valueButton, styles.number])}
|
||||
onClick={onCurrentClick}
|
||||
>
|
||||
{currentValue}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<div className={styles.spacer} />
|
||||
<span className={clsx([styles.number, styles.slash])}>/</span>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<span className={styles.label} aria-hidden="true">
|
||||
Max
|
||||
</span>
|
||||
<span
|
||||
className={accessibility.screenreaderOnly}
|
||||
>{`Max hit points`}</span>
|
||||
<div className={clsx([styles.number, styles.maxContainer])}>
|
||||
<span
|
||||
data-testid="max-hp"
|
||||
className={clsx([
|
||||
styles.max,
|
||||
isMaxDifferent &&
|
||||
"baseTotalHp" in hpInfo &&
|
||||
(hpInfo.totalHp - hpInfo.baseTotalHp > 0
|
||||
? styles.positive
|
||||
: styles.negative),
|
||||
])}
|
||||
>
|
||||
{hpInfo.totalHp}
|
||||
</span>
|
||||
{isMaxDifferent && "baseTotalHp" in hpInfo && (
|
||||
<span
|
||||
data-testid="origin-max-hp"
|
||||
className={clsx([styles.originalMax])}
|
||||
>
|
||||
({hpInfo.baseTotalHp})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={clsx([styles.item, styles.temp])}>
|
||||
<label
|
||||
htmlFor={tempId}
|
||||
aria-label={
|
||||
!tempValue
|
||||
? "Temporary hit points 0, set temporary hit points"
|
||||
: `Temporary hit points ${tempValue}, change temporary hit points`
|
||||
}
|
||||
className={styles.label}
|
||||
>
|
||||
Temp
|
||||
</label>
|
||||
{renderTempHitPoints()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useContext } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
AdvantageIcon,
|
||||
BoxBackground,
|
||||
DigitalDiceWrapper,
|
||||
InitiativeBoxSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import { DiceTools, RollKind, RollType } from "@dndbeyond/dice";
|
||||
import { GameLogContext } from "@dndbeyond/game-log-components";
|
||||
import { useCharacterTheme } from "~/contexts/CharacterTheme";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import {
|
||||
appEnvSelectors,
|
||||
characterRollContextSelectors,
|
||||
} from "~/tools/js/Shared/selectors";
|
||||
import { isNotNullOrUndefined } from "~/tools/js/Shared/utils/TypeScript/utils";
|
||||
import { PaneComponentEnum } from "../Sidebar/types";
|
||||
import styles from "./styles.module.css";
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
isMobile?: boolean;
|
||||
isTablet?: boolean;
|
||||
}
|
||||
export const InitiativeBox: FC<Props> = ({ isMobile, isTablet, ...props }) => {
|
||||
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
|
||||
useContext(GameLogContext);
|
||||
const {
|
||||
pane: { paneHistoryStart },
|
||||
} = useSidebar();
|
||||
const handleClick = (evt: React.MouseEvent): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
paneHistoryStart(PaneComponentEnum.INITIATIVE);
|
||||
};
|
||||
|
||||
const {
|
||||
hasInitiativeAdvantage,
|
||||
processedInitiative: initiative,
|
||||
characterTheme: theme,
|
||||
} = useCharacterEngine();
|
||||
const { isDarkMode } = useCharacterTheme();
|
||||
|
||||
const diceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
|
||||
const rollContext = useSelector(
|
||||
characterRollContextSelectors.getCharacterRollContext
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx([isMobile ? styles.boxMobile : styles.box])}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={clsx([
|
||||
styles.label,
|
||||
isDarkMode && !isTablet && styles.dark,
|
||||
])}
|
||||
data-testid="combat-initiative-label"
|
||||
>
|
||||
Initiative
|
||||
</div>
|
||||
)}
|
||||
<div className={isMobile ? styles.valueMobile : styles.value}>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<BoxBackground StyleComponent={InitiativeBoxSvg} theme={theme} />
|
||||
<h2 style={visuallyHidden}>Initiative</h2>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DigitalDiceWrapper
|
||||
diceNotation={DiceTools.CustomD20(initiative)}
|
||||
rollType={RollType.Roll}
|
||||
rollAction={"Initiative"}
|
||||
rollKind={hasInitiativeAdvantage ? RollKind.Advantage : RollKind.None}
|
||||
diceEnabled={diceEnabled}
|
||||
themeColor={theme.themeColor}
|
||||
rollContext={rollContext}
|
||||
rollTargetOptions={
|
||||
messageTargetOptions?.entities
|
||||
? Object.values(messageTargetOptions?.entities).filter(
|
||||
isNotNullOrUndefined
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
rollTargetDefault={defaultMessageTargetOption}
|
||||
userId={Number(userId)}
|
||||
>
|
||||
<NumberDisplay
|
||||
number={initiative}
|
||||
type="signed"
|
||||
size="large"
|
||||
className={clsx([
|
||||
isMobile && !theme.isDarkMode && styles.numberColorOverride,
|
||||
])}
|
||||
/>
|
||||
</DigitalDiceWrapper>
|
||||
</div>
|
||||
{hasInitiativeAdvantage && (
|
||||
<div
|
||||
className={clsx([styles.advantage, isMobile && styles.mobile])}
|
||||
aria-label="Has advantage on initiative"
|
||||
>
|
||||
<AdvantageIcon
|
||||
theme={theme}
|
||||
className={styles.advantageIcon}
|
||||
title={"Advantage on Initiative"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isMobile && <div className={styles.labelMobile}>Initiative</div>}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import {
|
||||
BoxBackground,
|
||||
InspirationBoxSvg,
|
||||
InspirationTokenSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
inspiration: boolean;
|
||||
onToggle?: () => void;
|
||||
onClick?: () => void;
|
||||
isInteractive: boolean;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
export const Inspiration: FC<Props> = ({
|
||||
inspiration,
|
||||
onClick,
|
||||
onToggle,
|
||||
isInteractive,
|
||||
isMobile,
|
||||
...props
|
||||
}) => {
|
||||
const { characterTheme: theme } = useCharacterEngine();
|
||||
|
||||
const inspirationText = "Heroic Inspiration";
|
||||
|
||||
const handleToggleClick = (evt: React.MouseEvent): void => {
|
||||
if (onToggle && isInteractive) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (evt: React.MouseEvent): void => {
|
||||
if (onClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className={styles.mobileWrapper} {...props}>
|
||||
<div
|
||||
className={clsx([
|
||||
styles.mobileButton,
|
||||
inspiration && styles.active,
|
||||
isInteractive && styles.interactive,
|
||||
])}
|
||||
onClick={handleToggleClick}
|
||||
role="checkbox"
|
||||
aria-checked={!!inspiration}
|
||||
data-testid="inspiration"
|
||||
>
|
||||
{inspirationText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={handleClick} {...props}>
|
||||
<div
|
||||
className={clsx([styles.box, isInteractive && styles.interactive])}
|
||||
onClick={handleToggleClick}
|
||||
role="checkbox"
|
||||
aria-checked={!!inspiration}
|
||||
data-testid="inspiration"
|
||||
>
|
||||
<BoxBackground StyleComponent={InspirationBoxSvg} theme={theme} />
|
||||
<div className={styles.status}>
|
||||
{inspiration && <InspirationTokenSvg theme={theme} />}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={clsx([styles.label, theme.isDarkMode && styles.dark])}
|
||||
onClick={handleClick}
|
||||
data-testid="inspiration-label"
|
||||
>
|
||||
{inspirationText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, type MouseEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import GridIcon from "@dndbeyond/fontawesome-cache/svgs/solid/grid.svg";
|
||||
import { useIsVisible } from "@dndbeyond/ttui/hooks/useIsVisible";
|
||||
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
|
||||
|
||||
import { sidebarId } from "../../constants";
|
||||
import { ArrowsLeftIcon } from "../Sidebar/svgs/ArrowsLeftIcon";
|
||||
import { ArrowsRightIcon } from "../Sidebar/svgs/ArrowsRightIcon";
|
||||
import { SectionMenu } from "./SectionMenu";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface MobileNavProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const MobileNav: FC<MobileNavProps> = ({ className, ...props }) => {
|
||||
const {
|
||||
ref,
|
||||
isVisible: isMenuVisible,
|
||||
setIsVisible: setIsMenuVisible,
|
||||
} = useIsVisible(false);
|
||||
const { sidebar } = useSidebar();
|
||||
|
||||
const handleToggleSidebar = useUnpropagatedClick(() => {
|
||||
setIsMenuVisible(false);
|
||||
sidebar.setIsVisible(!sidebar.isVisible);
|
||||
});
|
||||
|
||||
const handleToggleMenu = () => {
|
||||
setIsMenuVisible(!isMenuVisible);
|
||||
};
|
||||
|
||||
const handleCloseMenu = (e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
setIsMenuVisible(false);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className={clsx([styles.mobileNav, className])} ref={ref} {...props}>
|
||||
{!sidebar.isVisible && (
|
||||
<div>
|
||||
<button
|
||||
className={styles.navToggle}
|
||||
onClick={handleToggleMenu}
|
||||
aria-label="Show navigation"
|
||||
>
|
||||
<GridIcon className={styles.icon} />
|
||||
</button>
|
||||
<SectionMenu open={isMenuVisible} onClose={handleCloseMenu} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={styles.sidebarToggle}
|
||||
onClick={handleToggleSidebar}
|
||||
aria-controls={sidebarId}
|
||||
aria-label={`${sidebar.isVisible ? "Hide" : "Show"} sidebar`}
|
||||
>
|
||||
{sidebar.isVisible ? (
|
||||
<ArrowsRightIcon className={styles.icon} />
|
||||
) : (
|
||||
<ArrowsLeftIcon className={styles.icon} />
|
||||
)}
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import clsx from "clsx";
|
||||
import { FC } from "react";
|
||||
|
||||
import { ButtonProps } from "@dndbeyond/ttui/components/Button";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface SectionButtonProps extends ButtonProps {}
|
||||
|
||||
export const SectionButton: FC<SectionButtonProps> = ({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
className={clsx([styles.sectionButton, className])}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import clsx from "clsx";
|
||||
import { FC } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { rulesEngineSelectors } from "@dndbeyond/character-rules-engine";
|
||||
import { Dialog, DialogProps } from "@dndbeyond/ttui/components/Dialog";
|
||||
|
||||
import { useSheetContext } from "~/subApps/sheet/contexts/Sheet";
|
||||
import {
|
||||
ActionsIcon,
|
||||
AttributesIcon,
|
||||
DescriptionIcon,
|
||||
EquipmentIcon,
|
||||
ExtrasIcon,
|
||||
FeatureTraitsIcon,
|
||||
NotesIcon,
|
||||
ProficienciesSvg,
|
||||
SkillsIcon,
|
||||
SpellsIcon,
|
||||
} from "~/svgs";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
|
||||
import { SectionButton } from "../SectionButton";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface SectionMenuProps extends DialogProps {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export const SectionMenu: FC<SectionMenuProps> = ({
|
||||
className,
|
||||
open,
|
||||
...props
|
||||
}) => {
|
||||
const { setMobileActiveSectionId: setId } = useSheetContext();
|
||||
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
|
||||
const hasSpells = useSelector(rulesEngineSelectors.hasSpells);
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<Dialog
|
||||
className={clsx([styles.sectionNav, className])}
|
||||
open={open}
|
||||
modal
|
||||
{...props}
|
||||
>
|
||||
<SectionButton
|
||||
className={styles.fullWidth}
|
||||
onClick={() => setId("main")}
|
||||
>
|
||||
<AttributesIcon />
|
||||
<span>
|
||||
Abilities, Saves, Senses
|
||||
<span className={styles.mobile}>
|
||||
, Proficiencies, Training, Skills
|
||||
</span>
|
||||
</span>
|
||||
</SectionButton>
|
||||
<SectionButton
|
||||
className={styles.mobile}
|
||||
onClick={() => setId("skills")}
|
||||
>
|
||||
<SkillsIcon />
|
||||
Skills
|
||||
</SectionButton>
|
||||
<SectionButton onClick={() => setId("actions")}>
|
||||
<ActionsIcon />
|
||||
Actions
|
||||
</SectionButton>
|
||||
<SectionButton onClick={() => setId("equipment")}>
|
||||
<EquipmentIcon />
|
||||
Inventory
|
||||
</SectionButton>
|
||||
{hasSpells && (
|
||||
<SectionButton onClick={() => setId("spells")}>
|
||||
<SpellsIcon />
|
||||
Spells
|
||||
</SectionButton>
|
||||
)}
|
||||
<SectionButton onClick={() => setId("features_traits")}>
|
||||
<FeatureTraitsIcon />
|
||||
Features & Traits
|
||||
</SectionButton>
|
||||
<SectionButton
|
||||
className={styles.mobile}
|
||||
onClick={() => setId("proficiencies")}
|
||||
>
|
||||
<ProficienciesSvg />
|
||||
<span>Proficiencies & Training</span>
|
||||
</SectionButton>
|
||||
{!isReadonly && (
|
||||
<SectionButton onClick={() => setId("description")}>
|
||||
<DescriptionIcon />
|
||||
Background
|
||||
</SectionButton>
|
||||
)}
|
||||
{!isReadonly && (
|
||||
<SectionButton onClick={() => setId("notes")}>
|
||||
<NotesIcon />
|
||||
Notes
|
||||
</SectionButton>
|
||||
)}
|
||||
<SectionButton
|
||||
className={hasSpells ? styles.fullWidth : ""}
|
||||
onClick={() => setId("extras")}
|
||||
>
|
||||
<ExtrasIcon />
|
||||
Extras
|
||||
</SectionButton>
|
||||
</Dialog>
|
||||
{open && <div className={styles.backdrop} />}
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||
callout?: ReactNode;
|
||||
onClick?: () => void;
|
||||
parent?: ReactNode;
|
||||
preview?: ReactNode;
|
||||
}
|
||||
|
||||
export const Header: FC<HeaderProps> = ({
|
||||
callout,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
parent,
|
||||
preview,
|
||||
...props
|
||||
}) => {
|
||||
const handleClick = useUnpropagatedClick(onClick);
|
||||
|
||||
return (
|
||||
<header className={clsx(["ct-sidebar__header", className])} {...props}>
|
||||
<div onClick={handleClick} className={onClick && styles.interactive}>
|
||||
{parent}
|
||||
</div>
|
||||
<div className="ct-sidebar__header-primary">
|
||||
{preview}
|
||||
<h1 className={clsx(["ct-sidebar__heading", styles.heading])}>
|
||||
{children}
|
||||
</h1>
|
||||
{callout && <div className={styles.callout}>{callout}</div>}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
/**
|
||||
* This component is used to render a subheading in the sidebar.
|
||||
*/
|
||||
|
||||
type HeadingProps = HTMLAttributes<HTMLHeadingElement>;
|
||||
|
||||
export const Heading: FC<HeadingProps> = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<h2
|
||||
className={clsx([
|
||||
"ct-sidebar__subheading",
|
||||
props.onClick && styles.clickable,
|
||||
className,
|
||||
])}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import { useSidebar } from "~/contexts/Sidebar/Sidebar";
|
||||
import { PaneBorder } from "~/svgs";
|
||||
|
||||
import { PaneContent } from "../PaneContent";
|
||||
import { PaneControls } from "../PaneControls";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
/**
|
||||
* The Pane component serves as a way to render the active pane inside of the sidebar. It is a simple component that renders the children passed to it. It is used in the Sidebar component to render the active pane inside of the sidebar. The `isFullWidth` prop determines whether the pane should have padding on the left and right or not. Panes such as CharacterManagePane have items which should be full width. Most panes should have padding on the left and right.
|
||||
**/
|
||||
interface PaneProps extends HTMLAttributes<HTMLDivElement> {
|
||||
handlePrevious: () => void;
|
||||
handleNext: () => void;
|
||||
isFullWidth?: boolean;
|
||||
forceDarkMode?: boolean;
|
||||
}
|
||||
|
||||
export const Pane: FC<PaneProps> = ({
|
||||
isFullWidth,
|
||||
forceDarkMode,
|
||||
handlePrevious,
|
||||
handleNext,
|
||||
...props
|
||||
}) => {
|
||||
const {
|
||||
pane: { activePane },
|
||||
} = useSidebar();
|
||||
|
||||
return (
|
||||
<div className={styles.pane} {...props}>
|
||||
<PaneBorder
|
||||
className={clsx([styles.border, forceDarkMode && styles.dark])}
|
||||
/>
|
||||
<div className={clsx([styles.gap, forceDarkMode && styles.dark])} />
|
||||
<div
|
||||
className={clsx([
|
||||
styles.content,
|
||||
forceDarkMode && styles.dark,
|
||||
isFullWidth && styles.fullWidth,
|
||||
])}
|
||||
>
|
||||
<PaneControls handlePrevious={handlePrevious} handleNext={handleNext} />
|
||||
<PaneContent activePane={activePane} />
|
||||
</div>
|
||||
<PaneBorder
|
||||
className={clsx([
|
||||
styles.border,
|
||||
styles.bottom,
|
||||
forceDarkMode && styles.dark,
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import { useCharacterTheme } from "~/contexts/CharacterTheme";
|
||||
|
||||
import { getActiveEntryComponent } from "../../helpers/getActiveEntryComponent";
|
||||
import { PaneComponentInfo } from "../../types";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface PaneContentProps extends HTMLAttributes<HTMLElement> {
|
||||
activePane: PaneComponentInfo | null;
|
||||
}
|
||||
|
||||
export const PaneContent: FC<PaneContentProps> = ({ activePane, ...props }) => {
|
||||
const { isDarkMode } = useCharacterTheme();
|
||||
|
||||
// Empty pane
|
||||
if (!activePane)
|
||||
return (
|
||||
<div className={styles.default} {...props}>
|
||||
Select elements on the character sheet to display more information about
|
||||
</div>
|
||||
);
|
||||
|
||||
const Component = getActiveEntryComponent(activePane.type);
|
||||
|
||||
// Component not found
|
||||
if (!Component) return <div {...props}>Component was not found.</div>;
|
||||
|
||||
// Return correct component
|
||||
return (
|
||||
<div className={styles.maxHeight} {...props}>
|
||||
<Component
|
||||
theme={isDarkMode ? "DARK" : "LIGHT"}
|
||||
identifiers={activePane.identifiers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import ChevronLeft from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-left.svg";
|
||||
import ChevronRight from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
|
||||
|
||||
import { Button } from "~/components/Button";
|
||||
import { useCharacterTheme } from "~/contexts/CharacterTheme";
|
||||
import { useSidebar } from "~/contexts/Sidebar/Sidebar";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface PaneControlsProps extends HTMLAttributes<HTMLElement> {
|
||||
handlePrevious?: () => void;
|
||||
handleNext?: () => void;
|
||||
}
|
||||
|
||||
export const PaneControls: FC<PaneControlsProps> = ({
|
||||
handlePrevious,
|
||||
handleNext,
|
||||
...props
|
||||
}) => {
|
||||
const { isDarkMode } = useCharacterTheme();
|
||||
const {
|
||||
pane: { activePane, showControls, isAtStart, isAtEnd },
|
||||
} = useSidebar();
|
||||
|
||||
if (!activePane || !showControls) return null;
|
||||
|
||||
const forceDarkMode = !isDarkMode && activePane.type === "CHARACTER_MANAGE";
|
||||
|
||||
return (
|
||||
<nav className={styles.controls} {...props}>
|
||||
<Button
|
||||
className={clsx([styles.button, forceDarkMode && styles.dark])}
|
||||
size="xx-small"
|
||||
variant="text"
|
||||
disabled={isAtStart}
|
||||
onClick={isAtStart ? undefined : handlePrevious}
|
||||
>
|
||||
<ChevronLeft />
|
||||
<span className={styles.label}>Prev</span>
|
||||
</Button>
|
||||
<Button
|
||||
className={clsx([styles.button, forceDarkMode && styles.dark])}
|
||||
size="xx-small"
|
||||
variant="text"
|
||||
disabled={isAtEnd}
|
||||
onClick={isAtEnd ? undefined : handleNext}
|
||||
>
|
||||
<span className={styles.label}>Next</span>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { FC, useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
|
||||
import { toastMessageActions } from "../../../../../../tools/js/Shared/actions";
|
||||
|
||||
interface Props {
|
||||
errorMessage?: string;
|
||||
}
|
||||
export const PaneInitFailureContent: FC<Props> = ({ errorMessage }) => {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
sidebar: { setIsVisible },
|
||||
} = useSidebar();
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(false);
|
||||
dispatch(
|
||||
toastMessageActions.toastError(
|
||||
"Content Error",
|
||||
errorMessage ?? "We couldn't find that content! Please try again."
|
||||
)
|
||||
);
|
||||
}, [errorMessage, dispatch]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLUListElement> {
|
||||
className?: string;
|
||||
}
|
||||
export const PaneMenu: FC<Props> = ({ className, children, ...props }) => {
|
||||
return (
|
||||
<ul className={clsx([styles.menu, className])} {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import { PaneMenuItem } from "../PaneMenuItem";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
label: string;
|
||||
}
|
||||
export const PaneMenuGroup: FC<Props> = ({
|
||||
label,
|
||||
className = "",
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx([styles.group, className])} {...props}>
|
||||
<PaneMenuItem menukey={label} className={styles.label}>
|
||||
{label}
|
||||
</PaneMenuItem>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
|
||||
onClick?: (menuKey: string) => void;
|
||||
menukey: string;
|
||||
className?: string;
|
||||
prefixIcon?: React.ReactNode;
|
||||
suffixIcon?: React.ReactNode;
|
||||
}
|
||||
export const PaneMenuItem: FC<Props> = ({
|
||||
onClick,
|
||||
menukey,
|
||||
prefixIcon,
|
||||
suffixIcon,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const handleClick = (evt: React.MouseEvent): void => {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
|
||||
if (onClick) {
|
||||
onClick(menukey);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx([styles.item, className])}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{prefixIcon && <div className={styles.prefixIcon}>{prefixIcon}</div>}
|
||||
<div className={styles.label}>{children}</div>
|
||||
<div className={styles.suffixIcon}>{suffixIcon}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
interface PreviewProps extends HTMLAttributes<HTMLDivElement> {
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export const Preview: FC<PreviewProps> = ({
|
||||
children,
|
||||
className,
|
||||
imageUrl,
|
||||
...props
|
||||
}) => (
|
||||
<div className={clsx(["ct-sidebar__header-preview", className])} {...props}>
|
||||
{imageUrl ? (
|
||||
<div
|
||||
role="presentation"
|
||||
className="ct-sidebar__header-preview-image"
|
||||
style={{ backgroundImage: `url(${imageUrl})` }}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface BarProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "active" | "implied" | "inactive";
|
||||
value?: string | number | null;
|
||||
}
|
||||
|
||||
export const Bar: FC<BarProps> = ({
|
||||
children,
|
||||
className,
|
||||
variant = "inactive",
|
||||
value,
|
||||
...props
|
||||
}) => (
|
||||
<button className={clsx([styles.bar, styles[variant], className])} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import { Bar } from "./Bar";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface ProgressBarProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
|
||||
options: Array<number>;
|
||||
value: number | null;
|
||||
onClick: (val: string | number | null) => void;
|
||||
isInteractive?: boolean;
|
||||
}
|
||||
|
||||
export const ProgressBar: FC<ProgressBarProps> = ({
|
||||
className,
|
||||
options,
|
||||
value,
|
||||
onClick,
|
||||
isInteractive,
|
||||
...props
|
||||
}) => {
|
||||
const handleClick = (val: number | null) => {
|
||||
const isAlreadyNull = value === null && val === null;
|
||||
if (!isInteractive || isAlreadyNull) return;
|
||||
onClick(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.progressBar, className])} {...props}>
|
||||
<Bar
|
||||
value={null}
|
||||
variant={value !== null ? "implied" : "inactive"}
|
||||
onClick={() => handleClick(null)}
|
||||
aria-disabled={!isInteractive}
|
||||
>
|
||||
--
|
||||
</Bar>
|
||||
{options.map((number) => (
|
||||
<Bar
|
||||
variant={
|
||||
number === value
|
||||
? "active"
|
||||
: !!value && number < value
|
||||
? "implied"
|
||||
: "inactive"
|
||||
}
|
||||
value={number}
|
||||
onClick={() => handleClick(number)}
|
||||
aria-disabled={!isInteractive}
|
||||
key={`progress-bar-${number}`}
|
||||
>
|
||||
{number}
|
||||
</Bar>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import { ThemeButton } from "~/tools/js/Shared/components/common/Button";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
actions: { label: string; onClick: () => void; disabled: boolean }[];
|
||||
}
|
||||
|
||||
export const QuickActions: FC<Props> = ({ actions, ...props }) => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.title}>Quick Actions:</div>
|
||||
<div className={styles.buttons}>
|
||||
{actions.map((item, idx) => {
|
||||
return (
|
||||
<ThemeButton
|
||||
key={idx}
|
||||
size="medium"
|
||||
onClick={item.onClick}
|
||||
isInteractive={!item.disabled}
|
||||
>
|
||||
{item.label}
|
||||
</ThemeButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useSelector } from "react-redux";
|
||||
import { DOWN, SwipeEventData, UP } from "react-swipeable";
|
||||
|
||||
import { syncTransactionSelectors } from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { Swipeable } from "~/components/Swipeable";
|
||||
import { useCharacterTheme } from "~/contexts/CharacterTheme";
|
||||
import { useSidebar } from "~/contexts/Sidebar";
|
||||
|
||||
import { getPaneProperties } from "../../helpers/paneUtils";
|
||||
import { SidebarAlignmentEnum, SidebarPlacementEnum } from "../../types";
|
||||
import { Pane } from "../Pane";
|
||||
import { VisibilityControls } from "../VisiblilityControls";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface SidebarProps extends HTMLAttributes<HTMLDivElement> {
|
||||
setSwipedAmount?: (amount: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is responsible for rendering the sidebar and its contents. It uses volatile state to manage the visibility, placement, and alignment of the sidebar. It also uses the `Pane` component to render the active pane (the content of the sidebar) and the `VisibilityControls` component to render the controls for the sidebar. The sidebar is rendered as a portal to the body of the document.
|
||||
*/
|
||||
|
||||
export const Sidebar: FC<SidebarProps> = ({
|
||||
children,
|
||||
className,
|
||||
setSwipedAmount,
|
||||
...props
|
||||
}) => {
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const { isDarkMode } = useCharacterTheme();
|
||||
const isSyncActive = useSelector(syncTransactionSelectors.getActive);
|
||||
const {
|
||||
sidebar: {
|
||||
isLocked,
|
||||
isVisible,
|
||||
placement,
|
||||
alignment,
|
||||
setIsVisible,
|
||||
setIsLocked,
|
||||
setPlacement,
|
||||
setAlignment,
|
||||
},
|
||||
pane: { activePane, paneHistoryNext, paneHistoryPrevious },
|
||||
} = useSidebar();
|
||||
|
||||
const { isFullWidth, forceDarkMode } = getPaneProperties(activePane);
|
||||
|
||||
const handlePrevious = () => paneHistoryPrevious();
|
||||
|
||||
const handleNext = () => paneHistoryNext();
|
||||
|
||||
const handleTogglePlacement = (place: SidebarPlacementEnum) =>
|
||||
setPlacement(place);
|
||||
|
||||
const handleToggleAlignment = (align: SidebarAlignmentEnum) =>
|
||||
setAlignment(align);
|
||||
|
||||
const handleToggleLock = () => setIsLocked(!isLocked);
|
||||
|
||||
const handleToggleVisibility = () => {
|
||||
if (isLocked) return;
|
||||
if (isVisible && setSwipedAmount) setSwipedAmount(0);
|
||||
setIsVisible(!isVisible);
|
||||
};
|
||||
|
||||
const handleSwipe = ({ deltaX, dir }: SwipeEventData): void => {
|
||||
const isVerticalChange = [UP, DOWN].includes(dir);
|
||||
if (!isVerticalChange && setSwipedAmount) setSwipedAmount(deltaX);
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === "Escape" &&
|
||||
isVisible &&
|
||||
!isLocked &&
|
||||
placement !== SidebarPlacementEnum.FIXED
|
||||
)
|
||||
handleToggleVisibility();
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const sidebar = sidebarRef.current;
|
||||
const containsElement =
|
||||
e.composedPath().indexOf(sidebar as EventTarget) !== -1;
|
||||
const target = e.target as HTMLElement;
|
||||
const isDiceControl = target?.classList?.contains(
|
||||
"dice_notification_controls"
|
||||
);
|
||||
const isFixed = placement === SidebarPlacementEnum.FIXED;
|
||||
|
||||
// If element clicked is a dice control, don't close the sidebar
|
||||
if (isDiceControl) return;
|
||||
|
||||
// If element clicked is not in the sidebar, and the sidebar is visible, not locked, and not fixed, close the sidebar
|
||||
if (sidebar && !containsElement && isVisible && !isLocked && !isFixed)
|
||||
handleToggleVisibility();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.body.addEventListener("keydown", handleEscape);
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener("keydown", handleEscape);
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isVisible, isLocked, placement]);
|
||||
|
||||
return createPortal(
|
||||
<div className="ct-sidebar__portal">
|
||||
<div
|
||||
className={clsx([
|
||||
styles.mask,
|
||||
isVisible ? styles.visible : styles.hidden,
|
||||
isDarkMode && styles.dark,
|
||||
])}
|
||||
/>
|
||||
<div>
|
||||
<Swipeable
|
||||
handlerFunctions={{
|
||||
onSwipedRight: handleToggleVisibility,
|
||||
onSwiping: handleSwipe,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Information Sidebar"
|
||||
className={clsx([
|
||||
"ct-sidebar",
|
||||
styles.sidebar,
|
||||
styles[alignment],
|
||||
isVisible ? styles.visible : styles.hidden,
|
||||
isFullWidth && "ct-sidebar--is-full-width",
|
||||
isDarkMode && "ct-sidebar--is-dark-mode",
|
||||
])}
|
||||
ref={sidebarRef}
|
||||
{...props}
|
||||
>
|
||||
<div className="ct-sidebar__inner">
|
||||
<VisibilityControls
|
||||
isVisible={isVisible}
|
||||
isLocked={isLocked}
|
||||
placement={placement}
|
||||
alignment={alignment}
|
||||
onToggleVisibility={handleToggleVisibility}
|
||||
onToggleLock={handleToggleLock}
|
||||
onToggleAlignment={handleToggleAlignment}
|
||||
onTogglePlacement={handleTogglePlacement}
|
||||
/>
|
||||
{isVisible && (
|
||||
<Pane
|
||||
isFullWidth={isFullWidth}
|
||||
forceDarkMode={forceDarkMode || isDarkMode}
|
||||
handlePrevious={handlePrevious}
|
||||
handleNext={handleNext}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isSyncActive && <div className={styles.syncBlocker} />}
|
||||
</div>
|
||||
</Swipeable>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import Lock from "@dndbeyond/fontawesome-cache/svgs/solid/lock.svg";
|
||||
import Unlock from "@dndbeyond/fontawesome-cache/svgs/solid/unlock.svg";
|
||||
|
||||
import {
|
||||
SidebarAlignmentEnum,
|
||||
SidebarPlacementEnum,
|
||||
} from "~/subApps/sheet/components/Sidebar/types";
|
||||
import Tooltip from "~/tools/js/commonComponents/Tooltip";
|
||||
|
||||
import { AlignLeftIcon } from "../../svgs/AlignLeftIcon";
|
||||
import { AlignRightIcon } from "../../svgs/AlignRightIcon";
|
||||
import { ArrowsLeftIcon } from "../../svgs/ArrowsLeftIcon";
|
||||
import { ArrowsRightIcon } from "../../svgs/ArrowsRightIcon";
|
||||
import { FixedIcon } from "../../svgs/FixedIcon";
|
||||
import { OverlayIcon } from "../../svgs/OverlayIcon";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface VisibilityControlsProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isLocked: boolean;
|
||||
isVisible: boolean;
|
||||
placement: SidebarPlacementEnum;
|
||||
alignment: SidebarAlignmentEnum;
|
||||
onToggleLock: () => void;
|
||||
onToggleVisibility: () => void;
|
||||
onTogglePlacement: (val: "overlay" | "fixed") => void;
|
||||
onToggleAlignment: (val: "left" | "right") => void;
|
||||
}
|
||||
|
||||
export const VisibilityControls: FC<VisibilityControlsProps> = ({
|
||||
className,
|
||||
isLocked,
|
||||
isVisible,
|
||||
placement,
|
||||
alignment,
|
||||
onToggleLock,
|
||||
onToggleVisibility,
|
||||
onTogglePlacement,
|
||||
onToggleAlignment,
|
||||
...props
|
||||
}) => {
|
||||
const isActive = (value: "overlay" | "fixed" | "left" | "right") => {
|
||||
if (placement === value || alignment === value) return styles.active;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const tippyOpts = {
|
||||
dynamicTitle: true,
|
||||
interactive: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx([
|
||||
styles.controls,
|
||||
alignment === "left" && styles.left,
|
||||
className,
|
||||
])}
|
||||
{...props}
|
||||
>
|
||||
{isLocked ? (
|
||||
<Tooltip title="Locked" isDarkMode tippyOpts={tippyOpts}>
|
||||
<button
|
||||
className={styles.locked}
|
||||
onClick={onToggleLock}
|
||||
aria-label="Locked"
|
||||
>
|
||||
<Lock />
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.visibility}>
|
||||
<Tooltip
|
||||
className={!isVisible ? styles.closed : ""}
|
||||
title={isVisible ? "Hide Sidebar" : "Show Sidebar"}
|
||||
isDarkMode
|
||||
tippyOpts={{
|
||||
...tippyOpts,
|
||||
placement: "top-end",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={styles.arrows}
|
||||
onClick={onToggleVisibility}
|
||||
aria-label={isVisible ? "Hide sidebar" : "Show sidebar"}
|
||||
>
|
||||
{isVisible ? <ArrowsRightIcon /> : <ArrowsLeftIcon />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title="Unlocked"
|
||||
isDarkMode
|
||||
tippyOpts={tippyOpts}
|
||||
className={!isVisible ? styles.hidden : ""}
|
||||
>
|
||||
<button onClick={onToggleLock} aria-label="Unlocked">
|
||||
<Unlock />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
className={clsx([styles.overlay, !isVisible ? styles.hidden : ""])}
|
||||
>
|
||||
<Tooltip title="Overlay" isDarkMode tippyOpts={tippyOpts}>
|
||||
<button
|
||||
className={isActive("overlay")}
|
||||
onClick={() => onTogglePlacement("overlay")}
|
||||
aria-label="Set to overlay position"
|
||||
>
|
||||
<OverlayIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Fixed" isDarkMode tippyOpts={tippyOpts}>
|
||||
<button
|
||||
className={isActive("fixed")}
|
||||
onClick={() => onTogglePlacement("fixed")}
|
||||
aria-label="Set to fixed position"
|
||||
>
|
||||
<FixedIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
className={clsx([
|
||||
styles.placement,
|
||||
alignment === "left" && styles.left,
|
||||
!isVisible ? styles.hidden : "",
|
||||
])}
|
||||
>
|
||||
<Tooltip title="Left" isDarkMode tippyOpts={tippyOpts}>
|
||||
<button
|
||||
className={isActive("left")}
|
||||
onClick={() => onToggleAlignment("left")}
|
||||
aria-label="Align Left"
|
||||
>
|
||||
<AlignLeftIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Right" isDarkMode tippyOpts={tippyOpts}>
|
||||
<button
|
||||
className={isActive("right")}
|
||||
onClick={() => onToggleAlignment("right")}
|
||||
aria-label="Align Right"
|
||||
>
|
||||
<AlignRightIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import { CreaturePane } from "~/subApps/sheet/components/Sidebar/panes/CreaturePane";
|
||||
import { GameLogPane } from "~/subApps/sheet/components/Sidebar/panes/GameLogPane";
|
||||
import BlessingPane from "~/tools/js/Shared/containers/panes/BlessingPane";
|
||||
import CharacterSpellPane from "~/tools/js/Shared/containers/panes/CharacterSpellPane";
|
||||
import ClassSpellPane from "~/tools/js/Shared/containers/panes/ClassSpellPane";
|
||||
import ConditionManagePane from "~/tools/js/Shared/containers/panes/ConditionManagePane";
|
||||
import ContainerPane from "~/tools/js/Shared/containers/panes/ContainerPane";
|
||||
import CurrencyPane from "~/tools/js/Shared/containers/panes/CurrencyPane";
|
||||
import CustomActionPane from "~/tools/js/Shared/containers/panes/CustomActionPane";
|
||||
import CustomActionsPane from "~/tools/js/Shared/containers/panes/CustomActionsPane";
|
||||
import CustomSkillPane from "~/tools/js/Shared/containers/panes/CustomSkillPane";
|
||||
import DecoratePane from "~/tools/js/Shared/containers/panes/DecoratePane";
|
||||
import DefenseManagePane from "~/tools/js/Shared/containers/panes/DefenseManagePane";
|
||||
import DescriptionPane from "~/tools/js/Shared/containers/panes/DescriptionPane";
|
||||
import EncumbrancePane from "~/tools/js/Shared/containers/panes/EncumbrancePane";
|
||||
import EquipmentManagePane from "~/tools/js/Shared/containers/panes/EquipmentManagePane";
|
||||
import { ExportPdfPane } from "~/tools/js/Shared/containers/panes/ExportPdfPane";
|
||||
import ExtraManagePane from "~/tools/js/Shared/containers/panes/ExtraManagePane";
|
||||
import InfusionChoicePane from "~/tools/js/Shared/containers/panes/InfusionChoicePane";
|
||||
import ItemPane from "~/tools/js/Shared/containers/panes/ItemPane";
|
||||
import LongRestPane from "~/tools/js/Shared/containers/panes/LongRestPane";
|
||||
import NoteManagePane from "~/tools/js/Shared/containers/panes/NoteManagePane";
|
||||
import { PreferencesHitPointConfirmPane } from "~/tools/js/Shared/containers/panes/PreferencesHitPointConfirmPane";
|
||||
import PreferencesOptionalClassFeaturesConfirmPane from "~/tools/js/Shared/containers/panes/PreferencesOptionalClassFeaturesConfirmPane/PreferencesOptionalClassFeaturesConfirmPane";
|
||||
import PreferencesOptionalOriginsConfirmPane from "~/tools/js/Shared/containers/panes/PreferencesOptionalOriginsConfirmPane/PreferencesOptionalOriginsConfirmPane";
|
||||
import PreferencesPane from "~/tools/js/Shared/containers/panes/PreferencesPane";
|
||||
import { PreferencesProgressionConfirmPane } from "~/tools/js/Shared/containers/panes/PreferencesProgressionConfirmPane";
|
||||
import ProficienciesPane from "~/tools/js/Shared/containers/panes/ProficienciesPane";
|
||||
import ProficiencyBonusPane from "~/tools/js/Shared/containers/panes/ProficiencyBonusPane";
|
||||
import SavingThrowsPane from "~/tools/js/Shared/containers/panes/SavingThrowsPane";
|
||||
import SenseManagePane from "~/tools/js/Shared/containers/panes/SenseManagePane";
|
||||
import SettingsPane from "~/tools/js/Shared/containers/panes/SettingsPane";
|
||||
import { ShareUrlPane } from "~/tools/js/Shared/containers/panes/ShareUrlPane";
|
||||
import ShortRestPane from "~/tools/js/Shared/containers/panes/ShortRestPane";
|
||||
import SkillPane from "~/tools/js/Shared/containers/panes/SkillPane";
|
||||
import SkillsPane from "~/tools/js/Shared/containers/panes/SkillsPane";
|
||||
import SpeciesTraitPane from "~/tools/js/Shared/containers/panes/SpeciesTraitPane";
|
||||
import SpeedManagePane from "~/tools/js/Shared/containers/panes/SpeedManagePane";
|
||||
import SpellManagePane from "~/tools/js/Shared/containers/panes/SpellManagePane";
|
||||
import StartingEquipmentPane from "~/tools/js/Shared/containers/panes/StartingEquipmentPane";
|
||||
import TraitPane from "~/tools/js/Shared/containers/panes/TraitPane";
|
||||
import VehicleComponentPane from "~/tools/js/Shared/containers/panes/VehicleComponentPane";
|
||||
import VehiclePane from "~/tools/js/Shared/containers/panes/VehiclePane";
|
||||
|
||||
import { AbilityPane } from "../panes/AbilityPane";
|
||||
import { AbilitySavingThrowsPane } from "../panes/AbilitySavingThrowsPane";
|
||||
import { ActionPane } from "../panes/ActionPane";
|
||||
import { ArmorManagePane } from "../panes/ArmorManagePane";
|
||||
import { BackgroundPane } from "../panes/BackgroundPane";
|
||||
import { BasicActionPane } from "../panes/BasicActionPane";
|
||||
import { CampaignPane } from "../panes/CampaignPane";
|
||||
import { CharacterManagePane } from "../panes/CharacterManagePane";
|
||||
import { ClassFeaturePane } from "../panes/ClassFeaturePane";
|
||||
import { FeatPane } from "../panes/FeatPane";
|
||||
import { FeatsManagePane } from "../panes/FeatsManagePane";
|
||||
import { HitPointsManagePane } from "../panes/HitPointsManagePane";
|
||||
import { InitiativePane } from "../panes/InitiativePane";
|
||||
import { InspirationPane } from "../panes/InspirationPane";
|
||||
import { XpPane } from "../panes/XpPane";
|
||||
import { PaneComponentEnum, SidebarPaneComponent } from "../types";
|
||||
|
||||
// Refactor this to a hook that returns a component?
|
||||
export const getActiveEntryComponent = (
|
||||
componentType?: PaneComponentEnum
|
||||
): SidebarPaneComponent<any> | null => {
|
||||
if (!componentType) return null;
|
||||
|
||||
switch (componentType) {
|
||||
case PaneComponentEnum.HEALTH_MANAGE:
|
||||
return HitPointsManagePane;
|
||||
|
||||
case PaneComponentEnum.ITEM_DETAIL:
|
||||
return ItemPane;
|
||||
|
||||
case PaneComponentEnum.CHARACTER_SPELL_DETAIL:
|
||||
return CharacterSpellPane;
|
||||
|
||||
case PaneComponentEnum.CLASS_SPELL_DETAIL:
|
||||
return ClassSpellPane;
|
||||
|
||||
case PaneComponentEnum.ABILITY:
|
||||
return AbilityPane;
|
||||
|
||||
case PaneComponentEnum.SKILLS:
|
||||
return SkillsPane;
|
||||
|
||||
case PaneComponentEnum.PROFICIENCIES:
|
||||
return ProficienciesPane;
|
||||
|
||||
case PaneComponentEnum.XP:
|
||||
return XpPane;
|
||||
|
||||
case PaneComponentEnum.SHORT_REST:
|
||||
return ShortRestPane;
|
||||
|
||||
case PaneComponentEnum.LONG_REST:
|
||||
return LongRestPane;
|
||||
|
||||
case PaneComponentEnum.SPELL_MANAGE:
|
||||
return SpellManagePane;
|
||||
|
||||
case PaneComponentEnum.CONDITION_MANAGE:
|
||||
return ConditionManagePane;
|
||||
|
||||
case PaneComponentEnum.SPEED_MANAGE:
|
||||
return SpeedManagePane;
|
||||
|
||||
case PaneComponentEnum.SENSE_MANAGE:
|
||||
return SenseManagePane;
|
||||
|
||||
case PaneComponentEnum.DEFENSE_MANAGE:
|
||||
return DefenseManagePane;
|
||||
|
||||
case PaneComponentEnum.ARMOR_MANAGE:
|
||||
return ArmorManagePane;
|
||||
|
||||
case PaneComponentEnum.FEATS_MANAGE:
|
||||
return FeatsManagePane;
|
||||
|
||||
case PaneComponentEnum.NOTE_MANAGE:
|
||||
return NoteManagePane;
|
||||
|
||||
case PaneComponentEnum.TRAIT:
|
||||
return TraitPane;
|
||||
|
||||
case PaneComponentEnum.CAMPAIGN:
|
||||
return CampaignPane;
|
||||
|
||||
case PaneComponentEnum.EQUIPMENT_MANAGE:
|
||||
return EquipmentManagePane;
|
||||
|
||||
case PaneComponentEnum.CURRENCY:
|
||||
return CurrencyPane;
|
||||
|
||||
case PaneComponentEnum.ENCUMBRANCE:
|
||||
return EncumbrancePane;
|
||||
|
||||
case PaneComponentEnum.STARTING_EQUIPMENT:
|
||||
return StartingEquipmentPane;
|
||||
|
||||
case PaneComponentEnum.CUSTOM_ACTIONS:
|
||||
return CustomActionsPane;
|
||||
|
||||
case PaneComponentEnum.CUSTOM_ACTION:
|
||||
return CustomActionPane;
|
||||
|
||||
case PaneComponentEnum.CLASS_FEATURE_DETAIL:
|
||||
return ClassFeaturePane;
|
||||
|
||||
case PaneComponentEnum.FEAT_DETAIL:
|
||||
return FeatPane;
|
||||
|
||||
case PaneComponentEnum.SPECIES_TRAIT_DETAIL:
|
||||
return SpeciesTraitPane;
|
||||
|
||||
case PaneComponentEnum.BACKGROUND:
|
||||
return BackgroundPane;
|
||||
|
||||
case PaneComponentEnum.DESCRIPTION:
|
||||
return DescriptionPane;
|
||||
|
||||
case PaneComponentEnum.PROFICIENCY_BONUS:
|
||||
return ProficiencyBonusPane;
|
||||
|
||||
case PaneComponentEnum.INSPIRATION:
|
||||
return InspirationPane;
|
||||
|
||||
case PaneComponentEnum.INITIATIVE:
|
||||
return InitiativePane;
|
||||
|
||||
case PaneComponentEnum.ACTION:
|
||||
return ActionPane;
|
||||
|
||||
case PaneComponentEnum.PREFERENCES:
|
||||
return PreferencesPane;
|
||||
|
||||
case PaneComponentEnum.SKILL:
|
||||
return SkillPane;
|
||||
|
||||
case PaneComponentEnum.CUSTOM_SKILL:
|
||||
return CustomSkillPane;
|
||||
|
||||
case PaneComponentEnum.SAVING_THROWS:
|
||||
return SavingThrowsPane;
|
||||
|
||||
case PaneComponentEnum.ABILITY_SAVING_THROW:
|
||||
return AbilitySavingThrowsPane;
|
||||
|
||||
case PaneComponentEnum.BASIC_ACTION:
|
||||
return BasicActionPane;
|
||||
|
||||
case PaneComponentEnum.PREFERENCES_HIT_POINT_CONFIRM:
|
||||
return PreferencesHitPointConfirmPane;
|
||||
|
||||
case PaneComponentEnum.PREFERENCES_OPTIONAL_CLASS_FEATURES_CONFIRM:
|
||||
return PreferencesOptionalClassFeaturesConfirmPane;
|
||||
|
||||
case PaneComponentEnum.PREFERENCES_OPTIONAL_ORIGINS_CONFIRM:
|
||||
return PreferencesOptionalOriginsConfirmPane;
|
||||
|
||||
case PaneComponentEnum.PREFERENCES_PROGRESSION_CONFIRM:
|
||||
return PreferencesProgressionConfirmPane;
|
||||
|
||||
case PaneComponentEnum.SHARE_URL:
|
||||
return ShareUrlPane;
|
||||
|
||||
case PaneComponentEnum.CREATURE:
|
||||
return CreaturePane;
|
||||
|
||||
case PaneComponentEnum.VEHICLE:
|
||||
return VehiclePane;
|
||||
|
||||
case PaneComponentEnum.VEHICLE_COMPONENT:
|
||||
return VehicleComponentPane;
|
||||
|
||||
case PaneComponentEnum.EXTRA_MANAGE:
|
||||
return ExtraManagePane;
|
||||
|
||||
case PaneComponentEnum.INFUSION_CHOICE:
|
||||
return InfusionChoicePane;
|
||||
|
||||
case PaneComponentEnum.EXPORT_PDF:
|
||||
return ExportPdfPane;
|
||||
|
||||
case PaneComponentEnum.CHARACTER_MANAGE:
|
||||
return CharacterManagePane;
|
||||
|
||||
case PaneComponentEnum.GAME_LOG:
|
||||
return GameLogPane;
|
||||
|
||||
case PaneComponentEnum.CONTAINER:
|
||||
return ContainerPane;
|
||||
|
||||
case PaneComponentEnum.DECORATE:
|
||||
return DecoratePane;
|
||||
|
||||
case PaneComponentEnum.SETTINGS:
|
||||
return SettingsPane;
|
||||
|
||||
case PaneComponentEnum.BLESSING_DETAIL:
|
||||
default:
|
||||
return BlessingPane;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
BaseInventoryContract,
|
||||
Hack__BaseCharClass,
|
||||
FeatDetailsContract,
|
||||
RacialTraitContract,
|
||||
ClassFeatureUtils,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
DataOriginRef,
|
||||
DataOriginRefData,
|
||||
DataOriginUtils,
|
||||
FeatUtils,
|
||||
ItemUtils,
|
||||
RacialTraitUtils,
|
||||
DataOrigin,
|
||||
Spell,
|
||||
SpellUtils,
|
||||
ClassFeatureContract,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
|
||||
|
||||
import {
|
||||
PaneComponentEnum,
|
||||
PaneComponentInfo,
|
||||
PaneComponentProperties,
|
||||
PaneIdentifiers,
|
||||
} from "../types";
|
||||
|
||||
// List any full width panes
|
||||
const fullWidthPanes = [PaneComponentEnum.CHARACTER_MANAGE];
|
||||
|
||||
// Check to see if pane is in the list
|
||||
const isFullWidthPane = (activeEntry: PaneComponentInfo | null) =>
|
||||
activeEntry?.type && fullWidthPanes.includes(activeEntry.type);
|
||||
|
||||
// List any forced dark mode panes
|
||||
const darkModePanes = [PaneComponentEnum.CHARACTER_MANAGE];
|
||||
|
||||
// Check to see if pane is in the list
|
||||
const forceDarkMode = (activeEntry: PaneComponentInfo | null) =>
|
||||
activeEntry?.type && darkModePanes.includes(activeEntry.type);
|
||||
|
||||
// Return optional pane props
|
||||
export const getPaneProperties = (
|
||||
activeEntry: PaneComponentInfo | null
|
||||
): PaneComponentProperties => {
|
||||
const props: PaneComponentProperties = {
|
||||
isFullWidth: isFullWidthPane(activeEntry) ?? false,
|
||||
forceDarkMode: forceDarkMode(activeEntry) ?? false,
|
||||
};
|
||||
|
||||
return { ...props };
|
||||
};
|
||||
|
||||
export function getSpellComponentInfo(spell: Spell): PaneComponentInfo {
|
||||
let dataOrigin = SpellUtils.getDataOrigin(spell);
|
||||
let dataOriginType = SpellUtils.getDataOriginType(spell);
|
||||
|
||||
let type: PaneComponentEnum = PaneComponentEnum.CHARACTER_SPELL_DETAIL;
|
||||
let identifiers: PaneIdentifiers | null = null;
|
||||
|
||||
const mappingId = SpellUtils.getMappingId(spell);
|
||||
if (mappingId !== null) {
|
||||
if (dataOriginType === Constants.DataOriginTypeEnum.CLASS) {
|
||||
type = PaneComponentEnum.CLASS_SPELL_DETAIL;
|
||||
identifiers = PaneIdentifierUtils.generateClassSpell(
|
||||
ClassUtils.getMappingId(dataOrigin.primary as Hack__BaseCharClass),
|
||||
mappingId
|
||||
);
|
||||
} else {
|
||||
identifiers = PaneIdentifierUtils.generateCharacterSpell(mappingId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
identifiers,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDataOriginComponentInfo(
|
||||
dataOrigin: DataOrigin
|
||||
): PaneComponentInfo {
|
||||
let type: PaneComponentEnum = PaneComponentEnum.ERROR_404;
|
||||
let identifiers: PaneIdentifiers | null = null;
|
||||
switch (dataOrigin.type) {
|
||||
case Constants.DataOriginTypeEnum.ITEM:
|
||||
type = PaneComponentEnum.ITEM_DETAIL;
|
||||
identifiers = PaneIdentifierUtils.generateItem(
|
||||
ItemUtils.getMappingId(dataOrigin.primary as BaseInventoryContract)
|
||||
);
|
||||
break;
|
||||
|
||||
case Constants.DataOriginTypeEnum.CLASS_FEATURE:
|
||||
type = PaneComponentEnum.CLASS_FEATURE_DETAIL;
|
||||
identifiers = PaneIdentifierUtils.generateClassFeature(
|
||||
ClassFeatureUtils.getId(dataOrigin.primary as ClassFeatureContract),
|
||||
ClassUtils.getMappingId(dataOrigin.parent as Hack__BaseCharClass)
|
||||
);
|
||||
break;
|
||||
|
||||
case Constants.DataOriginTypeEnum.RACE:
|
||||
type = PaneComponentEnum.SPECIES_TRAIT_DETAIL;
|
||||
identifiers = PaneIdentifierUtils.generateRacialTrait(
|
||||
RacialTraitUtils.getId(dataOrigin.primary as RacialTraitContract)
|
||||
);
|
||||
break;
|
||||
|
||||
case Constants.DataOriginTypeEnum.FEAT:
|
||||
type = PaneComponentEnum.FEAT_DETAIL;
|
||||
identifiers = PaneIdentifierUtils.generateFeat(
|
||||
FeatUtils.getId(dataOrigin.primary as FeatDetailsContract)
|
||||
);
|
||||
break;
|
||||
|
||||
case Constants.DataOriginTypeEnum.BACKGROUND:
|
||||
type = PaneComponentEnum.BACKGROUND;
|
||||
break;
|
||||
|
||||
case Constants.DataOriginTypeEnum.FEAT_LIST:
|
||||
if (dataOrigin.parentType === Constants.DataOriginTypeEnum.BACKGROUND) {
|
||||
type = PaneComponentEnum.BACKGROUND;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Not implemented
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
identifiers,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDataOriginRefComponentInfo(
|
||||
ref: DataOriginRef,
|
||||
refData: DataOriginRefData
|
||||
): PaneComponentInfo {
|
||||
let type: PaneComponentEnum = PaneComponentEnum.ERROR_404;
|
||||
let identifiers: PaneIdentifiers | null = null;
|
||||
|
||||
switch (ref.type) {
|
||||
case Constants.DataOriginTypeEnum.ITEM: {
|
||||
const primary = DataOriginUtils.getRefPrimary(ref, refData);
|
||||
if (primary !== null) {
|
||||
type = PaneComponentEnum.ITEM_DETAIL;
|
||||
identifiers = PaneIdentifierUtils.generateItem(
|
||||
ItemUtils.getMappingId(primary)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Constants.DataOriginTypeEnum.CLASS_FEATURE: {
|
||||
const primary = DataOriginUtils.getRefPrimary(ref, refData);
|
||||
const parent = DataOriginUtils.getRefParent(ref, refData);
|
||||
if (primary !== null && parent !== null) {
|
||||
type = PaneComponentEnum.CLASS_FEATURE_DETAIL;
|
||||
identifiers = PaneIdentifierUtils.generateClassFeature(
|
||||
ClassFeatureUtils.getId(primary),
|
||||
ClassUtils.getMappingId(parent)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Constants.DataOriginTypeEnum.RACE: {
|
||||
const primary = DataOriginUtils.getRefPrimary(ref, refData);
|
||||
if (primary !== null) {
|
||||
type = PaneComponentEnum.SPECIES_TRAIT_DETAIL;
|
||||
identifiers = PaneIdentifierUtils.generateRacialTrait(
|
||||
RacialTraitUtils.getId(primary)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Constants.DataOriginTypeEnum.FEAT: {
|
||||
const primary = DataOriginUtils.getRefPrimary(ref, refData);
|
||||
if (primary !== null) {
|
||||
type = PaneComponentEnum.FEAT_DETAIL;
|
||||
identifiers = PaneIdentifierUtils.generateFeat(
|
||||
FeatUtils.getId(primary)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Constants.DataOriginTypeEnum.BACKGROUND:
|
||||
type = PaneComponentEnum.BACKGROUND;
|
||||
break;
|
||||
|
||||
default:
|
||||
// Not implemented
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
identifiers,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+214
@@ -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>
|
||||
);
|
||||
};
|
||||
+137
@@ -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>
|
||||
);
|
||||
};
|
||||
+69
@@ -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>
|
||||
);
|
||||
};
|
||||
+247
@@ -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>
|
||||
);
|
||||
};
|
||||
+280
@@ -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>
|
||||
);
|
||||
};
|
||||
+385
@@ -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>
|
||||
);
|
||||
};
|
||||
+105
@@ -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>
|
||||
);
|
||||
};
|
||||
+35
@@ -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>
|
||||
);
|
||||
};
|
||||
+131
@@ -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;
|
||||
+220
@@ -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>
|
||||
);
|
||||
};
|
||||
+88
@@ -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>
|
||||
);
|
||||
};
|
||||
+537
@@ -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>
|
||||
);
|
||||
};
|
||||
+104
@@ -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>
|
||||
);
|
||||
};
|
||||
+150
@@ -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>
|
||||
);
|
||||
};
|
||||
+84
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
type SvgProps = HTMLAttributes<SVGElement>;
|
||||
|
||||
export const AlignLeftIcon: FC<SvgProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
height="1em"
|
||||
aria-labelledby="alignLeftTitle"
|
||||
{...props}
|
||||
>
|
||||
<title id="alignLeftTitle">Sidebar Align Left Icon</title>
|
||||
<path d="M21,3V21H9.5V3H21m3-3H6.5V24H24V0Z" />
|
||||
<rect x="0.5" y="0.5" width="5.91" height="23" />
|
||||
<path d="M5.91,1V23H1V1H5.91m1-1H0V24H6.91V0Z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
type SvgProps = HTMLAttributes<SVGElement>;
|
||||
|
||||
export const AlignRightIcon: FC<SvgProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
height="1em"
|
||||
aria-labelledby="alignRightTitle"
|
||||
{...props}
|
||||
>
|
||||
<title id="alignRightTitle">Sidebar Align Right Icon</title>
|
||||
<path d="M15.5,3V21H3V3H15.5m3-3H0V24H18.5V0Z" />
|
||||
<rect x="17.59" y="0.5" width="5.91" height="23" />
|
||||
<path d="M23,1V23H18.09V1H23m1-1H17.09V24H24V0Z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
type SvgProps = HTMLAttributes<SVGElement>;
|
||||
|
||||
export const ArrowsLeftIcon: FC<SvgProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="1em"
|
||||
height="1em"
|
||||
aria-labelledby="leftArrowsTitle"
|
||||
{...props}
|
||||
>
|
||||
<title id="leftArrowsTitle">Left Arrows Icon</title>
|
||||
<path d="M11,2.48,5,8l6,5.52a1.3,1.3,0,0,1-.21,2.12h0a2.25,2.25,0,0,1-2.68-.17L0,8,8.11.53A2.25,2.25,0,0,1,10.79.36h0A1.3,1.3,0,0,1,11,2.48Z" />
|
||||
<polygon points="6.92 8 16 0 16 16 6.92 8" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
type SvgProps = HTMLAttributes<SVGElement>;
|
||||
|
||||
export const ArrowsRightIcon: FC<SvgProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="1em"
|
||||
height="1em"
|
||||
aria-labelledby="rightArrowsTitle"
|
||||
{...props}
|
||||
>
|
||||
<title id="rightArrowsTitle">Right Arrows Icon</title>
|
||||
<path d="M5.21.36h0A2.25,2.25,0,0,1,7.89.53L16,8,7.89,15.47a2.25,2.25,0,0,1-2.68.17h0A1.3,1.3,0,0,1,5,13.52L11,8,5,2.48A1.3,1.3,0,0,1,5.21.36Z" />
|
||||
<polygon points="9.09 8 0 0 0 16 9.09 8" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
type SvgProps = HTMLAttributes<SVGElement>;
|
||||
|
||||
export const FixedIcon: FC<SvgProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
height="1em"
|
||||
aria-labelledby="fixedTitle"
|
||||
{...props}
|
||||
>
|
||||
<title id="fixedTitle">Sidebar Fixed Icon</title>
|
||||
<path d="M15.5,3V21H3V3H15.5m3-3H0V24H18.5V0Z" />
|
||||
<rect x="17.59" y="0.5" width="5.91" height="23" />
|
||||
<path d="M23,1V23H18.09V1H23m1-1H17.09V24H24V0Z" />
|
||||
<line
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth="3px"
|
||||
x1="12"
|
||||
y1="5.5"
|
||||
x2="12"
|
||||
y2="19"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
type SvgProps = HTMLAttributes<SVGElement>;
|
||||
|
||||
export const OverlayIcon: FC<SvgProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
height="1em"
|
||||
aria-labelledby="overlayTitle"
|
||||
{...props}
|
||||
>
|
||||
<title id="overlayTitle">Sidebar Overlay Icon</title>
|
||||
<path d="M14.5,3V21H3V3H14.5m3-3H0V24H17.5V0Z" />
|
||||
<rect x="17.59" y="0.5" width="5.91" height="23" />
|
||||
<path d="M23,1V23H18.09V1H23m1-1H17.09V24H24V0Z" />
|
||||
<polygon points="6.28 12.32 12.5 19.14 12.5 5.5 6.28 12.32" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
AbilityManager,
|
||||
Constants,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
export interface PaneIdentifiersCommonId {
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersIntId {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersAction extends PaneIdentifiersCommonId {
|
||||
entityTypeId: string | null;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersClassSpell {
|
||||
classId: number;
|
||||
spellId: number;
|
||||
castLevel?: number;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersNote {
|
||||
noteType: string;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersClassFeature extends PaneIdentifiersCommonId {
|
||||
classMappingId: number;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersBasicAction extends PaneIdentifiersIntId {}
|
||||
export interface PaneIdentifiersPreferenceHitPointConfirm
|
||||
extends PaneIdentifiersIntId {}
|
||||
export interface PaneIdentifiersPreferenceProgressionConfirm
|
||||
extends PaneIdentifiersIntId {}
|
||||
export interface PaneIdentifiersFeat extends PaneIdentifiersIntId {}
|
||||
|
||||
export interface PaneIdentifiersItem extends PaneIdentifiersCommonId {}
|
||||
export interface PaneIdentifiersItem extends PaneIdentifiersCommonId {}
|
||||
export interface PaneIdentifiersCustomItem extends PaneIdentifiersCommonId {}
|
||||
export interface PaneIdentifiersCustomSkill extends PaneIdentifiersCommonId {}
|
||||
export interface PaneIdentifiersCustomAction extends PaneIdentifiersCommonId {}
|
||||
export interface PaneIdentifiersCharacterSpell extends PaneIdentifiersCommonId {
|
||||
castLevel?: number;
|
||||
}
|
||||
export interface PaneIdentifiersRacialTrait extends PaneIdentifiersCommonId {}
|
||||
export interface PaneIdentifiersAbilitySavingThrow
|
||||
extends PaneIdentifiersCommonId {}
|
||||
export interface PaneIdentifiersSkill extends PaneIdentifiersCommonId {}
|
||||
export interface PaneIdentifiersCreature extends PaneIdentifiersCommonId {}
|
||||
export interface PaneIdentifiersVehicle extends PaneIdentifiersCommonId {}
|
||||
export interface PaneIdentifiersInfusionChoice
|
||||
extends PaneIdentifiersCommonId {}
|
||||
|
||||
export interface PaneIdentifiersAbility {
|
||||
ability: AbilityManager;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersPreferenceOptionalClassFeaturesConfirm {
|
||||
spellListIds: Array<number>;
|
||||
newIsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersPreferenceOptionalOriginsConfirm {
|
||||
spellListIds: Array<number>;
|
||||
newIsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersVehicleComponent
|
||||
extends PaneIdentifiersCommonId {
|
||||
vehicleId: number;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersVehicleActionStation
|
||||
extends PaneIdentifiersCommonId {
|
||||
vehicleId: number;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersContainer {
|
||||
containerDefinitionKey: string;
|
||||
showAddItems?: boolean;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersCurrencyContext {
|
||||
containerDefinitionKeyContext: string | null;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersSettingsContext {
|
||||
context: string;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersBlessing {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PaneIdentifiersTrait {
|
||||
type: Constants.TraitTypeEnum;
|
||||
}
|
||||
|
||||
export type PaneIdentifiers =
|
||||
| PaneIdentifiersAction
|
||||
| PaneIdentifiersClassSpell
|
||||
| PaneIdentifiersNote
|
||||
| PaneIdentifiersClassFeature
|
||||
| PaneIdentifiersRacialTrait
|
||||
| PaneIdentifiersItem
|
||||
| PaneIdentifiersBasicAction
|
||||
| PaneIdentifiersCharacterSpell
|
||||
| PaneIdentifiersFeat
|
||||
| PaneIdentifiersCustomAction
|
||||
| PaneIdentifiersCustomItem
|
||||
| PaneIdentifiersCustomSkill
|
||||
| PaneIdentifiersAbility
|
||||
| PaneIdentifiersSkill
|
||||
| PaneIdentifiersAbilitySavingThrow
|
||||
| PaneIdentifiersPreferenceHitPointConfirm
|
||||
| PaneIdentifiersPreferenceOptionalClassFeaturesConfirm
|
||||
| PaneIdentifiersPreferenceOptionalOriginsConfirm
|
||||
| PaneIdentifiersPreferenceProgressionConfirm
|
||||
| PaneIdentifiersCreature
|
||||
| PaneIdentifiersVehicle
|
||||
| PaneIdentifiersVehicleComponent
|
||||
| PaneIdentifiersInfusionChoice
|
||||
| PaneIdentifiersContainer
|
||||
| PaneIdentifiersCurrencyContext
|
||||
| PaneIdentifiersSettingsContext
|
||||
| PaneIdentifiersBlessing
|
||||
| PaneIdentifiersTrait;
|
||||
|
||||
export interface PaneComponentProperties {
|
||||
forceDarkMode?: boolean;
|
||||
isFullWidth?: boolean;
|
||||
}
|
||||
|
||||
export type SidebarPaneComponent<T> = React.ComponentType<T>;
|
||||
|
||||
export interface PaneComponentInfo {
|
||||
type: PaneComponentEnum;
|
||||
identifiers?: PaneIdentifiers | null;
|
||||
}
|
||||
|
||||
export enum PaneComponentEnum {
|
||||
ERROR_404 = "ERROR_404",
|
||||
ABILITY = "ABILITY",
|
||||
ABILITY_SAVING_THROW = "ABILITY_SAVING_THROW",
|
||||
ACTION = "ACTION",
|
||||
ARMOR_MANAGE = "ARMOR_MANAGE",
|
||||
BASIC_ACTION = "BASIC_ACTION",
|
||||
BACKGROUND = "BACKGROUND",
|
||||
BLESSING_DETAIL = "BLESSING_DETAIL",
|
||||
CAMPAIGN = "CAMPAIGN",
|
||||
CHARACTER_MANAGE = "CHARACTER_MANAGE",
|
||||
CHARACTER_SPELL_DETAIL = "CHARACTER_SPELL_DETAIL",
|
||||
CLASS_FEATURE_DETAIL = "CLASS_FEATURE_DETAIL",
|
||||
CLASS_SPELL_DETAIL = "CLASS_SPELL_DETAIL",
|
||||
CONDITION_MANAGE = "CONDITION_MANAGE",
|
||||
CONTAINER = "CONTAINER",
|
||||
CREATURE = "CREATURE",
|
||||
CURRENCY = "CURRENCY",
|
||||
CUSTOM_ACTIONS = "CUSTOM_ACTIONS",
|
||||
CUSTOM_ACTION = "CUSTOM_ACTION",
|
||||
CUSTOM_SKILL = "CUSTOM_SKILL",
|
||||
DECORATE = "DECORATE",
|
||||
DEFENSE_MANAGE = "DEFENSE_MANAGE",
|
||||
DESCRIPTION = "DESCRIPTION",
|
||||
ENCUMBRANCE = "ENCUMBRANCE",
|
||||
EQUIPMENT_MANAGE = "EQUIPMENT_MANAGE",
|
||||
EXPORT_PDF = "EXPORT_PDF",
|
||||
EXTRA_MANAGE = "EXTRA_MANAGE",
|
||||
FEAT_DETAIL = "FEAT_DETAIL",
|
||||
FEATS_MANAGE = "FEATS_MANAGE",
|
||||
GAME_LOG = "GAME_LOG",
|
||||
HEALTH_MANAGE = "HEALTH_MANAGE",
|
||||
INFUSION_CHOICE = "INFUSION_CHOICE",
|
||||
INITIATIVE = "INITIATIVE",
|
||||
INSPIRATION = "INSPIRATION",
|
||||
ITEM_DETAIL = "ITEM_DETAIL",
|
||||
LONG_REST = "LONG_REST",
|
||||
NOTE_MANAGE = "NOTE_MANAGE",
|
||||
POSSESSIONS_MANAGE = "POSSESSIONS_MANAGE",
|
||||
PREFERENCES = "PREFERENCES",
|
||||
PREFERENCES_HIT_POINT_CONFIRM = "PREFERENCES_HIT_POINT_CONFIRM",
|
||||
PREFERENCES_OPTIONAL_CLASS_FEATURES_CONFIRM = "PREFERENCES_OPTIONAL_CLASS_FEATURES_CONFIRM",
|
||||
PREFERENCES_OPTIONAL_ORIGINS_CONFIRM = "PREFERENCES_OPTIONAL_ORIGINS_CONFIRM",
|
||||
PREFERENCES_PROGRESSION_CONFIRM = "PREFERENCES_PROGRESSION_CONFIRM",
|
||||
PROFICIENCIES = "PROFICIENCIES",
|
||||
PROFICIENCY_BONUS = "PROFICIENCY_BONUS",
|
||||
SPECIES_TRAIT_DETAIL = "SPECIES_TRAIT_DETAIL",
|
||||
SAVING_THROWS = "SAVING_THROWS",
|
||||
SENSE_MANAGE = "SENSE_MANAGE",
|
||||
SETTINGS = "SETTINGS",
|
||||
SHARE_URL = "SHARE_URL",
|
||||
SHORT_REST = "SHORT_REST",
|
||||
SKILLS = "SKILLS",
|
||||
SKILL = "SKILL",
|
||||
SPEED_MANAGE = "SPEED_MANAGE",
|
||||
SPELL_MANAGE = "SPELL_MANAGE",
|
||||
STARTING_EQUIPMENT = "STARTING_EQUIPMENT",
|
||||
TRAIT = "TRAIT",
|
||||
VEHICLE = "VEHICLE",
|
||||
VEHICLE_COMPONENT = "VEHICLE_COMPONENT",
|
||||
VEHICLE_ACTION_STATION = "VEHICLE_ACTION_STATION",
|
||||
XP = "XP",
|
||||
}
|
||||
|
||||
export enum SidebarPlacementEnum {
|
||||
FIXED = "fixed",
|
||||
OVERLAY = "overlay",
|
||||
}
|
||||
|
||||
export enum SidebarAlignmentEnum {
|
||||
LEFT = "left",
|
||||
RIGHT = "right",
|
||||
}
|
||||
|
||||
export interface SidebarPositionInfo {
|
||||
left: number | string;
|
||||
right: number | string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
Action,
|
||||
DataOriginRefData,
|
||||
Feat,
|
||||
RacialTrait,
|
||||
RacialTraitUtils,
|
||||
RuleData,
|
||||
SnippetData,
|
||||
Spell,
|
||||
CharacterTheme,
|
||||
FeatManager,
|
||||
CharacterFeaturesManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { SpeciesTraitFeatureSnippet } from "~/tools/js/CharacterSheet/components/FeatureSnippet";
|
||||
|
||||
export interface SpeciesDetailProps {
|
||||
onActionUseSet: (action: Action, uses: number) => void;
|
||||
onActionClick: (action: Action) => void;
|
||||
onSpellClick: (spell: Spell) => void;
|
||||
onSpellUseSet: (spell: Spell, uses: number) => void;
|
||||
onFeatureClick: (feature: RacialTrait) => void;
|
||||
feats: Array<Feat>;
|
||||
snippetData: SnippetData;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
speciesTraits: RacialTrait[];
|
||||
onFeatClick: (feat: FeatManager) => void;
|
||||
featuresManager: CharacterFeaturesManager
|
||||
}
|
||||
|
||||
export const SpeciesDetail: FC<SpeciesDetailProps> = ({
|
||||
speciesTraits,
|
||||
onActionUseSet,
|
||||
onActionClick,
|
||||
onSpellClick,
|
||||
onSpellUseSet,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
onFeatureClick,
|
||||
feats,
|
||||
isReadonly,
|
||||
snippetData,
|
||||
onFeatClick,
|
||||
featuresManager
|
||||
}) => {
|
||||
if (speciesTraits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{speciesTraits.map((speciesTrait) => (
|
||||
<SpeciesTraitFeatureSnippet
|
||||
key={RacialTraitUtils.getId(speciesTrait)}
|
||||
speciesTrait={speciesTrait}
|
||||
isReadonly={isReadonly}
|
||||
snippetData={snippetData}
|
||||
ruleData={ruleData}
|
||||
abilityLookup={abilityLookup}
|
||||
theme={theme}
|
||||
feats={feats}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
onActionUseSet={onActionUseSet}
|
||||
onActionClick={onActionClick}
|
||||
onSpellClick={onSpellClick}
|
||||
onSpellUseSet={onSpellUseSet}
|
||||
onFeatureClick={onFeatureClick}
|
||||
onFeatClick={onFeatClick}
|
||||
featuresManager={featuresManager}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export const sidebarId = "sidebar";
|
||||
|
||||
export const SIGNED_32BIT_INT_MAX_VALUE = 1_000_000_000;
|
||||
export const SIGNED_32BIT_INT_MIN_VALUE = -1_000_000_000;
|
||||
|
||||
export const HP_TEMP_VALUE = {
|
||||
MAX: 999,
|
||||
MIN: 0,
|
||||
};
|
||||
|
||||
export const HP_BONUS_VALUE = {
|
||||
MAX: 99,
|
||||
MIN: -99,
|
||||
};
|
||||
|
||||
export const HP_OVERRIDE_MAX_VALUE = 999;
|
||||
|
||||
export const HP_BASE_MAX_VALUE = 500;
|
||||
|
||||
export const HP_DAMAGE_TAKEN_VALUE = {
|
||||
MAX: SIGNED_32BIT_INT_MAX_VALUE,
|
||||
MIN: 0,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createContext, FC, useContext, useState } from "react";
|
||||
|
||||
import { MobileSections } from "../../types";
|
||||
|
||||
export interface SheetContextType {
|
||||
mobileActiveSectionId: MobileSections;
|
||||
setMobileActiveSectionId: (sectionId: MobileSections) => void;
|
||||
setSwipedAmount: (amount: number) => void;
|
||||
swipedAmount: number;
|
||||
}
|
||||
|
||||
export const SheetContext = createContext<SheetContextType>(null!);
|
||||
|
||||
export const SheetProvider: FC = ({ children }) => {
|
||||
const [mobileActiveSectionId, setMobileActiveSectionId] =
|
||||
useState<MobileSections>("main");
|
||||
const [swipedAmount, setSwipedAmount] = useState(0);
|
||||
|
||||
return (
|
||||
<SheetContext.Provider
|
||||
value={{
|
||||
mobileActiveSectionId,
|
||||
setMobileActiveSectionId,
|
||||
setSwipedAmount,
|
||||
swipedAmount,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SheetContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSheetContext = () => {
|
||||
return useContext(SheetContext);
|
||||
};
|
||||
Reference in New Issue
Block a user