2025-05-28 15:36:51 -07:00

286 lines
8.4 KiB
TypeScript

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>
);
};