Grabbed dndbeyond's source code

```
~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js
```
This commit is contained in:
2025-05-28 11:50:03 -07:00
commit 8df9031d27
3592 changed files with 319051 additions and 0 deletions
@@ -0,0 +1,142 @@
import { useContext } from "react";
import {
AbilityManager,
CharacterPreferences,
CharacterTheme,
Constants,
} from "@dndbeyond/character-rules-engine/es";
import { DiceTools, IRollContext, RollType } from "@dndbeyond/dice";
import { GameLogContext } from "@dndbeyond/game-log-components";
import { NumberDisplay } from "~/components/NumberDisplay";
import BoxBackground from "../BoxBackground";
import { DigitalDiceWrapper } from "../Dice";
import AbilityScoreBoxSvg from "../Svg/boxes/AbilityScoreBoxSvg";
import { isNotNullOrUndefined } from "../utils/TypeScriptUtils";
interface Props {
className?: string;
ability: AbilityManager | { name: string; score: number; modifier: number };
preferences: CharacterPreferences;
theme: CharacterTheme;
onClick?: (ability: AbilityManager) => void;
diceEnabled?: boolean;
rollContext: IRollContext;
}
export default function AbilitySummary({
className = "",
ability,
preferences,
theme,
onClick,
diceEnabled = false,
rollContext,
}: Props) {
const isManager = "getStatKey" in ability;
const handleClick = (evt: React.MouseEvent): void => {
if (onClick && isManager) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick(ability);
}
};
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
useContext(GameLogContext);
let classNames: Array<string> = [className, "ddbc-ability-summary"];
let primaryNode: React.ReactNode;
let secondaryNode: React.ReactNode;
if (!ability) return null;
const abilityStatKey = isManager ? ability.getStatKey() : 0;
const abilityLabel = isManager ? ability.getLabel() : ability.name;
const abilityName = isManager ? ability.getName() : ability.name;
const abilityModifier = isManager ? ability.getModifier() : ability.modifier;
const abilityTotalScore = isManager ? ability.getTotalScore() : ability.score;
const diceWrapperProps = {
rollType: RollType.Check,
rollAction: abilityName,
diceEnabled: diceEnabled,
diceNotation: DiceTools.CustomD20(abilityModifier ?? 0),
themeColor: theme.themeColor,
rollContext: rollContext,
rollTargetOptions: !!messageTargetOptions?.entities
? Object.values(messageTargetOptions.entities).filter(
isNotNullOrUndefined
)
: undefined,
rollTargetDefault :defaultMessageTargetOption,
userId: Number(userId),
};
if (
preferences.abilityScoreDisplayType ===
Constants.PreferenceAbilityScoreDisplayTypeEnum.SCORES_TOP
) {
primaryNode = (
<DigitalDiceWrapper
{...diceWrapperProps}
>
{abilityTotalScore}
</DigitalDiceWrapper>
);
if (abilityModifier === null) {
secondaryNode = "--";
} else {
secondaryNode = <NumberDisplay type="signed" number={abilityModifier} />;
}
} else {
if (abilityModifier === null) {
primaryNode = "--";
} else {
primaryNode = (
<DigitalDiceWrapper
{...diceWrapperProps}
>
<NumberDisplay
number={abilityModifier}
type="signed"
size="large"
/>
</DigitalDiceWrapper>
);
}
secondaryNode = abilityTotalScore;
}
return (
<div className={classNames.join(" ")} onClick={handleClick}>
<BoxBackground StyleComponent={AbilityScoreBoxSvg} theme={theme} />
<div className="ddbc-ability-summary__heading">
<span
className={`ddbc-ability-summary__label ${
theme.isDarkMode ? "ddbc-ability-summary__label--dark-mode" : ""
}`}
>
{abilityLabel}
</span>
<span className="ddbc-ability-summary__abbr">{abilityStatKey}</span>
</div>
<div
className={`ddbc-ability-summary__primary ${
theme.isDarkMode ? "ddbc-ability-summary__primary--dark-mode" : ""
}`}
>
{primaryNode}
</div>
<div
className={`ddbc-ability-summary__secondary ${
theme.isDarkMode ? "ddbc-ability-summary__secondary--dark-mode" : ""
}`}
>
{secondaryNode}
</div>
</div>
);
}
@@ -0,0 +1,4 @@
import AbilitySummary from "./AbilitySummary";
export default AbilitySummary;
export { AbilitySummary };
@@ -0,0 +1,53 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
Action,
ActionUtils,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
interface Props {
action: Action;
onClick?: (action: Action) => void;
className: string;
theme?: CharacterTheme;
}
export default class ActionName extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
handleClick = (evt: React.MouseEvent): void => {
const { onClick, action } = this.props;
if (onClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick(action);
}
};
render() {
const { action, className, theme } = this.props;
let isCustomized = ActionUtils.isCustomized(action);
let classNames: Array<string> = [className, "ddbc-action-name"];
return (
<span className={classNames.join(" ")} onClick={this.handleClick}>
{ActionUtils.getName(action)}
{isCustomized && (
<Tooltip
title="Action is Customized"
className="ddbc-action-name__customized"
isDarkMode={theme?.isDarkMode}
>
*
</Tooltip>
)}
</span>
);
}
}
@@ -0,0 +1,4 @@
import ActionName from "./ActionName";
export default ActionName;
export { ActionName };
@@ -0,0 +1,65 @@
import { visuallyHidden } from "@mui/utils";
import * as React from "react";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
import BoxBackground from "../BoxBackground";
import ArmorClassBoxSvg from "../Svg/boxes/ArmorClassBoxSvg";
interface Props {
armorClass: number;
theme: CharacterTheme;
onClick?: () => void;
className: string;
}
export default class ArmorClassBox extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
handleClick = (evt: React.MouseEvent): void => {
const { onClick } = this.props;
if (onClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick();
}
};
render() {
const { armorClass, className, theme } = this.props;
let classNames: Array<string> = [className, "ddbc-armor-class-box"];
return (
<section className={classNames.join(" ")} onClick={this.handleClick}>
<BoxBackground StyleComponent={ArmorClassBoxSvg} theme={theme} />
<h2 style={visuallyHidden}>Armor Class</h2>
<div
className={`ddbc-armor-class-box__label ${
theme.isDarkMode ? "ddbc-armor-class-box__label--dark-mode" : ""
}`}
>
Armor
</div>
<div
className={`ddbc-armor-class-box__value ${
theme.isDarkMode ? "ddbc-armor-class-box__value--dark-mode" : ""
}`}
data-testid="armor-class-value"
>
{armorClass}
</div>
<div
className={`ddbc-armor-class-box__label ${
theme.isDarkMode ? "ddbc-armor-class-box__label--dark-mode" : ""
}`}
>
Class
</div>
</section>
);
}
}
@@ -0,0 +1,165 @@
import {
AbilityLookup,
Action,
Attack,
CharacterTheme,
Constants,
DataOriginRefData,
Item,
RuleData,
Spell,
SpellCasterInfo,
WeaponSpellDamageGroup,
} from "@dndbeyond/character-rules-engine/es";
import { IRollContext } from "@dndbeyond/dice";
import CombatActionAttack from "../CombatAttack/CombatActionAttack";
import CombatItemAttack from "../CombatAttack/CombatItemAttack";
import CombatSpellAttack from "../CombatAttack/CombatSpellAttack";
import { useMemo } from "react";
import clsx from "clsx";
import styles from "./styles.module.css";
interface AttackTableProps extends React.HTMLAttributes<HTMLElement> {
theme: CharacterTheme;
showNotes?: boolean;
diceEnabled?: boolean;
attacks: Array<Attack>;
weaponSpellDamageGroups: Array<WeaponSpellDamageGroup>;
abilityLookup: AbilityLookup;
ruleData: RuleData;
spellCasterInfo: SpellCasterInfo;
onAttackClick?: (attack: Attack) => void;
rollContext: IRollContext;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
};
export const AttackTable = ({
theme,
attacks,
weaponSpellDamageGroups,
ruleData,
abilityLookup,
spellCasterInfo,
onAttackClick,
dataOriginRefData,
proficiencyBonus,
rollContext,
showNotes = true,
diceEnabled = false,
...props
}: AttackTableProps) => {
const columnClassNameBase = useMemo(() => [
styles.col,
theme?.isDarkMode && styles.darkMode,
], [theme]);
const getColumnClass = (addtionalClass: string) => clsx([
...columnClassNameBase,
addtionalClass,
]);
const renderedAttacks = useMemo(
(): React.ReactNode => !!attacks.length
? attacks.map((attack): React.ReactNode => {
const commonCombatAttackProps = {
key: attack.key,
attack,
ruleData,
abilityLookup,
onClick: onAttackClick,
showNotes,
diceEnabled,
theme,
rollContext,
proficiencyBonus,
className: styles.attack,
};
switch (attack.type) {
case Constants.AttackSourceTypeEnum.ACTION:
case Constants.AttackSourceTypeEnum.CUSTOM:
return (
<CombatActionAttack
action={attack.data as Action}
{...commonCombatAttackProps}
/>
);
case Constants.AttackSourceTypeEnum.ITEM:
return (
<CombatItemAttack
item={attack.data as Item}
weaponSpellDamageGroups={weaponSpellDamageGroups}
{...commonCombatAttackProps}
/>
);
case Constants.AttackSourceTypeEnum.SPELL:
return (
<CombatSpellAttack
spell={attack.data as Spell}
spellCasterInfo={spellCasterInfo}
dataOriginRefData={dataOriginRefData}
{...commonCombatAttackProps}
/>
);
default:
return null;
}
})
: (
<div className={clsx([
styles.default,
theme?.isDarkMode && styles.darkMode,
])}>
Equip weapons or add spells to see your attacks here.
</div>
),
[
attacks,
ruleData,
abilityLookup,
onAttackClick,
showNotes,
diceEnabled,
theme,
rollContext,
proficiencyBonus,
weaponSpellDamageGroups,
spellCasterInfo,
dataOriginRefData
]
);
return (
<div {...props}>
<div className={styles.tableHeader}>
<div className={getColumnClass(styles.icon)} />
<div className={getColumnClass(styles.name)}>
Attack
</div>
<div className={getColumnClass(styles.range)}>
Range
</div>
<div className={getColumnClass(styles.tohit)}>
Hit / DC
</div>
<div className={getColumnClass(styles.damage)}>
Damage
</div>
{showNotes && (
<div className={getColumnClass(styles.notes)}>
Notes
</div>
)}
</div>
<div data-testid="attack-table-content">
{renderedAttacks}
</div>
</div>
);
};
@@ -0,0 +1,152 @@
import * as React from "react";
import {
Item,
ItemUtils,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
import { ItemName } from "~/components/ItemName";
import BoxBackground from "../BoxBackground";
import {
EmptyAttunementSlotBoxSvg,
ThemedAttunementSlotBoxSvg,
ThemedWithOpacityAttunementSlotBoxSvg,
} from "../Svg/boxes/AttunementSlotBoxSvg";
interface Props {
slot: Item | null;
onClick?: (slot: Item) => void;
isMobile: boolean;
className: string;
theme: CharacterTheme;
}
export default class AttunementSlot extends React.PureComponent<Props, {}> {
static defaultProps = {
className: "",
isMobile: false,
};
handleClick = (evt: React.MouseEvent): void => {
const { onClick, slot } = this.props;
if (onClick && slot !== null) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick(slot);
}
};
renderMetaItems = (): React.ReactNode => {
const { slot } = this.props;
if (slot === null) {
return null;
}
let metaItems: Array<string> = [];
if (ItemUtils.isLegacy(slot)) {
metaItems.push("Legacy");
}
let type = ItemUtils.getType(slot);
if (type !== null) {
metaItems.push(type);
}
if (ItemUtils.isWeaponContract(slot)) {
if (ItemUtils.isOffhand(slot)) {
metaItems.push("Dual Wield");
}
} else if (ItemUtils.isArmorContract(slot)) {
let baseArmorName = ItemUtils.getBaseArmorName(slot);
if (baseArmorName) {
metaItems.push(baseArmorName);
}
} else if (ItemUtils.isGearContract(slot)) {
let subType = ItemUtils.getSubType(slot);
if (subType) {
metaItems.push(subType);
}
if (ItemUtils.isOffhand(slot)) {
metaItems.push("Dual Wield");
}
}
return (
<div className="ddbc-attunement-slot__meta">
{metaItems.map(
(metaItem, idx): React.ReactNode => (
<span className="ddbc-attunement-slot__meta-item" key={idx}>
{metaItem}
</span>
)
)}
</div>
);
};
renderItem = (): React.ReactNode => {
const { slot } = this.props;
if (slot === null) {
return null;
}
let itemImage = ItemUtils.getAvatarUrl(slot);
let previewStyles: React.CSSProperties = {
backgroundImage: `url(${itemImage})`,
};
return (
<React.Fragment>
<div className="ddbc-attunement-slot__preview" style={previewStyles} />
<div className="ddbc-attunement-slot__content">
<div className="ddbc-attunement-slot__name">
<ItemName item={slot} showAttunement={false} />
</div>
{this.renderMetaItems()}
</div>
</React.Fragment>
);
};
renderEmpty = (): React.ReactNode => {
const { isMobile } = this.props;
return (
<React.Fragment>
<div className="ddbc-attunement-slot__preview" />
<div className="ddbc-attunement-slot__content">
Choose an Item from {isMobile ? "Below" : " the Right"}
</div>
</React.Fragment>
);
};
render() {
const { slot, className, theme } = this.props;
let classNames: Array<string> = [className, "ddbc-attunement-slot"];
let StyleComponent: React.ComponentType<any> = theme?.isDarkMode
? ThemedWithOpacityAttunementSlotBoxSvg
: EmptyAttunementSlotBoxSvg;
if (slot) {
classNames.push("ddbc-attunement-slot--filled");
StyleComponent = ThemedAttunementSlotBoxSvg;
} else {
classNames.push("ddbc-attunement-slot--empty");
}
return (
<div className={classNames.join(" ")} onClick={this.handleClick}>
<BoxBackground StyleComponent={StyleComponent} theme={theme} />
{slot ? this.renderItem() : this.renderEmpty()}
</div>
);
}
}
@@ -0,0 +1,26 @@
import React from "react";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
interface Props {
StyleComponent: React.ComponentType<any>;
theme: CharacterTheme;
className: string;
}
export default class BoxBackground extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
render() {
const { StyleComponent, theme, className } = this.props;
let classNames: Array<string> = ["ddbc-box-background", className];
return (
<div className={classNames.join(" ")}>
<StyleComponent theme={theme} />
</div>
);
}
}
@@ -0,0 +1,4 @@
import BoxBackground from "./BoxBackground";
export default BoxBackground;
export { BoxBackground };
@@ -0,0 +1,110 @@
import clsx from "clsx";
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { ThemedPlayButtonSvg } from "@dndbeyond/character-components/es";
import {
CampaignDataContract,
CampaignUtils,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
import D6 from "@dndbeyond/fontawesome-cache/svgs/regular/dice-d6.svg";
import { GameLogNotificationWrapper } from "@dndbeyond/game-log-components";
import { Link } from "~/components/Link";
import { useSidebar } from "../../../../contexts/Sidebar";
import { PaneComponentEnum } from "../../../../subApps/sheet/components/Sidebar/types";
import { GameLogState } from "../../Shared/stores/typings";
import { NavigationUtils } from "../../Shared/utils";
import styles from "./styles.module.css";
interface Props {
campaign: CampaignDataContract;
onCampaignShow?: () => void;
className?: string;
gameLog?: GameLogState;
theme: CharacterTheme;
}
const CampaignSummary: React.FC<Props> = ({
campaign,
onCampaignShow,
className = "",
gameLog,
theme,
}) => {
const {
pane: { paneHistoryStart },
} = useSidebar();
const handleClick = (evt: React.MouseEvent): void => {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
if (onCampaignShow) {
onCampaignShow();
}
};
const handleGameLogClick = (evt: React.MouseEvent): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
// Assuming dispatch is available in props or context
paneHistoryStart(PaneComponentEnum.GAME_LOG);
};
return (
<div className={clsx(styles.container)}>
<div
className={clsx(styles.campaignSummary)}
onClick={handleClick}
data-testid="campaign-summary"
>
<span className={clsx(styles.summaryLabel)}>Campaign:</span>
<span className={clsx(styles.summaryName)}>
{CampaignUtils.getName(campaign)}
</span>
</div>
<Tooltip title="Launch Game" isDarkMode={theme.isDarkMode}>
<Link
href={NavigationUtils.getLaunchGameUrl(campaign)}
target="maps"
aria-label="Launch Game"
>
<div className={clsx(styles.campaignButtonGroup)}>
<div className={clsx(styles.campaignButton)}>
<ThemedPlayButtonSvg
className={clsx([
styles.campaignButtonIcon,
styles.campaignPlayButtonIcon,
])}
theme={theme}
/>
</div>
</div>
</Link>
</Tooltip>
<GameLogNotificationWrapper
themeColor={theme.themeColor}
gameLogIsOpen={gameLog?.isOpen ?? false}
notificationOnClick={handleGameLogClick}
>
<Tooltip title="Game Log" isDarkMode={theme.isDarkMode}>
<div
role="button"
aria-roledescription="Game Log"
className={clsx(styles.campaignButtonGroup)}
onClick={handleGameLogClick}
>
<div className={clsx(styles.campaignButton)}>
<D6 className={clsx(styles.campaignButtonIcon)} />
</div>
</div>
</Tooltip>
</GameLogNotificationWrapper>
</div>
);
};
export default CampaignSummary;
@@ -0,0 +1,76 @@
import * as React from "react";
import { AvatarInfo } from "@dndbeyond/character-rules-engine/es";
import CharacterAvatarPortrait from "./CharacterAvatarPortrait";
interface Props {
avatarInfo: AvatarInfo;
className: string;
}
export default class CharacterAvatar extends React.PureComponent<Props, {}> {
static defaultProps = {
className: "",
};
render() {
const { avatarInfo, className } = this.props;
const classNames: Array<string> = ["ddbc-character-avatar", className];
if (avatarInfo.frameId) {
classNames.push(`ddbc-character-avatar--${avatarInfo.frameId}`);
}
let animatedStyles: React.CSSProperties = {};
if (avatarInfo.frameAvatarDecorationKey) {
const [name, style, animationUrl] =
avatarInfo.frameAvatarDecorationKey.split("|");
name && classNames.push(`ddbc-character-avatar--${name}`);
style && classNames.push(`ddbc-character-avatar--${style}`);
if (animationUrl) {
classNames.push(`ddbc-character-avatar--animated`);
// NOTE: using the backround key for all of these did not work well so they are separated
animatedStyles = {
backgroundImage: `url(https://www.dndbeyond.com/attachments/${animationUrl}.png)`,
backgroundPositionX: "0px",
backgroundPositionY: "0px",
backgroundRepeat: "no-repeat",
backgroundSize: `1240px 1240px`,
};
}
}
let frameStyles: React.CSSProperties = {};
if (avatarInfo.frameUrl) {
frameStyles = {
backgroundImage: `url(${avatarInfo.frameUrl})`,
};
}
return (
<div className={classNames.join(" ")}>
<CharacterAvatarPortrait avatarUrl={avatarInfo.avatarUrl} />
{avatarInfo.frameId && (
<div className={"ddbc-character-avatar__frame"} style={frameStyles} />
)}
{avatarInfo.frameId && (
<div className={"ddbc-character-avatar__frame-extra1"}>
<div
className="ddbc-character-avatar__frame-extra-content ddbc-character-avatar__frame-extra1-content"
style={animatedStyles}
/>
</div>
)}
{avatarInfo.frameId && (
<div className={"ddbc-character-avatar__frame-extra2"}>
<div className="ddbc-character-avatar__frame-extra-content ddbc-character-avatar__frame-extra2-content" />
</div>
)}
{avatarInfo.frameId && (
<div className={"ddbc-character-avatar__frame-extra3"}>
<div className="ddbc-character-avatar__frame-extra-content ddbc-character-avatar__frame-extra3-content" />
</div>
)}
</div>
);
}
}
@@ -0,0 +1,42 @@
import React, { HTMLAttributes } from "react";
interface Props extends HTMLAttributes<HTMLDivElement | HTMLImageElement> {
className?: string;
avatarUrl: string | null;
}
const CharacterAvatarPortrait: React.FC<Props> = ({
className = "",
avatarUrl,
...props
}) => {
let portraitClasses: Array<string> = [
"ddbc-character-avatar__portrait",
className,
];
let portraitStyles: React.CSSProperties = {};
if (avatarUrl) {
portraitStyles = {
backgroundImage: `url(${avatarUrl})`,
};
} else {
portraitClasses.push("ddbc-character-avatar__portrait--none");
}
return avatarUrl ? (
<img
className={portraitClasses.join(" ")}
src={avatarUrl}
alt="Character portrait"
{...props}
/>
) : (
<div
className={portraitClasses.join(" ")}
style={portraitStyles}
{...props}
/>
);
};
export default CharacterAvatarPortrait;
@@ -0,0 +1,4 @@
import CharacterAvatarPortrait from "./CharacterAvatarPortrait";
export default CharacterAvatarPortrait;
export { CharacterAvatarPortrait };
@@ -0,0 +1,5 @@
import CharacterAvatar from "./CharacterAvatar";
import CharacterAvatarPortrait from "./CharacterAvatarPortrait";
export default CharacterAvatar;
export { CharacterAvatar, CharacterAvatarPortrait };
@@ -0,0 +1,40 @@
import Typography from "@mui/material/Typography";
import { DefaultCharacterName as DefaultName } from "~/constants";
interface Props {
defaultCharacterName?: string;
isDead?: boolean;
isFaceMenu?: boolean;
name: string;
}
export default function CharacterName({
defaultCharacterName = DefaultName,
isDead = false,
isFaceMenu = false,
name,
}: Props) {
let displayName: string = name ? name : defaultCharacterName;
if (isDead) {
displayName = `(Dead) ${displayName}`;
}
return (
<Typography
variant="h4"
component="h1"
style={{ fontFamily: "Roboto Condensed" }}
sx={{
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: "hidden",
fontSize: isFaceMenu ? "24px" : { xs: "18px", sm: "24px" },
lineHeight: 1.4,
letterSpacing: "normal",
}}
>
{displayName}
</Typography>
);
}
@@ -0,0 +1,4 @@
import CharacterName from "./CharacterName";
export default CharacterName;
export { CharacterName };
@@ -0,0 +1,54 @@
import * as React from "react";
import {
Constants,
ExperienceInfo,
FormatUtils,
RuleData,
} from "@dndbeyond/character-rules-engine/es";
import XpBar from "../XpBar";
interface Props {
className: string;
ruleData: RuleData;
xpInfo: ExperienceInfo;
progressionType: Constants.PreferenceProgressionTypeEnum;
}
export default class CharacterProgressionSummary extends React.PureComponent<
Props,
{}
> {
static defaultProps = {
className: "",
};
render() {
const { className, ruleData, xpInfo, progressionType } = this.props;
const classNames: Array<string> = [
"ddbc-character-progression-summary",
className,
];
return (
<div className={classNames.join(" ")}>
{progressionType ===
Constants.PreferenceProgressionTypeEnum.MILESTONE && (
<div className="ddbc-character-progression-summary__level">
Level {xpInfo.currentLevel}
</div>
)}
{progressionType === Constants.PreferenceProgressionTypeEnum.XP && (
<div className="ddbc-character-progression-summary__xp-bar">
<XpBar xp={xpInfo.currentLevelXp} ruleData={ruleData} />
<div className="ddbc-character-progression-summary__xp-data">
{FormatUtils.renderLocaleNumber(xpInfo.currentLevelXp)} /{" "}
{FormatUtils.renderLocaleNumber(xpInfo.nextLevelXp)} XP
</div>
</div>
)}
</div>
);
}
}
@@ -0,0 +1,4 @@
import CharacterProgressionSummary from "./CharacterProgressionSummary";
export default CharacterProgressionSummary;
export { CharacterProgressionSummary };
@@ -0,0 +1,356 @@
import CloseIcon from "@mui/icons-material/Close";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import {
Box,
Checkbox,
FormControl,
FormControlLabel,
FormLabel,
Grid,
IconButton,
Modal,
Paper,
Stack,
ToggleButton,
ToggleButtonGroup,
Typography,
} from "@mui/material";
import clsx from "clsx";
import { useContext } from "react";
import { RuleDataUtils } from "@dndbeyond/character-rules-engine/es";
import { SourceCategoryDescription } from "~/constants";
import { useFeatureFlags } from "~/contexts/FeatureFlag";
import { generateCharacterPreferences } from "~/helpers/generateCharacterPreferences";
import PreferenceUpdateLocation from "~/tools/js/Shared/constants/PreferenceUpdateLocation";
import {
CheckboxInfo,
FormCheckBoxesField,
} from "../../Shared/components/common/FormCheckBoxesField";
import AbilitySummary from "../AbilitySummary";
import PrivacyTypeRadio from "../PrivacyTypeRadio";
import { UserPreferenceContext } from "../UserPreference";
import styles from "./styles.module.css";
interface CharacterSettingsModalProps {
open: boolean;
updateLocation: PreferenceUpdateLocation;
handleClose: () => void;
}
const CharacterSettingsModal = ({
open,
updateLocation,
handleClose,
}: CharacterSettingsModalProps) => {
const {
privacyType,
isDarkModeEnabled,
abilityScoreDisplayType,
updatePreferences,
ruleData,
defaultEnabledSourceCategories,
isHomebrewEnabled,
} = useContext(UserPreferenceContext);
const { featureFlags } = useFeatureFlags();
const preferences = generateCharacterPreferences(featureFlags);
const getTheme = (darkMode: boolean) => ({
name: "DDB Red",
backgroundColor: darkMode ? "#10161ADB" : "#FEFEFE",
isDefault: true,
themeColor: "#C53131",
themeColorId: null,
isDarkMode: darkMode || false,
});
const rollContext = {
entityId: "52962664",
entityType: "character",
name: "Stor Hornraven",
avatarUrl:
"https://stg.dndbeyond.com/avatars/18589/680/1581111423-52962664.jpeg?width=150&height=150&fit=crop&quality=95&auto=webp",
};
const handleSourceCategoryChange = (
sourceId: number,
isActive: boolean
): void => {
const newSourceCategories = {
...defaultEnabledSourceCategories,
[sourceId]: isActive,
};
updatePreferences({
privacyType,
isDarkModeEnabled,
abilityScoreDisplayType,
updateLocation,
defaultEnabledSourceCategories: newSourceCategories,
isHomebrewEnabled,
});
};
let sourceToggles: Array<CheckboxInfo> = [];
let partneredSourceCheckboxes: Array<CheckboxInfo> = [];
let allPartneredSources: Array<number> = [];
if (ruleData) {
const activeSourceCategories = Object.keys(defaultEnabledSourceCategories)
.filter((key) => defaultEnabledSourceCategories[key])
.map(Number);
RuleDataUtils.getSourceCategories(ruleData).forEach((sourceCategory) => {
if (!sourceCategory.isToggleable) {
return null;
}
const checkbox: CheckboxInfo = {
label: `${sourceCategory.name}`,
initiallyEnabled: activeSourceCategories.includes(sourceCategory.id),
onChange: handleSourceCategoryChange.bind(this, sourceCategory.id),
sortOrder: sourceCategory.sortOrder,
description: sourceCategory.description ?? "",
};
if (sourceCategory.isPartneredContent) {
delete checkbox.description;
partneredSourceCheckboxes.push(checkbox);
allPartneredSources.push(sourceCategory.id);
} else {
sourceToggles.push(checkbox);
}
});
//Add Homebrew to sources for display
sourceToggles.push({
label: "Homebrew",
initiallyEnabled: isHomebrewEnabled,
description: SourceCategoryDescription.homebrew,
onChange: (isActive: boolean) => {
updatePreferences({
privacyType,
isDarkModeEnabled,
abilityScoreDisplayType,
updateLocation,
defaultEnabledSourceCategories,
isHomebrewEnabled: isActive,
});
},
sortOrder: 0,
});
}
return (
<Modal
open={open}
onClose={handleClose}
className={clsx(styles.modal)}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Paper className={clsx(styles.content)}>
<Stack
direction="row"
alignItems="center"
style={{ padding: 16, borderBottom: "1px solid #A2ACB2" }}
>
<SettingsOutlinedIcon
style={{
color: "rgba(0,0,0,0.54)",
marginRight: 5,
height: 22,
width: 22,
}}
/>
<label className={clsx(styles.modalTitle)}>
Default Character Settings
</label>
<IconButton
aria-label="close"
onClick={handleClose}
size="small"
style={{
marginLeft: "auto",
color: "rgba(0,0,0,0.54)",
}}
>
<CloseIcon style={{ width: 20 }} />
</IconButton>
</Stack>
<Box style={{ padding: 16 }}>
<label className={clsx(styles.modalDescription)}>
These settings apply defaults to all new Characters you create. Any
settings you apply to a specific Character will override the choices
you make here.
</label>
<>
<FormCheckBoxesField
heading="Sources"
description={SourceCategoryDescription.official}
checkboxes={sourceToggles}
showAccordion={false}
/>
<FormCheckBoxesField
heading="Partnered Content"
description={SourceCategoryDescription.partnered}
checkboxes={partneredSourceCheckboxes}
checkUncheckAllEnabled={true}
showAccordion={false}
accordionHeading="Choose Partners"
allText="All"
/>
</>
<PrivacyTypeRadio
color="info"
initialValue={privacyType}
handleChange={(e) =>
updatePreferences({
isDarkModeEnabled,
abilityScoreDisplayType,
updateLocation,
privacyType: parseInt(e.target.value),
defaultEnabledSourceCategories,
isHomebrewEnabled,
})
}
/>
<p
style={{
color: "#000",
fontSize: 16,
fontWeight: 700,
letterSpacing: -0.5,
textTransform: "uppercase",
margin: "19px 0 0",
padding: "10px 0",
}}
>
Display
</p>
<Grid container>
<Grid item xs={12} md={8}>
<FormControl>
<FormControlLabel
control={
<Checkbox
color="info"
checked={isDarkModeEnabled}
onChange={(e) =>
updatePreferences({
privacyType,
abilityScoreDisplayType,
updateLocation,
isDarkModeEnabled: e.target.checked,
defaultEnabledSourceCategories,
isHomebrewEnabled,
})
}
style={{ paddingTop: 0 }}
/>
}
label={
<>
<p style={{ margin: 0, fontSize: 14 }}>Underdark Mode</p>
<Typography
sx={{
color: "grey.500",
margin: 0,
fontSize: 12,
lineHeight: 1,
}}
>
Activate dark mode for Character Sheets.
</Typography>
</>
}
sx={{ display: "flex", alignItems: "flex-start" }}
/>
</FormControl>
<FormControl style={{ marginTop: 24 }}>
<FormLabel>
<p style={{ margin: 0, fontSize: 14, color: "#000" }}>
Ability Scores / Modifiers
</p>
<Typography
sx={{
color: "grey.500",
margin: "0 0 16px",
fontSize: 12,
lineHeight: 1,
}}
>
Reverse the display of Ability Scores and Modifiers.{" "}
</Typography>
</FormLabel>
<FormControl>
<ToggleButtonGroup
color="info"
value={abilityScoreDisplayType}
exclusive
onChange={(e, value) => {
if (value !== null)
updatePreferences({
privacyType,
isDarkModeEnabled,
updateLocation,
abilityScoreDisplayType: value,
defaultEnabledSourceCategories,
isHomebrewEnabled,
});
}}
aria-label="Platform"
>
<ToggleButton value={2}>Modifiers Top</ToggleButton>
<ToggleButton value={1}>Scores Top</ToggleButton>
</ToggleButtonGroup>
</FormControl>
</FormControl>
</Grid>
<Grid
item
xs={12}
md={4}
display="flex"
alignItems="flex-start"
justifyContent="flex-end"
>
<Box
sx={{
backgroundColor: "grey.50",
border: "1px solid transparent",
borderColor: "grey.200",
borderRadius: 1,
display: "inline-block",
py: 1,
px: 2,
}}
>
<p style={{ textAlign: "center", marginBottom: 4 }}>Preview</p>
<AbilitySummary
ability={{ name: "Strength", score: 14, modifier: 2 }}
preferences={{
...preferences,
abilityScoreDisplayType: abilityScoreDisplayType,
}}
theme={getTheme(isDarkModeEnabled || false)}
onClick={(e) => e.getBaseScore()}
rollContext={rollContext}
/>
</Box>
</Grid>
</Grid>
</Box>
</Paper>
</Modal>
);
};
export default CharacterSettingsModal;
@@ -0,0 +1,4 @@
import CharacterSettingsModal from "./CharacterSettingsModal";
export default CharacterSettingsModal;
export { CharacterSettingsModal };
@@ -0,0 +1,61 @@
import * as React from "react";
import {
CharClass,
ClassUtils,
Race,
RaceUtils,
} from "@dndbeyond/character-rules-engine/es";
interface Props {
classes: Array<CharClass> | null;
gender: string | null;
species: Race | null;
className: string;
}
export default class CharacterSummary extends React.PureComponent<Props, {}> {
static defaultProps = {
className: "",
};
renderClassList(): React.ReactNode {
const { classes } = this.props;
if (classes === null) {
return null;
}
const list: Array<string> = classes.map(
(charClass) =>
`${ClassUtils.getName(charClass)} ${ClassUtils.getLevel(charClass)}`
);
return (
<span className="ddbc-character-summary__classes">
{list.join(" / ")}
</span>
);
}
render() {
const { species, gender, className } = this.props;
const classNames: Array<string> = ["ddbc-character-summary", className];
let speciesNode: React.ReactNode;
if (species !== null) {
speciesNode = RaceUtils.getFullName(species);
}
return (
<div className={classNames.join(" ")}>
{gender && (
<span className="ddbc-character-summary__gender">{gender}</span>
)}
{speciesNode && (
<span className="ddbc-character-summary__race">{speciesNode}</span>
)}
{this.renderClassList()}
</div>
);
}
}
@@ -0,0 +1,4 @@
import CharacterSummary from "./CharacterSummary";
export default CharacterSummary;
export { CharacterSummary };
@@ -0,0 +1,107 @@
import * as React from "react";
import {
CharacterPreferences,
CharClass,
Constants,
DecorationInfo,
DecorationUtils,
ExperienceInfo,
Race,
RuleData,
} from "@dndbeyond/character-rules-engine/es";
import { DefaultCharacterName as DefaultName } from "~/constants";
import CharacterAvatar from "../CharacterAvatar";
import CharacterName from "../CharacterName";
import CharacterProgressionSummary from "../CharacterProgressionSummary";
import CharacterSummary from "../CharacterSummary";
interface Props {
classes: Array<CharClass>;
name: string;
gender: string | null;
species: Race | null;
decorationInfo: DecorationInfo;
deathCause: Constants.DeathCauseEnum;
preferences: CharacterPreferences;
ruleData: RuleData;
xpInfo: ExperienceInfo;
isInteractive: boolean;
defaultCharacterName: string;
className: string;
calloutNode?: React.ReactNode;
}
export default class CharacterTidbits extends React.PureComponent<Props, {}> {
static defaultProps = {
deathCause: Constants.DeathCauseEnum.NONE,
isInteractive: true,
className: "",
defaultCharacterName: DefaultName,
};
render() {
const {
name,
classes,
gender,
species,
decorationInfo,
deathCause,
preferences,
ruleData,
xpInfo,
defaultCharacterName,
isInteractive,
className,
calloutNode,
} = this.props;
let classNames: Array<string> = [className, "ddbc-character-tidbits"];
if (isInteractive) {
classNames.push("ddbc-character-tidbits--is-interactive");
}
return (
<div className={classNames.join(" ")}>
<div className="ddbc-character-tidbits__avatar">
<CharacterAvatar
avatarInfo={DecorationUtils.getAvatarInfo(decorationInfo)}
/>
</div>
<div className="ddbc-character-tidbits__body">
<div className="ddbc-character-tidbits__heading">
<CharacterName
name={name}
isDead={deathCause !== Constants.DeathCauseEnum.NONE}
defaultCharacterName={defaultCharacterName}
/>
{isInteractive && calloutNode && (
<div className="ddbc-character-tidbits__menu-callout">
{calloutNode}
</div>
)}
</div>
<div className="ddbc-character-tidbits__meta">
<CharacterSummary
classes={classes}
species={species}
gender={gender}
/>
</div>
{xpInfo && ruleData && (
<div className="ddbc-character-tidbits__progression">
<CharacterProgressionSummary
ruleData={ruleData}
progressionType={preferences.progressionType}
xpInfo={xpInfo}
/>
</div>
)}
</div>
</div>
);
}
}
@@ -0,0 +1,160 @@
import * as React from "react";
import { CharacterTheme } from "@dndbeyond/character-rules-engine";
import { CharacterColorEnum } from "../../../../constants";
import { PositiveCheckSvg, ThemedCheckSvg, CheckSvg } from "../Svg";
interface Props {
className: string;
initiallyEnabled: boolean;
enabled?: boolean;
onChange?: (isEnabled: boolean) => void;
onChangePromise?: (
newIsEnabled: boolean,
accept: () => void,
reject: () => void
) => void;
label?: string;
preventDefault: boolean;
stopPropagation: boolean;
isInteractive?: boolean;
theme?: CharacterTheme;
variant?: "primary" | "theme";
}
interface State {
enabled: boolean;
}
export default class Checkbox extends React.PureComponent<Props, State> {
static defaultProps = {
isInteractive: true,
initiallyEnabled: false,
preventDefault: false,
stopPropagation: false,
className: "",
};
constructor(props: Props) {
super(props);
this.state = {
enabled:
typeof props.enabled !== "undefined"
? props.enabled
: props.initiallyEnabled,
};
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>,
snapshot?: any
): void {
const { enabled, initiallyEnabled } = this.props;
if (
typeof enabled !== "undefined" ||
initiallyEnabled !== prevState.enabled
) {
this.setState({
enabled: typeof enabled === "undefined" ? initiallyEnabled : enabled,
});
}
}
handleClick = (evt: React.MouseEvent): void => {
const { onChangePromise, preventDefault, stopPropagation } = this.props;
const { enabled } = this.state;
const newValue: boolean = !enabled;
if (stopPropagation) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
}
if (preventDefault) {
evt.preventDefault();
}
if (onChangePromise) {
onChangePromise(
newValue,
() => {
this.setEnabled();
},
() => {}
);
} else {
this.setEnabled();
}
};
setEnabled = (): void => {
const { onChange } = this.props;
this.setState((prevState) => {
let newValue = !prevState.enabled;
if (onChange) {
onChange(newValue);
}
return {
enabled: newValue,
};
});
};
render() {
const { enabled } = this.state;
const { label, className, isInteractive, theme, variant } = this.props;
const toggleConClasses: Array<string> = [
"ddbc-checkbox",
"character-checkbox",
className,
];
if (enabled) {
toggleConClasses.push("ddbc-checkbox--is-enabled");
toggleConClasses.push("character-checkbox-enabled");
} else {
toggleConClasses.push("ddbc-checkbox--is-disabled");
toggleConClasses.push("character-checkbox-disabled");
}
if (isInteractive) {
toggleConClasses.push("ddbc-checkbox--is-interactive");
toggleConClasses.push("character-checkbox-interactive");
}
return (
<div
className={toggleConClasses.join(" ")}
onClick={isInteractive ? this.handleClick : undefined}
role="checkbox"
aria-checked={enabled}
>
<div className="ddbc-checkbox__input character-checkbox-input">
{enabled &&
(variant === "primary" ? (
<CheckSvg
fillColor={CharacterColorEnum.RED}
secondaryFillColor=""
/>
) : theme ? (
<ThemedCheckSvg theme={theme} />
) : (
<PositiveCheckSvg />
))}
</div>
{label && (
<div className="ddbc-checkbox__label character-checkbox-label">
{label}
</div>
)}
</div>
);
}
}
@@ -0,0 +1,4 @@
import Checkbox from "./Checkbox";
export default Checkbox;
export { Checkbox };
@@ -0,0 +1,165 @@
import * as React from "react";
import {
DarkChevronUpSvg,
DisabledChevronDownSvg,
LightChevronUpSvg,
} from "../Svg";
import CollapsibleHeaderContent from "./CollapsibleHeaderContent";
interface Props {
header: React.ReactNode;
headerFooter?: React.ReactNode;
className: string;
initiallyCollapsed: boolean;
collapsed?: boolean;
overrideCollapsed?: boolean;
onChangeHandler?: (isCollapsed: boolean) => void;
layoutType: "standard" | "minimal";
useBuilderStyles?: boolean;
}
interface State {
isCollapsed: boolean;
isDarkMode: boolean;
}
export class Collapsible extends React.PureComponent<Props, State> {
static defaultProps = {
initiallyCollapsed: true,
className: "",
layoutType: "standard",
useBuilderStyles: false,
};
constructor(props: Props) {
super(props);
let initialCollapsed = props.initiallyCollapsed;
if (typeof props.collapsed !== "undefined") {
initialCollapsed = props.collapsed;
}
this.state = {
isCollapsed: initialCollapsed,
isDarkMode: false,
};
}
componentDidMount() {
this.setState({
isDarkMode: !!global.document.getElementsByClassName(
"ct-character-sheet--dark-mode"
).length,
});
}
componentDidUpdate(
prevProps: Readonly<Props>,
prevState: Readonly<State>,
snapshot?: any
): void {
const { collapsed, overrideCollapsed } = this.props;
// Allow for the collapsed prop to set the state if the prop previously set the state
if (
typeof prevProps.collapsed !== "undefined" &&
typeof collapsed !== "undefined" &&
collapsed !== prevProps.collapsed
) {
this.setState({
isCollapsed: collapsed,
});
}
// Allow the overrideCollapsed prop to set the state if the component previously set its own state
if (
typeof overrideCollapsed !== "undefined" &&
this.state.isCollapsed === prevState.isCollapsed
) {
this.setState({
isCollapsed: overrideCollapsed,
});
}
}
handleClick = (evt: React.MouseEvent): void => {
evt.stopPropagation();
const { onChangeHandler } = this.props;
this.setState((prevState, preProps) => {
let newIsCollapsed = !prevState.isCollapsed;
if (onChangeHandler) {
onChangeHandler(newIsCollapsed);
return {
isCollapsed: prevState.isCollapsed,
};
} else {
return {
isCollapsed: newIsCollapsed,
};
}
});
};
render() {
const { isCollapsed, isDarkMode } = this.state;
const {
header,
className,
children,
headerFooter,
layoutType,
useBuilderStyles,
} = this.props;
let headerContentNode: React.ReactNode;
if (typeof header === "string") {
headerContentNode = <CollapsibleHeaderContent heading={header} />;
} else {
headerContentNode = header;
}
let classNames: Array<string> = ["ddbc-collapsible", className];
classNames.push(
isCollapsed ? "ddbc-collapsible--collapsed" : "ddbc-collapsible--opened"
);
switch (layoutType) {
case "minimal":
classNames.push("ddbc-collapsible--minimal");
break;
default:
// not implements
}
let chevronNode: React.ReactNode = <DisabledChevronDownSvg />;
if (!isCollapsed) {
chevronNode =
isDarkMode && !useBuilderStyles ? (
<LightChevronUpSvg />
) : (
<DarkChevronUpSvg />
);
}
return (
<div className={classNames.join(" ")}>
<div className="ddbc-collapsible__header" onClick={this.handleClick}>
{headerContentNode}
<div className="ddbc-collapsible__header-status">{chevronNode}</div>
</div>
{headerFooter && (
<div className="ddbc-collapsible__header-footer">{headerFooter}</div>
)}
{!isCollapsed && (
<div className="ddbc-collapsible__content">{children}</div>
)}
</div>
);
}
}
export default Collapsible;
@@ -0,0 +1,26 @@
import * as React from "react";
interface Props {
extra: React.ReactNode;
value: React.ReactNode;
}
export default class CollapsibleHeaderCallout extends React.PureComponent<
Props,
{}
> {
static defaultProps = {
extra: null,
};
render() {
const { extra, value } = this.props;
return (
<span className="ddbc-collapsible__header-callout">
<span className="ddbc-collapsible__header-callout-extra">{extra}</span>
<span className="ddbc-collapsible__header-callout-value">{value}</span>
</span>
);
}
}
@@ -0,0 +1,3 @@
import CollapsibleHeaderCallout from "./CollapsibleHeaderCallout";
export default CollapsibleHeaderCallout;
@@ -0,0 +1,75 @@
import * as React from "react";
import CollapsibleHeaderCallout from "../CollapsibleHeaderCallout";
import CollapsibleHeading from "../CollapsibleHeading";
interface Props {
heading: React.ReactNode;
metaItems?: Array<React.ReactNode>;
callout?: React.ReactNode;
className: string;
}
export default class CollapsibleHeaderContent extends React.PureComponent<
Props,
{}
> {
static defaultProps = {
className: "",
};
renderMetaItems(): React.ReactNode {
const { metaItems } = this.props;
if (!metaItems) {
return null;
}
return (
<div className="ddbc-collapsible__header-meta">
{metaItems.map((item, idx) => (
<span className="ddbc-collapsible__header-meta-item" key={idx}>
{item}
</span>
))}
</div>
);
}
render() {
const { className, heading, callout } = this.props;
let headingNode: React.ReactNode;
if (typeof heading === "string") {
headingNode = <CollapsibleHeading>{heading}</CollapsibleHeading>;
} else {
headingNode = heading;
}
let calloutNode: React.ReactNode;
if (typeof callout === "string") {
calloutNode = <CollapsibleHeaderCallout value={callout} />;
} else {
calloutNode = callout;
}
let classNames: Array<string> = [
"ddbc-collapsible__header-content",
className,
];
return (
<div className={classNames.join(" ")}>
<div className="ddbc-collapsible__header-content-primary">
{headingNode}
{this.renderMetaItems()}
</div>
{calloutNode && (
<div className="ddbc-collapsible__header-content-callout">
{calloutNode}
</div>
)}
</div>
);
}
}
@@ -0,0 +1,3 @@
import CollapsibleHeaderContent from "./CollapsibleHeaderContent";
export default CollapsibleHeaderContent;
@@ -0,0 +1,9 @@
import * as React from "react";
export default class CollapsibleHeading extends React.PureComponent<{}, {}> {
render() {
return (
<div className="ddbc-collapsible__heading">{this.props.children}</div>
);
}
}
@@ -0,0 +1,3 @@
import CollapsibleHeading from "./CollapsibleHeading";
export default CollapsibleHeading;
@@ -0,0 +1,12 @@
import Collapsible from "./Collapsible";
import CollapsibleHeaderCallout from "./CollapsibleHeaderCallout";
import CollapsibleHeaderContent from "./CollapsibleHeaderContent";
import CollapsibleHeading from "./CollapsibleHeading";
export default Collapsible;
export {
Collapsible,
CollapsibleHeading,
CollapsibleHeaderContent,
CollapsibleHeaderCallout,
};
@@ -0,0 +1,473 @@
import * as React from "react";
import {
AbilityLookup,
Action,
ActionUtils,
Attack,
CharacterTheme,
Constants,
DiceUtils,
EntityUtils,
FormatUtils,
RuleData,
RuleDataUtils,
} from "@dndbeyond/character-rules-engine/es";
import {
Dice,
RollType,
DiceEvent,
RollKind,
IRollContext,
} from "@dndbeyond/dice";
import { GameLogContext } from "@dndbeyond/game-log-components";
import { NumberDisplay } from "~/components/NumberDisplay";
import ActionName from "../../ActionName";
import { AttackTypeIcon } from "../../Icons";
import NoteComponents from "../../NoteComponents";
import { DiceComponentUtils } from "../../utils";
import CombatAttack from "../CombatAttack";
import { CombatActionAttackComponentRangeInfo } from "./CombatActionAttackTypings";
import { DigitalDiceWrapper } from "../../Dice";
import Damage from "../../Damage";
interface Props {
attack: Attack;
action: Action;
onClick?: (attack: Attack) => void;
ruleData: RuleData;
abilityLookup: AbilityLookup;
showNotes: boolean;
className: string;
diceEnabled: boolean;
theme: CharacterTheme;
rollContext: IRollContext;
proficiencyBonus: number;
}
interface State {
isCriticalHit: boolean;
}
class CombatActionAttack extends React.PureComponent<Props, State> {
diceEventHandler: (eventData: any) => void;
static defaultProps = {
showNotes: true,
className: "",
diceEnabled: false,
};
constructor(props: Props) {
super(props);
this.state = {
isCriticalHit: false,
};
}
componentDidMount = () => {
this.diceEventHandler = DiceComponentUtils.setupResetCritStateOnRoll(
ActionUtils.getName(this.props.attack.data as Action),
this
);
};
componentWillUnmount = () => {
Dice.removeEventListener(DiceEvent.ROLL, this.diceEventHandler);
};
handleClick = (): void => {
const { onClick, attack } = this.props;
if (onClick) {
onClick(attack);
}
};
handleRoll = (wasCrit: boolean) => {
this.setState({ isCriticalHit: wasCrit });
};
getRangeInfo = (): CombatActionAttackComponentRangeInfo => {
const { action, ruleData, theme } = this.props;
const attackRange = ActionUtils.getRange(action);
const attackReach = ActionUtils.getReach(action);
const attackTypeId = ActionUtils.getAttackRangeId(action);
let rangeValueNode: React.ReactNode;
let rangeLabel: string = "Range";
switch (ActionUtils.getActionTypeId(action)) {
case Constants.ActionTypeEnum.WEAPON:
if (
attackRange !== null &&
attackTypeId === Constants.AttackTypeRangeEnum.RANGED
) {
let { longRange, range } = attackRange;
if (longRange) {
rangeValueNode = (
<React.Fragment>
<span
className={`ddbc-combat-attack__range-value-close ${
theme.isDarkMode
? "ddbc-combat-attack__range-value-close--dark-mode"
: ""
}`}
>
{range}
</span>
<span
className={`ddbc-combat-attack__range-value-long ${
theme.isDarkMode
? "ddbc-combat-attack__range-value-long--dark-mode"
: ""
}`}
>
({longRange})
</span>
</React.Fragment>
);
rangeLabel = "";
} else {
rangeValueNode = (
<NumberDisplay type="distanceInFt" number={range} />
);
rangeLabel = "Range";
}
} else if (attackReach !== null) {
rangeValueNode = (
<NumberDisplay type="distanceInFt" number={attackReach} />
);
rangeLabel = "Reach";
}
break;
case Constants.ActionTypeEnum.SPELL:
if (attackRange !== null) {
if (
attackRange.origin &&
attackRange.origin !== Constants.SpellRangeTypeEnum.RANGED
) {
let spellRangeType = RuleDataUtils.getSpellRangeType(
attackRange.origin,
ruleData
);
if (spellRangeType) {
rangeValueNode = spellRangeType.name;
}
}
if (attackRange.range) {
rangeValueNode = (
<NumberDisplay type="distanceInFt" number={attackRange.range} />
);
}
}
rangeLabel = "";
if (attackTypeId === Constants.AttackTypeRangeEnum.MELEE) {
rangeLabel = "Reach";
}
break;
case Constants.ActionTypeEnum.GENERAL:
if (attackRange !== null && attackRange.range) {
rangeValueNode = (
<NumberDisplay type="distanceInFt" number={attackRange.range} />
);
}
if (
attackReach !== null &&
attackTypeId === Constants.AttackTypeRangeEnum.MELEE
) {
rangeValueNode = (
<NumberDisplay type="distanceInFt" number={attackReach} />
);
rangeLabel = "Reach";
}
break;
default:
// not implemented
}
return {
rangeValueNode,
rangeLabel,
};
};
getClassName = (): string => {
const { action } = this.props;
let type: string = "";
switch (ActionUtils.getActionTypeId(action)) {
case Constants.ActionTypeEnum.WEAPON:
type = "weapon";
break;
case Constants.ActionTypeEnum.SPELL:
type = "spell";
break;
case Constants.ActionTypeEnum.GENERAL:
type = "general";
break;
}
return FormatUtils.slugify(`ddbc-combat-action-attack--${type}`);
};
getIconKey = (): string => {
const { action } = this.props;
const attackTypeId = ActionUtils.getAttackRangeId(action);
let attackType: string = "";
if (attackTypeId) {
attackType = RuleDataUtils.getAttackTypeRangeName(attackTypeId);
}
let type: string = "";
let iconType: string = RuleDataUtils.getAttackTypeRangeName(
Constants.AttackTypeRangeEnum.MELEE
);
switch (ActionUtils.getActionTypeId(action)) {
case Constants.ActionTypeEnum.WEAPON:
type = "weapon";
iconType = attackType;
break;
case Constants.ActionTypeEnum.SPELL:
type = "spell";
if (attackType) {
iconType = attackType;
}
break;
case Constants.ActionTypeEnum.GENERAL:
type = "general";
if (attackType) {
iconType = attackType;
}
break;
default:
//not implemented
}
return FormatUtils.slugify(`action-attack-${type}-${iconType}`);
};
getIconNode = (): React.ReactNode => {
const { action, theme } = this.props;
let rangeType: Constants.AttackTypeRangeEnum =
ActionUtils.getAttackRangeId(action) ??
Constants.AttackTypeRangeEnum.MELEE;
//this sucks, but have to switch these around because of how unarmed strike is currently a weapon type
//and most others that come thru here are custom actions
let actionTypeId = ActionUtils.getActionTypeId(action);
let actionType: Constants.ActionTypeEnum | null = actionTypeId;
switch (actionTypeId) {
case Constants.ActionTypeEnum.WEAPON:
if (rangeType === Constants.AttackTypeRangeEnum.MELEE) {
actionType = Constants.ActionTypeEnum.GENERAL;
}
break;
case Constants.ActionTypeEnum.GENERAL:
if (rangeType === Constants.AttackTypeRangeEnum.MELEE) {
actionType = Constants.ActionTypeEnum.WEAPON;
}
break;
case Constants.ActionTypeEnum.SPELL:
break;
default:
//not implemented
}
return (
<AttackTypeIcon
actionType={actionType}
rangeType={rangeType}
themeMode={theme.isDarkMode ? "gray" : "dark"}
className={`ddbc-combat-attack__icon-img--${this.getIconKey()}`}
/>
);
};
getMetaItems = (): Array<string> => {
const { action } = this.props;
const damage = ActionUtils.getDamage(action);
const attackTypeId = ActionUtils.getAttackRangeId(action);
let attackType: string | null = null;
if (attackTypeId) {
attackType = RuleDataUtils.getAttackTypeRangeName(attackTypeId);
}
const isOffhand = ActionUtils.isOffhand(action);
let combinedMetaItems: Array<string> = [];
if (attackType) {
combinedMetaItems.push(`${attackType} Attack`);
}
if (damage.isMartialArts) {
combinedMetaItems.push("Martial Arts");
}
if (damage.value && damage.dataOrigin) {
combinedMetaItems.push(EntityUtils.getDataOriginName(damage.dataOrigin));
}
switch (ActionUtils.getActionTypeId(action)) {
case Constants.ActionTypeEnum.WEAPON:
if (isOffhand) {
combinedMetaItems.push("Dual Wield");
}
break;
default:
// not implemented
}
if (ActionUtils.isCustomized(action)) {
combinedMetaItems.push("Customized");
}
return combinedMetaItems;
};
renderNotes = (): React.ReactNode => {
const {
action,
showNotes,
ruleData,
abilityLookup,
proficiencyBonus,
theme,
} = this.props;
if (!showNotes) {
return null;
}
return (
<div className="ddbc-combat-action-attack__notes">
<NoteComponents
notes={ActionUtils.getNoteComponents(
action,
ruleData,
abilityLookup,
proficiencyBonus
)}
theme={theme}
/>
</div>
);
};
render() {
const {
action,
attack,
ruleData,
showNotes,
className,
diceEnabled,
theme,
rollContext,
} = this.props;
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
this.context;
const { isCriticalHit } = this.state;
const proficiency = ActionUtils.isProficient(action);
const damage = ActionUtils.getDamage(action);
let rangeInfo = this.getRangeInfo();
let toHit: number | null = null;
let attackSaveValue: number | null = null;
let attackSaveLabel: React.ReactNode = "";
if (ActionUtils.requiresAttackRoll(action)) {
toHit = ActionUtils.getToHit(action);
} else if (ActionUtils.requiresSavingThrow(action)) {
let saveStatId = ActionUtils.getSaveStatId(action);
if (saveStatId) {
attackSaveLabel = RuleDataUtils.getStatNameById(saveStatId, ruleData);
}
attackSaveValue = ActionUtils.getAttackSaveValue(action);
}
let damageNode: React.ReactNode;
if (damage.value !== null) {
damageNode = (
<DigitalDiceWrapper
diceNotation={
typeof damage.value === "number"
? damage.value.toString()
: DiceUtils.renderDice(damage.value)
}
rollType={RollType.Damage}
rollAction={ActionUtils.getName(attack.data as Action)}
rollKind={isCriticalHit ? RollKind.CriticalHit : RollKind.None}
diceEnabled={diceEnabled}
themeColor={theme.themeColor}
rollContext={rollContext}
rollTargetOptions={
messageTargetOptions
? Object.values(messageTargetOptions?.entities)
: undefined
}
rollTargetDefault={defaultMessageTargetOption}
userId={userId}
>
<Damage
type={damage.type ? damage.type.name : null}
damage={DiceComponentUtils.getDamageDiceNotation(
damage.value,
isCriticalHit
)}
theme={theme}
/>
</DigitalDiceWrapper>
);
}
let classNames: Array<string> = [className, this.getClassName()];
if (isCriticalHit) {
classNames.push("ddbc-combat-attack--crit");
}
return (
<CombatAttack
attack={attack}
className={classNames.join(" ")}
icon={this.getIconNode()}
name={<ActionName theme={theme} action={action} />}
metaItems={this.getMetaItems()}
rangeValue={rangeInfo.rangeValueNode}
rangeLabel={rangeInfo.rangeLabel}
isProficient={proficiency}
toHit={toHit}
attackSaveLabel={attackSaveLabel}
attackSaveValue={attackSaveValue}
damage={damageNode}
onClick={this.handleClick}
notes={this.renderNotes()}
showNotes={showNotes}
diceEnabled={diceEnabled}
onRoll={this.handleRoll}
theme={theme}
rollContext={rollContext}
/>
);
}
}
CombatActionAttack.contextType = GameLogContext;
export default CombatActionAttack;
@@ -0,0 +1,4 @@
import CombatActionAttack from "./CombatActionAttack";
export default CombatActionAttack;
export { CombatActionAttack };
@@ -0,0 +1,250 @@
import * as React from "react";
import {
Action,
ActionUtils,
Attack,
CharacterTheme,
Item,
ItemUtils,
Spell,
SpellUtils,
} from "@dndbeyond/character-rules-engine/es";
import { AttackSourceTypeEnum } from "@dndbeyond/character-rules-engine/es/engine/Character";
import {
DiceTools,
RollType,
RollRequest,
IRollContext,
} from "@dndbeyond/dice";
import { GameLogContext } from "@dndbeyond/game-log-components";
import { DiceComponentUtils } from "../utils";
import { NumberDisplay } from "~/components/NumberDisplay";
import { DigitalDiceWrapper } from "../Dice";
interface Props {
attack: Attack;
className: string;
icon: React.ReactNode;
name: React.ReactNode;
metaItems: Array<string>;
rangeValue: React.ReactNode;
rangeLabel: React.ReactNode;
isProficient: boolean;
toHit: number | null;
attackSaveValue?: number | null;
attackSaveLabel?: React.ReactNode;
damage: React.ReactNode;
notes?: React.ReactNode;
theme: CharacterTheme;
rollContext: IRollContext;
onClick?: (attack: Attack) => void;
showNotes: boolean;
diceEnabled: boolean;
onRoll?: (wasCrit: boolean) => void;
}
class CombatAttack extends React.PureComponent<Props, {}> {
static defaultProps = {
rangeLabel: "Reach",
damage: "--",
className: "",
showNotes: true,
isProficient: false,
diceEnabled: false,
};
handleClick = (evt: React.MouseEvent): void => {
const { onClick, attack } = this.props;
if (onClick) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
onClick(attack);
}
};
handleRollResults = (result: RollRequest) => {
const { onRoll } = this.props;
let wasCrit = DiceComponentUtils.isCriticalRoll(result);
if (onRoll) {
onRoll(wasCrit);
}
};
render() {
const {
icon,
name,
metaItems,
rangeLabel,
rangeValue,
toHit,
attackSaveValue,
attackSaveLabel,
damage,
notes,
className,
showNotes,
diceEnabled,
attack,
theme,
rollContext,
} = this.props;
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
this.context;
let actionNode: React.ReactNode = null;
if (toHit !== null) {
let rollAction: string;
switch (attack.type) {
case AttackSourceTypeEnum.ACTION:
case AttackSourceTypeEnum.CUSTOM:
rollAction = ActionUtils.getName(attack.data as Action);
break;
case AttackSourceTypeEnum.ITEM:
rollAction = ItemUtils.getName(attack.data as Item);
break;
case AttackSourceTypeEnum.SPELL:
rollAction = SpellUtils.getName(attack.data as Spell);
break;
}
actionNode = (
<div className="ddbc-combat-attack__tohit">
<DigitalDiceWrapper
diceNotation={DiceTools.CustomD20(toHit)}
rollType={RollType.ToHit}
rollAction={rollAction}
diceEnabled={diceEnabled}
onRollResults={this.handleRollResults}
themeColor={theme.themeColor}
rollContext={rollContext}
rollTargetOptions={
messageTargetOptions
? Object.values(messageTargetOptions?.entities)
: undefined
}
rollTargetDefault={defaultMessageTargetOption}
userId={userId}
>
<NumberDisplay
number={toHit}
type="signed"
isModified={false}
/>
</DigitalDiceWrapper>
</div>
);
} else if (attackSaveValue !== null) {
actionNode = (
<div className="ddbc-combat-attack__save">
<span
className={`ddbc-combat-attack__save-value ${
theme.isDarkMode
? "ddbc-combat-attack__save-value--dark-mode"
: ""
}`}
>
{attackSaveValue}
</span>
<span
className={`ddbc-combat-attack__save-label ${
theme.isDarkMode
? "ddbc-combat-attack__save-label--dark-mode"
: ""
}`}
>
{attackSaveLabel}
</span>
</div>
);
}
let classNames: Array<string> = [className, "ddbc-combat-attack"];
return (
<div
className={classNames.join(" ")}
style={
theme?.isDarkMode
? { borderColor: `${theme.themeColor}66` }
: undefined
}
onClick={this.handleClick}
>
<div className="ddbc-combat-attack__icon">{icon}</div>
<div className="ddbc-combat-attack__name">
<div
className={`ddbc-combat-attack__label ${
theme.isDarkMode ? "ddbc-combat-attack__label--dark-mode" : ""
}`}
>
{name}
</div>
{metaItems.length > 0 && (
<div
className={`ddbc-combat-attack__meta ${
theme.isDarkMode ? "ddbc-combat-attack__meta--dark-mode" : ""
}`}
>
{metaItems.map((metaItem, idx) => (
<span className="ddbc-combat-attack__meta-item" key={idx}>
{metaItem}
</span>
))}
</div>
)}
</div>
<div className="ddbc-combat-attack__range">
<div
className={`ddbc-combat-attack__range-value ${
theme.isDarkMode
? "ddbc-combat-attack__range-value--dark-mode"
: ""
}`}
>
{rangeValue ? (
rangeValue
) : (
<span className="ddbc-combat-attack__empty">--</span>
)}
</div>
<div
className={`ddbc-combat-attack__range-label ${
theme.isDarkMode
? "ddbc-combat-attack__range-label--dark-mode"
: ""
}`}
>
{rangeLabel}
</div>
</div>
<div className="ddbc-combat-attack__action">
{actionNode ? (
actionNode
) : (
<span className="ddbc-combat-attack__empty">--</span>
)}
</div>
<div className="ddbc-combat-attack__damage">{damage}</div>
{showNotes && (
<div
className={`ddbc-combat-attack__notes ${
theme.isDarkMode ? "ddbc-combat-attack__notes--dark-mode" : ""
}`}
>
{notes}
</div>
)}
</div>
);
}
}
CombatAttack.contextType = GameLogContext;
export default CombatAttack;
@@ -0,0 +1,379 @@
import * as React from "react";
import {
AbilityLookup,
Attack,
CharacterTheme,
Constants,
DiceUtils,
FormatUtils,
Item,
ItemUtils,
RuleData,
RuleDataUtils,
WeaponSpellDamageGroup,
} from "@dndbeyond/character-rules-engine/es";
import {
Dice,
RollType,
DiceEvent,
RollKind,
IRollContext,
} from "@dndbeyond/dice";
import StarIcon from "@dndbeyond/fontawesome-cache/svgs/solid/star.svg";
import { GameLogContext } from "@dndbeyond/game-log-components";
import { ItemName } from "~/components/ItemName";
import { NumberDisplay } from "~/components/NumberDisplay";
import Tooltip from "~/tools/js/commonComponents/Tooltip";
import { AttackTypeIcon } from "../../Icons";
import NoteComponents from "../../NoteComponents";
import { DiceComponentUtils } from "../../utils";
import CombatAttack from "../CombatAttack";
import { DigitalDiceWrapper } from "../../Dice";
import Damage from "../../Damage";
interface Props {
attack: Attack;
item: Item;
weaponSpellDamageGroups: Array<WeaponSpellDamageGroup>;
onClick?: (attack: Attack) => void;
abilityLookup: AbilityLookup;
ruleData: RuleData;
showNotes: boolean;
className: string;
diceEnabled: boolean;
theme: CharacterTheme;
rollContext: IRollContext;
proficiencyBonus: number;
}
interface State {
isCriticalHit: boolean;
}
class CombatItemAttack extends React.PureComponent<Props, State> {
diceEventHandler: (eventData: any) => void;
constructor(props: Props) {
super(props);
this.state = {
isCriticalHit: false,
};
}
static defaultProps = {
showNotes: true,
className: "",
diceEnabled: false,
};
componentDidMount = () => {
this.diceEventHandler = DiceComponentUtils.setupResetCritStateOnRoll(
ItemUtils.getName(this.props.attack.data as Item),
this
);
};
componentWillUnmount = () => {
Dice.removeEventListener(DiceEvent.ROLL, this.diceEventHandler);
};
handleClick = (): void => {
const { onClick, attack } = this.props;
if (onClick) {
onClick(attack);
}
};
renderNotes = (): React.ReactNode => {
const {
item,
weaponSpellDamageGroups,
ruleData,
abilityLookup,
showNotes,
proficiencyBonus,
theme,
} = this.props;
if (!showNotes) {
return null;
}
return (
<div className="ddbc-combat-item-attack__notes">
<NoteComponents
notes={ItemUtils.getNoteComponents(
item,
weaponSpellDamageGroups,
ruleData,
abilityLookup,
proficiencyBonus
)}
theme={theme}
/>
</div>
);
};
handleRoll = (wasCrit: boolean) => {
this.setState({ isCriticalHit: wasCrit });
};
render() {
const {
attack,
item,
weaponSpellDamageGroups,
showNotes,
diceEnabled,
theme,
rollContext,
className,
} = this.props;
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
this.context;
const { isCriticalHit } = this.state;
const toHit = ItemUtils.getToHit(item);
const proficiency = ItemUtils.hasProficiency(item);
const metaItems = ItemUtils.getMetaItems(item);
const type = ItemUtils.getType(item);
const damage = ItemUtils.getDamage(item);
const damageType = ItemUtils.getDamageType(item);
const attackType = ItemUtils.getAttackType(item);
let attackTypeName: string = "";
if (attackType) {
attackTypeName = RuleDataUtils.getAttackTypeRangeName(attackType);
}
const versatileDamage = ItemUtils.getVersatileDamage(item);
const reach = ItemUtils.getReach(item);
const range = ItemUtils.getRange(item);
const longRange = ItemUtils.getLongRange(item);
const isHexWeapon = ItemUtils.isHexWeapon(item);
const isPactWeapon = ItemUtils.isPactWeapon(item);
const isDedicatedWeapon = ItemUtils.isDedicatedWeapon(item);
const isLegacy = ItemUtils.isLegacy(item);
let combinedMetaItems: Array<string> = [];
if (isLegacy) {
combinedMetaItems.push("Legacy");
}
if (type === Constants.WeaponTypeEnum.AMMUNITION) {
combinedMetaItems.push("Ammunition");
} else {
if (isHexWeapon || isPactWeapon || isDedicatedWeapon) {
if (isHexWeapon) {
combinedMetaItems.push("Hex Weapon");
}
if (isPactWeapon) {
combinedMetaItems.push("Pact Weapon");
}
if (isDedicatedWeapon) {
combinedMetaItems.push("Dedicated Weapon");
}
} else {
combinedMetaItems.push(`${attackTypeName} Weapon`);
}
}
if (ItemUtils.isOffhand(item)) {
combinedMetaItems.push("Dual Wield");
}
if (ItemUtils.isAdamantine(item)) {
combinedMetaItems.push("Adamantine");
}
if (ItemUtils.isSilvered(item)) {
combinedMetaItems.push("Silvered");
}
if (ItemUtils.isCustomized(item)) {
combinedMetaItems.push("Customized");
}
if (ItemUtils.getMasteryName(item)) {
combinedMetaItems.push("Mastery");
}
combinedMetaItems = [...combinedMetaItems, ...metaItems];
let versatileDamageNode: React.ReactNode;
if (versatileDamage) {
versatileDamageNode = (
<DigitalDiceWrapper
diceNotation={DiceUtils.renderDice(versatileDamage)}
rollType={RollType.Damage}
rollAction={ItemUtils.getName(attack.data as Item)}
rollKind={isCriticalHit ? RollKind.CriticalHit : RollKind.None}
diceEnabled={diceEnabled}
themeColor={theme.themeColor}
rollContext={rollContext}
rollTargetOptions={
messageTargetOptions
? Object.values(messageTargetOptions?.entities)
: undefined
}
rollTargetDefault={defaultMessageTargetOption}
userId={userId}
>
<Damage
damage={DiceComponentUtils.getDamageDiceNotation(
versatileDamage,
isCriticalHit
)}
type={damageType}
theme={theme}
isVersatile={true}
/>
</DigitalDiceWrapper>
);
}
let classNames: Array<string> = ["ddbc-combat-item-attack__damage"];
if (versatileDamage) {
classNames.push("ddb-combat-item-attack__damage--is-versatile");
}
let damageNode: React.ReactNode;
if (damage === null) {
damageNode = null;
} else {
damageNode = (
<DigitalDiceWrapper
diceNotation={
typeof damage === "number"
? damage.toString()
: DiceUtils.renderDice(damage)
}
rollType={RollType.Damage}
rollAction={ItemUtils.getName(attack.data as Item)}
rollKind={isCriticalHit ? RollKind.CriticalHit : RollKind.None}
diceEnabled={diceEnabled}
themeColor={theme.themeColor}
rollContext={rollContext}
rollTargetOptions={
messageTargetOptions
? Object.values(messageTargetOptions?.entities)
: undefined
}
rollTargetDefault={defaultMessageTargetOption}
userId={userId}
>
<Damage
damage={DiceComponentUtils.getDamageDiceNotation(
damage,
isCriticalHit
)}
type={damageType}
theme={theme}
/>
</DigitalDiceWrapper>
);
}
let damageDisplayNode: React.ReactNode = (
<div className={classNames.join(" ")}>
{damageNode}
{versatileDamageNode}
</div>
);
const showRange: boolean =
attackType === Constants.AttackTypeRangeEnum.RANGED ||
ItemUtils.hasWeaponProperty(item, Constants.WeaponPropertyEnum.THROWN) ||
ItemUtils.hasWeaponProperty(item, Constants.WeaponPropertyEnum.RANGE);
let rangeValue: React.ReactNode;
let rangeLabel: React.ReactNode;
if (showRange) {
rangeValue = (
<React.Fragment>
<span
className={`ddbc-combat-attack__range-value-close ${
theme.isDarkMode
? "ddbc-combat-attack__range-value-close--dark-mode"
: ""
}`}
>
{range}
</span>
{longRange !== null && (
<span
className={`ddbc-combat-attack__range-value-long ${
theme.isDarkMode
? "ddbc-combat-attack__range-value-long--dark-mode"
: ""
}`}
>
({longRange})
</span>
)}
</React.Fragment>
);
rangeLabel = "";
} else {
rangeValue = <NumberDisplay type="distanceInFt" number={reach} />;
}
let filteredWeaponSpellDamageGroups =
ItemUtils.getApplicableWeaponSpellDamageGroups(
item,
weaponSpellDamageGroups
);
let iconKey: string = `weapon-${FormatUtils.slugify(attackTypeName)}`;
if (filteredWeaponSpellDamageGroups.length) {
iconKey = "weapon-spell-damage";
}
let attackClassNames: Array<string> = [
"ddbc-combat-attack--item",
`ddbc-combat-item-attack--${FormatUtils.slugify(attackTypeName)}`,
className,
];
if (isCriticalHit) {
attackClassNames.push("ddbc-combat-attack--crit");
}
return (
<CombatAttack
attack={attack}
className={attackClassNames.join(" ")}
icon={
<>
{ItemUtils.getMasteryName(item) && (
<Tooltip title="Weapon Mastery" isDarkMode={theme.isDarkMode}>
<StarIcon
style={{ fill: theme.themeColor, marginLeft: "1px" }}
/>
</Tooltip>
)}
<AttackTypeIcon
actionType={Constants.ActionTypeEnum.WEAPON}
rangeType={attackType ?? Constants.AttackTypeRangeEnum.MELEE}
themeMode={theme.isDarkMode ? "gray" : "dark"}
className={`ddbc-combat-attack__icon-img--${iconKey}`}
overrideType={
filteredWeaponSpellDamageGroups.length ? "weapon-spell" : null
}
/>
</>
}
name={<ItemName item={item} />}
metaItems={combinedMetaItems}
rangeValue={rangeValue}
rangeLabel={rangeLabel}
isProficient={proficiency}
toHit={toHit}
damage={damageDisplayNode}
notes={this.renderNotes()}
showNotes={showNotes}
onClick={this.handleClick}
diceEnabled={diceEnabled}
onRoll={this.handleRoll}
rollContext={rollContext}
theme={theme}
/>
);
}
}
CombatItemAttack.contextType = GameLogContext;
export default CombatItemAttack;
@@ -0,0 +1,4 @@
import CombatItemAttack from "./CombatItemAttack";
export default CombatItemAttack;
export { CombatItemAttack };
@@ -0,0 +1,277 @@
import * as React from "react";
import {
AbilityLookup,
Attack,
Constants,
DataOriginRefData,
EntityUtils,
FormatUtils,
Spell,
SpellCasterInfo,
SpellUtils,
RuleData,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
import { Dice, DiceEvent, IRollContext } from "@dndbeyond/dice";
import { NumberDisplay } from "~/components/NumberDisplay";
import { SpellName } from "~/components/SpellName";
import { SpellSchoolIcon } from "../../Icons";
import NoteComponents from "../../NoteComponents";
import SpellDamageEffect from "../../SpellDamageEffect";
import { SpellSchoolPropType } from "../../componentConstants";
import { DiceComponentUtils } from "../../utils";
import CombatAttack from "../CombatAttack";
interface Props {
attack: Attack;
spell: Spell;
onClick?: (attack: Attack) => void;
ruleData: RuleData;
abilityLookup: AbilityLookup;
spellCasterInfo: SpellCasterInfo;
showNotes: boolean;
className: string;
diceEnabled: boolean;
theme: CharacterTheme;
rollContext: IRollContext;
dataOriginRefData: DataOriginRefData;
proficiencyBonus: number;
}
interface State {
isCriticalHit: boolean;
}
export default class CombatSpellAttack extends React.PureComponent<
Props,
State
> {
diceEventHandler: (eventData: any) => void;
constructor(props: Props) {
super(props);
this.state = {
isCriticalHit: false,
};
}
static defaultProps = {
showNotes: true,
className: "",
diceEnabled: false,
};
componentDidMount = () => {
this.diceEventHandler = DiceComponentUtils.setupResetCritStateOnRoll(
SpellUtils.getName(this.props.spell),
this
);
};
componentWillUnmount = () => {
Dice.removeEventListener(DiceEvent.ROLL, this.diceEventHandler);
};
handleClick = (): void => {
const { onClick, attack } = this.props;
if (onClick) {
onClick(attack);
}
};
renderNotes = (): React.ReactNode => {
const {
spell,
abilityLookup,
ruleData,
showNotes,
spellCasterInfo,
proficiencyBonus,
theme,
} = this.props;
if (!showNotes) {
return null;
}
const castLevel = SpellUtils.getMinCastLevel(
spell,
spellCasterInfo,
ruleData
);
const characterLevel = SpellUtils.getCharacterLevel(spell);
let scaledAmount: number = 0;
return (
<div className="ddbc-combat-spell-attack__notes">
<NoteComponents
notes={SpellUtils.getNoteComponents(
spell,
castLevel,
characterLevel,
abilityLookup,
ruleData,
scaledAmount,
proficiencyBonus
)}
theme={theme}
/>
</div>
);
};
handleRoll = (wasCrit: boolean) => {
this.setState({ isCriticalHit: wasCrit });
};
render() {
const {
attack,
spell,
ruleData,
spellCasterInfo,
showNotes,
diceEnabled,
theme,
dataOriginRefData,
rollContext,
className,
} = this.props;
const { isCriticalHit } = this.state;
const characterLevel = SpellUtils.getCharacterLevel(spell);
const attackSaveValue = SpellUtils.getAttackSaveValue(spell);
const toHit = SpellUtils.getToHit(spell);
const isCustomized = SpellUtils.isCustomized(spell);
const range = SpellUtils.getRange(spell);
const level = SpellUtils.getLevel(spell);
const school = SpellUtils.getSchool(spell);
const concentration = SpellUtils.getConcentration(spell);
const requiresAttackRoll = SpellUtils.getRequiresAttackRoll(spell);
const requiresSavingThrow = SpellUtils.getRequiresSavingThrow(spell);
const castLevel = SpellUtils.getMinCastLevel(
spell,
spellCasterInfo,
ruleData
);
let metaItems: Array<string> = [];
if (SpellUtils.isLegacy(spell)) {
metaItems.push("Legacy");
}
metaItems.push(FormatUtils.renderSpellLevelName(level));
const spellDataOrigin = SpellUtils.getDataOrigin(spell);
if (spellDataOrigin) {
const dataOriginName = EntityUtils.getDataOriginName(spellDataOrigin);
metaItems.push(dataOriginName);
}
let expandedDataOrigin = SpellUtils.getExpandedDataOriginRef(spell);
if (expandedDataOrigin) {
metaItems.push(
EntityUtils.getDataOriginRefName(expandedDataOrigin, dataOriginRefData)
);
}
if (concentration) {
metaItems.push("Concentration");
}
if (isCustomized) {
metaItems.push("Customized");
}
let rangeAreaNode: React.ReactNode;
if (range !== null) {
rangeAreaNode = (
<span className="ddbc-combat-attack__spell-range">
{!!range.origin &&
range.origin !== Constants.SpellRangeTypeNameEnum.RANGED && (
<span
className={`ddbc-combat-attack__spell-range-origin ${
theme.isDarkMode
? "ddbc-combat-attack__spell-range-origin--dark-mode"
: ""
}`}
>
{range.origin}
</span>
)}
{!!range.rangeValue && (
<span className="ddbc-combat-attack__spell-range-value">
<NumberDisplay type="distanceInFt" number={range.rangeValue} />
</span>
)}
</span>
);
}
let toHitDisplay: number | null = null;
let saveDcValue: number | null = null;
let saveDcLabel: React.ReactNode;
if (requiresAttackRoll) {
toHitDisplay = toHit;
} else if (requiresSavingThrow) {
saveDcValue = attackSaveValue;
saveDcLabel = SpellUtils.getSaveDcAbilityShortName(spell, ruleData);
}
let attackClassNames: Array<string> = ["ddbc-combat-attack--spell", className];
if (isCriticalHit) {
attackClassNames.push("ddbc-combat-attack--crit");
}
return (
<CombatAttack
attack={attack}
className={attackClassNames.join(" ")}
icon={
<SpellSchoolIcon
school={FormatUtils.slugify(school) as SpellSchoolPropType}
themeMode={theme.isDarkMode ? "gray" : "dark"}
className={`ddbc-combat-attack__icon-img--spell-school-${FormatUtils.slugify(
school
)}`}
/>
}
name={
<SpellName
spell={spell}
showSpellLevel={false}
dataOriginRefData={dataOriginRefData}
/>
}
metaItems={metaItems}
rangeValue={rangeAreaNode}
rangeLabel={""}
toHit={toHitDisplay}
attackSaveValue={saveDcValue}
attackSaveLabel={saveDcLabel}
rollContext={rollContext}
damage={
<SpellDamageEffect
spell={spell}
characterLevel={characterLevel}
castLevel={castLevel}
ruleData={ruleData}
diceEnabled={diceEnabled}
theme={theme}
isCriticalHit={isCriticalHit}
rollContext={rollContext}
/>
}
onClick={this.handleClick}
notes={this.renderNotes()}
showNotes={showNotes}
diceEnabled={diceEnabled}
onRoll={this.handleRoll}
theme={theme}
/>
);
}
}
@@ -0,0 +1,4 @@
import CombatSpellAttack from "./CombatSpellAttack";
export default CombatSpellAttack;
export { CombatSpellAttack };
@@ -0,0 +1,62 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
ConditionUtils,
Condition,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
interface Props {
className: string;
condition: Condition;
tooltipText: string;
theme?: CharacterTheme;
}
export default class ConditionName extends React.PureComponent<Props, {}> {
static defaultProps = {
className: "",
tooltipText: "",
};
renderConditionLevel = (condition: Condition): React.ReactNode => {
const levels = ConditionUtils.getLevels(condition);
if (!levels.length) {
return null;
}
return (
<span className="ddbc-condition__level">
{" "}
(Level {ConditionUtils.getActiveLevel(condition)})
</span>
);
};
render() {
const { className, condition, tooltipText, theme } = this.props;
let classNames: Array<string> = [className, "ddbc-condition"];
return (
<span className={classNames.join(" ")}>
<Tooltip
title={tooltipText}
enabled={!!tooltipText}
isDarkMode={theme?.isDarkMode}
>
<span
className={`ddbc-condition__name ${
theme?.isDarkMode ? "ddbc-condition__name--dark-mode" : ""
}`}
>
{ConditionUtils.getName(condition)}
{this.renderConditionLevel(condition)}
</span>
</Tooltip>
</span>
);
}
}
@@ -0,0 +1,87 @@
import { orderBy } from "lodash";
import * as React from "react";
import {
CharacterTheme,
Creature,
CreatureUtils,
RuleData,
} from "@dndbeyond/character-rules-engine/es";
import CreatureListRow from "./CreatureListRow";
//TODO this component is currently replaced by ExtraList for Creatures and Vehicles as Extras
interface Props {
className: string;
creatures: Array<Creature>;
showNotes: boolean;
ruleData: RuleData;
onShow: (id: number) => void;
onStatusChange: (id: number, isActive: boolean) => void;
isReadonly: boolean;
theme: CharacterTheme;
}
export default class CreatureList extends React.PureComponent<Props, {}> {
static defaultProps = {
showNotes: true,
className: "",
};
render() {
const {
creatures,
ruleData,
showNotes,
onShow,
onStatusChange,
isReadonly,
className,
theme,
} = this.props;
let orderedCreatures: Array<Creature> = orderBy(creatures, (creature) =>
CreatureUtils.getName(creature)
);
let classNames: Array<string> = [className, "ddbc-creature-list"];
return (
<div className={classNames.join(" ")}>
<div className="ddbc-creature-list__row-header">
<div className="ddbc-creature-list__col ddbc-creature-list__col--preview" />
<div className="ddbc-creature-list__col ddbc-creature-list__col--primary">
Name
</div>
<div className="ddbc-creature-list__col ddbc-creature-list__col--ac">
AC
</div>
<div className="ddbc-creature-list__col ddbc-creature-list__col--hp">
Hit Points
</div>
<div className="ddbc-creature-list__col ddbc-creature-list__col--speed">
Speed
</div>
{showNotes && (
<div className="ddbc-creature-list__col ddbc-creature-list__col--notes">
Notes
</div>
)}
{/*<div className="ddbc-creature-list__col ddbc-creature-list__col--action">Active</div>*/}
</div>
<div className="ddbc-creature-list__items">
{orderedCreatures.map((creature) => (
<CreatureListRow
key={CreatureUtils.getUniqueKey(creature)}
creature={creature}
ruleData={ruleData}
onShow={onShow}
onStatusChange={onStatusChange}
showNotes={showNotes}
isReadonly={isReadonly}
theme={theme}
/>
))}
</div>
</div>
);
}
}
@@ -0,0 +1,198 @@
import * as React from "react";
import {
Creature,
CreatureUtils,
Constants,
RuleData,
RuleDataUtils,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
import { NumberDisplay } from "~/components/NumberDisplay";
import { NoteComponents } from "../../NoteComponents";
//TODO this component is currently replaced by ExtraListRow for Creatures and Vehicles as Extras
interface Props {
creature: Creature;
ruleData: RuleData;
onShow?: (id: number) => void;
onStatusChange?: (id: number, isActive: boolean) => void;
showNotes: boolean;
isReadonly: boolean;
className: string;
theme: CharacterTheme;
}
export default class CreatureListRow extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
handleClick = (evt: React.MouseEvent): void => {
const { onShow, creature } = this.props;
if (onShow) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onShow(CreatureUtils.getMappingId(creature));
}
};
handleActivate = (): void => {
const { onStatusChange, creature } = this.props;
if (onStatusChange) {
onStatusChange(CreatureUtils.getMappingId(creature), true);
}
};
handleDeactivate = (): void => {
const { onStatusChange, creature } = this.props;
if (onStatusChange) {
onStatusChange(CreatureUtils.getMappingId(creature), false);
}
};
renderMetaItems = (): React.ReactNode => {
const { creature, ruleData } = this.props;
let metaItems: Array<string> = [];
const sizeInfo = RuleDataUtils.getCreatureSizeInfo(
CreatureUtils.getSizeId(creature),
ruleData
);
let size: string = "";
if (sizeInfo && sizeInfo.name !== null) {
size = sizeInfo.name;
}
let type = CreatureUtils.getTypeName(creature, ruleData);
let description: string = `${size} ${type}`.trim();
if (description) {
metaItems.push(description);
}
return (
<div className="ddbc-creature-list__row-meta">
{metaItems.map((metaItem, idx) => (
<span key={idx} className="ddbc-creature-list__row-meta-item">
{metaItem}
</span>
))}
</div>
);
};
renderHitPoints = (): React.ReactNode => {
const { creature } = this.props;
const hitPointInfo = CreatureUtils.getHitPointInfo(creature);
const tempHp = hitPointInfo.tempHp === null ? 0 : hitPointInfo.tempHp;
let classNames: Array<string> = ["ddbc-creature-list__row-hp"];
if (tempHp > 0) {
classNames.push("ddbc-creature-list__row-hp--has-temp");
}
return (
<div className={classNames.join(" ")}>
<span className="ddbc-creature-list__row-hp-value ddbc-creature-list__row-hp-value--current">
{hitPointInfo.remainingHp + tempHp}
</span>
<span className="ddbc-creature-list__row-hp-sep">/</span>
<span className="ddbc-creature-list__row-hp-value ddbc-creature-list__row-hp-value--total">
{hitPointInfo.totalHp + tempHp}
</span>
</div>
);
};
renderSpeed = (): React.ReactNode => {
const { creature, ruleData, theme } = this.props;
const movementInfo = CreatureUtils.getHighestMovementInfo(creature);
let contentNode: React.ReactNode;
if (movementInfo) {
contentNode = (
<React.Fragment>
<div className="ddbc-creature-list__row-speed-value">
<NumberDisplay type="distanceInFt" number={movementInfo.speed} />
</div>
{movementInfo.movementId !== Constants.MovementTypeEnum.WALK && (
<div className="ddbc-creature-list__row-speed-callout">
{RuleDataUtils.getMovementDescription(
movementInfo.movementId,
ruleData
)}
</div>
)}
</React.Fragment>
);
} else {
contentNode = (
<div className="ddbc-creature-list__row-speed-value">--</div>
);
}
return <div className="ddbc-creature-list__row-speed">{contentNode}</div>;
};
render() {
const { creature, ruleData, isReadonly, showNotes, className, theme } =
this.props;
const avatarUrl = CreatureUtils.getAvatarUrl(creature);
const name = CreatureUtils.getName(creature);
const armorClass = CreatureUtils.getArmorClass(creature);
const movementInfo = CreatureUtils.getHighestMovementInfo(creature);
const isActive = CreatureUtils.isActive(creature);
let classNames: Array<string> = [className, "ddbc-creature-row"];
return (
<div className={classNames.join(" ")} onClick={this.handleClick}>
<div className="ddbc-creature-list__row-preview">
{avatarUrl && (
<img
className="ddbc-creature-list__row-img"
src={avatarUrl}
alt={`${name} preview`}
/>
)}
</div>
<div className="ddbc-creature-list__row-primary">
<div className="ddbc-creature-list__row-name">{name}</div>
{this.renderMetaItems()}
</div>
<div className="ddbc-creature-list__row-ac">{armorClass}</div>
{this.renderHitPoints()}
{this.renderSpeed()}
{showNotes && (
<div className="ddbc-creature-list__row-notes">
<NoteComponents
notes={CreatureUtils.getNoteComponents(creature, ruleData)}
theme={theme}
/>
</div>
)}
{/*<div className="ddbc-creature-list__row-action">
<SlotManager
used={isActive ? 1 : 0}
available={1}
size="small"
onUse={this.handleActivate}
onClear={this.handleDeactivate}
isInteractive={!isReadonly}
/>
</div>*/}
</div>
);
}
}
@@ -0,0 +1,4 @@
import CreatureListRow from "./CreatureListRow";
export default CreatureListRow;
export { CreatureListRow };
@@ -0,0 +1,53 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
CharacterTheme,
Creature,
CreatureUtils,
} from "@dndbeyond/character-rules-engine/es";
interface Props {
creature: Creature;
onClick?: (creature: Creature) => void;
className: string;
theme?: CharacterTheme;
}
export default class CreatureName extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
handleClick = (evt: React.MouseEvent): void => {
const { onClick, creature } = this.props;
if (onClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick(creature);
}
};
render() {
const { creature, className, theme } = this.props;
let isCustomized = CreatureUtils.isCustomized(creature);
let classNames: Array<string> = [className, "ddbc-creature-name"];
return (
<span className={classNames.join(" ")} onClick={this.handleClick}>
{CreatureUtils.getName(creature)}
{isCustomized && (
<Tooltip
title="Creature is Customized"
className="ddbc-creature-name__customized"
isDarkMode={theme?.isDarkMode}
>
*
</Tooltip>
)}
</span>
);
}
}
@@ -0,0 +1,74 @@
import * as React from "react";
import {
Creature,
CreatureUtils,
RuleData,
} from "@dndbeyond/character-rules-engine/es";
interface Props {
creature: Creature;
ruleData: RuleData;
onClick?: (creature: Creature) => void;
}
export class CreaturePreview extends React.PureComponent<Props> {
handleClick = (evt: React.MouseEvent): void => {
const { onClick, creature } = this.props;
if (onClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick(creature);
}
};
renderMetaText = (): React.ReactNode => {
const { ruleData, creature } = this.props;
let metaItems: Array<string> = [];
const sizeInfo = CreatureUtils.getSizeInfo(creature);
let size: string | null = "";
if (sizeInfo !== null) {
size = sizeInfo.name;
}
let description: string = `${size} ${CreatureUtils.getTypeName(
creature,
ruleData
)}`.trim();
if (description.length) {
metaItems.push(description);
}
return (
<div className="ddbc-creature-preview__meta">
{metaItems.map((metaItem, idx) => (
<span className="ddbc-creature-preview__meta-item" key={idx}>
{metaItem}
</span>
))}
</div>
);
};
render() {
const { creature } = this.props;
const imageStyles: React.CSSProperties = {
backgroundImage: `url(${CreatureUtils.getAvatarUrl(creature)})`,
};
return (
<div className="ddbc-creature-preview" onClick={this.handleClick}>
<div className="ddbc-creature-preview__avatar" style={imageStyles} />
<div className="ddbc-creature-preview__name">
{CreatureUtils.getName(creature)}
{this.renderMetaText()}
</div>
</div>
);
}
}
export default CreaturePreview;
@@ -0,0 +1,94 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
CharacterTheme,
DiceContract,
DiceUtils,
FormatUtils,
} from "@dndbeyond/character-rules-engine/es";
import { DamageTypeIcon } from "../Icons";
import { DamageTypePropType } from "../Icons/IconConstants";
export interface Props {
className: string;
damage: DiceContract | string | number | null;
type: string | null;
info?: string;
isVersatile: boolean;
showInfoTooltip: boolean;
showInfoInline: boolean;
theme?: CharacterTheme;
}
export default class Damage extends React.PureComponent<Props> {
static defaultProps = {
className: "",
type: null,
isVersatile: false,
showInfoTooltip: true,
showInfoInline: false,
};
render() {
const {
className,
damage,
type,
isVersatile,
info,
showInfoInline,
showInfoTooltip,
theme,
} = this.props;
let displayDamage: React.ReactNode;
if (damage !== null) {
if (typeof damage === "string" || typeof damage === "number") {
displayDamage = damage;
} else {
displayDamage = DiceUtils.renderDice(damage);
}
}
let classNames: Array<string> = [className, "ddbc-damage"];
if (isVersatile) {
classNames.push("ddbc-damage--versatile");
}
if (theme?.isDarkMode) {
classNames.push("ddbc-damage--dark-mode");
}
let tooltipContent: string | null = null;
if (showInfoTooltip && (info || type)) {
tooltipContent = info ? info : type;
}
return (
<Tooltip
isDarkMode={theme?.isDarkMode}
className={classNames.join(" ")}
title={tooltipContent === null ? "" : tooltipContent}
enabled={showInfoTooltip && tooltipContent !== null}
>
<span
className={`ddbc-damage__value ${
theme?.isDarkMode ? "ddbc-damage__value--dark-mode" : ""
}`}
>
{displayDamage}
</span>
{!isVersatile && !!type && (
<span className="ddbc-damage__icon">
<DamageTypeIcon
theme={theme}
type={FormatUtils.slugify(type) as DamageTypePropType}
showTooltip={!showInfoTooltip}
/>
</span>
)}
{showInfoInline && <span className="ddbc-damage__info">{info}</span>}
</Tooltip>
);
}
}
@@ -0,0 +1,4 @@
import Damage from "./Damage";
export default Damage;
export { Damage };
@@ -0,0 +1,44 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
CharacterTheme,
DamageAdjustmentContract,
} from "@dndbeyond/character-rules-engine/es";
interface Props {
className: string;
damageAdjustment: DamageAdjustmentContract;
tooltipText: string;
theme?: CharacterTheme;
}
export default class DamageAdjustment extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
render() {
const { className, damageAdjustment, tooltipText, theme } = this.props;
let classNames: Array<string> = [className, "ddbc-damage-adjustment"];
return (
<span className={classNames.join(" ")}>
<Tooltip
title={tooltipText}
enabled={!!tooltipText}
isDarkMode={theme?.isDarkMode}
>
<span className="ddbc-damage-adjustment__preview">
<i
className={`ddbc-damage-adjustment__icon i-damage-adjustment-${damageAdjustment.slug}`}
/>
</span>
<span className="ddbc-damage-adjustment__name">
{damageAdjustment.name}
</span>
</Tooltip>
</span>
);
}
}
@@ -0,0 +1,4 @@
import DamageAdjustment from "./DamageAdjustment";
export default DamageAdjustment;
export { DamageAdjustment };
@@ -0,0 +1,61 @@
import * as React from "react";
import {
CharacterTheme,
Constants,
DataOrigin,
EntityUtils,
} from "@dndbeyond/character-rules-engine/es";
import { ItemName } from "~/components/ItemName";
interface Props {
dataOrigin: DataOrigin;
tryParent?: boolean;
onClick?: (dataOrigin: DataOrigin) => void;
className: string;
theme: CharacterTheme;
}
export default class DataOriginName extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
handleClick = (evt: React.MouseEvent): void => {
const { onClick, dataOrigin } = this.props;
if (onClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick(dataOrigin);
}
};
render() {
const { dataOrigin, className, tryParent } = this.props;
let displayNode: React.ReactNode;
switch (dataOrigin.type) {
case Constants.DataOriginTypeEnum.ITEM:
displayNode = <ItemName item={dataOrigin.primary} />;
break;
default:
displayNode = EntityUtils.getDataOriginName(
dataOrigin,
undefined,
tryParent
);
}
let classNames: Array<string> = [className, "ddbc-data-origin-name"];
return (
<span className={classNames.join(" ")} onClick={this.handleClick}>
{displayNode}
</span>
);
}
}
@@ -0,0 +1,4 @@
import DataOriginName from "./DataOriginName";
export default DataOriginName;
export { DataOriginName };
@@ -0,0 +1,36 @@
import { RollType } from "@dndbeyond/dice";
import { WithDiceRollv2 } from "@dndbeyond/dice-components";
import { RollableComponentProps } from "@dndbeyond/dice-components/dist/types/src/components/WithDiceRollv2";
import { useMemo } from "react";
export interface DigitalDiceWrapperProps extends
Omit<RollableComponentProps, "BaseComponent" | "useCustomizableDiceNotation">
{
}
export const DigitalDiceWrapper: React.FC<DigitalDiceWrapperProps> = ({
useAdvancedMenu = true,
rollType,
children,
...props
}) => {
const useCustomizableDiceNotation = useMemo(
() => rollType === RollType.Damage
|| rollType === RollType.Heal,
[rollType]
);
return (
<WithDiceRollv2
BaseComponent={(
<>
{children}
</>
)}
useAdvancedMenu={useAdvancedMenu}
useCustomizableDiceNotation={useCustomizableDiceNotation}
rollType={rollType}
{...props}
/>
);
};
@@ -0,0 +1,119 @@
import React, { useCallback } from "react";
import { RollRequest } from "@dndbeyond/dice";
import LoadingPlaceholder from "../../LoadingPlaceholder";
import { DataLoadingStatusEnum } from "../../componentConstants";
import {
DiceRollActionNode,
DiceRollActionNodeProps,
} from "./DiceRollActionNode";
import { DiceRollValues, DiceRollValuesProps } from "./DiceRollValues";
export interface DiceRollProps {
rollKey: string;
nextRollKey: string | null;
rollTotal: number | null;
diceRollRequest: RollRequest;
onChange?: (
key: string,
newValue: string | null,
prevValue: string | null,
nextRollKey: string | null,
rollTotal: number | null,
rollValues: DiceRollValuesProps["rollValues"]
) => void;
onRoll: (
key: string,
diceRollRequest: RollRequest,
nextRollKey: string | null
) => void;
rollValues: DiceRollValuesProps["rollValues"];
rollStatus: DiceRollActionNodeProps["rollStatus"];
options?: DiceRollActionNodeProps["options"];
assignedValue: DiceRollActionNodeProps["assignedValue"];
rollButtonText?: DiceRollActionNodeProps["rollButtonText"];
selectPlaceholderText?: DiceRollActionNodeProps["selectPlaceholderText"];
}
const DiceRoll: React.FunctionComponent<DiceRollProps> = ({
rollKey,
nextRollKey,
options,
rollTotal,
rollValues,
assignedValue,
diceRollRequest,
onChange,
onRoll,
rollStatus,
rollButtonText,
selectPlaceholderText,
}) => {
const handleChange = useCallback(
(changedValue: string): void => {
const prevValue = assignedValue;
const newValue: string | null = changedValue ?? null;
if (newValue !== prevValue) {
if (onChange) {
onChange(
rollKey,
newValue,
prevValue,
nextRollKey,
rollTotal,
rollValues
);
}
}
},
[onChange, rollKey, assignedValue, nextRollKey, rollTotal, rollValues]
);
const handleRollClick = useCallback((): void => {
onRoll(rollKey, diceRollRequest, nextRollKey);
}, [onRoll, rollKey, diceRollRequest, nextRollKey]);
let contentNode: React.ReactNode = null;
switch (rollStatus) {
case DataLoadingStatusEnum.LOADING:
contentNode = <LoadingPlaceholder label="Rolling" />;
break;
default: {
const diceTotalClassNames: Array<string> = ["ddbc-dice-roll__total"];
if (rollTotal === null) {
diceTotalClassNames.push("ddbc-dice-roll__total--empty");
}
contentNode = (
<React.Fragment>
<div className={diceTotalClassNames.join(" ")}>
<label className="ddbc-dice-roll__total-label" htmlFor={rollKey}>
{rollTotal ?? "--"}
</label>
</div>
<div className="ddbc-dice-roll__dice">
<DiceRollValues rollValues={rollValues} />
</div>
<DiceRollActionNode
rollKey={rollKey}
rollStatus={rollStatus}
rollTotal={rollTotal}
assignedValue={assignedValue}
options={options}
onRoll={handleRollClick}
onChange={handleChange}
rollButtonText={rollButtonText}
selectPlaceholderText={selectPlaceholderText}
/>
</React.Fragment>
);
break;
}
}
return <div className="ddbc-dice-roll">{contentNode}</div>;
};
export default React.memo(DiceRoll);
@@ -0,0 +1,53 @@
import React from "react";
import { HtmlSelectOption } from "@dndbeyond/character-rules-engine/es";
import { DataLoadingStatusEnum } from "../../../componentConstants";
import { Button } from "../../../legacy/Button";
import { Select } from "../../../legacy/Select";
export interface DiceRollActionNodeProps {
rollKey: string;
rollStatus: DataLoadingStatusEnum;
rollTotal: number | null;
assignedValue: string | null;
options?: Array<HtmlSelectOption>;
rollButtonText?: string;
selectPlaceholderText?: string;
onRoll: () => void;
onChange?: (newValue: string | null) => void;
}
const DiceRollActionNode: React.FunctionComponent<DiceRollActionNodeProps> = ({
rollKey,
rollStatus,
rollTotal,
assignedValue,
options,
onRoll,
onChange,
rollButtonText = "Roll",
selectPlaceholderText = "--",
}) => {
if (options?.length && rollTotal !== null) {
return (
<Select
id={rollKey}
value={assignedValue}
options={options}
placeholder={selectPlaceholderText}
onChange={onChange}
/>
);
}
return (
<Button
size="large"
// style='outline'
onClick={onRoll}
>
{rollButtonText}
</Button>
);
};
export default React.memo(DiceRollActionNode);
@@ -0,0 +1,64 @@
import { orderBy } from "lodash";
import React from "react";
import {
RollValueContract,
Constants,
} from "@dndbeyond/character-rules-engine/es";
//eventually use RollValueContract.dieType to display dice svgs
export interface DiceRollValuesProps {
rollValues: Array<RollValueContract>;
}
const DiceRollValues: React.FunctionComponent<DiceRollValuesProps> = ({
rollValues,
}) => {
if (rollValues.length === 0) {
return null;
}
const keptDiceValues = rollValues.filter(
(valueContract) =>
valueContract.excludeType === Constants.DiceRollExcludeTypeEnum.ALL
);
const discardedValues = rollValues.filter(
(valueContract) =>
valueContract.excludeType === Constants.DiceRollExcludeTypeEnum.NONE
);
const orderedKeptDiceValues = orderBy(
keptDiceValues,
(valueContract) => valueContract.value,
"desc"
);
const orderedDiscardedValues = orderBy(
discardedValues,
(valueContract) => valueContract.value,
"desc"
);
const classNames: Array<string> = ["ddbc-dice-roll__dice-value"];
return (
<React.Fragment>
{orderedKeptDiceValues.map((rollValue: RollValueContract, idx) => {
return (
<div key={idx} className={classNames.join(" ")}>
{rollValue.value}
</div>
);
})}
{orderedDiscardedValues.map((rollValue: RollValueContract, idx) => {
classNames.push("ddbc-dice-roll__dice-value--discarded");
return (
<div key={idx} className={classNames.join(" ")}>
{rollValue.value}
</div>
);
})}
</React.Fragment>
);
};
export default React.memo(DiceRollValues);
@@ -0,0 +1,289 @@
import React, { useCallback, useMemo } from "react";
import {
HelperUtils,
HtmlSelectOption,
RollResultContract,
RollValueContract,
} from "@dndbeyond/character-rules-engine/es";
import { Dice, RollRequest } from "@dndbeyond/dice";
import { DataLoadingStatusEnum } from "../componentConstants";
import { Button, RemoveButton } from "../legacy";
import { TypeScriptUtils, DiceComponentUtils } from "../utils";
import { DiceRoll, DiceRollProps } from "./DiceRoll";
export interface DiceRollGroupProps {
className: string;
componentKey: string;
diceRolls: Array<RollResultContract>;
exclusiveOptions?: boolean;
confirmButtonText?: string;
onConfirm?: (
groupKey: string,
rollResults: Array<RollResultContract>
) => void;
onUpdateGroup?: (
groupKey: string,
rollResults: Array<RollResultContract>
) => void;
onRollError?: (errorMessage: string) => void;
groupKey: string;
nextGroupKey: string | null;
showRemoveButton?: boolean;
onRemoveGroup?: (groupKey: string, nextGroupKey: string | null) => void;
onResetGroup?: (
groupKey: string,
rollResults: Array<RollResultContract>
) => void;
onUpdateDiceRoll: (
rollKey: string,
properties: Omit<Partial<RollResultContract>, "rollKey">,
nextRollKey: string | null
) => void;
onSetRollStatus: (rollKey: string, status: DataLoadingStatusEnum) => void;
rollStatusLookup: Record<string, DataLoadingStatusEnum>;
options?: DiceRollProps["options"];
diceRollRequest:
| DiceRollProps["diceRollRequest"]
| Array<DiceRollProps["diceRollRequest"]>;
}
const DiceRollGroup: React.FunctionComponent<DiceRollGroupProps> = ({
className = "",
componentKey,
diceRolls,
options,
confirmButtonText = "Confirm",
showRemoveButton = false,
exclusiveOptions = false,
diceRollRequest,
groupKey,
nextGroupKey,
onUpdateGroup,
onRemoveGroup,
onRollError,
onConfirm,
onResetGroup,
onSetRollStatus,
onUpdateDiceRoll,
rollStatusLookup,
}) => {
const classNames = useMemo<Array<string>>(
() => ["ddbc-dice-roll-group", className],
[className]
);
const areRollsEmpty = useMemo<boolean>(
() => !diceRolls.some((roll) => roll.rollTotal !== null),
[diceRolls]
);
const areScoresUnassigned = useMemo<boolean>(
() =>
!diceRolls.some(
(roll) => roll.assignedValue !== null && roll.assignedValue !== ""
),
[diceRolls]
);
const assignedValues = useMemo<Array<string>>(() => {
return diceRolls
.map((roll) => roll.assignedValue)
.filter(TypeScriptUtils.isNotNullOrUndefined);
}, [diceRolls]);
const generatedOptions = useMemo<Array<Array<HtmlSelectOption>>>(() => {
if (!options) {
return [];
}
return diceRolls.map((roll): Array<HtmlSelectOption> => {
if (!exclusiveOptions) {
return options;
}
return options.filter(
(option) =>
roll.assignedValue === option.value ||
!assignedValues.includes(option.value.toString())
);
});
}, [options, diceRolls, exclusiveOptions, assignedValues]);
const generatedRollRequests = useMemo<Array<RollRequest>>(() => {
let requests: Array<RollRequest> = [];
for (let i = 0; i < diceRolls.length; i++) {
if (!Array.isArray(diceRollRequest)) {
requests.push(diceRollRequest);
} else {
if (diceRollRequest[i]) {
requests.push(diceRollRequest[i]);
} else {
requests.push(diceRollRequest[0]);
}
}
}
return requests;
}, [diceRollRequest, diceRolls]);
const handleRoll = useCallback(
(
rollKey: string,
diceRollRequest: RollRequest,
nextRollKey: string | null
): void => {
onSetRollStatus(rollKey, DataLoadingStatusEnum.LOADING);
Dice.roll(diceRollRequest)
.then((result) => {
//RollResult | undefined - RollResult is not exported from Dice;
// only expect to have one single rollRequest.
const rollResult = result.rolls[0].result ?? null;
if (!rollResult) {
throw new Error("error in rollResult");
}
const rollValues =
DiceComponentUtils.generateRollValueContracts(result);
if (!rollValues) {
throw new Error("error in rollResult values");
}
//update status
onSetRollStatus(rollKey, DataLoadingStatusEnum.LOADED);
//update diceRoll
onUpdateDiceRoll(
rollKey,
{ rollValues, rollTotal: rollResult.total },
nextRollKey
);
})
.catch((error: Error) => {
//this could handle different errors types from dice package or thrown errors from result
onSetRollStatus(rollKey, DataLoadingStatusEnum.NOT_LOADED);
if (onRollError) {
onRollError(error.message);
}
console.log("roll error", error);
});
},
[]
);
const handleChange = useCallback(
(
rollKey: string,
newValue: string | null,
prevValue: string | null,
nextRollKey: string | null,
rollTotal: number | null,
rollValues: Array<RollValueContract>
): void => {
if (newValue === prevValue) {
return;
}
onUpdateDiceRoll(
rollKey,
{ assignedValue: newValue, rollTotal, rollValues },
nextRollKey
);
},
[]
);
const handleConfirm = useCallback((): void => {
if (onConfirm) {
return onConfirm(groupKey, diceRolls);
}
}, [onConfirm, groupKey, diceRolls]);
const handleResetGroup = useCallback((): void => {
if (onResetGroup) {
onResetGroup(groupKey, diceRolls);
}
diceRolls.forEach((roll) => {
onSetRollStatus(roll.rollKey, DataLoadingStatusEnum.NOT_INITIALIZED);
});
}, [onResetGroup, groupKey]);
const handleRemoveGroup = useCallback((): void => {
if (onRemoveGroup) {
onRemoveGroup(groupKey, nextGroupKey);
}
}, [onRemoveGroup, groupKey, nextGroupKey]);
return (
<div className={classNames.join(" ")}>
<div className="ddbc-dice-roll-group__actions">
{showRemoveButton && (
<RemoveButton
size="small"
onClick={handleRemoveGroup}
enableConfirm={true}
>
Delete group
</RemoveButton>
)}
</div>
<div className="ddbc-dice-roll-group__rolls">
{diceRolls.map((diceRoll, idx) => {
const diceRollRequest = generatedRollRequests[idx];
if (!diceRollRequest) {
return null;
}
const rollStatus = HelperUtils.lookupDataOrFallback(
rollStatusLookup,
diceRoll.rollKey,
DataLoadingStatusEnum.NOT_INITIALIZED
);
return (
<DiceRoll
key={diceRoll.rollKey}
rollKey={diceRoll.rollKey}
nextRollKey={diceRoll.nextRollKey}
rollTotal={diceRoll.rollTotal}
rollValues={diceRoll.rollValues}
assignedValue={diceRoll.assignedValue}
options={generatedOptions[idx]}
onChange={handleChange}
onRoll={handleRoll}
diceRollRequest={diceRollRequest}
rollStatus={rollStatus}
/>
);
})}
</div>
<div className="ddbc-dice-roll-group__actions">
<RemoveButton
size="medium"
style="filled"
onClick={handleResetGroup}
disabled={areRollsEmpty}
enableConfirm={true}
>
Reset Group
</RemoveButton>
{onConfirm && (
<Button
size="medium"
onClick={handleConfirm}
disabled={areScoresUnassigned}
>
{confirmButtonText}
</Button>
)}
</div>
</div>
);
};
export default React.memo(DiceRollGroup);
@@ -0,0 +1,91 @@
import React from "react";
import { RollGroupContract } from "@dndbeyond/character-rules-engine/es";
import { DiceRollGroup, DiceRollGroupProps } from "../DiceRollGroup";
import { Button } from "../legacy/Button";
export interface DiceRollGroupManagerProps {
groups: Array<RollGroupContract>;
showAddGroupButton?: boolean;
onAddGroup?: () => void;
componentKey: DiceRollGroupProps["componentKey"];
options?: DiceRollGroupProps["options"];
exclusiveOptions?: DiceRollGroupProps["exclusiveOptions"];
diceRollRequest: DiceRollGroupProps["diceRollRequest"];
onConfirm?: DiceRollGroupProps["onConfirm"];
confirmButtonText?: DiceRollGroupProps["confirmButtonText"];
onRollError?: DiceRollGroupProps["onRollError"];
onRemoveGroup?: DiceRollGroupProps["onRemoveGroup"];
onUpdateGroup?: DiceRollGroupProps["onUpdateGroup"];
onResetGroup: DiceRollGroupProps["onResetGroup"];
onSetRollStatus: DiceRollGroupProps["onSetRollStatus"];
rollStatusLookup: DiceRollGroupProps["rollStatusLookup"];
onUpdateDiceRoll: DiceRollGroupProps["onUpdateDiceRoll"];
}
const DiceRollGroupManager: React.FunctionComponent<
DiceRollGroupManagerProps
> = ({
componentKey,
groups,
options,
diceRollRequest,
onConfirm,
confirmButtonText,
onRollError,
showAddGroupButton = false,
onUpdateGroup,
onResetGroup,
onAddGroup,
onRemoveGroup,
exclusiveOptions,
onSetRollStatus,
rollStatusLookup,
onUpdateDiceRoll,
}) => {
return (
<div className="ddbc-dice-roll-group-manager">
<div className="ddbc-dice-roll-group-manager__actions">
{showAddGroupButton && (
<Button
size="small"
onClick={onAddGroup}
disabled={groups.length >= 10}
>
+Add Group
</Button>
)}
<span className="ddbc-dice-roll-group-manager__actions-label">{`Groups: ${groups.length}`}</span>
</div>
<div className="ddbc-dice-roll-group-manager__groups">
{groups.map((group, idx) => {
return (
<DiceRollGroup
className="ddbc-dice-roll-group-manager__group"
key={group.groupKey}
componentKey={componentKey}
groupKey={group.groupKey}
nextGroupKey={group.nextGroupKey}
options={options}
exclusiveOptions={exclusiveOptions}
confirmButtonText={confirmButtonText}
onConfirm={onConfirm}
onUpdateGroup={onUpdateGroup}
onRollError={onRollError}
showRemoveButton={groups.length > 1}
onRemoveGroup={onRemoveGroup}
onResetGroup={onResetGroup}
diceRollRequest={diceRollRequest}
diceRolls={group.rollResults}
onSetRollStatus={onSetRollStatus}
rollStatusLookup={rollStatusLookup}
onUpdateDiceRoll={onUpdateDiceRoll}
/>
);
})}
</div>
</div>
);
};
export default React.memo(DiceRollGroupManager);
@@ -0,0 +1,59 @@
import * as React from "react";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
import Checkbox from "../Checkbox";
interface Props {
choices: Array<string>;
onSelection?: (slotIdx: number) => void;
activeChoice: number | null;
theme?: CharacterTheme;
}
export default class ExclusiveCheckbox extends React.PureComponent<Props, {}> {
handleSelection = (slotIdx: number): void => {
const { onSelection } = this.props;
if (onSelection) {
onSelection(slotIdx);
}
};
render() {
const { activeChoice, choices, theme } = this.props;
const parentClassNames: Array<string> = ["ddbc-exclusive-checkbox"];
if (theme?.isDarkMode) {
parentClassNames.push("ddbc-exclusive-checkbox--dark-mode");
}
return (
<div className={parentClassNames.join(" ")}>
{choices.map((choice, slotIdx) => {
const classNames: Array<string> = ["ddbc-exclusive-checkbox__slot"];
if (activeChoice === null) {
classNames.push("ddbc-exclusive-checkbox__slot--active");
} else {
if (activeChoice === slotIdx) {
classNames.push("ddbc-exclusive-checkbox__slot--selected");
} else {
classNames.push("ddbc-exclusive-checkbox__slot--inactive");
}
}
return (
<div key={slotIdx} className={classNames.join(" ")}>
<Checkbox
label={choice}
enabled={activeChoice === slotIdx}
onChange={this.handleSelection.bind(this, slotIdx)}
/>
</div>
);
})}
</div>
);
}
}
@@ -0,0 +1,98 @@
import * as React from "react";
import { Constants } from "@dndbeyond/character-rules-engine/es";
import {
DarkCharismaSvg,
DarkConstitutionSvg,
DarkDexteritySvg,
DarkIntelligenceSvg,
DarkStrengthSvg,
DarkWisdomSvg,
LightCharismaSvg,
LightConstitutionSvg,
LightDexteritySvg,
LightIntelligenceSvg,
LightStrengthSvg,
LightWisdomSvg,
} from "../../Svg";
//TODO should dynamically create props based on which component and expected props, for now this is duplicated from InjectedSvgProps hocTypings.ts
interface SvgInjectedProps {
className?: string;
}
interface Props {
statId: Constants.AbilityStatEnum;
themeMode?: "dark" | "light";
className: string;
}
export default class AbilityIcon extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
getDarkSvg = (): React.ComponentType<SvgInjectedProps> | null => {
const { statId } = this.props;
switch (statId) {
case Constants.AbilityStatEnum.CHARISMA:
return DarkCharismaSvg;
case Constants.AbilityStatEnum.CONSTITUTION:
return DarkConstitutionSvg;
case Constants.AbilityStatEnum.DEXTERITY:
return DarkDexteritySvg;
case Constants.AbilityStatEnum.INTELLIGENCE:
return DarkIntelligenceSvg;
case Constants.AbilityStatEnum.STRENGTH:
return DarkStrengthSvg;
case Constants.AbilityStatEnum.WISDOM:
return DarkWisdomSvg;
default:
return null;
}
};
getLightSvg = (): React.ComponentType<SvgInjectedProps> | null => {
const { statId } = this.props;
switch (statId) {
case Constants.AbilityStatEnum.CHARISMA:
return LightCharismaSvg;
case Constants.AbilityStatEnum.CONSTITUTION:
return LightConstitutionSvg;
case Constants.AbilityStatEnum.DEXTERITY:
return LightDexteritySvg;
case Constants.AbilityStatEnum.INTELLIGENCE:
return LightIntelligenceSvg;
case Constants.AbilityStatEnum.STRENGTH:
return LightStrengthSvg;
case Constants.AbilityStatEnum.WISDOM:
return LightWisdomSvg;
default:
return null;
}
};
render() {
const { themeMode, className } = this.props;
let classNames: Array<string> = [className, "ddbc-ability-icon"];
let SvgIcon: React.ComponentType<SvgInjectedProps> | null = null;
switch (themeMode) {
case "light":
SvgIcon = this.getLightSvg();
break;
case "dark":
default:
SvgIcon = this.getDarkSvg();
}
if (SvgIcon === null) {
return null;
}
return <SvgIcon className={classNames.join(" ")} />;
}
}
@@ -0,0 +1,55 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
import { AdvantageDisadvantageSvg } from "../../Svg";
import {
SvgConstantDarkModePositiveTheme,
SvgConstantDarkModeNegativeTheme,
SvgConstantPositiveTheme,
SvgConstantNegativeTheme,
} from "../../componentConstants";
interface Props {
title: string;
className: string;
theme: CharacterTheme;
}
export default class AdvantageDisadvantageIcon extends React.PureComponent<Props> {
static defaultProps = {
title: "",
className: "",
theme: {},
};
render() {
const { title, className, theme } = this.props;
let classNames: Array<string> = [
className,
"ddbc-advantage-disadvantage-icon",
];
return (
<Tooltip
title={title}
className={classNames.join(" ")}
isDarkMode={theme?.isDarkMode}
>
<AdvantageDisadvantageSvg
fillColor={
theme.isDarkMode
? SvgConstantDarkModePositiveTheme.fill
: SvgConstantPositiveTheme.fill
}
secondaryFillColor={
theme.isDarkMode
? SvgConstantDarkModeNegativeTheme.fill
: SvgConstantNegativeTheme.fill
}
/>
</Tooltip>
);
}
}
@@ -0,0 +1,4 @@
import AdvantageDisadvantageIcon from "./AdvantageDisadvantageIcon";
export default AdvantageDisadvantageIcon;
export { AdvantageDisadvantageIcon };
@@ -0,0 +1,42 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
import { DarkModePositiveAdvantageSvg, PositiveAdvantageSvg } from "../../Svg";
interface Props {
title: string;
className: string;
theme?: CharacterTheme;
secondaryFill?: string;
}
export default class AdvantageIcon extends React.PureComponent<Props> {
static defaultProps = {
title: "Advantage",
className: "",
theme: {},
};
render() {
const { title, className, theme } = this.props;
let classNames: Array<string> = [className, "ddbc-advantage-icon"];
return (
<Tooltip
title={title}
className={classNames.join(" ")}
isDarkMode={theme?.isDarkMode}
>
<span aria-label="Advantage">
{theme?.isDarkMode ? (
<DarkModePositiveAdvantageSvg />
) : (
<PositiveAdvantageSvg />
)}
</span>
</Tooltip>
);
}
}
@@ -0,0 +1,125 @@
import * as React from "react";
import {
DarkConeSvg,
DarkCubeSvg,
DarkCylinderSvg,
DarkLineSvg,
DarkSphereSvg,
DarkSquareSvg,
GrayConeSvg,
GrayCubeSvg,
GrayCylinderSvg,
GrayLineSvg,
GraySphereSvg,
GraySquareSvg,
LightConeSvg,
LightCubeSvg,
LightCylinderSvg,
LightLineSvg,
LightSphereSvg,
LightSquareSvg,
} from "../../Svg";
import { AoeTypePropType } from "../IconConstants";
interface SvgInjectedProps {
className?: string;
}
interface Props {
type?: AoeTypePropType;
themeMode?: "dark" | "gray" | "light";
className?: string;
}
const AoeTypeIcon: React.FunctionComponent<Props> = ({
className = "",
themeMode = "dark",
type,
}) => {
const DarkSvg = (): React.ComponentType<SvgInjectedProps> | null => {
switch (type) {
case "cone":
return DarkConeSvg;
case "cube":
return DarkCubeSvg;
case "cylinder":
return DarkCylinderSvg;
case "line":
return DarkLineSvg;
case "sphere":
case "emanation":
return DarkSphereSvg;
case "square":
return DarkSquareSvg;
default:
return null;
}
};
const GraySvg = (): React.ComponentType<SvgInjectedProps> | null => {
switch (type) {
case "cone":
return GrayConeSvg;
case "cube":
return GrayCubeSvg;
case "cylinder":
return GrayCylinderSvg;
case "line":
return GrayLineSvg;
case "sphere":
case "emanation":
return GraySphereSvg;
case "square":
return GraySquareSvg;
default:
return null;
}
};
const LightSvg = (): React.ComponentType<SvgInjectedProps> | null => {
switch (type) {
case "cone":
return LightConeSvg;
case "cube":
return LightCubeSvg;
case "cylinder":
return LightCylinderSvg;
case "line":
return LightLineSvg;
case "sphere":
case "emanation":
return LightSphereSvg;
case "square":
return LightSquareSvg;
default:
return null;
}
};
let classNames: Array<string> = [
className,
"ddbc-aoe-type-icon",
`ddbc-aoe-type-icon--${type}`,
];
let SvgIcon: React.ComponentType<SvgInjectedProps> | null = null;
switch (themeMode) {
case "light":
SvgIcon = LightSvg();
break;
case "gray":
SvgIcon = GraySvg();
break;
case "dark":
default:
SvgIcon = DarkSvg();
}
if (SvgIcon === null) {
return null;
}
return <SvgIcon className={classNames.join(" ")} />;
};
export default AoeTypeIcon;
@@ -0,0 +1,161 @@
import * as React from "react";
import { Constants } from "@dndbeyond/character-rules-engine/es";
import {
DarkMeleeSpellSvg,
DarkMeleeWeaponSvg,
DarkRangedSpellSvg,
DarkRangedWeaponSvg,
DarkThrownSvg,
DarkUnarmedStrikeSvg,
DarkWeaponSpellDamageSvg,
GrayMeleeSpellSvg,
GrayMeleeWeaponSvg,
GrayRangedSpellSvg,
GrayRangedWeaponSvg,
GrayThrownSvg,
GrayUnarmedStrikeSvg,
GrayWeaponSpellDamageSvg,
LightMeleeSpellSvg,
LightMeleeWeaponSvg,
LightRangedSpellSvg,
LightRangedWeaponSvg,
LightThrownSvg,
LightUnarmedStrikeSvg,
LightWeaponSpellDamageSvg,
} from "../../Svg";
interface SvgInjectedProps {
className?: string;
}
interface Props {
actionType: Constants.ActionTypeEnum | null;
rangeType: Constants.AttackTypeRangeEnum;
themeMode?: "dark" | "gray" | "light";
className?: string;
overrideType?: "weapon-spell" | null;
}
const AttackTypeIcon: React.FunctionComponent<Props> = ({
className = "",
themeMode = "dark",
actionType,
rangeType,
overrideType,
}) => {
const DarkSvg = (): React.ComponentType<SvgInjectedProps> | null => {
switch (true) {
case actionType === Constants.ActionTypeEnum.GENERAL &&
rangeType === Constants.AttackTypeRangeEnum.MELEE:
return DarkUnarmedStrikeSvg;
case actionType === Constants.ActionTypeEnum.GENERAL &&
rangeType === Constants.AttackTypeRangeEnum.RANGED:
return DarkThrownSvg;
case actionType === Constants.ActionTypeEnum.SPELL &&
rangeType === Constants.AttackTypeRangeEnum.MELEE:
return DarkMeleeSpellSvg;
case actionType === Constants.ActionTypeEnum.SPELL &&
rangeType === Constants.AttackTypeRangeEnum.RANGED:
return DarkRangedSpellSvg;
case actionType === Constants.ActionTypeEnum.WEAPON &&
overrideType === "weapon-spell":
return DarkWeaponSpellDamageSvg;
case actionType === Constants.ActionTypeEnum.WEAPON &&
rangeType === Constants.AttackTypeRangeEnum.MELEE:
return DarkMeleeWeaponSvg;
case actionType === Constants.ActionTypeEnum.WEAPON &&
rangeType === Constants.AttackTypeRangeEnum.RANGED:
return DarkRangedWeaponSvg;
default:
return null;
}
};
const GraySvg = (): React.ComponentType<SvgInjectedProps> | null => {
switch (true) {
case actionType === Constants.ActionTypeEnum.GENERAL &&
rangeType === Constants.AttackTypeRangeEnum.MELEE:
return GrayUnarmedStrikeSvg;
case actionType === Constants.ActionTypeEnum.GENERAL &&
rangeType === Constants.AttackTypeRangeEnum.RANGED:
return GrayThrownSvg;
case actionType === Constants.ActionTypeEnum.SPELL &&
rangeType === Constants.AttackTypeRangeEnum.MELEE:
return GrayMeleeSpellSvg;
case actionType === Constants.ActionTypeEnum.SPELL &&
rangeType === Constants.AttackTypeRangeEnum.RANGED:
return GrayRangedSpellSvg;
case actionType === Constants.ActionTypeEnum.WEAPON &&
overrideType === "weapon-spell":
return GrayWeaponSpellDamageSvg;
case actionType === Constants.ActionTypeEnum.WEAPON &&
rangeType === Constants.AttackTypeRangeEnum.MELEE:
return GrayMeleeWeaponSvg;
case actionType === Constants.ActionTypeEnum.WEAPON &&
rangeType === Constants.AttackTypeRangeEnum.RANGED:
return GrayRangedWeaponSvg;
default:
return null;
}
};
const LightSvg = (): React.ComponentType<SvgInjectedProps> | null => {
switch (true) {
case actionType === Constants.ActionTypeEnum.GENERAL &&
rangeType === Constants.AttackTypeRangeEnum.MELEE:
return LightUnarmedStrikeSvg;
case actionType === Constants.ActionTypeEnum.GENERAL &&
rangeType === Constants.AttackTypeRangeEnum.RANGED:
return LightThrownSvg;
case actionType === Constants.ActionTypeEnum.SPELL &&
rangeType === Constants.AttackTypeRangeEnum.MELEE:
return LightMeleeSpellSvg;
case actionType === Constants.ActionTypeEnum.SPELL &&
rangeType === Constants.AttackTypeRangeEnum.RANGED:
return LightRangedSpellSvg;
case actionType === Constants.ActionTypeEnum.WEAPON &&
overrideType === "weapon-spell":
return LightWeaponSpellDamageSvg;
case actionType === Constants.ActionTypeEnum.WEAPON &&
rangeType === Constants.AttackTypeRangeEnum.MELEE:
return LightMeleeWeaponSvg;
case actionType === Constants.ActionTypeEnum.WEAPON &&
rangeType === Constants.AttackTypeRangeEnum.RANGED:
return LightRangedWeaponSvg;
default:
return null;
}
};
let classNames: Array<string> = [
className,
"ddbc-attack-type-icon",
`ddbc-attack-type-icon--${actionType}-${rangeType}`,
];
if (overrideType) {
classNames.push(`ddbc-attack-type-icon--${overrideType}`);
}
let SvgIcon: React.ComponentType<SvgInjectedProps> | null = null;
switch (themeMode) {
case "light":
SvgIcon = LightSvg();
break;
case "gray":
SvgIcon = GraySvg();
break;
case "dark":
default:
SvgIcon = DarkSvg();
}
if (SvgIcon === null) {
return null;
}
return <SvgIcon className={classNames.join(" ")} />;
};
export default AttackTypeIcon;
@@ -0,0 +1,43 @@
import * as React from "react";
import { v4 as uuidv4 } from "uuid";
import { Tooltip } from "~/components/Tooltip";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { DarkAttunementSvg, GrayAttunementSvg } from "../../Svg";
interface SvgInjectedProps {
className?: string;
}
interface Props {
className?: string;
showTooltip?: boolean;
}
const AttunementIcon: React.FunctionComponent<Props> = ({
className = "",
showTooltip = true,
}) => {
const { isDarkMode } = useCharacterTheme();
let classNames: Array<string> = [className, "ddbc-attunement-icon"];
let SvgIcon: React.ComponentType<SvgInjectedProps> = isDarkMode
? GrayAttunementSvg
: DarkAttunementSvg;
const tooltipId = `attunmentIcon-${uuidv4()}`;
return (
<>
<span
className={classNames.join(" ")}
data-tooltip-id={tooltipId}
data-tooltip-content="Item is Attuned"
>
<SvgIcon className="ddbc-attunement-icon__icon" />
</span>
{showTooltip && <Tooltip id={tooltipId} />}
</>
);
};
export default AttunementIcon;
@@ -0,0 +1,59 @@
import * as React from "react";
import { Constants } from "@dndbeyond/character-rules-engine/es";
import {
GoldCoinSvg,
SilverCoinSvg,
CopperCoinSvg,
ElectrumCoinSvg,
PlatinumCoinSvg,
} from "../../Svg";
//TODO should dynamically create props based on which component and expected props, for now this is duplicated from InjectedSvgProps hocTypings.ts
interface SvgInjectedProps {
className?: string;
}
interface Props {
coinType: Constants.CoinTypeEnum;
className: string;
}
export default class AbilityIcon extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
render() {
const { className, coinType } = this.props;
let classNames: Array<string> = [className, "ddbc-ability-icon"];
let SvgIcon: React.ComponentType<SvgInjectedProps> | null = null;
switch (coinType) {
case Constants.CoinTypeEnum.cp:
SvgIcon = CopperCoinSvg;
break;
case Constants.CoinTypeEnum.ep:
SvgIcon = ElectrumCoinSvg;
break;
case Constants.CoinTypeEnum.gp:
SvgIcon = GoldCoinSvg;
break;
case Constants.CoinTypeEnum.pp:
SvgIcon = PlatinumCoinSvg;
break;
case Constants.CoinTypeEnum.sp:
SvgIcon = SilverCoinSvg;
break;
default:
SvgIcon = null;
}
if (SvgIcon === null) {
return null;
}
return <SvgIcon className={classNames.join(" ")} />;
}
}
@@ -0,0 +1,4 @@
import CoinIcon from "./CoinIcon";
export default CoinIcon;
export { CoinIcon };
@@ -0,0 +1,60 @@
import clsx from "clsx";
import * as React from "react";
import { v4 as uuidv4 } from "uuid";
import { Tooltip } from "~/components/Tooltip";
import {
DarkConcentrationSvg,
GrayConcentrationSvg,
LightConcentrationSvg,
} from "../../Svg";
import styles from "./styles.module.css";
interface SvgInjectedProps {
className?: string;
}
interface Props {
themeMode?: "dark" | "gray" | "light";
className: string;
}
export default class ConcentrationIcon extends React.PureComponent<Props> {
static defaultProps = {
className: "",
themeMode: "dark",
};
render() {
const { themeMode, className, ...props } = this.props;
const id = uuidv4();
let SvgIcon: React.ComponentType<SvgInjectedProps> | null = null;
switch (themeMode) {
case "light":
SvgIcon = LightConcentrationSvg;
break;
case "gray":
SvgIcon = GrayConcentrationSvg;
break;
case "dark":
default:
SvgIcon = DarkConcentrationSvg;
}
if (SvgIcon === null) {
return null;
}
return (
<div
className={clsx([styles.concentrationIcon, className])}
data-tooltip-id={id}
data-tooltip-content="Concentration"
>
<SvgIcon className={styles.svg} {...props} />
<Tooltip id={id} />
</div>
);
}
}
@@ -0,0 +1,4 @@
import ConcentrationIcon from "./ConcentrationIcon";
export default ConcentrationIcon;
export { ConcentrationIcon };
@@ -0,0 +1,129 @@
import * as React from "react";
import { Constants } from "@dndbeyond/character-rules-engine/es";
import {
DarkBlindedSvg,
LightBlindedSvg,
DarkCharmedSvg,
LightCharmedSvg,
DarkDeafenedSvg,
LightDeafenedSvg,
LightExhaustedSvg,
DarkExhaustedSvg,
LightFrightenedSvg,
DarkFrightenedSvg,
LightGrappledSvg,
DarkGrappledSvg,
DarkIncapacitatedSvg,
LightIncapacitatedSvg,
LightInvisibleSvg,
DarkInvisibleSvg,
DarkParalyzedSvg,
LightParalyzedSvg,
DarkPetrifiedSvg,
LightPetrifiedSvg,
LightPoisonedSvg,
DarkPoisonedSvg,
LightProneSvg,
DarkProneSvg,
LightRestrainedSvg,
DarkRestrainedSvg,
LightStunnedSvg,
DarkStunnedSvg,
LightUnconsciousSvg,
DarkUnconsciousSvg,
} from "../../Svg";
interface SvgInjectedProps {
className?: string;
}
interface Props {
conditionType: Constants.ConditionIdEnum;
className: string;
isDarkMode: boolean;
}
const iconMap = {
[Constants.ConditionIdEnum.BLINDED]: {
light: LightBlindedSvg,
dark: DarkBlindedSvg,
},
[Constants.ConditionIdEnum.CHARMED]: {
light: LightCharmedSvg,
dark: DarkCharmedSvg,
},
[Constants.ConditionIdEnum.DEAFENED]: {
light: LightDeafenedSvg,
dark: DarkDeafenedSvg,
},
[Constants.ConditionIdEnum.EXHAUSTION]: {
light: LightExhaustedSvg,
dark: DarkExhaustedSvg,
},
[Constants.ConditionIdEnum.FRIGHTENED]: {
light: LightFrightenedSvg,
dark: DarkFrightenedSvg,
},
[Constants.ConditionIdEnum.GRAPPLED]: {
light: LightGrappledSvg,
dark: DarkGrappledSvg,
},
[Constants.ConditionIdEnum.INCAPACITATED]: {
light: LightIncapacitatedSvg,
dark: DarkIncapacitatedSvg,
},
[Constants.ConditionIdEnum.INVISIBLE]: {
light: LightInvisibleSvg,
dark: DarkInvisibleSvg,
},
[Constants.ConditionIdEnum.PARALYZED]: {
light: LightParalyzedSvg,
dark: DarkParalyzedSvg,
},
[Constants.ConditionIdEnum.PETRIFIED]: {
light: LightPetrifiedSvg,
dark: DarkPetrifiedSvg,
},
[Constants.ConditionIdEnum.POISONED]: {
light: LightPoisonedSvg,
dark: DarkPoisonedSvg,
},
[Constants.ConditionIdEnum.PRONE]: {
light: LightProneSvg,
dark: DarkProneSvg,
},
[Constants.ConditionIdEnum.RESTRAINED]: {
light: LightRestrainedSvg,
dark: DarkRestrainedSvg,
},
[Constants.ConditionIdEnum.STUNNED]: {
light: LightStunnedSvg,
dark: DarkStunnedSvg,
},
[Constants.ConditionIdEnum.UNCONSCIOUS]: {
light: LightUnconsciousSvg,
dark: DarkUnconsciousSvg,
},
};
export default class ConditionIcon extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
render() {
const { className, conditionType, isDarkMode } = this.props;
let classNames: Array<string> = [className, "ddbc-condition-icon"];
let SvgIcon: React.ComponentType<SvgInjectedProps> | null =
iconMap[conditionType]?.[isDarkMode ? "light" : "dark"] ?? null;
if (SvgIcon === null) {
return null;
}
return <SvgIcon className={classNames.join(" ")} />;
}
}
@@ -0,0 +1,4 @@
import ConditionIcon from "./ConditionIcon";
export default ConditionIcon;
export { ConditionIcon };
@@ -0,0 +1,204 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
import {
DarkAcidSvg,
GrayAcidSvg,
LightAcidSvg,
DarkBludgeoningSvg,
GrayBludgeoningSvg,
LightBludgeoningSvg,
DarkColdSvg,
GrayColdSvg,
LightColdSvg,
DarkFireSvg,
GrayFireSvg,
LightFireSvg,
DarkForceSvg,
GrayForceSvg,
LightForceSvg,
DarkLightningSvg,
GrayLightningSvg,
LightLightningSvg,
DarkNecroticSvg,
GrayNecroticSvg,
LightNecroticSvg,
DarkPiercingSvg,
GrayPiercingSvg,
LightPiercingSvg,
DarkPoisonSvg,
GrayPoisonSvg,
LightPoisonSvg,
DarkPsychicSvg,
GrayPsychicSvg,
LightPsychicSvg,
DarkRadiantSvg,
GrayRadiantSvg,
LightRadiantSvg,
DarkSlashingSvg,
GraySlashingSvg,
LightSlashingSvg,
DarkThunderSvg,
GrayThunderSvg,
LightThunderSvg,
} from "../../Svg";
import { DamageTypePropType } from "../IconConstants";
interface SvgInjectedProps {
className?: string;
}
interface Props {
type: DamageTypePropType;
showTooltip?: boolean;
className?: string;
theme?: CharacterTheme;
themeMode?: "dark" | "gray" | "light";
}
const DamageTypeIcon: React.FunctionComponent<Props> = ({
showTooltip = true,
className = "",
type,
theme,
themeMode = theme?.isDarkMode ? "gray" : "dark",
}) => {
const DarkSvg = (): React.ComponentType<SvgInjectedProps> | null => {
switch (type) {
case "acid":
return DarkAcidSvg;
case "bludgeoning":
return DarkBludgeoningSvg;
case "cold":
return DarkColdSvg;
case "fire":
return DarkFireSvg;
case "force":
return DarkForceSvg;
case "lightning":
return DarkLightningSvg;
case "necrotic":
return DarkNecroticSvg;
case "piercing":
return DarkPiercingSvg;
case "poison":
return DarkPoisonSvg;
case "psychic":
return DarkPsychicSvg;
case "radiant":
return DarkRadiantSvg;
case "slashing":
return DarkSlashingSvg;
case "thunder":
return DarkThunderSvg;
default:
return null;
}
};
const GraySvg = (): React.ComponentType<SvgInjectedProps> | null => {
switch (type) {
case "acid":
return GrayAcidSvg;
case "bludgeoning":
return GrayBludgeoningSvg;
case "cold":
return GrayColdSvg;
case "fire":
return GrayFireSvg;
case "force":
return GrayForceSvg;
case "lightning":
return GrayLightningSvg;
case "necrotic":
return GrayNecroticSvg;
case "piercing":
return GrayPiercingSvg;
case "poison":
return GrayPoisonSvg;
case "psychic":
return GrayPsychicSvg;
case "radiant":
return GrayRadiantSvg;
case "slashing":
return GraySlashingSvg;
case "thunder":
return GrayThunderSvg;
default:
return null;
}
};
const LightSvg = (): React.ComponentType<SvgInjectedProps> | null => {
switch (type) {
case "acid":
return LightAcidSvg;
case "bludgeoning":
return LightBludgeoningSvg;
case "cold":
return LightColdSvg;
case "fire":
return LightFireSvg;
case "force":
return LightForceSvg;
case "lightning":
return LightLightningSvg;
case "necrotic":
return LightNecroticSvg;
case "piercing":
return LightPiercingSvg;
case "poison":
return LightPoisonSvg;
case "psychic":
return LightPsychicSvg;
case "radiant":
return LightRadiantSvg;
case "slashing":
return LightSlashingSvg;
case "thunder":
return LightThunderSvg;
default:
return null;
}
};
let classNames: Array<string> = [
className,
"ddbc-damage-type-icon",
`ddbc-damage-type-icon--${type}`,
];
let SvgIcon: React.ComponentType<SvgInjectedProps> | null = null;
switch (themeMode) {
case "light":
SvgIcon = LightSvg();
break;
case "gray":
SvgIcon = GraySvg();
break;
case "dark":
default:
SvgIcon = DarkSvg();
}
if (SvgIcon === null) {
return null;
}
return (
<span className={classNames.join(" ")} aria-label={`${type} damage`}>
<Tooltip
title={type}
enabled={showTooltip}
isDarkMode={theme?.isDarkMode}
>
<SvgIcon
className={`ddbc-damage-type-icon__img ddbc-damage-type-icon__img--${type}`}
/>
</Tooltip>
</span>
);
};
export default DamageTypeIcon;
@@ -0,0 +1,44 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
import {
DarkModeNegativeDisadvantageSvg,
NegativeDisadvantageSvg,
} from "../../Svg";
interface Props {
title: string;
className: string;
theme?: CharacterTheme;
}
export default class DisadvantageIcon extends React.PureComponent<Props> {
static defaultProps = {
title: "Disadvantage",
className: "",
theme: {},
};
render() {
const { title, className, theme } = this.props;
const classNames: Array<string> = [className, "ddbc-disadvantage-icon"];
return (
<Tooltip
title={title}
className={classNames.join(" ")}
isDarkMode={theme?.isDarkMode}
>
<span aria-label="Disadvantage">
{theme?.isDarkMode ? (
<DarkModeNegativeDisadvantageSvg />
) : (
<NegativeDisadvantageSvg />
)}
</span>
</Tooltip>
);
}
}
@@ -0,0 +1,56 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
import { DarkHealingSvg, GrayHealingSvg } from "../../Svg";
interface SvgInjectedProps {
className?: string;
}
interface Props {
className: string;
isHp: boolean;
isTemp: boolean;
showTooltip: boolean;
theme?: CharacterTheme;
}
export default class HealingIcon extends React.PureComponent<Props> {
static defaultProps = {
className: "",
isHp: false,
isTemp: false,
showTooltip: true,
};
render() {
const { isHp, isTemp, showTooltip, className, theme } = this.props;
let classNames: Array<string> = [className, "ddbc-healing-icon"];
let tooltips: Array<string> = [];
if (isHp) {
classNames.push("ddbc-healing-icon--hp");
tooltips.push("Healing");
}
if (isTemp) {
classNames.push("ddbc-healing-icon--temp");
tooltips.push("Temp HP Healing");
}
let SvgIcon: React.ComponentType<SvgInjectedProps> = theme?.isDarkMode
? GrayHealingSvg
: DarkHealingSvg;
return (
<span className={classNames.join(" ")} aria-label="healing">
<Tooltip
title={tooltips.join(", ")}
enabled={showTooltip}
isDarkMode={theme?.isDarkMode}
>
<SvgIcon className="ddbc-healing-icon__icon" />
</Tooltip>
</span>
);
}
}
@@ -0,0 +1,40 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
import { DarkModePositiveImmunitySvg, PositiveImmunitySvg } from "../../Svg";
interface SvgInjectedProps {
className?: string;
}
interface Props {
title: string;
className: string;
theme?: CharacterTheme;
secondaryFill?: string;
}
export default class ImmunityIcon extends React.PureComponent<Props> {
static defaultProps = {
title: "Immunity",
className: "",
theme: {},
};
render() {
const { title, className, theme } = this.props;
let classNames: Array<string> = [className, "ddbc-immunity-icon"];
let SvgIcon: React.ComponentType<SvgInjectedProps> = theme?.isDarkMode
? DarkModePositiveImmunitySvg
: PositiveImmunitySvg;
return (
<Tooltip title={title} isDarkMode={theme?.isDarkMode}>
<SvgIcon className={classNames.join(" ")} />
</Tooltip>
);
}
}
@@ -0,0 +1,97 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
interface Props {
className: string;
tooltip: string;
onClick?: () => void;
enableTooltip: boolean;
showIconOnEdge: boolean;
showIcon: boolean;
theme: CharacterTheme;
}
export default class ManageIcon extends React.PureComponent<Props> {
static defaultProps = {
tooltip: "Manage",
enableTooltip: true,
showIconOnEdge: true,
showIcon: true,
className: "",
};
handleClick = (evt: React.MouseEvent): void => {
const { onClick } = this.props;
if (onClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick();
}
};
render() {
const {
tooltip,
children,
onClick,
enableTooltip,
showIconOnEdge,
showIcon,
className,
theme,
} = this.props;
let classNames: Array<string> = [className, "ddbc-manage-icon"];
if (showIconOnEdge) {
classNames.push("ddbc-manage-icon--edge-icon");
}
if (showIcon) {
classNames.push("ddbc-manage-icon--interactive");
}
if (theme?.isDarkMode) {
classNames.push("ddbc-manage-icon--dark-mode");
}
let contentNode: React.ReactNode = (
<React.Fragment>
{children && (
<span className="ddbc-manage-icon__content">{children}</span>
)}
{showIcon && (
<span
className={`ddbc-manage-icon__icon ${
theme?.isDarkMode ? `ddbc-manage-icon__icon--dark-mode` : ""
}`}
/>
)}
</React.Fragment>
);
if (enableTooltip) {
return (
<Tooltip
title={tooltip}
className={classNames.join(" ")}
onClick={onClick}
isInteractive={true}
isDarkMode={theme?.isDarkMode}
>
{contentNode}
</Tooltip>
);
}
return (
<span
className={classNames.join(" ")}
onClick={this.handleClick}
role="button"
aria-label="Manage"
>
{contentNode}
</span>
);
}
}
@@ -0,0 +1,46 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
CharacterTheme,
Constants,
} from "@dndbeyond/character-rules-engine/es";
import { ModifiedProficiencyHalfSvg, ProficiencyHalfSvg } from "../../../Svg";
interface Props {
isModified: boolean;
className: string;
theme?: CharacterTheme;
}
export default class HalfProficiencyIcon extends React.PureComponent<Props> {
static defaultProps = {
isModified: false,
className: "",
};
render() {
const { isModified, className, theme } = this.props;
let IconComponent: React.ComponentType<any> = isModified
? ModifiedProficiencyHalfSvg
: ProficiencyHalfSvg;
let classNames: Array<string> = ["ddbc-half-proficiency-icon", className];
if (isModified) {
classNames.push("ddbc-half-proficiency-icon--modified");
}
let fillColor;
if (theme?.isDarkMode) {
fillColor = isModified
? Constants.CharacterColorEnum.DARKMODE_TEXT
: theme?.themeColor;
}
return (
<Tooltip title="Half Proficiency" isDarkMode={theme?.isDarkMode}>
<IconComponent className={classNames.join(" ")} fillColor={fillColor} />
</Tooltip>
);
}
}
@@ -0,0 +1,4 @@
import HalfProficiencyIcon from "./HalfProficiencyIcon";
export default HalfProficiencyIcon;
export { HalfProficiencyIcon };
@@ -0,0 +1,35 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
interface Props {
isModified: boolean;
className: string;
theme?: CharacterTheme;
}
export default class NoProficiencyIcon extends React.PureComponent<Props> {
static defaultProps = {
isModified: false,
className: "",
};
render() {
const { isModified, className, theme } = this.props;
let classNames: Array<string> = ["ddbc-no-proficiency-icon", className];
if (isModified) {
classNames.push("ddbc-no-proficiency-icon--modified");
}
if (theme?.isDarkMode) {
classNames.push("ddbc-no-proficiency-icon--dark-mode");
}
return (
<Tooltip title="Not Proficient" isDarkMode={theme?.isDarkMode}>
<span className={classNames.join(" ")} />
</Tooltip>
);
}
}
@@ -0,0 +1,4 @@
import NoProficiencyIcon from "./NoProficiencyIcon";
export default NoProficiencyIcon;
export { NoProficiencyIcon };
@@ -0,0 +1,50 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
CharacterTheme,
Constants,
} from "@dndbeyond/character-rules-engine/es";
import { ProficiencySvg, ModifiedProficiencySvg } from "../../../Svg";
interface Props {
isModified: boolean;
className: string;
theme?: CharacterTheme;
}
export default class ProficiencyIcon extends React.PureComponent<Props> {
static defaultProps = {
isModified: false,
className: "",
};
render() {
const { isModified, className, theme } = this.props;
let IconComponent: React.ComponentType<any> = isModified
? ModifiedProficiencySvg
: ProficiencySvg;
let classNames: Array<string> = ["ddbc-proficiency-icon", className];
if (isModified) {
classNames.push("ddbc-proficiency-icon--modified");
}
return (
<Tooltip title="Proficiency" isDarkMode={theme?.isDarkMode}>
<IconComponent
className={classNames.join(" ")}
fillColor={
theme?.isDarkMode && !isModified
? theme.themeColor
: theme?.isDarkMode
? Constants.CharacterColorEnum.DARKMODE_TEXT
: undefined
}
/>
</Tooltip>
);
}
}
@@ -0,0 +1,4 @@
import ProficiencyIcon from "./ProficiencyIcon";
export default ProficiencyIcon;
export { ProficiencyIcon };
@@ -0,0 +1,63 @@
import * as React from "react";
import {
CharacterTheme,
Constants,
} from "@dndbeyond/character-rules-engine/es";
import HalfProficiencyIcon from "./HalfProficiencyIcon";
import NoProficiencyIcon from "./NoProficiencyIcon";
import ProficiencyIcon from "./ProficiencyIcon";
import TwiceProficiencyIcon from "./TwiceProficiencyIcon";
interface Props {
proficiencyLevel: number;
isModified: boolean;
theme?: CharacterTheme;
}
export default class ProficiencyLevelIcon extends React.PureComponent<Props> {
static defaultProps = {
isModified: false,
};
render() {
const { proficiencyLevel, isModified, theme } = this.props;
let IconComponent: React.ComponentType<any> | null = null;
let proficiencyVerbiage: string = "Not Proficient";
switch (proficiencyLevel) {
case Constants.ProficiencyLevelEnum.NONE:
IconComponent = NoProficiencyIcon;
break;
case Constants.ProficiencyLevelEnum.HALF:
IconComponent = HalfProficiencyIcon;
proficiencyVerbiage = "Half Proficient";
break;
case Constants.ProficiencyLevelEnum.FULL:
IconComponent = ProficiencyIcon;
proficiencyVerbiage = "Proficient";
break;
case Constants.ProficiencyLevelEnum.EXPERT:
IconComponent = TwiceProficiencyIcon;
proficiencyVerbiage = "Expert";
break;
default:
//not implemented
}
if (IconComponent === null) {
return null;
}
return (
<span aria-label={proficiencyVerbiage}>
<IconComponent
className="ddbc-proficiency-level-icon"
isModified={isModified}
theme={theme}
/>
</span>
);
}
}
@@ -0,0 +1,56 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
CharacterTheme,
Constants,
} from "@dndbeyond/character-rules-engine/es";
import {
ModifiedProficiencyDoubleSvg,
ProficiencyDoubleSvg,
} from "../../../Svg";
interface Props {
isModified: boolean;
className: string;
theme?: CharacterTheme;
}
export default class TwiceProficiencyIcon extends React.PureComponent<Props> {
static defaultProps = {
isModified: false,
className: "",
};
render() {
const { isModified, className, theme } = this.props;
let IconComponent: React.ComponentType<any> = isModified
? ModifiedProficiencyDoubleSvg
: ProficiencyDoubleSvg;
let classNames: Array<string> = ["ddbc-twice-proficiency-icon", className];
if (isModified) {
classNames.push("ddbc-twice-proficiency-icon--modified");
}
let secondaryFill;
if (theme?.isDarkMode) {
secondaryFill = isModified
? Constants.CharacterColorEnum.DARKMODE_TEXT
: theme?.themeColor;
}
return (
<Tooltip title="Expertise" isDarkMode={theme?.isDarkMode}>
<span className={classNames.join(" ")}>
<IconComponent
className="ddbc-twice-proficiency-icon__inner"
fillColor={theme?.isDarkMode ? theme.themeColor : undefined}
secondaryFillColor={secondaryFill}
/>
</span>
</Tooltip>
);
}
}
@@ -0,0 +1,4 @@
import TwiceProficiencyIcon from "./TwiceProficiencyIcon";
export default TwiceProficiencyIcon;
export { TwiceProficiencyIcon };
@@ -0,0 +1,43 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
import {
PositiveResistanceSvg,
DarkModePositiveResistanceSvg,
} from "../../Svg";
interface SvgInjectedProps {
className?: string;
}
interface Props {
title: string;
className: string;
theme?: CharacterTheme;
secondaryFill?: string;
}
export default class ResistanceIcon extends React.PureComponent<Props> {
static defaultProps = {
title: "Resistance",
className: "",
theme: {},
};
render() {
const { title, className, theme } = this.props;
let classNames: Array<string> = [className, "ddbc-resistance-icon"];
let SvgIcon: React.ComponentType<SvgInjectedProps> = theme?.isDarkMode
? DarkModePositiveResistanceSvg
: PositiveResistanceSvg;
return (
<Tooltip title={title} isDarkMode={theme?.isDarkMode}>
<SvgIcon className={classNames.join(" ")} />
</Tooltip>
);
}
}
@@ -0,0 +1,4 @@
import ResistanceIcon from "./ResistanceIcon";
export default ResistanceIcon;
export { ResistanceIcon };
@@ -0,0 +1,57 @@
import clsx from "clsx";
import * as React from "react";
import { v4 as uuidv4 } from "uuid";
import { Tooltip } from "~/components/Tooltip";
import { DarkRitualSvg, GrayRitualSvg, LightRitualSvg } from "../../Svg";
import styles from "./styles.module.css";
interface SvgInjectedProps {
className?: string;
}
interface Props {
themeMode?: "dark" | "gray" | "light";
className: string;
}
export default class RitualIcon extends React.PureComponent<Props> {
static defaultProps = {
className: "",
themeMode: "dark",
};
render() {
const { themeMode, className, ...props } = this.props;
const id = uuidv4();
let SvgIcon: React.ComponentType<SvgInjectedProps> | null = null;
switch (themeMode) {
case "light":
SvgIcon = LightRitualSvg;
break;
case "gray":
SvgIcon = GrayRitualSvg;
break;
case "dark":
default:
SvgIcon = DarkRitualSvg;
}
if (SvgIcon === null) {
return null;
}
return (
<div
className={clsx([styles.ritualIcon, className])}
data-tooltip-id={id}
data-tooltip-content="Ritual"
>
<SvgIcon className={styles.svg} {...props} />
<Tooltip id={id} />
</div>
);
}
}
@@ -0,0 +1,140 @@
import * as React from "react";
import {
DarkAbjurationSvg,
DarkConjurationSvg,
DarkDivinationSvg,
DarkEnchantmentSvg,
DarkEvocationSvg,
DarkIllusionSvg,
DarkNecromancySvg,
DarkTransmutationSvg,
GrayAbjurationSvg,
GrayConjurationSvg,
GrayDivinationSvg,
GrayEnchantmentSvg,
GrayEvocationSvg,
GrayIllusionSvg,
GrayNecromancySvg,
GrayTransmutationSvg,
LightAbjurationSvg,
LightConjurationSvg,
LightDivinationSvg,
LightEnchantmentSvg,
LightEvocationSvg,
LightIllusionSvg,
LightNecromancySvg,
LightTransmutationSvg,
} from "../../Svg";
import { SpellSchoolPropType } from "../IconConstants";
interface SvgInjectedProps {
className?: string;
}
interface Props {
school: SpellSchoolPropType;
themeMode?: "dark" | "gray" | "light";
className?: string;
}
const SpellSchoolIcon: React.FunctionComponent<Props> = ({
className = "",
themeMode = "dark",
school,
}) => {
const DarkSvg = (): React.ComponentType<SvgInjectedProps> | null => {
switch (school) {
case "abjuration":
return DarkAbjurationSvg;
case "conjuration":
return DarkConjurationSvg;
case "divination":
return DarkDivinationSvg;
case "enchantment":
return DarkEnchantmentSvg;
case "evocation":
return DarkEvocationSvg;
case "illusion":
return DarkIllusionSvg;
case "necromancy":
return DarkNecromancySvg;
case "transmutation":
return DarkTransmutationSvg;
default:
return null;
}
};
const GraySvg = (): React.ComponentType<SvgInjectedProps> | null => {
switch (school) {
case "abjuration":
return GrayAbjurationSvg;
case "conjuration":
return GrayConjurationSvg;
case "divination":
return GrayDivinationSvg;
case "enchantment":
return GrayEnchantmentSvg;
case "evocation":
return GrayEvocationSvg;
case "illusion":
return GrayIllusionSvg;
case "necromancy":
return GrayNecromancySvg;
case "transmutation":
return GrayTransmutationSvg;
default:
return null;
}
};
const LightSvg = (): React.ComponentType<SvgInjectedProps> | null => {
switch (school) {
case "abjuration":
return LightAbjurationSvg;
case "conjuration":
return LightConjurationSvg;
case "divination":
return LightDivinationSvg;
case "enchantment":
return LightEnchantmentSvg;
case "evocation":
return LightEvocationSvg;
case "illusion":
return LightIllusionSvg;
case "necromancy":
return LightNecromancySvg;
case "transmutation":
return LightTransmutationSvg;
default:
return null;
}
};
let classNames: Array<string> = [
className,
"ddbc-spell-school-icon",
`ddbc-spell-school-icon--${school}`,
];
let SvgIcon: React.ComponentType<SvgInjectedProps> | null = null;
switch (themeMode) {
case "light":
SvgIcon = LightSvg();
break;
case "gray":
SvgIcon = GraySvg();
break;
case "dark":
default:
SvgIcon = DarkSvg();
}
if (SvgIcon === null) {
return null;
}
return <SvgIcon className={classNames.join(" ")} />;
};
export default SpellSchoolIcon;
@@ -0,0 +1,33 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
interface Props {
title: string;
className: string;
theme?: CharacterTheme;
}
export default class TodoIcon extends React.PureComponent<Props> {
static defaultProps = {
title: "Missing",
className: "",
};
render() {
const { title, className, theme } = this.props;
let classNames: Array<string> = [className, "ddbc-todo-icon"];
return (
<Tooltip
title={title}
className={classNames.join(" ")}
isDarkMode={theme?.isDarkMode}
>
!
</Tooltip>
);
}
}
@@ -0,0 +1,4 @@
import TodoIcon from "./TodoIcon";
export default TodoIcon;
export { TodoIcon };
@@ -0,0 +1,43 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
import {
DarkModeNegativeVulnerabilitySvg,
NegativeVulnerabilitySvg,
} from "../../Svg";
interface SvgInjectedProps {
className?: string;
}
interface Props {
title: string;
className: string;
theme?: CharacterTheme;
secondaryFill?: string;
}
export default class VulnerabilityIcon extends React.PureComponent<Props> {
static defaultProps = {
title: "Vulnerability",
className: "",
theme: {},
};
render() {
const { title, className, theme } = this.props;
let classNames: Array<string> = [className, "ddbc-vulnerability-icon"];
let SvgIcon: React.ComponentType<SvgInjectedProps> = theme?.isDarkMode
? DarkModeNegativeVulnerabilitySvg
: NegativeVulnerabilitySvg;
return (
<Tooltip title={title} isDarkMode={theme?.isDarkMode}>
<SvgIcon className={classNames.join(" ")} />
</Tooltip>
);
}
}
@@ -0,0 +1,103 @@
import * as React from "react";
import {
AccessUtils,
FormatUtils,
Infusion,
InfusionChoiceLookup,
InfusionUtils,
RuleData,
RuleDataUtils,
SnippetData,
} from "@dndbeyond/character-rules-engine/es";
import MarketplaceCta from "../MarketplaceCta";
import Snippet from "../Snippet";
interface Props {
infusion: Infusion;
infusionChoiceLookup: InfusionChoiceLookup;
snippetData: SnippetData;
ruleData: RuleData;
onClick?: (infusion: Infusion) => void;
proficiencyBonus: number;
}
class InfusionPreview extends React.PureComponent<Props> {
handleClick = (evt: React.MouseEvent): void => {
const { onClick, infusion } = this.props;
if (onClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick(infusion);
}
};
renderDescription = (): React.ReactNode => {
const { infusion, ruleData, snippetData, proficiencyBonus } = this.props;
let contentNode: React.ReactNode;
if (AccessUtils.isAccessible(InfusionUtils.getAccessType(infusion))) {
let classLevel: number | null =
InfusionUtils.getSelectedModifierData(infusion)?.value ?? null;
contentNode = (
<Snippet
snippetData={snippetData}
classLevel={classLevel}
proficiencyBonus={proficiencyBonus}
>
{InfusionUtils.getSnippet(infusion)}
</Snippet>
);
} else {
const sources = InfusionUtils.getSources(infusion);
let sourceNames: Array<string> = [];
if (sources.length > 0) {
sources.forEach((source) => {
let sourceInfo = RuleDataUtils.getSourceDataInfo(
source.sourceId,
ruleData
);
if (sourceInfo !== null && sourceInfo.description !== null) {
sourceNames.push(sourceInfo.description);
}
});
}
contentNode = (
<MarketplaceCta
showImage={false}
sourceName={
sourceNames.length > 0
? FormatUtils.renderNonOxfordCommaList(sourceNames)
: null
}
description="To unlock this infusion, check out the Marketplace to view purchase options."
/>
);
}
return contentNode;
};
render() {
const { infusion } = this.props;
return (
<div className="ddbc-infusion-preview" onClick={this.handleClick}>
<div className="ddbc-infusion-preview__name">
Infusion: {InfusionUtils.getName(infusion)}
</div>
<div className="ddbc-infusion-preview__snippet">
{this.renderDescription()}
</div>
</div>
);
}
}
export default InfusionPreview;
@@ -0,0 +1,83 @@
import * as React from "react";
import {
CharacterTheme,
Item,
ItemUtils,
} from "@dndbeyond/character-rules-engine/es";
import { ItemName } from "~/components/ItemName";
interface Props {
item: Item;
onClick?: (item: Item) => void;
theme: CharacterTheme;
}
class ItemPreview extends React.PureComponent<Props> {
handleClick = (evt: React.MouseEvent): void => {
const { onClick, item } = this.props;
if (onClick) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onClick(item);
}
};
renderMetaText = (): React.ReactNode => {
const { item } = this.props;
let metaItems: Array<string> = [];
if (ItemUtils.isLegacy(item)) {
metaItems.push("Legacy");
}
const type = ItemUtils.getType(item);
if (type) {
metaItems.push(type);
}
if (ItemUtils.isArmorContract(item)) {
let baseArmorName = ItemUtils.getBaseArmorName(item);
if (baseArmorName) {
metaItems.push(baseArmorName);
}
} else if (ItemUtils.isGearContract(item)) {
let subType = ItemUtils.getSubType(item);
if (subType) {
metaItems.push(subType);
}
}
return (
<div className="ddbc-item-preview__meta">
{metaItems.map((metaItem, idx) => (
<span className="ddbc-item-preview__meta-item" key={idx}>
{metaItem}
</span>
))}
</div>
);
};
render() {
const { item } = this.props;
const imageStyles: React.CSSProperties = {
backgroundImage: `url(${ItemUtils.getAvatarUrl(item)})`,
};
return (
<div className="ddbc-item-preview" onClick={this.handleClick}>
<div className="ddbc-item-preview__avatar" style={imageStyles} />
<div className="ddbc-item-preview__name">
<ItemName item={item} />
{this.renderMetaText()}
</div>
</div>
);
}
}
export default ItemPreview;
@@ -0,0 +1,18 @@
import * as React from "react";
import { AnimatedLoadingRingSvg } from "../Svg";
interface LoadingPlaceholderProps {
label?: React.ReactNode;
}
const LoadingPlaceholder: React.FunctionComponent<LoadingPlaceholderProps> = ({
label = "Loading",
}) => (
<div className="ddbc-loading-placeholder">
<AnimatedLoadingRingSvg className="ddbc-loading-placeholder__icon" />
<div className="ddbc-loading-placeholder__label">{label}</div>
</div>
);
export default React.memo(LoadingPlaceholder);
@@ -0,0 +1,4 @@
import LoadingPlaceholder from "./LoadingPlaceholder";
export default LoadingPlaceholder;
export { LoadingPlaceholder };
@@ -0,0 +1,62 @@
import React from "react";
import { FormatUtils } from "@dndbeyond/character-rules-engine/es";
interface Props {
sourceName: string | null;
sourceNames?: Array<string>;
url: string;
description: string | null;
showImage: boolean;
}
export default class MarketplaceCta extends React.PureComponent<Props> {
static defaultProps = {
sourceName: null,
url: "/marketplace",
description: null,
showImage: true,
};
render() {
const { sourceName, sourceNames, url, description, showImage } = this.props;
const allSourceNames: Array<string> = [];
if (sourceName) {
allSourceNames.push(sourceName);
}
if (sourceNames) {
allSourceNames.push(...sourceNames);
}
return (
<div className="ddbc-marketplace-cta">
<a href={url} target="_blank">
<div className="ddbc-marketplace-cta__content">
{showImage && <div className="ddbc-marketplace-cta__image" />}
{allSourceNames.length > 0 && (
<div className="ddbc-marketplace-cta__header">
<div className="ddbc-marketplace-cta__header-intro">
Available in
</div>
<div className="ddbc-marketplace-cta__header-title">
{FormatUtils.renderNonOxfordCommaList(allSourceNames)}
</div>
</div>
)}
{description && (
<div className="ddbc-marketplace-cta__description">
{description}
</div>
)}
<div className="ddbc-marketplace-cta__cta">
<div className="ddbc-marketplace-cta__cta-button">
Go To Marketplace
</div>
</div>
</div>
</a>
</div>
);
}
}
@@ -0,0 +1,4 @@
import MarketplaceCta from "./MarketplaceCta";
export default MarketplaceCta;
export { MarketplaceCta };
@@ -0,0 +1,212 @@
import * as React from "react";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
Note,
FormatUtils,
Constants,
NoteUtils,
CharacterTheme,
} from "@dndbeyond/character-rules-engine/es";
import { NumberDisplay } from "~/components/NumberDisplay";
import Damage from "../Damage";
import { AoeTypeIcon, DisadvantageIcon } from "../Icons";
import { AoeTypePropType } from "../Icons/IconConstants";
interface Props {
notes: Array<Note>;
className: string;
theme: CharacterTheme;
}
export default class NoteComponents extends React.PureComponent<Props> {
static defaultProps = {
className: "",
};
renderPlainText = (note: Note): React.ReactNode => {
const { theme } = this.props;
let classNames: Array<string> = [
"ddbc-note-components__component",
"ddbc-note-components__component--plain",
];
if (note.data.displayIntention) {
classNames.push(
`ddbc-note-components__component--${note.data.displayIntention}`
);
}
if (theme.isDarkMode) {
classNames.push("ddbc-note-components__component--dark-mode");
}
return <span className={classNames.join(" ")}>{note.data.text}</span>;
};
renderTooltip = (note: Note): React.ReactNode => {
const { theme } = this.props;
let classNames: Array<string> = [
"ddbc-note-components__component",
"ddbc-note-components__component--tooltip",
];
if (note.data.displayIntention) {
classNames.push(
`ddbc-note-components__component--${note.data.displayIntention}`
);
}
if (theme.isDarkMode) {
classNames.push("ddbc-note-components__component--dark-mode");
}
return (
<Tooltip
className={classNames.join(" ")}
title={note.data.tooltip}
tippyOpts={note.data.tooltipOpts}
isDarkMode={theme.isDarkMode}
>
{note.data.text}
</Tooltip>
);
};
renderDistance = (note: Note): React.ReactNode => {
const { theme } = this.props;
let classNames: Array<string> = [
"ddbc-note-components__component",
"ddbc-note-components__component--distance",
];
return (
<span className={classNames.join(" ")}>
<NumberDisplay type="distanceInFt" number={note.data.distance} />
</span>
);
};
renderAoeIcon = (note: Note): React.ReactNode => {
const { theme } = this.props;
let classNames: Array<string> = [
"ddbc-note-components__component",
"ddbc-note-components__component--aoe-icon",
];
return (
<span className={classNames.join(" ")}>
<AoeTypeIcon
type={FormatUtils.slugify(note.data.type) as AoeTypePropType}
themeMode={theme?.isDarkMode ? "gray" : "dark"}
/>
</span>
);
//`
};
renderDamage = (note: Note): React.ReactNode => {
const { theme } = this.props;
let classNames: Array<string> = [
"ddbc-note-components__component",
"ddbc-note-components__component--damage",
];
return (
<span className={classNames.join(" ")}>
<Damage
theme={theme}
type={note.data.type}
damage={note.data.damage}
info={note.data.info}
/>
</span>
);
};
renderDisadvantageIcon = (note: Note): React.ReactNode => {
const { theme } = this.props;
let classNames: Array<string> = [
"ddbc-note-components__component",
"ddbc-note-components__component--disadvantage-icon",
];
return (
<span className={classNames.join(" ")}>
<DisadvantageIcon theme={theme} />
</span>
);
};
renderGroup = (
notes: Array<Note> | null,
separator: string = " ",
key: string | number | null = null
): React.ReactNode => {
if (notes === null) {
return null;
}
return (
<React.Fragment key={key ? key : "start-note-group-key"}>
{notes.map((note, idx): React.ReactNode => {
if (note === null) {
return null;
}
return (
<React.Fragment key={`${NoteUtils.getType(note)}-${idx}`}>
{this.renderNote(note)}
{idx + 1 < notes.length ? separator : ""}
</React.Fragment>
);
})}
</React.Fragment>
);
};
renderNote = (note: Note | null): React.ReactNode => {
if (note === null) {
return null;
}
switch (NoteUtils.getType(note)) {
case Constants.NoteTypeEnum.PLAIN_TEXT:
return this.renderPlainText(note);
case Constants.NoteTypeEnum.TOOLTIP:
return this.renderTooltip(note);
case Constants.NoteTypeEnum.DISTANCE:
return this.renderDistance(note);
case Constants.NoteTypeEnum.AOE_ICON:
return this.renderAoeIcon(note);
case Constants.NoteTypeEnum.DAMAGE:
return this.renderDamage(note);
case Constants.NoteTypeEnum.DISADVANTAGE_ICON:
return this.renderDisadvantageIcon(note);
case Constants.NoteTypeEnum.GROUP:
return this.renderGroup(
note.data.notes,
note.data.separator,
note.data.key
);
default:
// not implement
}
return null;
};
render() {
const { notes, className } = this.props;
let classNames: Array<string> = [className, "ddbc-note-components"];
return (
<div className={classNames.join(" ")}>
{this.renderGroup(notes, ", ")}
</div>
);
}
}

Some files were not shown because too many files have changed in this diff Show More