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,40 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
import styles from "./styles.module.css";
interface HeaderProps extends HTMLAttributes<HTMLElement> {
callout?: ReactNode;
onClick?: () => void;
parent?: ReactNode;
preview?: ReactNode;
}
export const Header: FC<HeaderProps> = ({
callout,
children,
className,
onClick,
parent,
preview,
...props
}) => {
const handleClick = useUnpropagatedClick(onClick);
return (
<header className={clsx(["ct-sidebar__header", className])} {...props}>
<div onClick={handleClick} className={onClick && styles.interactive}>
{parent}
</div>
<div className="ct-sidebar__header-primary">
{preview}
<h1 className={clsx(["ct-sidebar__heading", styles.heading])}>
{children}
</h1>
{callout && <div className={styles.callout}>{callout}</div>}
</div>
</header>
);
};
@@ -0,0 +1,27 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
/**
* This component is used to render a subheading in the sidebar.
*/
type HeadingProps = HTMLAttributes<HTMLHeadingElement>;
export const Heading: FC<HeadingProps> = ({
className,
children,
...props
}) => (
<h2
className={clsx([
"ct-sidebar__subheading",
props.onClick && styles.clickable,
className,
])}
{...props}
>
{children}
</h2>
);
@@ -0,0 +1,57 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { useSidebar } from "~/contexts/Sidebar/Sidebar";
import { PaneBorder } from "~/svgs";
import { PaneContent } from "../PaneContent";
import { PaneControls } from "../PaneControls";
import styles from "./styles.module.css";
/**
* The Pane component serves as a way to render the active pane inside of the sidebar. It is a simple component that renders the children passed to it. It is used in the Sidebar component to render the active pane inside of the sidebar. The `isFullWidth` prop determines whether the pane should have padding on the left and right or not. Panes such as CharacterManagePane have items which should be full width. Most panes should have padding on the left and right.
**/
interface PaneProps extends HTMLAttributes<HTMLDivElement> {
handlePrevious: () => void;
handleNext: () => void;
isFullWidth?: boolean;
forceDarkMode?: boolean;
}
export const Pane: FC<PaneProps> = ({
isFullWidth,
forceDarkMode,
handlePrevious,
handleNext,
...props
}) => {
const {
pane: { activePane },
} = useSidebar();
return (
<div className={styles.pane} {...props}>
<PaneBorder
className={clsx([styles.border, forceDarkMode && styles.dark])}
/>
<div className={clsx([styles.gap, forceDarkMode && styles.dark])} />
<div
className={clsx([
styles.content,
forceDarkMode && styles.dark,
isFullWidth && styles.fullWidth,
])}
>
<PaneControls handlePrevious={handlePrevious} handleNext={handleNext} />
<PaneContent activePane={activePane} />
</div>
<PaneBorder
className={clsx([
styles.border,
styles.bottom,
forceDarkMode && styles.dark,
])}
/>
</div>
);
};
@@ -0,0 +1,38 @@
import { FC, HTMLAttributes } from "react";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { getActiveEntryComponent } from "../../helpers/getActiveEntryComponent";
import { PaneComponentInfo } from "../../types";
import styles from "./styles.module.css";
interface PaneContentProps extends HTMLAttributes<HTMLElement> {
activePane: PaneComponentInfo | null;
}
export const PaneContent: FC<PaneContentProps> = ({ activePane, ...props }) => {
const { isDarkMode } = useCharacterTheme();
// Empty pane
if (!activePane)
return (
<div className={styles.default} {...props}>
Select elements on the character sheet to display more information about
</div>
);
const Component = getActiveEntryComponent(activePane.type);
// Component not found
if (!Component) return <div {...props}>Component was not found.</div>;
// Return correct component
return (
<div className={styles.maxHeight} {...props}>
<Component
theme={isDarkMode ? "DARK" : "LIGHT"}
identifiers={activePane.identifiers}
/>
</div>
);
};
@@ -0,0 +1,56 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import ChevronLeft from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-left.svg";
import ChevronRight from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-right.svg";
import { Button } from "~/components/Button";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar/Sidebar";
import styles from "./styles.module.css";
interface PaneControlsProps extends HTMLAttributes<HTMLElement> {
handlePrevious?: () => void;
handleNext?: () => void;
}
export const PaneControls: FC<PaneControlsProps> = ({
handlePrevious,
handleNext,
...props
}) => {
const { isDarkMode } = useCharacterTheme();
const {
pane: { activePane, showControls, isAtStart, isAtEnd },
} = useSidebar();
if (!activePane || !showControls) return null;
const forceDarkMode = !isDarkMode && activePane.type === "CHARACTER_MANAGE";
return (
<nav className={styles.controls} {...props}>
<Button
className={clsx([styles.button, forceDarkMode && styles.dark])}
size="xx-small"
variant="text"
disabled={isAtStart}
onClick={isAtStart ? undefined : handlePrevious}
>
<ChevronLeft />
<span className={styles.label}>Prev</span>
</Button>
<Button
className={clsx([styles.button, forceDarkMode && styles.dark])}
size="xx-small"
variant="text"
disabled={isAtEnd}
onClick={isAtEnd ? undefined : handleNext}
>
<span className={styles.label}>Next</span>
<ChevronRight />
</Button>
</nav>
);
};
@@ -0,0 +1,28 @@
import { FC, useEffect } from "react";
import { useDispatch } from "react-redux";
import { useSidebar } from "~/contexts/Sidebar";
import { toastMessageActions } from "../../../../../../tools/js/Shared/actions";
interface Props {
errorMessage?: string;
}
export const PaneInitFailureContent: FC<Props> = ({ errorMessage }) => {
const dispatch = useDispatch();
const {
sidebar: { setIsVisible },
} = useSidebar();
useEffect(() => {
setIsVisible(false);
dispatch(
toastMessageActions.toastError(
"Content Error",
errorMessage ?? "We couldn't find that content! Please try again."
)
);
}, [errorMessage, dispatch]);
return null;
};
@@ -0,0 +1,15 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLUListElement> {
className?: string;
}
export const PaneMenu: FC<Props> = ({ className, children, ...props }) => {
return (
<ul className={clsx([styles.menu, className])} {...props}>
{children}
</ul>
);
};
@@ -0,0 +1,25 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { PaneMenuItem } from "../PaneMenuItem";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
className?: string;
label: string;
}
export const PaneMenuGroup: FC<Props> = ({
label,
className = "",
children,
...props
}) => {
return (
<div className={clsx([styles.group, className])} {...props}>
<PaneMenuItem menukey={label} className={styles.label}>
{label}
</PaneMenuItem>
{children}
</div>
);
};
@@ -0,0 +1,42 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
onClick?: (menuKey: string) => void;
menukey: string;
className?: string;
prefixIcon?: React.ReactNode;
suffixIcon?: React.ReactNode;
}
export const PaneMenuItem: FC<Props> = ({
onClick,
menukey,
prefixIcon,
suffixIcon,
className,
children,
...props
}) => {
const handleClick = (evt: React.MouseEvent): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
if (onClick) {
onClick(menukey);
}
};
return (
<div
className={clsx([styles.item, className])}
onClick={handleClick}
{...props}
>
{prefixIcon && <div className={styles.prefixIcon}>{prefixIcon}</div>}
<div className={styles.label}>{children}</div>
<div className={styles.suffixIcon}>{suffixIcon}</div>
</div>
);
};
@@ -0,0 +1,25 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
interface PreviewProps extends HTMLAttributes<HTMLDivElement> {
imageUrl?: string;
}
export const Preview: FC<PreviewProps> = ({
children,
className,
imageUrl,
...props
}) => (
<div className={clsx(["ct-sidebar__header-preview", className])} {...props}>
{imageUrl ? (
<div
role="presentation"
className="ct-sidebar__header-preview-image"
style={{ backgroundImage: `url(${imageUrl})` }}
/>
) : (
children
)}
</div>
);
@@ -0,0 +1,21 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
export interface BarProps extends HTMLAttributes<HTMLButtonElement> {
variant?: "active" | "implied" | "inactive";
value?: string | number | null;
}
export const Bar: FC<BarProps> = ({
children,
className,
variant = "inactive",
value,
...props
}) => (
<button className={clsx([styles.bar, styles[variant], className])} {...props}>
{children}
</button>
);
@@ -0,0 +1,58 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { Bar } from "./Bar";
import styles from "./styles.module.css";
export interface ProgressBarProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
options: Array<number>;
value: number | null;
onClick: (val: string | number | null) => void;
isInteractive?: boolean;
}
export const ProgressBar: FC<ProgressBarProps> = ({
className,
options,
value,
onClick,
isInteractive,
...props
}) => {
const handleClick = (val: number | null) => {
const isAlreadyNull = value === null && val === null;
if (!isInteractive || isAlreadyNull) return;
onClick(val);
};
return (
<div className={clsx([styles.progressBar, className])} {...props}>
<Bar
value={null}
variant={value !== null ? "implied" : "inactive"}
onClick={() => handleClick(null)}
aria-disabled={!isInteractive}
>
--
</Bar>
{options.map((number) => (
<Bar
variant={
number === value
? "active"
: !!value && number < value
? "implied"
: "inactive"
}
value={number}
onClick={() => handleClick(number)}
aria-disabled={!isInteractive}
key={`progress-bar-${number}`}
>
{number}
</Bar>
))}
</div>
);
};
@@ -0,0 +1,31 @@
import { FC, HTMLAttributes } from "react";
import { ThemeButton } from "~/tools/js/Shared/components/common/Button";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
actions: { label: string; onClick: () => void; disabled: boolean }[];
}
export const QuickActions: FC<Props> = ({ actions, ...props }) => {
return (
<div className={styles.container}>
<div className={styles.title}>Quick Actions:</div>
<div className={styles.buttons}>
{actions.map((item, idx) => {
return (
<ThemeButton
key={idx}
size="medium"
onClick={item.onClick}
isInteractive={!item.disabled}
>
{item.label}
</ThemeButton>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,171 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useSelector } from "react-redux";
import { DOWN, SwipeEventData, UP } from "react-swipeable";
import { syncTransactionSelectors } from "@dndbeyond/character-rules-engine";
import { Swipeable } from "~/components/Swipeable";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar";
import { getPaneProperties } from "../../helpers/paneUtils";
import { SidebarAlignmentEnum, SidebarPlacementEnum } from "../../types";
import { Pane } from "../Pane";
import { VisibilityControls } from "../VisiblilityControls";
import styles from "./styles.module.css";
export interface SidebarProps extends HTMLAttributes<HTMLDivElement> {
setSwipedAmount?: (amount: number) => void;
}
/**
* This component is responsible for rendering the sidebar and its contents. It uses volatile state to manage the visibility, placement, and alignment of the sidebar. It also uses the `Pane` component to render the active pane (the content of the sidebar) and the `VisibilityControls` component to render the controls for the sidebar. The sidebar is rendered as a portal to the body of the document.
*/
export const Sidebar: FC<SidebarProps> = ({
children,
className,
setSwipedAmount,
...props
}) => {
const sidebarRef = useRef<HTMLDivElement>(null);
const { isDarkMode } = useCharacterTheme();
const isSyncActive = useSelector(syncTransactionSelectors.getActive);
const {
sidebar: {
isLocked,
isVisible,
placement,
alignment,
setIsVisible,
setIsLocked,
setPlacement,
setAlignment,
},
pane: { activePane, paneHistoryNext, paneHistoryPrevious },
} = useSidebar();
const { isFullWidth, forceDarkMode } = getPaneProperties(activePane);
const handlePrevious = () => paneHistoryPrevious();
const handleNext = () => paneHistoryNext();
const handleTogglePlacement = (place: SidebarPlacementEnum) =>
setPlacement(place);
const handleToggleAlignment = (align: SidebarAlignmentEnum) =>
setAlignment(align);
const handleToggleLock = () => setIsLocked(!isLocked);
const handleToggleVisibility = () => {
if (isLocked) return;
if (isVisible && setSwipedAmount) setSwipedAmount(0);
setIsVisible(!isVisible);
};
const handleSwipe = ({ deltaX, dir }: SwipeEventData): void => {
const isVerticalChange = [UP, DOWN].includes(dir);
if (!isVerticalChange && setSwipedAmount) setSwipedAmount(deltaX);
};
const handleEscape = (e: KeyboardEvent) => {
if (
e.key === "Escape" &&
isVisible &&
!isLocked &&
placement !== SidebarPlacementEnum.FIXED
)
handleToggleVisibility();
};
const handleClickOutside = (e: MouseEvent) => {
const sidebar = sidebarRef.current;
const containsElement =
e.composedPath().indexOf(sidebar as EventTarget) !== -1;
const target = e.target as HTMLElement;
const isDiceControl = target?.classList?.contains(
"dice_notification_controls"
);
const isFixed = placement === SidebarPlacementEnum.FIXED;
// If element clicked is a dice control, don't close the sidebar
if (isDiceControl) return;
// If element clicked is not in the sidebar, and the sidebar is visible, not locked, and not fixed, close the sidebar
if (sidebar && !containsElement && isVisible && !isLocked && !isFixed)
handleToggleVisibility();
};
useEffect(() => {
document.body.addEventListener("keydown", handleEscape);
document.addEventListener("click", handleClickOutside);
return () => {
document.body.removeEventListener("keydown", handleEscape);
document.removeEventListener("click", handleClickOutside);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible, isLocked, placement]);
return createPortal(
<div className="ct-sidebar__portal">
<div
className={clsx([
styles.mask,
isVisible ? styles.visible : styles.hidden,
isDarkMode && styles.dark,
])}
/>
<div>
<Swipeable
handlerFunctions={{
onSwipedRight: handleToggleVisibility,
onSwiping: handleSwipe,
}}
>
<div
role="dialog"
aria-label="Information Sidebar"
className={clsx([
"ct-sidebar",
styles.sidebar,
styles[alignment],
isVisible ? styles.visible : styles.hidden,
isFullWidth && "ct-sidebar--is-full-width",
isDarkMode && "ct-sidebar--is-dark-mode",
])}
ref={sidebarRef}
{...props}
>
<div className="ct-sidebar__inner">
<VisibilityControls
isVisible={isVisible}
isLocked={isLocked}
placement={placement}
alignment={alignment}
onToggleVisibility={handleToggleVisibility}
onToggleLock={handleToggleLock}
onToggleAlignment={handleToggleAlignment}
onTogglePlacement={handleTogglePlacement}
/>
{isVisible && (
<Pane
isFullWidth={isFullWidth}
forceDarkMode={forceDarkMode || isDarkMode}
handlePrevious={handlePrevious}
handleNext={handleNext}
/>
)}
</div>
{isSyncActive && <div className={styles.syncBlocker} />}
</div>
</Swipeable>
</div>
</div>,
document.body
);
};
@@ -0,0 +1,156 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import Lock from "@dndbeyond/fontawesome-cache/svgs/solid/lock.svg";
import Unlock from "@dndbeyond/fontawesome-cache/svgs/solid/unlock.svg";
import {
SidebarAlignmentEnum,
SidebarPlacementEnum,
} from "~/subApps/sheet/components/Sidebar/types";
import Tooltip from "~/tools/js/commonComponents/Tooltip";
import { AlignLeftIcon } from "../../svgs/AlignLeftIcon";
import { AlignRightIcon } from "../../svgs/AlignRightIcon";
import { ArrowsLeftIcon } from "../../svgs/ArrowsLeftIcon";
import { ArrowsRightIcon } from "../../svgs/ArrowsRightIcon";
import { FixedIcon } from "../../svgs/FixedIcon";
import { OverlayIcon } from "../../svgs/OverlayIcon";
import styles from "./styles.module.css";
interface VisibilityControlsProps extends HTMLAttributes<HTMLDivElement> {
isLocked: boolean;
isVisible: boolean;
placement: SidebarPlacementEnum;
alignment: SidebarAlignmentEnum;
onToggleLock: () => void;
onToggleVisibility: () => void;
onTogglePlacement: (val: "overlay" | "fixed") => void;
onToggleAlignment: (val: "left" | "right") => void;
}
export const VisibilityControls: FC<VisibilityControlsProps> = ({
className,
isLocked,
isVisible,
placement,
alignment,
onToggleLock,
onToggleVisibility,
onTogglePlacement,
onToggleAlignment,
...props
}) => {
const isActive = (value: "overlay" | "fixed" | "left" | "right") => {
if (placement === value || alignment === value) return styles.active;
return undefined;
};
const tippyOpts = {
dynamicTitle: true,
interactive: true,
};
return (
<div
className={clsx([
styles.controls,
alignment === "left" && styles.left,
className,
])}
{...props}
>
{isLocked ? (
<Tooltip title="Locked" isDarkMode tippyOpts={tippyOpts}>
<button
className={styles.locked}
onClick={onToggleLock}
aria-label="Locked"
>
<Lock />
</button>
</Tooltip>
) : (
<>
<div className={styles.visibility}>
<Tooltip
className={!isVisible ? styles.closed : ""}
title={isVisible ? "Hide Sidebar" : "Show Sidebar"}
isDarkMode
tippyOpts={{
...tippyOpts,
placement: "top-end",
}}
>
<button
className={styles.arrows}
onClick={onToggleVisibility}
aria-label={isVisible ? "Hide sidebar" : "Show sidebar"}
>
{isVisible ? <ArrowsRightIcon /> : <ArrowsLeftIcon />}
</button>
</Tooltip>
<Tooltip
title="Unlocked"
isDarkMode
tippyOpts={tippyOpts}
className={!isVisible ? styles.hidden : ""}
>
<button onClick={onToggleLock} aria-label="Unlocked">
<Unlock />
</button>
</Tooltip>
</div>
<div
className={clsx([styles.overlay, !isVisible ? styles.hidden : ""])}
>
<Tooltip title="Overlay" isDarkMode tippyOpts={tippyOpts}>
<button
className={isActive("overlay")}
onClick={() => onTogglePlacement("overlay")}
aria-label="Set to overlay position"
>
<OverlayIcon />
</button>
</Tooltip>
<Tooltip title="Fixed" isDarkMode tippyOpts={tippyOpts}>
<button
className={isActive("fixed")}
onClick={() => onTogglePlacement("fixed")}
aria-label="Set to fixed position"
>
<FixedIcon />
</button>
</Tooltip>
</div>
<div
className={clsx([
styles.placement,
alignment === "left" && styles.left,
!isVisible ? styles.hidden : "",
])}
>
<Tooltip title="Left" isDarkMode tippyOpts={tippyOpts}>
<button
className={isActive("left")}
onClick={() => onToggleAlignment("left")}
aria-label="Align Left"
>
<AlignLeftIcon />
</button>
</Tooltip>
<Tooltip title="Right" isDarkMode tippyOpts={tippyOpts}>
<button
className={isActive("right")}
onClick={() => onToggleAlignment("right")}
aria-label="Align Right"
>
<AlignRightIcon />
</button>
</Tooltip>
</div>
</>
)}
</div>
);
};
@@ -0,0 +1,244 @@
import { CreaturePane } from "~/subApps/sheet/components/Sidebar/panes/CreaturePane";
import { GameLogPane } from "~/subApps/sheet/components/Sidebar/panes/GameLogPane";
import BlessingPane from "~/tools/js/Shared/containers/panes/BlessingPane";
import CharacterSpellPane from "~/tools/js/Shared/containers/panes/CharacterSpellPane";
import ClassSpellPane from "~/tools/js/Shared/containers/panes/ClassSpellPane";
import ConditionManagePane from "~/tools/js/Shared/containers/panes/ConditionManagePane";
import ContainerPane from "~/tools/js/Shared/containers/panes/ContainerPane";
import CurrencyPane from "~/tools/js/Shared/containers/panes/CurrencyPane";
import CustomActionPane from "~/tools/js/Shared/containers/panes/CustomActionPane";
import CustomActionsPane from "~/tools/js/Shared/containers/panes/CustomActionsPane";
import CustomSkillPane from "~/tools/js/Shared/containers/panes/CustomSkillPane";
import DecoratePane from "~/tools/js/Shared/containers/panes/DecoratePane";
import DefenseManagePane from "~/tools/js/Shared/containers/panes/DefenseManagePane";
import DescriptionPane from "~/tools/js/Shared/containers/panes/DescriptionPane";
import EncumbrancePane from "~/tools/js/Shared/containers/panes/EncumbrancePane";
import EquipmentManagePane from "~/tools/js/Shared/containers/panes/EquipmentManagePane";
import { ExportPdfPane } from "~/tools/js/Shared/containers/panes/ExportPdfPane";
import ExtraManagePane from "~/tools/js/Shared/containers/panes/ExtraManagePane";
import InfusionChoicePane from "~/tools/js/Shared/containers/panes/InfusionChoicePane";
import ItemPane from "~/tools/js/Shared/containers/panes/ItemPane";
import LongRestPane from "~/tools/js/Shared/containers/panes/LongRestPane";
import NoteManagePane from "~/tools/js/Shared/containers/panes/NoteManagePane";
import { PreferencesHitPointConfirmPane } from "~/tools/js/Shared/containers/panes/PreferencesHitPointConfirmPane";
import PreferencesOptionalClassFeaturesConfirmPane from "~/tools/js/Shared/containers/panes/PreferencesOptionalClassFeaturesConfirmPane/PreferencesOptionalClassFeaturesConfirmPane";
import PreferencesOptionalOriginsConfirmPane from "~/tools/js/Shared/containers/panes/PreferencesOptionalOriginsConfirmPane/PreferencesOptionalOriginsConfirmPane";
import PreferencesPane from "~/tools/js/Shared/containers/panes/PreferencesPane";
import { PreferencesProgressionConfirmPane } from "~/tools/js/Shared/containers/panes/PreferencesProgressionConfirmPane";
import ProficienciesPane from "~/tools/js/Shared/containers/panes/ProficienciesPane";
import ProficiencyBonusPane from "~/tools/js/Shared/containers/panes/ProficiencyBonusPane";
import SavingThrowsPane from "~/tools/js/Shared/containers/panes/SavingThrowsPane";
import SenseManagePane from "~/tools/js/Shared/containers/panes/SenseManagePane";
import SettingsPane from "~/tools/js/Shared/containers/panes/SettingsPane";
import { ShareUrlPane } from "~/tools/js/Shared/containers/panes/ShareUrlPane";
import ShortRestPane from "~/tools/js/Shared/containers/panes/ShortRestPane";
import SkillPane from "~/tools/js/Shared/containers/panes/SkillPane";
import SkillsPane from "~/tools/js/Shared/containers/panes/SkillsPane";
import SpeciesTraitPane from "~/tools/js/Shared/containers/panes/SpeciesTraitPane";
import SpeedManagePane from "~/tools/js/Shared/containers/panes/SpeedManagePane";
import SpellManagePane from "~/tools/js/Shared/containers/panes/SpellManagePane";
import StartingEquipmentPane from "~/tools/js/Shared/containers/panes/StartingEquipmentPane";
import TraitPane from "~/tools/js/Shared/containers/panes/TraitPane";
import VehicleComponentPane from "~/tools/js/Shared/containers/panes/VehicleComponentPane";
import VehiclePane from "~/tools/js/Shared/containers/panes/VehiclePane";
import { AbilityPane } from "../panes/AbilityPane";
import { AbilitySavingThrowsPane } from "../panes/AbilitySavingThrowsPane";
import { ActionPane } from "../panes/ActionPane";
import { ArmorManagePane } from "../panes/ArmorManagePane";
import { BackgroundPane } from "../panes/BackgroundPane";
import { BasicActionPane } from "../panes/BasicActionPane";
import { CampaignPane } from "../panes/CampaignPane";
import { CharacterManagePane } from "../panes/CharacterManagePane";
import { ClassFeaturePane } from "../panes/ClassFeaturePane";
import { FeatPane } from "../panes/FeatPane";
import { FeatsManagePane } from "../panes/FeatsManagePane";
import { HitPointsManagePane } from "../panes/HitPointsManagePane";
import { InitiativePane } from "../panes/InitiativePane";
import { InspirationPane } from "../panes/InspirationPane";
import { XpPane } from "../panes/XpPane";
import { PaneComponentEnum, SidebarPaneComponent } from "../types";
// Refactor this to a hook that returns a component?
export const getActiveEntryComponent = (
componentType?: PaneComponentEnum
): SidebarPaneComponent<any> | null => {
if (!componentType) return null;
switch (componentType) {
case PaneComponentEnum.HEALTH_MANAGE:
return HitPointsManagePane;
case PaneComponentEnum.ITEM_DETAIL:
return ItemPane;
case PaneComponentEnum.CHARACTER_SPELL_DETAIL:
return CharacterSpellPane;
case PaneComponentEnum.CLASS_SPELL_DETAIL:
return ClassSpellPane;
case PaneComponentEnum.ABILITY:
return AbilityPane;
case PaneComponentEnum.SKILLS:
return SkillsPane;
case PaneComponentEnum.PROFICIENCIES:
return ProficienciesPane;
case PaneComponentEnum.XP:
return XpPane;
case PaneComponentEnum.SHORT_REST:
return ShortRestPane;
case PaneComponentEnum.LONG_REST:
return LongRestPane;
case PaneComponentEnum.SPELL_MANAGE:
return SpellManagePane;
case PaneComponentEnum.CONDITION_MANAGE:
return ConditionManagePane;
case PaneComponentEnum.SPEED_MANAGE:
return SpeedManagePane;
case PaneComponentEnum.SENSE_MANAGE:
return SenseManagePane;
case PaneComponentEnum.DEFENSE_MANAGE:
return DefenseManagePane;
case PaneComponentEnum.ARMOR_MANAGE:
return ArmorManagePane;
case PaneComponentEnum.FEATS_MANAGE:
return FeatsManagePane;
case PaneComponentEnum.NOTE_MANAGE:
return NoteManagePane;
case PaneComponentEnum.TRAIT:
return TraitPane;
case PaneComponentEnum.CAMPAIGN:
return CampaignPane;
case PaneComponentEnum.EQUIPMENT_MANAGE:
return EquipmentManagePane;
case PaneComponentEnum.CURRENCY:
return CurrencyPane;
case PaneComponentEnum.ENCUMBRANCE:
return EncumbrancePane;
case PaneComponentEnum.STARTING_EQUIPMENT:
return StartingEquipmentPane;
case PaneComponentEnum.CUSTOM_ACTIONS:
return CustomActionsPane;
case PaneComponentEnum.CUSTOM_ACTION:
return CustomActionPane;
case PaneComponentEnum.CLASS_FEATURE_DETAIL:
return ClassFeaturePane;
case PaneComponentEnum.FEAT_DETAIL:
return FeatPane;
case PaneComponentEnum.SPECIES_TRAIT_DETAIL:
return SpeciesTraitPane;
case PaneComponentEnum.BACKGROUND:
return BackgroundPane;
case PaneComponentEnum.DESCRIPTION:
return DescriptionPane;
case PaneComponentEnum.PROFICIENCY_BONUS:
return ProficiencyBonusPane;
case PaneComponentEnum.INSPIRATION:
return InspirationPane;
case PaneComponentEnum.INITIATIVE:
return InitiativePane;
case PaneComponentEnum.ACTION:
return ActionPane;
case PaneComponentEnum.PREFERENCES:
return PreferencesPane;
case PaneComponentEnum.SKILL:
return SkillPane;
case PaneComponentEnum.CUSTOM_SKILL:
return CustomSkillPane;
case PaneComponentEnum.SAVING_THROWS:
return SavingThrowsPane;
case PaneComponentEnum.ABILITY_SAVING_THROW:
return AbilitySavingThrowsPane;
case PaneComponentEnum.BASIC_ACTION:
return BasicActionPane;
case PaneComponentEnum.PREFERENCES_HIT_POINT_CONFIRM:
return PreferencesHitPointConfirmPane;
case PaneComponentEnum.PREFERENCES_OPTIONAL_CLASS_FEATURES_CONFIRM:
return PreferencesOptionalClassFeaturesConfirmPane;
case PaneComponentEnum.PREFERENCES_OPTIONAL_ORIGINS_CONFIRM:
return PreferencesOptionalOriginsConfirmPane;
case PaneComponentEnum.PREFERENCES_PROGRESSION_CONFIRM:
return PreferencesProgressionConfirmPane;
case PaneComponentEnum.SHARE_URL:
return ShareUrlPane;
case PaneComponentEnum.CREATURE:
return CreaturePane;
case PaneComponentEnum.VEHICLE:
return VehiclePane;
case PaneComponentEnum.VEHICLE_COMPONENT:
return VehicleComponentPane;
case PaneComponentEnum.EXTRA_MANAGE:
return ExtraManagePane;
case PaneComponentEnum.INFUSION_CHOICE:
return InfusionChoicePane;
case PaneComponentEnum.EXPORT_PDF:
return ExportPdfPane;
case PaneComponentEnum.CHARACTER_MANAGE:
return CharacterManagePane;
case PaneComponentEnum.GAME_LOG:
return GameLogPane;
case PaneComponentEnum.CONTAINER:
return ContainerPane;
case PaneComponentEnum.DECORATE:
return DecoratePane;
case PaneComponentEnum.SETTINGS:
return SettingsPane;
case PaneComponentEnum.BLESSING_DETAIL:
default:
return BlessingPane;
}
};
@@ -0,0 +1,203 @@
import {
BaseInventoryContract,
Hack__BaseCharClass,
FeatDetailsContract,
RacialTraitContract,
ClassFeatureUtils,
ClassUtils,
Constants,
DataOriginRef,
DataOriginRefData,
DataOriginUtils,
FeatUtils,
ItemUtils,
RacialTraitUtils,
DataOrigin,
Spell,
SpellUtils,
ClassFeatureContract,
} from "@dndbeyond/character-rules-engine/es";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import {
PaneComponentEnum,
PaneComponentInfo,
PaneComponentProperties,
PaneIdentifiers,
} from "../types";
// List any full width panes
const fullWidthPanes = [PaneComponentEnum.CHARACTER_MANAGE];
// Check to see if pane is in the list
const isFullWidthPane = (activeEntry: PaneComponentInfo | null) =>
activeEntry?.type && fullWidthPanes.includes(activeEntry.type);
// List any forced dark mode panes
const darkModePanes = [PaneComponentEnum.CHARACTER_MANAGE];
// Check to see if pane is in the list
const forceDarkMode = (activeEntry: PaneComponentInfo | null) =>
activeEntry?.type && darkModePanes.includes(activeEntry.type);
// Return optional pane props
export const getPaneProperties = (
activeEntry: PaneComponentInfo | null
): PaneComponentProperties => {
const props: PaneComponentProperties = {
isFullWidth: isFullWidthPane(activeEntry) ?? false,
forceDarkMode: forceDarkMode(activeEntry) ?? false,
};
return { ...props };
};
export function getSpellComponentInfo(spell: Spell): PaneComponentInfo {
let dataOrigin = SpellUtils.getDataOrigin(spell);
let dataOriginType = SpellUtils.getDataOriginType(spell);
let type: PaneComponentEnum = PaneComponentEnum.CHARACTER_SPELL_DETAIL;
let identifiers: PaneIdentifiers | null = null;
const mappingId = SpellUtils.getMappingId(spell);
if (mappingId !== null) {
if (dataOriginType === Constants.DataOriginTypeEnum.CLASS) {
type = PaneComponentEnum.CLASS_SPELL_DETAIL;
identifiers = PaneIdentifierUtils.generateClassSpell(
ClassUtils.getMappingId(dataOrigin.primary as Hack__BaseCharClass),
mappingId
);
} else {
identifiers = PaneIdentifierUtils.generateCharacterSpell(mappingId);
}
}
return {
type,
identifiers,
};
}
export function getDataOriginComponentInfo(
dataOrigin: DataOrigin
): PaneComponentInfo {
let type: PaneComponentEnum = PaneComponentEnum.ERROR_404;
let identifiers: PaneIdentifiers | null = null;
switch (dataOrigin.type) {
case Constants.DataOriginTypeEnum.ITEM:
type = PaneComponentEnum.ITEM_DETAIL;
identifiers = PaneIdentifierUtils.generateItem(
ItemUtils.getMappingId(dataOrigin.primary as BaseInventoryContract)
);
break;
case Constants.DataOriginTypeEnum.CLASS_FEATURE:
type = PaneComponentEnum.CLASS_FEATURE_DETAIL;
identifiers = PaneIdentifierUtils.generateClassFeature(
ClassFeatureUtils.getId(dataOrigin.primary as ClassFeatureContract),
ClassUtils.getMappingId(dataOrigin.parent as Hack__BaseCharClass)
);
break;
case Constants.DataOriginTypeEnum.RACE:
type = PaneComponentEnum.SPECIES_TRAIT_DETAIL;
identifiers = PaneIdentifierUtils.generateRacialTrait(
RacialTraitUtils.getId(dataOrigin.primary as RacialTraitContract)
);
break;
case Constants.DataOriginTypeEnum.FEAT:
type = PaneComponentEnum.FEAT_DETAIL;
identifiers = PaneIdentifierUtils.generateFeat(
FeatUtils.getId(dataOrigin.primary as FeatDetailsContract)
);
break;
case Constants.DataOriginTypeEnum.BACKGROUND:
type = PaneComponentEnum.BACKGROUND;
break;
case Constants.DataOriginTypeEnum.FEAT_LIST:
if (dataOrigin.parentType === Constants.DataOriginTypeEnum.BACKGROUND) {
type = PaneComponentEnum.BACKGROUND;
}
break;
default:
// Not implemented
}
return {
type,
identifiers,
};
}
export function getDataOriginRefComponentInfo(
ref: DataOriginRef,
refData: DataOriginRefData
): PaneComponentInfo {
let type: PaneComponentEnum = PaneComponentEnum.ERROR_404;
let identifiers: PaneIdentifiers | null = null;
switch (ref.type) {
case Constants.DataOriginTypeEnum.ITEM: {
const primary = DataOriginUtils.getRefPrimary(ref, refData);
if (primary !== null) {
type = PaneComponentEnum.ITEM_DETAIL;
identifiers = PaneIdentifierUtils.generateItem(
ItemUtils.getMappingId(primary)
);
}
break;
}
case Constants.DataOriginTypeEnum.CLASS_FEATURE: {
const primary = DataOriginUtils.getRefPrimary(ref, refData);
const parent = DataOriginUtils.getRefParent(ref, refData);
if (primary !== null && parent !== null) {
type = PaneComponentEnum.CLASS_FEATURE_DETAIL;
identifiers = PaneIdentifierUtils.generateClassFeature(
ClassFeatureUtils.getId(primary),
ClassUtils.getMappingId(parent)
);
}
break;
}
case Constants.DataOriginTypeEnum.RACE: {
const primary = DataOriginUtils.getRefPrimary(ref, refData);
if (primary !== null) {
type = PaneComponentEnum.SPECIES_TRAIT_DETAIL;
identifiers = PaneIdentifierUtils.generateRacialTrait(
RacialTraitUtils.getId(primary)
);
}
break;
}
case Constants.DataOriginTypeEnum.FEAT: {
const primary = DataOriginUtils.getRefPrimary(ref, refData);
if (primary !== null) {
type = PaneComponentEnum.FEAT_DETAIL;
identifiers = PaneIdentifierUtils.generateFeat(
FeatUtils.getId(primary)
);
}
break;
}
case Constants.DataOriginTypeEnum.BACKGROUND:
type = PaneComponentEnum.BACKGROUND;
break;
default:
// Not implemented
}
return {
type,
identifiers,
};
}
@@ -0,0 +1,83 @@
import { FC, HTMLAttributes, ReactNode } from "react";
import { useSelector } from "react-redux";
import { AbilityScoreManager } from "~/components/AbilityScoreManager";
import { HtmlContent } from "~/components/HtmlContent";
import { NumberDisplay } from "~/components/NumberDisplay";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useAbilities } from "~/hooks/useAbilities";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { PaneInitFailureContent } from "~/subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { AbilityIcon } from "~/tools/js/smartComponents/Icons";
import { PaneIdentifiersAbility } from "../../types";
import styles from "./styles.module.css";
/*
AbilityPane is the specific Sidebar for each Ability on a character
(Strength, Dexterity, Constitution, Wisdom, Intelligence, Charisma)
*/
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersAbility | null;
}
export const AbilityPane: FC<Props> = ({ identifiers, ...props }) => {
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const { isDarkMode } = useCharacterTheme();
const abilities = useAbilities();
const ability =
(identifiers &&
abilities.find((a) => a.getId() === identifiers?.ability.getId())) ||
identifiers?.ability;
if (!ability) {
return <PaneInitFailureContent />;
}
const statId = ability.getId();
let sidebarPreviewNode: ReactNode = (
<Preview>
<AbilityIcon
className={styles.icon}
statId={statId}
themeMode={isDarkMode ? "light" : "dark"}
/>
</Preview>
);
const modifier = ability.getModifier();
const abilityCompendiumText = ability.getCompendiumText();
return (
<div key={statId} {...props}>
<Header preview={sidebarPreviewNode}>
{ability.getLabel()} {ability.getTotalScore()}
{modifier !== null && (
<span className={styles.modifier}>
(
<NumberDisplay
type="signed"
number={modifier}
className={styles.signedNumber}
/>
)
</span>
)}
</Header>
<AbilityScoreManager
ability={ability}
showHeader={false}
isReadonly={isReadonly}
/>
{abilityCompendiumText && (
<HtmlContent
className={styles.description}
html={abilityCompendiumText}
withoutTooltips
/>
)}
</div>
);
};
@@ -0,0 +1,214 @@
import { ComponentType, FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
AbilityIcon,
Collapsible,
DarkModeNegativeBonusNegativeSvg,
DarkModePositiveBonusPositiveSvg,
NegativeBonusNegativeSvg,
PositiveBonusPositiveSvg,
} from "@dndbeyond/character-components/es";
import {
AbilityUtils,
characterActions,
Constants,
FormatUtils,
ModifierUtils,
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { NumberDisplay } from "~/components/NumberDisplay";
import { RuleKeyEnum } from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import { Ability, SituationalSavingThrowInfo, StatDataContract } from "~/types";
import EditorBox from "../../../../../../tools/js/Shared/components/EditorBox";
import ValueEditor from "../../../../../../tools/js/Shared/components/ValueEditor";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import {
PaneComponentEnum,
PaneIdentifiersAbilitySavingThrow,
} from "../../types";
import styles from "./styles.module.css";
/*
AbilitySavingThrowsPane is the specific Sidebar for each Ability Saving Throw on a character - (Strength, Dexterity, Constitution, Wisdom, Intelligence, Charisma) - it displays the details, modifiers, any customizations and overrides, as well as the situational bonuses.
*/
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersAbilitySavingThrow | null;
}
export const AbilitySavingThrowsPane: FC<Props> = ({
identifiers,
...props
}) => {
const dispatch = useDispatch();
const {
ruleData,
situationalBonusSavingThrowsLookup,
abilityLookup,
entityValueLookup,
} = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
const { isDarkMode } = useCharacterTheme();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const {
pane: { paneHistoryPush },
} = useSidebar();
const abilityDataLookup = ruleDataUtils.getStatsLookup(ruleData);
const ability: Ability | null = identifiers && abilityLookup[identifiers.id];
const abilityData: StatDataContract | null =
identifiers && abilityDataLookup[identifiers.id];
const situationalBonusSavingThrowsInfo: SituationalSavingThrowInfo[] | null =
identifiers && situationalBonusSavingThrowsLookup[identifiers.id];
const rulesText = ruleDataUtils.getRule(RuleKeyEnum.SAVING_THROWS, ruleData);
const handleCustomDataUpdate = (
key: number,
value: any,
source: string
): void => {
if (!abilityData) return;
dispatch(
characterActions.valueSet(
key,
value,
source,
ValueUtils.hack__toString(abilityData.id),
ValueUtils.hack__toString(abilityData.entityTypeId)
)
);
};
const handleParentClick = (): void => {
paneHistoryPush(PaneComponentEnum.SAVING_THROWS);
};
if (!ability || !abilityData) {
return <PaneInitFailureContent />;
}
const statId = AbilityUtils.getId(ability);
let sidebarPreviewNode: ReactNode = (
<Preview>
<AbilityIcon
className={styles.icon}
statId={statId}
themeMode={isDarkMode ? "light" : "dark"}
/>
</Preview>
);
return (
<div key={statId} {...props}>
<Header
preview={sidebarPreviewNode}
parent="Saving Throws"
onClick={handleParentClick}
>
{abilityData.name} Saving Throw
<span className={styles.modifier}>
<NumberDisplay type="signed" number={AbilityUtils.getSave(ability)} />
</span>
</Header>
{!isReadonly && (
<Collapsible
layoutType={"minimal"}
header="Customize"
className={styles.customize}
>
<EditorBox className={styles.editorBox}>
<ValueEditor
dataLookup={ValueUtils.getEntityData(
entityValueLookup,
ValueUtils.hack__toString(abilityData.id),
ValueUtils.hack__toString(abilityData.entityTypeId)
)}
onDataUpdate={handleCustomDataUpdate}
valueEditors={[
Constants.AdjustmentTypeEnum.SAVING_THROW_OVERRIDE,
Constants.AdjustmentTypeEnum.SAVING_THROW_MAGIC_BONUS,
Constants.AdjustmentTypeEnum.SAVING_THROW_MISC_BONUS,
Constants.AdjustmentTypeEnum.SAVING_THROW_PROFICIENCY_LEVEL,
]}
ruleData={ruleData}
/>
</EditorBox>
</Collapsible>
)}
{situationalBonusSavingThrowsInfo && (
<div className={styles.situational}>
{situationalBonusSavingThrowsInfo.map((situationalBonus) => {
let abilityNode: ReactNode;
if (
situationalBonus.type ===
Constants.SituationalBonusSavingThrowTypeEnum.MODIFIER
) {
let restriction = ModifierUtils.getRestriction(
situationalBonus.extra
);
if (restriction) {
restriction = restriction.trim();
restriction = FormatUtils.lowerCaseLetters(restriction, 0);
abilityNode = <>{restriction}</>;
}
}
if (!abilityNode) {
abilityNode = <>on saves</>;
}
const saveAmount = situationalBonus.value;
let IconComponent: ComponentType<any>;
if (situationalBonus.value >= 0) {
IconComponent = isDarkMode
? DarkModePositiveBonusPositiveSvg
: PositiveBonusPositiveSvg;
} else {
IconComponent = isDarkMode
? DarkModeNegativeBonusNegativeSvg
: NegativeBonusNegativeSvg;
}
return (
<div
className={styles.situationalBonus}
key={situationalBonus.key}
>
<IconComponent className={styles.situationalBonusIcon} />
<span className={styles.situationalBonusValue}>
{saveAmount}
</span>
<span className={styles.situationalBonusRestriction}>
{abilityNode}
</span>
</div>
);
})}
</div>
)}
{rulesText && rulesText.description && (
<HtmlContent
className={styles.description}
html={rulesText.description}
withoutTooltips
/>
)}
</div>
);
};
@@ -0,0 +1,195 @@
import { FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch, useSelector } from "react-redux";
import { ActionName } from "@dndbeyond/character-components/es";
import {
ActionUtils,
characterActions,
InfusionUtils,
HelperUtils,
ItemUtils,
EntityUtils,
ItemManager,
} from "@dndbeyond/character-rules-engine/es";
import { ItemName } from "~/components/ItemName";
import { DataOriginTypeEnum } from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { BaseInventoryContract } from "~/types";
import { ActionDetail } from "../../../../../../tools/js/Shared/components/ActionDetail";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { getDataOriginComponentInfo } from "../../helpers/paneUtils";
import { PaneComponentEnum, PaneIdentifiersAction } from "../../types";
/*
ActionPane is the Sidebar content that displays the details and usages of actions. If the Action is directly represented as an Item in the Actions section - it will display the ItemPane in the Sidebar. Some Items contain actions (reactions or bonus actions) when attuned - These will display seperately from the Item and when clicked they use the ActionPane with special data depending on the DataOrigin.
*/
interface ActionPaneProps extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersAction | null;
}
export const ActionPane: FC<ActionPaneProps> = ({ identifiers, ...props }) => {
const {
actions,
abilityLookup,
ruleData,
entityValueLookup,
proficiencyBonus,
inventoryLookup,
characterTheme,
} = useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const dispatch = useDispatch();
const {
pane: { paneHistoryPush },
} = useSidebar();
const action = actions.find(
(action) =>
identifiers?.id === ActionUtils.getMappingId(action) &&
identifiers?.entityTypeId === ActionUtils.getMappingEntityTypeId(action)
);
const handleCustomDataUpdate = (
key: number,
value: string,
source: string
): void => {
if (action) {
dispatch(
characterActions.valueSet(
key,
value,
source,
ActionUtils.getMappingId(action),
ActionUtils.getMappingEntityTypeId(action)
)
);
}
};
const handleRemoveCustomizations = (): void => {
if (action) {
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
dispatch(
characterActions.actionCustomizationsDelete(
mappingId,
mappingEntityTypeId
)
);
}
}
};
const handleLimitedUseSet = (uses: number): void => {
if (action) {
const mappingId = ActionUtils.getMappingId(action);
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
if (mappingId === null || mappingEntityTypeId === null) {
return;
}
let dataOriginType = ActionUtils.getDataOriginType(action);
if (dataOriginType === DataOriginTypeEnum.ITEM) {
const dataOrigin = ActionUtils.getDataOrigin(action);
const itemData = dataOrigin.primary as BaseInventoryContract;
const item = ItemManager.getItem(ItemUtils.getMappingId(itemData));
item.handleItemLimitedUseSet(uses);
} else {
dispatch(
characterActions.actionUseSet(
mappingId,
mappingEntityTypeId,
uses,
dataOriginType
)
);
}
}
};
const handleParentClick = (): void => {
if (action) {
let dataOrigin = ActionUtils.getDataOrigin(action);
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
}
};
if (!action) {
return <PaneInitFailureContent />;
}
//We could do the dataOrigin stuff better, this could be refactored
//Check ItemPane and InfusionPanes for similar usages of DataOrigin
const dataOrigin = ActionUtils.getDataOrigin(action);
const dataOriginType = ActionUtils.getDataOriginType(action);
let name: ReactNode = null;
if (ActionUtils.getName(action) !== "") {
name = <ActionName theme={characterTheme} action={action} />;
}
let parentName: string | null = null;
let parentClick: (() => void) | undefined = undefined;
if (dataOriginType === DataOriginTypeEnum.ITEM) {
const itemContract = dataOrigin.primary as BaseInventoryContract;
const itemMappingId = ItemUtils.getMappingId(itemContract);
const item = HelperUtils.lookupDataOrFallback(
inventoryLookup,
itemMappingId
);
if (item !== null) {
let infusion = ItemUtils.getInfusion(item);
if (infusion) {
parentName = `Infusion: ${InfusionUtils.getName(infusion)}`;
parentClick = handleParentClick;
}
if (name === null) {
name = <ItemName item={item} onClick={handleParentClick} />;
}
}
} else {
if (dataOrigin.primary) {
parentName = EntityUtils.getDataOriginName(dataOrigin);
parentClick = handleParentClick;
}
}
return (
<div key={ActionUtils.getUniqueKey(action)} {...props}>
<Header parent={parentName} onClick={parentClick}>
{name}
</Header>
<ActionDetail
theme={characterTheme}
action={action}
abilityLookup={abilityLookup}
showCustomize={dataOriginType !== DataOriginTypeEnum.ITEM}
onCustomDataUpdate={handleCustomDataUpdate}
onCustomizationsRemove={handleRemoveCustomizations}
ruleData={ruleData}
entityValueLookup={entityValueLookup}
inventoryLookup={inventoryLookup}
isReadonly={isReadonly}
onLimitedUseSet={handleLimitedUseSet}
proficiencyBonus={proficiencyBonus}
/>
</div>
);
};
@@ -0,0 +1,137 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { DataOriginName } from "@dndbeyond/character-components/es";
import {
FormatUtils,
ItemUtils,
ModifierUtils,
} from "@dndbeyond/character-rules-engine";
import { ItemName } from "~/components/ItemName";
import { NumberDisplay } from "~/components/NumberDisplay";
import { ArmorClassExtraTypeEnum, ArmorClassTypeEnum } from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import { Item, DataOrigin, Modifier } from "~/types";
import { getDataOriginComponentInfo } from "../../../helpers/paneUtils";
import { PaneComponentEnum } from "../../../types";
import styles from "./styles.module.css";
export interface ArmorClassDetailProps extends HTMLAttributes<HTMLDivElement> {}
export const ArmorClassDetail: FC<ArmorClassDetailProps> = ({
className,
...props
}) => {
const {
pane: { paneHistoryPush },
} = useSidebar();
const { acAdjustments, acSuppliers, characterTheme } = useCharacterEngine();
let hasOverrideAcAdjustment =
acAdjustments.overrideAc && acAdjustments.overrideAc.value !== null;
const handleItemDetailShow = (item: Item): void => {
paneHistoryPush(
PaneComponentEnum.ITEM_DETAIL,
PaneIdentifierUtils.generateItem(ItemUtils.getMappingId(item))
);
};
const handleDataOriginShowDetail = (dataOrigin: DataOrigin): void => {
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
};
return (
<div
className={clsx([
styles.items,
hasOverrideAcAdjustment && styles.itemsOverridden,
])}
>
{acSuppliers.map((armorSupplier, idx) => {
const { type, amount, extra, extraType } = armorSupplier;
if (!amount) {
return null;
}
let amountDisplay: ReactNode;
switch (type) {
case ArmorClassTypeEnum.ARMOR:
case ArmorClassTypeEnum.OVERRIDE_BASE_ARMOR:
amountDisplay = amount;
break;
default:
amountDisplay = <NumberDisplay type="signed" number={amount} />;
}
let extraDisplay: ReactNode;
switch (type) {
case ArmorClassTypeEnum.DEX_BONUS:
extraDisplay = extra === null ? "" : `Max ${extra}`;
break;
default:
switch (extraType) {
case ArmorClassExtraTypeEnum.MODIFIER:
extraDisplay = (
<DataOriginName
dataOrigin={ModifierUtils.getDataOrigin(extra as Modifier)}
onClick={handleDataOriginShowDetail}
theme={characterTheme}
/>
);
break;
case ArmorClassExtraTypeEnum.ITEM:
if (extra) {
extraDisplay = (
<ItemName
item={extra as Item}
onClick={() => handleItemDetailShow(extra as Item)}
/>
);
} else {
extraDisplay = "None";
}
break;
case ArmorClassExtraTypeEnum.NUMBER:
extraDisplay = extra
? FormatUtils.renderLocaleNumber(extra as number)
: "";
break;
case ArmorClassExtraTypeEnum.STRING:
default:
extraDisplay = extra;
}
break;
}
return (
<div
className={styles.item}
key={idx}
{...props}
data-testid="armor-class-detail"
>
<div className={styles.value}>{amountDisplay}</div>
<div className={styles.label}>
<span>{type}</span>
{extraDisplay && (
<span className={styles.source}>({extraDisplay})</span>
)}
</div>
</div>
);
})}
</div>
);
};
@@ -0,0 +1,106 @@
import { FC, HTMLAttributes } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Collapsible } from "@dndbeyond/character-components/es";
import {
characterActions,
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { AdjustmentTypeEnum, RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import EditorBox from "~/tools/js/Shared/components/EditorBox";
import ValueEditor from "~/tools/js/Shared/components/ValueEditor";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { ArmorClassDetail } from "./ArmorClassDetail";
import styles from "./styles.module.css";
/*
This is the sidebar information for Armor Class. It displays the sources of any Armor Class bonuses and you can provide customizations and overrides here.
*/
interface ArmorManagePaneProps extends HTMLAttributes<HTMLDivElement> {}
export const ArmorManagePane: FC<ArmorManagePaneProps> = ({ ...props }) => {
const dispatch = useDispatch();
const { acAdjustments, acTotal, valueLookup, ruleData } =
useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const { ruleDataUtils } = useRuleData();
const acRule =
ruleDataUtils.getRule(RuleKeyEnum.ARMOR_CLASS, ruleData)?.description ??
null;
const customizationValues = [
AdjustmentTypeEnum.OVERRIDE_AC,
AdjustmentTypeEnum.OVERRIDE_BASE_ARMOR,
AdjustmentTypeEnum.MAGIC_BONUS_AC,
AdjustmentTypeEnum.MISC_BONUS_AC,
];
let hasOverrideAcAdjustment: boolean =
acAdjustments.overrideAc !== null &&
acAdjustments.overrideAc.value !== null;
let overrideAcAdjustmentSource: string | null =
hasOverrideAcAdjustment && acAdjustments.overrideAc
? acAdjustments.overrideAc.notes
: "";
const handleCustomDataUpdate = (
key: number,
value: string,
source: string
): void => {
dispatch(characterActions.valueSet(key, value, source));
};
return (
<div {...props}>
<Header>
Armor Class: {acTotal}
{hasOverrideAcAdjustment ? "*" : ""}
{hasOverrideAcAdjustment && (
<span className={styles.headingOverride}>
(
{overrideAcAdjustmentSource
? overrideAcAdjustmentSource
: "Override"}
)
</span>
)}
</Header>
<ArmorClassDetail />
{/* AC CUSTOMIZATION */}
{!isReadonly && (
<Collapsible
layoutType={"minimal"}
header="Customize"
className={styles.customize}
>
<EditorBox className={styles.editorBox}>
<ValueEditor
dataLookup={ValueUtils.getDataLookup(
valueLookup,
customizationValues
)}
onDataUpdate={handleCustomDataUpdate}
valueEditors={customizationValues}
ruleData={ruleData}
/>
</EditorBox>
</Collapsible>
)}
{/* AC Description */}
{acRule && (
<HtmlContent
className={styles.description}
html={acRule}
withoutTooltips
/>
)}
</div>
);
};
@@ -0,0 +1,75 @@
import { FC, HTMLAttributes } from "react";
import { BackgroundUtils } from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
import { PaneInitFailureContent } from "~/subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
import { FeatureSnippetChoices } from "../../../../../../tools/js/CharacterSheet/components/FeatureSnippet";
import styles from "./styles.module.css";
/*
This is the Sidebar display for Background. You can access this Pane by clicking on the Background data displayed in the Description tab section of the Character Sheet.
*/
interface BackgroundPaneProps extends HTMLAttributes<HTMLDivElement> {}
export const BackgroundPane: FC<BackgroundPaneProps> = ({ ...props }) => {
const {
ruleData,
abilityLookup,
backgroundInfo,
snippetData,
originRef,
proficiencyBonus,
characterTheme,
} = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
if (!backgroundInfo) {
return <PaneInitFailureContent />;
}
const hasCustomBackground =
BackgroundUtils.getHasCustomBackground(backgroundInfo);
const description = BackgroundUtils.getDescription(backgroundInfo);
const featureName = BackgroundUtils.getFeatureName(backgroundInfo);
return (
<div {...props}>
<Header>{BackgroundUtils.getName(backgroundInfo)}</Header>
<div className={styles.data}>
<div className={styles.choices}>
<FeatureSnippetChoices
choices={BackgroundUtils.getChoices(backgroundInfo)}
ruleData={ruleData}
abilityLookup={abilityLookup}
snippetData={snippetData}
sourceDataLookup={ruleDataUtils.getSourceDataLookup(ruleData)}
dataOriginRefData={originRef}
proficiencyBonus={proficiencyBonus}
theme={characterTheme}
/>
</div>
</div>
{hasCustomBackground ? (
<div className={styles.customDescription}>{description}</div>
) : (
<HtmlContent html={description ? description : ""} withoutTooltips />
)}
{hasCustomBackground && featureName && (
<div>
<Heading>Feature: {featureName}</Heading>
<HtmlContent
html={BackgroundUtils.getFeatureDescription(backgroundInfo) ?? ""}
withoutTooltips
/>
</div>
)}
</div>
);
};
@@ -0,0 +1,41 @@
import { FC, HTMLAttributes } from "react";
import { HtmlContent } from "~/components/HtmlContent";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { PaneIdentifiersBasicAction } from "../../types";
/*
This is the Sidebar display for Basic Actions. You can see this Pane by clicking on the Basic Actions (actions, bonus actions, reactions, etc.) displayed in the Actions section of the Character Sheet. Examples of Basic Actions include Attack, Dash, Disengage, Dodge, Help, Hide, Ready, Search, Use an Object, etc.
*/
interface BasicActionPaneProps extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersBasicAction | null;
}
export const BasicActionPane: FC<BasicActionPaneProps> = ({
identifiers,
...props
}) => {
const { ruleData } = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
const action =
identifiers && ruleDataUtils.getBasicAction(identifiers.id, ruleData);
if (action === null) {
return <PaneInitFailureContent />;
}
return (
<div {...props}>
<Header>{action.name}</Header>
{action.description !== null && (
<HtmlContent html={action.description} withoutTooltips />
)}
</div>
);
};
@@ -0,0 +1,69 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { useSelector } from "react-redux";
import { CharacterAvatarPortrait } from "@dndbeyond/character-components/es";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import styles from "./styles.module.css";
/*
This is a single character in the CampaignPane. It displays the character's avatar, name, and user name.
*/
export interface CampaignCharacterProps extends HTMLAttributes<HTMLDivElement> {
characterId: number;
avatarUrl: string;
characterName: string;
userName: string;
}
export const CampaignCharacter: FC<CampaignCharacterProps> = ({
characterId,
avatarUrl,
characterName,
userName,
className,
...props
}) => {
const { isDarkMode } = useCharacterTheme();
const currentCharacterId = useSelector(appEnvSelectors.getCharacterId);
const isCurrentCharacter = currentCharacterId === characterId;
return (
<div
className={clsx([
styles.character,
isCurrentCharacter && styles.current,
className,
])}
key={characterId}
{...props}
>
<CharacterAvatarPortrait
className={styles.preview}
avatarUrl={avatarUrl}
/>
<div className={styles.content}>
<div className={styles.name}>
{isCurrentCharacter ? (
<span>{characterName}</span>
) : (
<a
href={`/characters/${characterId}`}
className={clsx([styles.link, isDarkMode && styles.darkMode])}
target="_blank"
rel="noreferrer"
>
{characterName}
</a>
)}
</div>
<div className={styles.user}>{userName}</div>
</div>
</div>
);
};
@@ -0,0 +1,100 @@
import { FC, HTMLAttributes } from "react";
import { useSelector } from "react-redux";
import {
DarkPlayButtonSvg,
LightPlayButtonSvg,
} from "@dndbeyond/character-components/es";
import { Button } from "~/components/Button/Button";
import { HtmlContent } from "~/components/HtmlContent";
import { PreferencePrivacyTypeEnum } from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { PaneInitFailureContent } from "~/subApps/sheet/components/Sidebar/components/PaneInitFailureContent";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { NavigationUtils } from "~/tools/js/Shared/utils";
import { CampaignCharacterContract } from "~/types";
import { CampaignCharacter } from "./CampaignCharacter";
import styles from "./styles.module.css";
/*
This is the Sidebar for a Campaign. If the character has joined a Campaign, you can click on the campaign button in the Character Sheet header to see this Sidebar. It displays the Campaign name, maps link, DM, and all characters in the campaign. If the character is the DM, they will see all characters in the campaign. If the character is not the DM, they will only see characters that have been set to public or campaign only.
*/
interface CampaignPaneProps extends HTMLAttributes<HTMLDivElement> {}
export const CampaignPane: FC<CampaignPaneProps> = ({ ...props }) => {
const { campaign, campaignUtils } = useCharacterEngine();
const { isDarkMode } = useCharacterTheme();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
if (!campaign) {
return <PaneInitFailureContent />;
}
const characters = campaignUtils.getCharacters(campaign);
const description = campaignUtils.getDescription(campaign);
const filteredCharacters = characters
? characters.filter(
(character) =>
character.privacyType === PreferencePrivacyTypeEnum.PUBLIC ||
character.privacyType === PreferencePrivacyTypeEnum.CAMPAIGN_ONLY
)
: [];
const orderedCharacters = filteredCharacters.sort((a, b) =>
(a.characterName ?? "").localeCompare(b.characterName ?? "")
);
return (
<div {...props}>
<div className={styles.header}>
<Header>Campaign</Header>
<Button
href={NavigationUtils.getLaunchGameUrl(campaign)}
target="_blank"
size="x-small"
variant="text"
className={styles.launchButton}
themed
>
{isDarkMode ? <LightPlayButtonSvg /> : <DarkPlayButtonSvg />}
<span>Launch Game</span>
</Button>
</div>
<div className={styles.name}>
{isReadonly ? (
campaignUtils.getName(campaign)
) : (
<a
className={styles.link}
href={campaignUtils.getLink(campaign) ?? ""}
>
{campaignUtils.getName(campaign)}
</a>
)}
</div>
{!isReadonly && description !== null && (
<HtmlContent html={description} withoutTooltips />
)}
<div className={styles.dm}>
<span className={styles.dmLabel}>DM:</span>
<span>{campaignUtils.getDmUsername(campaign)}</span>
</div>
<div className={styles.characters}>
{orderedCharacters.map((character: CampaignCharacterContract) => (
<CampaignCharacter
key={character.characterId}
characterId={character.characterId}
avatarUrl={character.avatarUrl ?? ""}
characterName={character.characterName ?? ""}
userName={character.username ?? ""}
/>
))}
</div>
</div>
);
};
@@ -0,0 +1,247 @@
import { ComponentType, FC, HTMLAttributes, ReactNode } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import {
LightLinkOutSvg,
ThemedBackdropSvg,
ThemedBuilderSvg,
ThemedExportSvg,
ThemedFrameSvg,
ThemedLongRestSvg,
ThemedManageLevelSvg,
ThemedManageXpSvg,
ThemedPortraitSvg,
ThemedPreferencesSvg,
ThemedShareSvg,
ThemedShortRestSvg,
ThemedThemeIconSvg,
ThemedChatBubbleSvg,
} from "@dndbeyond/character-components/es";
import D6 from "@dndbeyond/fontawesome-cache/svgs/regular/dice-d6.svg";
import { Button } from "~/components/Button";
import config from "~/config";
import {
PreferencePrivacyTypeEnum,
PreferenceProgressionTypeEnum,
} from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { sheetAppSelectors } from "~/tools/js/CharacterSheet/selectors";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { CharacterStatusSlug } from "~/types";
import {
PaneMenu,
PaneMenuGroup,
PaneMenuItem,
} from "../../components/PaneMenu";
import { PaneComponentEnum } from "../../types";
import { Overview } from "./Overview";
import styles from "./styles.module.css";
const BASE_PATHNAME = config.basePathname;
interface CharacterManagePaneProps extends HTMLAttributes<HTMLDivElement> {}
export const CharacterManagePane: FC<CharacterManagePaneProps> = ({
...props
}) => {
const navigate = useNavigate();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const builderUrl = useSelector(sheetAppSelectors.getBuilderUrl);
const {
preferences,
decorationInfo,
helperUtils,
decorationUtils,
characterStatusSlug: characterStatus,
characterTheme,
} = useCharacterEngine();
const {
pane: { paneHistoryPush },
} = useSidebar();
const handleXpMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.XP);
};
const handleGameLogMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.GAME_LOG);
};
const handleShortRestMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.SHORT_REST);
};
const handleLongRestMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.LONG_REST);
};
const handlePreferencesMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.PREFERENCES);
};
const handleShareMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.SHARE_URL);
};
const handlePdfClick = (): void => {
paneHistoryPush(PaneComponentEnum.EXPORT_PDF);
};
const handleBuilderClick = (): void => {
navigate(builderUrl);
};
//USE A LOOKUP TO GET EACH MENU ICON
const getMenuIconNode = (menuKey: string): ReactNode => {
const menuIconLookup: Record<string, ComponentType> = {
builder: ThemedBuilderSvg,
shortrest: ThemedShortRestSvg,
longrest: ThemedLongRestSvg,
share: ThemedShareSvg,
managelevel: ThemedManageLevelSvg,
managexp: ThemedManageXpSvg,
gamelog: ThemedChatBubbleSvg,
frame: ThemedFrameSvg,
backdrop: ThemedBackdropSvg,
theme: ThemedThemeIconSvg,
portrait: ThemedPortraitSvg,
preferences: ThemedPreferencesSvg,
downloadpdf: ThemedExportSvg,
};
const IconNode: ComponentType<any> | null =
menuIconLookup[menuKey] &&
helperUtils.lookupDataOrFallback(menuIconLookup, menuKey);
if (IconNode === null) {
return null;
}
if (menuKey === "gamelog") {
return (
<D6
className="ddbc-svg ddbc-svg--themed"
style={{ fill: characterTheme.themeColor }}
/>
);
}
return (
<IconNode theme={decorationUtils.getCharacterTheme(decorationInfo)} />
);
};
return (
<div
className={styles.characterManagePane}
data-testid="character-manage-pane"
{...props}
>
<Overview />
{/* Display Readonly CTA or MENU */}
{isReadonly ? (
<div className={styles.readonlyBackground}>
<div className={styles.readonlyContent}>
<div>
You are viewing a{" "}
{characterStatus === CharacterStatusSlug.PREMADE
? "premade"
: "read-only"}{" "}
Character Sheet.
</div>
<Button
className={styles.createCTA}
size="medium"
variant="solid"
target="_blank"
themed
href={`${BASE_PATHNAME}/builder`}
forceThemeMode="dark"
>
<span>Create a Character</span>
<LightLinkOutSvg />
</Button>
</div>
</div>
) : (
<PaneMenu>
<PaneMenuGroup label="My Character">
<PaneMenuItem
menukey="builder"
prefixIcon={getMenuIconNode("builder")}
suffixIcon={<LightLinkOutSvg />}
onClick={handleBuilderClick}
>
Manage Character & Levels
</PaneMenuItem>
{preferences.progressionType ===
PreferenceProgressionTypeEnum.XP && (
<PaneMenuItem
menukey="managexp"
prefixIcon={getMenuIconNode("managexp")}
onClick={handleXpMenuClick}
>
Manage Experience
</PaneMenuItem>
)}
<PaneMenuItem
menukey="preferences"
prefixIcon={getMenuIconNode("preferences")}
onClick={handlePreferencesMenuClick}
>
Character Settings
</PaneMenuItem>
</PaneMenuGroup>
<PaneMenuGroup label="Play">
<PaneMenuItem
menukey="gamelog"
prefixIcon={getMenuIconNode("gamelog")}
onClick={handleGameLogMenuClick}
>
Game Log
</PaneMenuItem>
<PaneMenuItem
menukey="shortrest"
prefixIcon={getMenuIconNode("shortrest")}
onClick={handleShortRestMenuClick}
>
Short Rest
</PaneMenuItem>
<PaneMenuItem
menukey="longrest"
prefixIcon={getMenuIconNode("longrest")}
onClick={handleLongRestMenuClick}
>
Long Rest
</PaneMenuItem>
</PaneMenuGroup>
<PaneMenuGroup label="Share">
{preferences.privacyType === PreferencePrivacyTypeEnum.PUBLIC && (
<PaneMenuItem
menukey="share"
prefixIcon={getMenuIconNode("share")}
onClick={handleShareMenuClick}
>
Shareable Link
</PaneMenuItem>
)}
<PaneMenuItem
menukey="downloadpdf"
prefixIcon={getMenuIconNode("downloadpdf")}
onClick={handlePdfClick}
>
Export to PDF
</PaneMenuItem>
</PaneMenuGroup>
</PaneMenu>
)}
</div>
);
};
@@ -0,0 +1,280 @@
import clsx from "clsx";
import {
ChangeEvent,
FC,
HTMLAttributes,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import {
CharacterAvatarPortrait,
CharacterName,
CharacterProgressionSummary,
CharacterSummary,
LightPencilSvg,
ThemedPaintBrushSvg,
} from "@dndbeyond/character-components/es";
import { Button } from "~/components/Button/Button";
import {
CharacterNameLimitMsg,
DeathCauseEnum,
InputLimits,
} from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useMaxLengthErrorHandling } from "~/hooks/useErrorHandling/useMaxLengthErrorHandling";
import useUserId from "~/hooks/useUserId";
import { appEnvActions } from "~/tools/js/Shared/actions";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { CharacterStatusSlug } from "~/types";
import { Popover } from "../../../../../../../components/Popover";
import { PopoverContent } from "../../../../../../../components/PopoverContent";
import { PaneComponentEnum } from "../../../types";
import styles from "./styles.module.css";
export interface OverviewProps extends HTMLAttributes<HTMLDivElement> {}
export const Overview: FC<OverviewProps> = ({ className, ...props }) => {
const {
ruleData,
characterName,
characterGender: gender,
race: species,
experienceInfo: xpInfo,
deathCause,
preferences,
decorationInfo,
playerName,
playerId,
characterActions,
decorationUtils,
classUtils,
classes,
characterStatusSlug: characterStatus,
} = useCharacterEngine();
const dispatch = useDispatch();
const { isDarkMode } = useCharacterTheme();
const userId = useUserId();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const canOverrideReadOnly = useSelector(
appEnvSelectors.getCanOverrideReadOnly
);
const {
pane: { paneHistoryPush },
} = useSidebar();
const { MaxLengthErrorMessage, hideError, handleMaxLengthErrorMsg } =
useMaxLengthErrorHandling(
(characterName?.length ?? 0) >= InputLimits.characterNameMaxLength,
InputLimits.characterNameMaxLength,
CharacterNameLimitMsg
);
const isUserCharacter = playerId === Number(userId);
const isPremade = characterStatus === CharacterStatusSlug.PREMADE;
const [isNameEditorVisible, setIsNameEditorVisible] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isNameEditorVisible && inputRef.current) {
inputRef.current.focus();
}
}, [isNameEditorVisible]);
const handleNameClick = (): void => {
setIsNameEditorVisible(true);
};
const handleNameChange = (evt: ChangeEvent<HTMLInputElement>): void => {
const inputValue = evt.target.value;
if (inputValue !== characterName && inputValue !== "") {
dispatch(characterActions.nameSet(inputValue));
}
setIsNameEditorVisible(false);
hideError();
};
const handleToggleEdit = (status: boolean): void => {
dispatch(appEnvActions.dataSet({ isReadonly: status }));
};
const handleNameUpdate = (evt: ChangeEvent<HTMLInputElement>): void => {
handleMaxLengthErrorMsg(evt.target.value);
};
const handleDecorateMenuClick = (): void => {
paneHistoryPush(PaneComponentEnum.DECORATE);
};
const isEditingCharacterName = useMemo(
() => isNameEditorVisible && !isReadonly,
[isNameEditorVisible, isReadonly]
);
return (
<div
className={clsx([styles.intro, isDarkMode && styles.dark, className])}
{...props}
>
<div className={styles.summary}>
<CharacterAvatarPortrait
className={styles.avatar}
avatarUrl={decorationUtils.getAvatarInfo(decorationInfo).avatarUrl}
/>
{
<div
className={clsx([
styles.characterName,
isEditingCharacterName && styles.editName,
])}
onClick={isReadonly ? undefined : handleNameClick}
>
{isEditingCharacterName ? (
<>
<input
ref={inputRef}
type="text"
defaultValue={characterName ?? ""}
maxLength={InputLimits.characterNameMaxLength}
onBlur={handleNameChange}
onChange={handleNameUpdate}
onFocus={handleNameUpdate}
/>
<MaxLengthErrorMessage />
</>
) : (
<>
<CharacterName
name={characterName ?? ""}
isDead={deathCause !== DeathCauseEnum.NONE}
isFaceMenu={true}
/>
{!isReadonly && <LightPencilSvg />}
</>
)}
</div>
}
{/* DISPLAY PLAYER NAME IF ITS NOT A PREMADE OR YOUR CHARACTER */}
{!isUserCharacter && !isPremade && (
<div>
<span className={styles.playerNameLabel}>Player: </span>
{playerName}
</div>
)}
{/* SHOW SPECIES, GENDER AND XP/LEVEL INFO */}
<div className={styles.description}>
<CharacterSummary
className={styles.summaryText}
classes={null}
species={species}
gender={gender}
/>
<CharacterProgressionSummary
ruleData={ruleData}
progressionType={preferences.progressionType}
xpInfo={xpInfo}
/>
</div>
</div>
{/* DISPLAY CLASS LIST */}
<div className={styles.classList}>
{classes.map((charClass) => (
<div className={styles.classItem} key={classUtils.getId(charClass)}>
<div
className={styles.classImage}
style={{
backgroundImage: `url(${classUtils.getPortraitUrl(charClass)})`,
}}
>
<div className={styles.classLevel}>
{classUtils.getLevel(charClass)}
</div>
</div>
<div className={styles.classSummary}>
<div>{classUtils.getName(charClass)}</div>
<div className={styles.classMeta}>
{classUtils.getSubclassName(charClass) ?? "No Subclass"}
</div>
</div>
</div>
))}
</div>
<div className={styles.buttonGroup}>
{/* This is only for PREMADE characters */}
{canOverrideReadOnly && (
<div className={styles.featureCallout}>
{isReadonly ? (
<Popover
trigger={
<Button
size="x-small"
variant="solid"
className={clsx([styles.toggleButton])}
forceThemeMode="dark"
>
<LightPencilSvg />
<span>Enable Edit Premade</span>
</Button>
}
maxWidth={284}
className={styles.popoverToggle}
>
<PopoverContent
title="PROCEED WITH CAUTION!!!"
content="⚠️ If you enable editing you will be modifying a published character ⚠️"
confirmText="Enable"
onConfirm={() => handleToggleEdit(false)}
withCancel
/>
</Popover>
) : (
<Button
size="x-small"
variant="solid"
className={clsx([styles.toggleButton])}
forceThemeMode="dark"
onClick={() => handleToggleEdit(true)}
>
<LightPencilSvg />
<span>DISABLE Edit Premade</span>
</Button>
)}
</div>
)}
{/* DISPLAY DECORATE BUTTON AS A "FEATURE CALLOUT" */}
{!isReadonly && (
<div className={styles.featureCallout}>
<Button
size="x-small"
variant="outline"
className={styles.decorateButton}
forceThemeMode="dark"
themed
onClick={handleDecorateMenuClick}
>
<ThemedPaintBrushSvg
theme={decorationUtils.getCharacterTheme(decorationInfo)}
/>
<span>Change Sheet Appearance</span>
</Button>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,385 @@
import {
FC,
HTMLAttributes,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import {
ApiAdapterUtils,
FeatManager,
} from "@dndbeyond/character-rules-engine";
import { FeatureChoice } from "~/components/FeatureChoice";
import { BuilderChoiceTypeEnum } from "~/constants";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { apiCreatorSelectors } from "~/tools/js/Shared/selectors";
import { AppLoggerUtils, PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import LoadingPlaceholder from "~/tools/js/smartComponents/LoadingPlaceholder";
import {
Action,
CharClass,
ClassDefinitionContract,
ClassFeature,
Feat,
InfusionChoice,
Spell,
} from "~/types";
import { ClassFeatureSnippet } from "../../../../../../tools/js/CharacterSheet/components/FeatureSnippet";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { Heading } from "../../components/Heading";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { PaneComponentEnum, PaneIdentifiersClassFeature } from "../../types";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersClassFeature | null;
}
export const ClassFeaturePane: FC<Props> = ({ identifiers, ...props }) => {
const dispatch = useDispatch();
const {
infusionChoiceUtils,
actionUtils,
characterActions,
spellUtils,
classUtils,
classFeatureUtils,
classes,
feats,
snippetData,
ruleData,
abilityLookup,
proficiencyBonus,
originRef: dataOriginRefData,
characterTheme: theme,
choiceUtils,
featUtils,
helperUtils,
} = useCharacterEngine();
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const {
pane: { paneHistoryPush },
} = useSidebar();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const loadAvailableFeats = useSelector(
apiCreatorSelectors.makeLoadAvailableFeats
);
const loadAvailableSubclasses = useSelector(
apiCreatorSelectors.makeLoadAvailableSubclasses
);
const [subclassData, setSubclassData] = useState<
Array<ClassDefinitionContract>
>([]);
const [featsData, setFeatsData] = useState<Array<Feat>>([]);
const [featLoadingStatus, setFeatLoadingStatus] =
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
const [subclassLoadingStatus, setSubclassLoadingStatus] =
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
const currentCharClass = useRef<CharClass | null>(null);
const charClass = identifiers?.classMappingId
? classes.find(
(charClass) =>
identifiers.classMappingId === classUtils.getMappingId(charClass)
)
: null;
const classFeature =
charClass?.classFeatures.find(
(feature) => identifiers?.id === classFeatureUtils.getId(feature)
) ?? null;
const featureChoices = classFeature
? classFeatureUtils.getChoices(classFeature)
: [];
const hasFeatChoice = featureChoices.some(
(choice) =>
choiceUtils.getType(choice) === BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION
);
const hasSubclassChoice = featureChoices.some(
(choice) =>
choiceUtils.getType(choice) === BuilderChoiceTypeEnum.SUB_CLASS_OPTION
);
useEffect(() => {
const isSameClass = (
c1: CharClass | null,
c2: CharClass | null
): boolean => {
if (!c1 || !c2) return false;
return classUtils.getId(c1) === classUtils.getId(c2);
};
if (
hasFeatChoice &&
featLoadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
) {
setFeatLoadingStatus(DataLoadingStatusEnum.LOADING);
loadAvailableFeats({
// cancelToken: new axios.CancelToken((c) => {
// this.loadFeatsCanceler = c;
// }),
})
.then((response) => {
let featsData: Array<Feat> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
featsData = data.map((definition) =>
featUtils.simulateFeat(definition)
);
}
setFeatsData(featsData);
setFeatLoadingStatus(DataLoadingStatusEnum.LOADED);
// this.loadFeatsCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
if (
charClass &&
!isSameClass(charClass, currentCharClass.current) && // Only pull data if the class has changed from previous fetch
hasSubclassChoice &&
subclassLoadingStatus !== DataLoadingStatusEnum.LOADING // Check if its not already fetching data
) {
currentCharClass.current = charClass; //Update ref of fetched class
setSubclassLoadingStatus(DataLoadingStatusEnum.LOADING);
loadAvailableSubclasses(classUtils.getId(charClass), {
// cancelToken: new axios.CancelToken((c) => {
// this.loadSubclassesCanceler = c;
// }),
})
.then((response) => {
let subclassData: Array<ClassDefinitionContract> = [];
const data = ApiAdapterUtils.getResponseData(response);
if (data !== null) {
subclassData = data;
}
setSubclassData(subclassData);
setSubclassLoadingStatus(DataLoadingStatusEnum.LOADED);
// this.loadSubclassesCanceler = null;
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
}, [
hasFeatChoice,
hasSubclassChoice,
subclassLoadingStatus,
featLoadingStatus,
charClass,
classUtils,
featUtils,
loadAvailableFeats,
loadAvailableSubclasses,
currentCharClass,
]);
const handleInfusionChoiceShow = (infusionChoice: InfusionChoice): void => {
const choiceKey = infusionChoiceUtils.getKey(infusionChoice);
if (choiceKey !== null) {
paneHistoryPush(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
};
const handleChoiceChange = (
id: string,
type: number,
value: any,
parentChoiceId: string | null
): void => {
if (!charClass || !classFeature) {
return;
}
dispatch(
characterActions.classFeatureChoiceSetRequest(
classUtils.getActiveId(charClass),
classFeatureUtils.getId(classFeature),
type,
id,
helperUtils.parseInputInt(value),
parentChoiceId
)
);
};
const handleActionUseSet = (action: Action, uses: number): void => {
const id = actionUtils.getId(action);
const entityTypeId = actionUtils.getEntityTypeId(action);
if (id !== null && entityTypeId !== null) {
dispatch(
characterActions.actionUseSet(
id,
entityTypeId,
uses,
actionUtils.getDataOriginType(action)
)
);
}
};
const handleSpellUseSet = (spell: Spell, uses: number): void => {
const mappingId = spellUtils.getMappingId(spell);
const mappingEntityTypeId = spellUtils.getMappingEntityTypeId(spell);
const dataOriginType = spellUtils.getDataOriginType(spell);
if (mappingId && mappingEntityTypeId && dataOriginType) {
dispatch(
characterActions.spellUseSet(
mappingId,
mappingEntityTypeId,
uses,
dataOriginType
)
);
}
};
const handleSpellDetailShow = (spell: Spell): void => {
const mappingId = spellUtils.getMappingId(spell);
if (mappingId !== null) {
paneHistoryPush(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId)
);
}
};
const handleClassFeatureShow = (
feature: ClassFeature,
charClass: CharClass
): void => {
paneHistoryPush(
PaneComponentEnum.CLASS_FEATURE_DETAIL,
PaneIdentifierUtils.generateClassFeature(
classFeatureUtils.getId(feature),
classUtils.getMappingId(charClass)
)
);
};
const handleActionShow = (action: Action): void => {
const mappingId = actionUtils.getMappingId(action);
const mappingEntityTypeId = actionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneHistoryPush(
PaneComponentEnum.ACTION,
PaneIdentifierUtils.generateAction(mappingId, mappingEntityTypeId)
);
}
};
const handleFeatShow = (feat: FeatManager): void => {
paneHistoryPush(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(feat.getId())
);
};
const isDataLoaded = (): boolean => {
if (hasSubclassChoice) {
if (subclassLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
if (hasFeatChoice) {
if (featLoadingStatus !== DataLoadingStatusEnum.LOADED) {
return false;
}
}
return true;
};
if (!charClass || !classFeature) {
return <PaneInitFailureContent />;
}
return (
<div
key={`${classUtils.getMappingId(charClass)}-${classFeatureUtils.getId(
classFeature
)}`}
{...props}
>
<Header
parent={classUtils.getName(charClass)}
preview={<Preview imageUrl={classUtils.getPortraitUrl(charClass)} />}
>
{classFeatureUtils.getName(classFeature)}
</Header>
<ClassFeatureSnippet
charClass={charClass}
feature={classFeature}
onActionUseSet={handleActionUseSet}
onActionClick={handleActionShow}
onSpellClick={handleSpellDetailShow}
onSpellUseSet={handleSpellUseSet}
onFeatureClick={handleClassFeatureShow}
onInfusionChoiceClick={handleInfusionChoiceShow}
showHeader={false}
showDescription={true}
feats={feats}
snippetData={snippetData}
ruleData={ruleData}
abilityLookup={abilityLookup}
dataOriginRefData={dataOriginRefData}
isReadonly={isReadonly}
proficiencyBonus={proficiencyBonus}
theme={theme}
onFeatClick={handleFeatShow}
featuresManager={characterFeaturesManager}
/>
{!isReadonly && featureChoices.length > 0 && (
<>
<Heading className={styles.heading}>{`Option${
featureChoices.length > 1 ? "s" : ""
}`}</Heading>
{isDataLoaded() ? (
featureChoices.map((choice) => (
<FeatureChoice
charClass={charClass}
choice={choice}
feature={classFeature}
featsData={featsData}
subclassData={subclassData}
className={styles.choice}
key={choiceUtils.getId(choice)}
onChoiceChange={handleChoiceChange}
collapseDescription={true}
/>
))
) : (
<LoadingPlaceholder />
)}
</>
)}
</div>
);
};
@@ -0,0 +1,606 @@
import clsx from "clsx";
import {
FC,
Fragment,
HTMLAttributes,
useCallback,
useEffect,
useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import {
Collapsible,
CollapsibleHeaderCallout,
CollapsibleHeaderContent,
CreatureName,
InfusionPreview,
} from "@dndbeyond/character-components/es";
import {
characterActions,
Constants,
Creature,
CreatureUtils,
InfusionUtils,
serviceDataActions,
SourceUtils,
ValueUtils,
} from "@dndbeyond/character-rules-engine/es";
import { Button } from "~/components/Button";
import { EditableName } from "~/components/EditableName";
import { HtmlContent } from "~/components/HtmlContent";
import { Reference } from "~/components/Reference";
import { TagGroup } from "~/components/TagGroup";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { CreatureBlock } from "~/subApps/sheet/components/CreatureBlock";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
import { Preview } from "~/subApps/sheet/components/Sidebar/components/Preview";
import {
PaneComponentEnum,
PaneIdentifiersCreature,
} from "~/subApps/sheet/components/Sidebar/types";
import CustomizeDataEditor from "../../../../../../tools/js/Shared/components/CustomizeDataEditor";
import EditorBox from "../../../../../../tools/js/Shared/components/EditorBox";
import ValueEditor from "../../../../../../tools/js/Shared/components/ValueEditor";
import { RemoveButton } from "../../../../../../tools/js/Shared/components/common/Button";
import { appEnvSelectors } from "../../../../../../tools/js/Shared/selectors";
import { PaneIdentifierUtils } from "../../../../../../tools/js/Shared/utils";
import { DeathSummary } from "../../../HitPointsBox/DeathSummary";
import { HitPointsSummary } from "../../../HitPointsBox/HitPointsSummary";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { QuickActions } from "../../components/QuickActions";
import { HitPointsAdjuster } from "../HitPointsManagePane/HitPointsAdjuster";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersCreature | null;
}
export const CreaturePane: FC<Props> = ({ identifiers, ...props }) => {
const { pane } = useSidebar();
const {
ruleData,
creatures,
entityValueLookup,
snippetData,
infusionChoiceLookup,
proficiencyBonus,
characterTheme: theme,
hpInfo,
deathCause,
} = useCharacterEngine();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const dispatch = useDispatch();
const getCreature = useCallback((): Creature | null => {
return (
creatures.find(
(creature) => identifiers?.id === CreatureUtils.getMappingId(creature)
) ?? null
);
}, [creatures, identifiers]);
const [creature, setCreature] = useState<Creature | null>(getCreature());
const [isCustomizeClosed, setIsCustomizeClosed] = useState(true);
useEffect(() => {
const foundCreature = getCreature();
setCreature(foundCreature);
}, [getCreature]);
const getData = (): Record<string, any> => {
if (!creature) {
return {};
}
return {
name: CreatureUtils.getName(creature),
description: creature.description,
groupId: CreatureUtils.getGroupId(creature),
};
};
const handleToggleCustomize = () => {
setIsCustomizeClosed(!isCustomizeClosed);
};
const dispatchTempHp = (value: number): void => {
if (!creature) {
return;
}
const useOwnerHp = CreatureUtils.getUseOwnerHp(creature);
const id = CreatureUtils.getMappingId(creature);
const hitPointInfo = useOwnerHp
? hpInfo
: CreatureUtils.getHitPointInfo(creature);
dispatch(
useOwnerHp
? characterActions.hitPointsSet(hitPointInfo.removedHp, value)
: characterActions.creatureHitPointsSet(
id,
hitPointInfo.removedHp,
value
)
);
};
const handleQuickTempAction = (value: number): void => {
dispatchTempHp(value);
};
const handleSaveProperties = (properties: Record<string, any>): void => {
if (!creature) {
return;
}
const data = getData();
const prevData: Record<string, any> = data || {};
const newProperties: Record<string, any> = {
...prevData,
...properties,
};
dispatch(
characterActions.creatureDataSet(
CreatureUtils.getMappingId(creature),
newProperties as any
)
);
};
const handleDataUpdate = (data: Record<string, any>): void => {
if (!data.name) {
data.name = null;
}
handleSaveProperties(data);
};
const handleCustomDataUpdate = (
key: number,
value: string,
source: string | null
): void => {
if (!creature) {
return;
}
dispatch(
characterActions.valueSet(
key,
value,
source,
ValueUtils.hack__toString(CreatureUtils.getMappingId(creature)),
ValueUtils.hack__toString(
CreatureUtils.getMappingEntityTypeId(creature)
)
)
);
};
const handleRemoveCustomizations = (): void => {
if (creature) {
dispatch(
characterActions.creatureCustomizationsDelete(
CreatureUtils.getMappingId(creature),
CreatureUtils.getMappingEntityTypeId(creature)
)
);
}
};
const handleRemove = (): void => {
if (!creature) {
return;
}
pane.paneHistoryStart(PaneComponentEnum.EXTRA_MANAGE);
dispatch(
characterActions.creatureRemove(CreatureUtils.getMappingId(creature))
);
};
const handleRemoveInfusion = (): void => {
if (!creature) {
return;
}
const infusion = CreatureUtils.getInfusion(creature);
if (infusion) {
const infusionId = InfusionUtils.getId(infusion);
if (infusionId === null) {
return;
}
const choiceKey = InfusionUtils.getChoiceKey(infusion);
if (choiceKey !== null) {
pane.paneHistoryStart(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
dispatch(
serviceDataActions.infusionMappingDestroy(
infusionId,
InfusionUtils.getInventoryMappingId(infusion)
)
);
}
};
const handleInfusionClick = (): void => {
if (!creature) {
return;
}
const infusion = CreatureUtils.getInfusion(creature);
if (infusion) {
const choiceKey = InfusionUtils.getChoiceKey(infusion);
if (choiceKey !== null) {
pane.paneHistoryPush(
PaneComponentEnum.INFUSION_CHOICE,
PaneIdentifierUtils.generateInfusionChoice(choiceKey)
);
}
}
};
const handleDeathSummaryClick = (evt: React.MouseEvent): void => {
if (!isReadonly) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
pane.paneHistoryPush(PaneComponentEnum.HEALTH_MANAGE);
}
};
const renderHealthAdjuster = (): React.ReactNode => {
if (!creature) {
return null;
}
const creatureHpInfo = CreatureUtils.getHitPointInfo(creature);
// Key to check if the creature has useOwnerHp set to true
const creatureGroupName = CreatureUtils.getGroupInfo(creature)?.name;
const useOwnerHp = CreatureUtils.getUseOwnerHp(creature);
const initialTempHp = CreatureUtils.getInitialTempHp(creature);
const hp = useOwnerHp ? hpInfo : creatureHpInfo;
const tempHp = hp.tempHp ?? 0;
const extraNode: React.ReactNode = (
<>
<span className={clsx([tempHp > 0 && styles.hasTempHp])}>
{hp.remainingHp + tempHp}
</span>
<span className={styles.valueSep}>/</span>
<span className={clsx([tempHp > 0 && styles.hasTempHp])}>
{hp.totalHp + tempHp}
</span>
</>
);
const headerCalloutNode: React.ReactNode = (
<CollapsibleHeaderCallout extra={extraNode} value={null} />
);
const headerNode: React.ReactNode = (
<CollapsibleHeaderContent
heading="Hit Points"
callout={headerCalloutNode}
/>
);
return (
<div className={clsx([styles.adjuster, styles.hasSeparator])}>
<Collapsible header={headerNode}>
{useOwnerHp &&
(deathCause === Constants.DeathCauseEnum.CONDITION ||
hpInfo.remainingHp <= 0) ? (
<DeathSummary
className={styles.deathSummary}
onClick={handleDeathSummaryClick}
/>
) : (
<div className={styles.hitPointsContainer}>
{creatureGroupName === Constants.DB_STRING_WILDSHAPE_2024 &&
useOwnerHp && (
<p className={clsx([styles.wildshapeInfo, styles.block])}>
Wild Shape modifies your character's HP. Your sheet will
reflect these changes.
</p>
)}
<HitPointsSummary
hpInfo={hp}
creature={useOwnerHp ? undefined : creature}
showPermanentInputs
/>
{!isReadonly && (
<HitPointsAdjuster
hpInfo={hp}
creature={useOwnerHp ? undefined : creature}
/>
)}
{creatureGroupName === Constants.DB_STRING_WILDSHAPE_2024 &&
initialTempHp !== 0 && (
<p className={styles.wildshapeInfo}>
When you assume a Wild Shape form, you gain{" "}
<b>{initialTempHp}</b> Temporary HP.
</p>
)}
{!isReadonly && renderQuickActions()}
</div>
)}
</Collapsible>
</div>
);
};
const renderQuickActions = (): React.ReactNode => {
if (!creature) {
return null;
}
// If initialTempHp exists and is not 0, render a button to add the initial temp hp to the creature.
const initialTempHp = CreatureUtils.getInitialTempHp(creature);
if (!initialTempHp) {
return null;
}
const actions = [] as {
label: string;
onClick: () => void;
disabled: boolean;
}[];
actions.push({
label: `Set Temp HP to ${initialTempHp}`,
onClick: () => handleQuickTempAction(initialTempHp),
disabled: false,
});
return <QuickActions actions={actions}></QuickActions>;
};
const renderDescription = (
label: React.ReactNode,
description: string | null
): React.ReactNode => {
if (!description) {
return null;
}
return (
<div className={styles.description}>
{label && <Heading>{label}</Heading>}
<HtmlContent html={description} withoutTooltips />
</div>
);
};
const renderTags = (): React.ReactNode => {
if (!creature) {
return null;
}
const tags = CreatureUtils.getTags(creature);
const envTags = CreatureUtils.getEnvironmentTags(creature);
return (
<>
{tags.length > 0 && <TagGroup label="Tags" tags={tags} />}
{envTags.length > 0 && <TagGroup label="Habitats" tags={envTags} />}
</>
);
};
const renderCustomize = (): React.ReactNode => {
if (isReadonly || !creature) {
return null;
}
const useOwnerHp = CreatureUtils.getUseOwnerHp(creature);
const optionRestrictions: Record<number, Array<number> | null> = {};
const groupInfo = CreatureUtils.getGroupInfo(creature);
if (groupInfo?.monsterTypes?.length) {
optionRestrictions[Constants.AdjustmentTypeEnum.CREATURE_TYPE_OVERRIDE] =
groupInfo.monsterTypes;
}
const isCustomized = CreatureUtils.isCustomized(creature);
const valueEditors = [
Constants.AdjustmentTypeEnum.CREATURE_SIZE,
Constants.AdjustmentTypeEnum.CREATURE_TYPE_OVERRIDE,
Constants.AdjustmentTypeEnum.CREATURE_ALIGNMENT,
Constants.AdjustmentTypeEnum.CREATURE_AC,
Constants.AdjustmentTypeEnum.CREATURE_HIT_POINTS,
Constants.AdjustmentTypeEnum.CREATURE_NOTES,
];
// Filter out hitpoints if the creature has useOwnerHp set to true
if (useOwnerHp) {
valueEditors.splice(
valueEditors.indexOf(Constants.AdjustmentTypeEnum.CREATURE_HIT_POINTS),
1
);
}
const labelOverrides = {
[Constants.AdjustmentTypeEnum.CREATURE_AC]: "Armor Class",
[Constants.AdjustmentTypeEnum.CREATURE_ALIGNMENT]: "Alignment",
[Constants.AdjustmentTypeEnum.CREATURE_HIT_POINTS]: "Max HP",
[Constants.AdjustmentTypeEnum.CREATURE_SIZE]: "Size",
[Constants.AdjustmentTypeEnum.CREATURE_TYPE_OVERRIDE]: "Type",
[Constants.AdjustmentTypeEnum.CREATURE_NOTES]: "Notes",
};
return (
<div className={styles.hasSeparator}>
<Collapsible
layoutType={"minimal"}
header={`Customize${isCustomized ? "*" : ""}`}
collapsed={isCustomizeClosed}
onChangeHandler={handleToggleCustomize}
>
<EditorBox className={styles.editor}>
<CustomizeDataEditor
data={getData()}
enableName={true}
onDataUpdate={handleDataUpdate}
/>
<ValueEditor
dataLookup={ValueUtils.getEntityData(
entityValueLookup,
ValueUtils.hack__toString(CreatureUtils.getMappingId(creature)),
ValueUtils.hack__toString(
CreatureUtils.getMappingEntityTypeId(creature)
)
)}
onDataUpdate={handleCustomDataUpdate}
valueEditors={valueEditors}
labelOverrides={labelOverrides}
layoutType={"standard"}
optionRestrictions={optionRestrictions}
ruleData={ruleData}
/>
<RemoveButton
enableConfirm={true}
size="medium"
style="filled"
disabled={!isCustomized}
isInteractive={isCustomized}
onClick={handleRemoveCustomizations}
>
{isCustomized ? "Remove" : "No"} Customizations
</RemoveButton>
</EditorBox>
</Collapsible>
</div>
);
};
const renderInfusionPreview = (): React.ReactNode => {
if (!creature) {
return null;
}
const infusion = CreatureUtils.getInfusion(creature);
if (infusion) {
return (
<div className={styles.hasSeparator}>
<InfusionPreview
infusion={infusion}
snippetData={snippetData}
ruleData={ruleData}
infusionChoiceLookup={infusionChoiceLookup}
onClick={handleInfusionClick}
proficiencyBonus={proficiencyBonus}
/>
</div>
);
}
return null;
};
const key: string = creature ? CreatureUtils.getUniqueKey(creature) : "";
const groupInfo = creature ? CreatureUtils.getGroupInfo(creature) : null;
const largeAvatarUrl = creature
? CreatureUtils.getLargeAvatarUrl(creature)
: null;
const infusion = creature ? CreatureUtils.getInfusion(creature) : null;
return (
<div key={key} {...props}>
{!creature ? (
<>
<Header>Missing Creature</Header>
<PaneInitFailureContent />
</>
) : (
<>
<Header
parent={groupInfo?.name || null}
preview={
<Preview imageUrl={CreatureUtils.getAvatarUrl(creature)} />
}
>
<EditableName onClick={handleToggleCustomize}>
<CreatureName theme={theme} creature={creature} />
</EditableName>
</Header>
{CreatureUtils.isHomebrew(creature) ? (
<Reference isDarkMode={theme.isDarkMode} name="Homebrew" />
) : (
SourceUtils.getSourceFullNames(
CreatureUtils.getSources(creature),
ruleData
).map((source, idx) => {
return (
<Fragment key={source}>
{idx > 0 ? " /" : ""}{" "}
<Reference isDarkMode={theme.isDarkMode} name={source} />
</Fragment>
);
})
)}
{renderCustomize()}
{renderHealthAdjuster()}
<div className={styles.block}>
<CreatureBlock
variant="default"
creature={creature}
ruleData={ruleData}
/>
</div>
{largeAvatarUrl && (
<img
className={styles.img}
src={largeAvatarUrl}
alt={CreatureUtils.getDefinitionName(creature)}
/>
)}
{renderTags()}
{renderDescription(
null,
CreatureUtils.getCharacteristicsDescription(creature)
)}
{renderDescription(
"Lair",
CreatureUtils.getLairDescription(creature)
)}
{renderInfusionPreview()}
{!isReadonly && (
<div className={clsx([styles.hasSeparator, styles.removeButton])}>
<Button
variant="outline"
size="xx-small"
themed
onClick={() => {
if (infusion) {
handleRemoveInfusion();
} else {
handleRemove();
}
}}
>
{infusion ? "Remove Infusion" : "Delete"}
</Button>
</div>
)}
</>
)}
</div>
);
};
@@ -0,0 +1,187 @@
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Accordion } from "~/components/Accordion";
import { Button } from "~/components/Button";
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
import { HtmlContent } from "~/components/HtmlContent";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { FeatFeatureSnippet } from "~/tools/js/CharacterSheet/components/FeatureSnippet";
import { DetailChoiceFeat } from "~/tools/js/Shared/containers/DetailChoice";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { PaneIdentifierUtils } from "~/tools/js/Shared/utils";
import { DdbBadgeSvg } from "~/tools/js/smartComponents/Svg";
import { Action, Spell } from "~/types";
import { Header } from "../../components/Header";
import { PaneInitFailureContent } from "../../components/PaneInitFailureContent";
import { getDataOriginComponentInfo } from "../../helpers/paneUtils";
import { PaneComponentEnum, PaneIdentifiersFeat } from "../../types";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
identifiers: PaneIdentifiersFeat | null;
}
export const FeatPane: FC<Props> = ({ identifiers, ...props }) => {
const dispatch = useDispatch();
const {
actionUtils,
characterActions,
spellUtils,
snippetData,
ruleData,
abilityLookup,
proficiencyBonus,
originRef: dataOriginRefData,
characterTheme: theme,
entityUtils,
} = useCharacterEngine();
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const {
pane: { paneHistoryPush, paneHistoryStart },
} = useSidebar();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const feat = identifiers?.id
? characterFeaturesManager.getFeatById(identifiers.id)
: null;
const handleActionUseSet = (action: Action, uses: number): void => {
const id = actionUtils.getId(action);
const entityTypeId = actionUtils.getEntityTypeId(action);
if (id !== null && entityTypeId !== null) {
dispatch(
characterActions.actionUseSet(
id,
entityTypeId,
uses,
actionUtils.getDataOriginType(action)
)
);
}
};
const handleSpellUseSet = (spell: Spell, uses: number): void => {
const mappingId = spellUtils.getMappingId(spell);
const mappingEntityTypeId = spellUtils.getMappingEntityTypeId(spell);
if (mappingId && mappingEntityTypeId) {
dispatch(
characterActions.spellUseSet(
mappingId,
mappingEntityTypeId,
uses,
spellUtils.getDataOriginType(spell)
)
);
}
};
const handleSpellDetailShow = (spell: Spell): void => {
const mappingId = spellUtils.getMappingId(spell);
if (mappingId !== null) {
paneHistoryPush(
PaneComponentEnum.CHARACTER_SPELL_DETAIL,
PaneIdentifierUtils.generateCharacterSpell(mappingId)
);
}
};
const handleActionShow = (action: Action): void => {
const mappingId = actionUtils.getMappingId(action);
const mappingEntityTypeId = actionUtils.getMappingEntityTypeId(action);
if (mappingId !== null && mappingEntityTypeId !== null) {
paneHistoryPush(
PaneComponentEnum.ACTION,
PaneIdentifierUtils.generateAction(mappingId, mappingEntityTypeId)
);
}
};
const handleParentClick = (): void => {
if (feat) {
let component = getDataOriginComponentInfo(feat.getDataOrigin());
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
}
};
if (feat === null) {
return <PaneInitFailureContent />;
}
const prerequisiteDescription = feat.getPrerequisiteDescription();
return (
<div key={feat.getId()} {...props}>
<Header
parent={entityUtils.getDataOriginName(feat.getDataOrigin(), "", true)}
onClick={handleParentClick}
>
{feat.getName()}
</Header>
<HelperTextAccordion
builderHelperText={feat.getHelperText()}
size="small"
useTheme
/>
{prerequisiteDescription && (
<div className={styles.prereq}>
Prerequisite: {prerequisiteDescription}
</div>
)}
<FeatFeatureSnippet
feat={feat}
onActionUseSet={handleActionUseSet}
onActionClick={handleActionShow}
onSpellUseSet={handleSpellUseSet}
onSpellClick={handleSpellDetailShow}
onFeatureClick={() =>
paneHistoryStart(
PaneComponentEnum.FEAT_DETAIL,
PaneIdentifierUtils.generateFeat(feat.getId())
)
}
showHeader={false}
showDescription={true}
snippetData={snippetData}
ruleData={ruleData}
abilityLookup={abilityLookup}
dataOriginRefData={dataOriginRefData}
isReadonly={isReadonly}
proficiencyBonus={proficiencyBonus}
theme={theme}
/>
{!isReadonly && feat.getChoices().length > 0 && (
<div className={styles.choices}>
<DetailChoiceFeat featId={feat.getId()} />
</div>
)}
{!feat.isHiddenFeat() &&
!isReadonly && ( // don't show "Delete" if readonly mode, or a hidden feat
<div className={styles.footer}>
<Button
variant="outline"
size="xx-small"
themed
onClick={() => {
paneHistoryStart(PaneComponentEnum.FEATS_MANAGE);
feat.handleRemove();
}}
>
Delete
</Button>
</div>
)}
</div>
);
};
@@ -0,0 +1,105 @@
import { FC, useContext, useEffect, useState } from "react";
import { FeatureManager } from "@dndbeyond/character-rules-engine";
import { ThemeButton } from "~/tools/js/Shared/components/common/Button";
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { AppLoggerUtils, AppNotificationUtils } from "~/tools/js/Shared/utils";
import Collapsible, {
CollapsibleHeaderContent,
} from "~/tools/js/smartComponents/Collapsible";
export const BlessingShoppe: FC<{}> = () => {
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const [loadingStatus, setLoadingStatus] = useState<DataLoadingStatusEnum>(
DataLoadingStatusEnum.NOT_INITIALIZED
);
const [blessingShoppe, setBlessingShoppe] = useState<Array<FeatureManager>>(
[]
);
const [blessings, setBlessings] = useState<Array<FeatureManager>>([]);
async function fetchBlessings() {
const blessings = await characterFeaturesManager.getBlessings();
setBlessings(blessings);
}
useEffect(() => {
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
characterFeaturesManager
.getBlessingShoppe()
.then((blessingShoppe) => {
setLoadingStatus(DataLoadingStatusEnum.LOADED);
setBlessingShoppe(blessingShoppe);
})
.catch(AppLoggerUtils.handleAdhocApiError);
}
fetchBlessings();
}, [loadingStatus, characterFeaturesManager]);
return (
<div>
<p>
A blessing is usually bestowed by a god or a godlike being. A character
might receive a blessing from a deity for doing something truly
momentous an accomplishment that catches the attention of both gods
and mortals.
</p>
<div>
{blessingShoppe.map((blessing) => {
return (
<Collapsible
key={blessing.getId()}
layoutType={"minimal"}
header={
<CollapsibleHeaderContent
heading={blessing.getName()}
callout={
characterFeaturesManager.hasBlessing(blessing) ? (
<ThemeButton
onClick={() => {
blessing.handleRemove(() => {
fetchBlessings();
// AppNotificationUtils.dispatchSuccess(
// 'Removed Blessing',
// 'You removed a blessing',
// );
});
}}
size="small"
>
Remove
</ThemeButton>
) : (
<ThemeButton
onClick={() => {
blessing.handleAdd(() => {
fetchBlessings();
AppNotificationUtils.dispatchSuccess(
"Added Blessing",
"You added a blessing"
);
});
}}
size="small"
style="outline"
>
Add
</ThemeButton>
)
}
/>
}
>
{blessing.getDescription()}
</Collapsible>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,130 @@
import clsx from "clsx";
import { FC, ReactNode } from "react";
import { FeatManager } from "@dndbeyond/character-rules-engine/es";
import { Reference } from "~/components/Reference";
import { DataOriginTypeEnum } from "~/constants";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import Collapsible, {
CollapsibleHeaderContent,
} from "~/tools/js/smartComponents/Collapsible";
import { TodoIcon } from "~/tools/js/smartComponents/Icons";
import PrerequisiteFailureSummary from "~/tools/js/smartComponents/PrerequisiteFailureSummary";
import { CharacterTheme } from "~/types";
import {
RemoveButton,
ThemeButton,
} from "../../../../../../../tools/js/Shared/components/common/Button";
import { FeatDetail } from "../FeatDetail";
import styles from "./styles.module.css";
interface Props {
feat: FeatManager;
enableRemove?: boolean;
enableAdd?: boolean;
showFailures?: boolean;
theme: CharacterTheme;
}
export const Feat: FC<Props> = ({
feat,
enableAdd = true,
enableRemove = true,
showFailures = false,
theme,
}) => {
const { entityUtils } = useCharacterEngine();
const { isDarkMode } = useCharacterTheme();
let missingChoiceCount: number = feat.getUnfinishedChoices().length;
let calloutNode: ReactNode = null;
let dataOrigin = feat.getDataOrigin();
let dataOriginType = feat.getDataOriginType();
switch (dataOriginType) {
case DataOriginTypeEnum.ADHOC:
if (enableRemove) {
calloutNode = (
<RemoveButton
onClick={feat.handleRemove}
style="filled"
className={styles.button}
/>
);
}
break;
case DataOriginTypeEnum.SIMULATED:
if (enableAdd) {
calloutNode = (
<ThemeButton
onClick={feat.handleAdd}
size="small"
style="outline"
className={styles.button}
>
Add
</ThemeButton>
);
}
break;
default:
const dataOriginExtra = entityUtils.getDataOriginName(
dataOrigin,
"Unknown",
true
);
calloutNode = (
<span className={styles.origin}>
<span className={styles.originLabel}>From</span>
<span className={styles.originName}>{dataOriginExtra}</span>
</span>
);
}
return (
<Collapsible
layoutType={"minimal"}
className={styles.feat}
header={
<CollapsibleHeaderContent
heading={
<div>
<div>
<span>{feat.getName()}</span>
{missingChoiceCount > 0 && (
<TodoIcon
theme={theme}
title={`Choice${
missingChoiceCount !== 1 ? "s" : ""
} Needed`}
/>
)}
</div>
<span className={styles.source}>
<Reference name={feat.getPrimarySourceName()} />
</span>
{showFailures && (
<PrerequisiteFailureSummary
failures={feat.getPrerequisiteFailures()}
className={styles.failures}
/>
)}
</div>
}
callout={calloutNode}
/>
}
>
<FeatDetail
featManager={feat}
className={clsx([styles.detail, isDarkMode && styles.dark])}
/>
</Collapsible>
);
};
@@ -0,0 +1,35 @@
import { FC, HTMLAttributes } from "react";
import { FeatManager } from "@dndbeyond/character-rules-engine/es";
import { HtmlContent } from "~/components/HtmlContent";
import { DetailChoiceFeat } from "~/tools/js/Shared/containers/DetailChoice";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
featManager: FeatManager;
}
export const FeatDetail: FC<Props> = ({ featManager, className, ...props }) => {
const prerequisiteDescription = featManager.getPrerequisiteDescription();
const featDescription = featManager.getDescription();
return (
<div className={className} {...props}>
{prerequisiteDescription && (
<div className={styles.prereq}>
Prerequisite: {prerequisiteDescription}
</div>
)}
{featDescription && (
<HtmlContent html={featDescription} withoutTooltips />
)}
{featManager.getChoices().length > 0 && (
<div className={styles.choices}>
<DetailChoiceFeat featId={featManager.getId()} />
</div>
)}
</div>
);
};
@@ -0,0 +1,131 @@
import { FC, useContext, useEffect, useState } from "react";
import { FeatManager } from "@dndbeyond/character-rules-engine";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useSource } from "~/hooks/useSource";
import DataLoadingStatusEnum from "~/tools/js/Shared/constants/DataLoadingStatusEnum";
import { CharacterFeaturesManagerContext } from "~/tools/js/Shared/managers/CharacterFeaturesManagerContext";
import { AppLoggerUtils } from "~/tools/js/Shared/utils";
import { isNotNullOrUndefined } from "~/tools/js/Shared/utils/TypeScript";
import Collapsible, {
CollapsibleHeaderContent,
CollapsibleHeading,
} from "~/tools/js/smartComponents/Collapsible";
import LoadingPlaceholder from "~/tools/js/smartComponents/LoadingPlaceholder";
import { Feat } from "../Feat/Feat";
import styles from "../styles.module.css";
export const FeatShoppe: FC<{}> = () => {
const { characterFeaturesManager } = useContext(
CharacterFeaturesManagerContext
);
const {
feats: currentCharacterFeats,
characterTheme: theme,
preferences,
} = useCharacterEngine();
const { getSourceCategoryGroups } = useSource();
const [loadingStatus, setLoadingStatus] = useState<DataLoadingStatusEnum>(
DataLoadingStatusEnum.NOT_INITIALIZED
);
const [featShoppe, setFeatShoppe] = useState<Array<FeatManager>>([]);
useEffect(() => {
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
characterFeaturesManager
.getFeatShoppe()
.then((featShoppe) => {
setLoadingStatus(DataLoadingStatusEnum.LOADED);
setFeatShoppe(featShoppe);
})
.catch(AppLoggerUtils.handleAdhocApiError);
} else {
setFeatShoppe(characterFeaturesManager.updateFeatShoppe(featShoppe));
}
}, [currentCharacterFeats]);
const getFeatsInSourceCategoryGroups = (feats: FeatManager[]) => {
const featDefs = feats
.map((feat) => feat.getDefinition())
.filter(isNotNullOrUndefined);
return getSourceCategoryGroups(featDefs).map((group) => {
return {
...group,
items: characterFeaturesManager.transformLoadedFeats(group.items),
};
});
};
return loadingStatus !== DataLoadingStatusEnum.LOADED ? (
<LoadingPlaceholder />
) : (
<>
<div className={styles.featList}>
{getFeatsInSourceCategoryGroups(
characterFeaturesManager.getAvailableFeats(featShoppe)
).map((group) => (
<Collapsible
key={group.id + "available"}
layoutType="minimal"
initiallyCollapsed={false}
header={<h2>{group.name}</h2>}
>
<div>
{group.items.map((feat) => (
<Feat theme={theme} key={feat.getId()} feat={feat} />
))}
</div>
</Collapsible>
))}
</div>
{characterFeaturesManager.getUnavailableFeats(featShoppe).length > 0 && (
<Collapsible
header={
<CollapsibleHeaderContent
heading={
<CollapsibleHeading>
Unavailable Feats
<span className={styles.unavailableCallout}>
Prerequisites Not Met
</span>
</CollapsibleHeading>
}
/>
}
className={styles.featList}
>
<>
{getFeatsInSourceCategoryGroups(
characterFeaturesManager.getUnavailableFeats(featShoppe)
).map((group) => (
<Collapsible
key={group.id + "unavailable"}
layoutType="minimal"
initiallyCollapsed={false}
header={<h2>{group.name}</h2>}
>
<div>
{group.items.map((feat) => (
<Feat
theme={theme}
key={feat.getId()}
feat={feat}
enableAdd={!preferences?.enforceFeatRules}
showFailures={true}
/>
))}
</div>
</Collapsible>
))}
</>
</Collapsible>
)}
</>
);
};
@@ -0,0 +1,66 @@
import { FC } from "react";
import { useSelector } from "react-redux";
import { useFeatureFlags } from "~/contexts/FeatureFlag";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import SettingsButton from "~/tools/js/CharacterSheet/components/SettingsButton";
import { SettingsContextsEnum } from "~/tools/js/Shared/containers/panes/SettingsPane/typings";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import Collapsible, {
CollapsibleHeaderContent,
CollapsibleHeading,
} from "~/tools/js/smartComponents/Collapsible";
import { BlessingShoppe } from "./BlessingShoppe";
import { FeatShoppe } from "./FeatShoppe";
import styles from "./styles.module.css";
export const FeatsManagePane: FC<{}> = () => {
const { gfsBlessingsUiFlag } = useFeatureFlags();
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
if (isReadonly) {
return null;
}
return (
<div>
<Header
callout={
<SettingsButton
context={SettingsContextsEnum.FEATURES}
isReadonly={isReadonly}
/>
}
>
Manage {gfsBlessingsUiFlag ? "Features" : "Feats"}
</Header>
{gfsBlessingsUiFlag ? (
<Collapsible
header={
<CollapsibleHeaderContent
heading={<CollapsibleHeading>Add Feats</CollapsibleHeading>}
/>
}
className={styles.featList}
>
<FeatShoppe />
</Collapsible>
) : (
<FeatShoppe />
)}
{gfsBlessingsUiFlag && (
<Collapsible
header={
<CollapsibleHeaderContent
heading={<CollapsibleHeading>Add Blessings</CollapsibleHeading>}
/>
}
className={styles.featList}
>
<BlessingShoppe />
</Collapsible>
)}
</div>
);
};
@@ -0,0 +1,61 @@
import clsx from "clsx";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Dice } from "@dndbeyond/dice";
import { GameLog } from "@dndbeyond/game-log-components";
import { appEnvActions } from "~/tools/js/Shared/actions/appEnv";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import styles from "./styles.module.css";
export const GameLogPane = () => {
const dispatch = useDispatch();
const gameLog = useSelector(appEnvSelectors.getGameLog);
const characterId = useSelector(appEnvSelectors.getCharacterId);
useEffect(
() => {
const updateOpenState = (isOpen: boolean) => {
const lastMessageTime = new Date().getTime();
dispatch(
appEnvActions.dataSet({
gameLog: {
...gameLog,
isOpen: isOpen,
lastMessageTime: lastMessageTime,
},
})
);
try {
localStorage.setItem(
`gameLog-lastMessageTime-${characterId}`,
lastMessageTime.toString()
);
} catch (e) {}
// turn the dice notifications off if the panel is open
Dice.setDiceNotificationEnabled(!isOpen);
};
updateOpenState(true);
return () => {
updateOpenState(false);
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<div className={clsx(styles.gameLogPane)} data-testid="gamelog-pane">
{characterId && <GameLog />}
</div>
);
};
export default GameLogPane;
@@ -0,0 +1,220 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useContext } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
DigitalDiceWrapper,
LightDiceSvg,
} from "@dndbeyond/character-components/es";
import { RollKind, RollRequest, RollType } from "@dndbeyond/dice";
import { GameLogContext } from "@dndbeyond/game-log-components";
import { useSidebar } from "~/contexts/Sidebar";
import { isNotNullOrUndefined } from "~/helpers/validation";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { getDataOriginComponentInfo } from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import DiceAdjustmentSummary from "~/tools/js/Shared/components/DiceAdjustmentSummary";
import {
appEnvSelectors,
characterRollContextSelectors,
} from "~/tools/js/Shared/selectors";
import { DataOrigin } from "~/types";
import { Heading } from "../../../components/Heading";
import { DeathSavesMarks } from "./DeathSavesMarks";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
damageValue: number;
}
export const DeathSavesManager: FC<Props> = ({
damageValue,
className,
...props
}) => {
const {
characterTheme,
deathSaveInfo,
ruleData,
characterActions,
ruleDataUtils,
} = useCharacterEngine();
const isDiceEnabled = useSelector(appEnvSelectors.getDiceEnabled);
const characterRollContext = useSelector(
characterRollContextSelectors.getCharacterRollContext
);
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
useContext(GameLogContext);
const dispatch = useDispatch();
const {
pane: { paneHistoryPush },
} = useSidebar();
const oneHPRestoreType = ruleDataUtils
.getRestoreTypes(ruleData)
.find((r) => r.name === "OneHP");
const hasAdvantage = deathSaveInfo.advantageAdjustments.length > 0;
const hasDisadvantage = deathSaveInfo.disadvantageAdjustments.length > 0;
let rollKind = RollKind.None;
if (hasAdvantage && !hasDisadvantage) {
rollKind = RollKind.Advantage;
} else if (hasDisadvantage && !hasAdvantage) {
rollKind = RollKind.Disadvantage;
}
/* --- On Click Functions --- */
const onClickDataOrigin = (dataOrigin: DataOrigin): void => {
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
};
const dispatchDeathSaves = (fails: number, successes: number) => {
dispatch(
characterActions.deathSavesSet(
Math.min(ruleDataUtils.getMaxDeathsavesFail(ruleData), fails),
Math.min(ruleDataUtils.getMaxDeathsavesSuccess(ruleData), successes)
)
);
};
const onFail = (evt?: React.MouseEvent, isCritical = false) => {
const uses = isCritical ? 2 : 1;
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(
deathSaveInfo.failCount + uses,
deathSaveInfo.successCount
);
};
const onFailClear = (evt?: React.MouseEvent) => {
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(deathSaveInfo.failCount - 1, deathSaveInfo.successCount);
};
const onSuccess = (evt?: React.MouseEvent) => {
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(deathSaveInfo.failCount, deathSaveInfo.successCount + 1);
};
const onSuccessClear = (evt?: React.MouseEvent) => {
if (evt) {
evt.nativeEvent.stopImmediatePropagation();
evt.stopPropagation();
}
dispatchDeathSaves(deathSaveInfo.failCount, deathSaveInfo.successCount - 1);
};
return (
<div className={clsx([styles.column, className])} {...props}>
<div className={clsx([styles.row, styles.heading])}>
<Heading>Death Saves</Heading>
{isDiceEnabled && (
<DigitalDiceWrapper
diceNotation={"1d20"}
onRollResults={(rollRequest: RollRequest) => {
const roll = rollRequest.rolls[0].result?.total;
if (roll) {
if (roll === 1) {
onFail(undefined, true);
} else if (roll === 20 && oneHPRestoreType) {
dispatch(characterActions.restoreLife(oneHPRestoreType.id));
} else if (roll >= 10) {
onSuccess.bind(this)();
} else {
onFail.bind(this)();
}
}
}}
rollType={RollType.Save}
rollKind={rollKind}
rollAction={"Death"}
diceEnabled={isDiceEnabled}
rollContext={characterRollContext}
rollTargetOptions={
messageTargetOptions?.entities
? Object.values(messageTargetOptions.entities).filter(
isNotNullOrUndefined
)
: undefined
}
rollTargetDefault={defaultMessageTargetOption}
userId={Number(userId)}
>
<LightDiceSvg />
<span>Roll</span>
</DigitalDiceWrapper>
)}
</div>
<div className={clsx([styles.row, styles.deathSavesGroups])}>
<DeathSavesMarks
label="Failures"
type="fails"
activeCount={deathSaveInfo.failCount}
willBeActiveCount={damageValue !== 0 ? 1 : 0}
totalCount={ruleData.maxDeathsavesFail}
onUse={onFail}
onClear={onFailClear}
/>
<DeathSavesMarks
label="Successes"
type="successes"
activeCount={deathSaveInfo.successCount}
totalCount={ruleData.maxDeathsavesSuccess}
onUse={onSuccess}
onClear={onSuccessClear}
/>
</div>
{(!!deathSaveInfo.advantageAdjustments.length ||
!!deathSaveInfo.disadvantageAdjustments.length) && (
<div className={styles.diceAdjustments}>
{deathSaveInfo.advantageAdjustments.map((diceAdjustment) => {
return (
<DiceAdjustmentSummary
key={diceAdjustment.uniqueKey}
diceAdjustment={diceAdjustment}
ruleData={ruleData}
theme={characterTheme}
onDataOriginClick={onClickDataOrigin}
/>
);
})}
{deathSaveInfo.disadvantageAdjustments.map((diceAdjustment) => {
return (
<DiceAdjustmentSummary
key={diceAdjustment.uniqueKey}
diceAdjustment={diceAdjustment}
ruleData={ruleData}
theme={characterTheme}
onDataOriginClick={onClickDataOrigin}
/>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,88 @@
import clsx from "clsx";
import { HTMLAttributes, FC } from "react";
import { CloseSvg } from "@dndbeyond/character-components/es";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
type: "successes" | "fails";
label: string;
activeCount: number;
totalCount: number;
willBeActiveCount?: number;
onUse: (evt: React.MouseEvent) => void;
onClear: (evt: React.MouseEvent) => void;
}
export const DeathSavesMarks: FC<Props> = ({
type,
label,
activeCount,
totalCount,
willBeActiveCount = 0,
onUse,
onClear,
className,
...props
}) => {
// TODO: Need to make these marks accessible - they currently cannot be interacted with with a keyboard.
let marks: Array<React.ReactNode> = [];
let availableSlots: number = totalCount;
for (let i = 0; i < activeCount; i++) {
marks.push(
<span
className={styles.mark}
key={`${type}-active-${i}`}
onClick={onClear}
>
<CloseSvg
fillColor={type === "successes" ? "#00c797" : "#c53131"}
secondaryFillColor=""
key={`${type}-active-${i}`}
className={clsx([styles.mark, styles.activeMark])}
/>
</span>
);
}
availableSlots -= activeCount;
for (let i = 0; i < Math.min(availableSlots, willBeActiveCount); i++) {
marks.push(
<span
key={`${type}-willbe-${i}`}
className={clsx([
styles.mark,
styles.willBeActive,
type === "fails" && styles.willBeFail,
])}
/>
);
}
availableSlots -= willBeActiveCount;
for (let i = 0; i < Math.min(availableSlots, totalCount); i++) {
marks.push(
<span
key={`${type}-inactive-${i}`}
className={clsx([styles.mark])}
onClick={onUse}
/>
);
}
return (
<div
className={clsx([styles.container, className])}
data-testid={`${type}-group`}
{...props}
>
<p className={styles.label}>{label}</p>
<div className={styles.marks}>{marks}</div>
</div>
);
};
@@ -0,0 +1,537 @@
import clsx from "clsx";
import { createRef, FC, HTMLAttributes, useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { DataOriginName } from "@dndbeyond/character-components/es";
import { Constants, ItemManager } from "@dndbeyond/character-rules-engine";
import { Button } from "~/components/Button";
import { Checkbox } from "~/components/Checkbox";
import { ItemName } from "~/components/ItemName";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { HP_DAMAGE_TAKEN_VALUE } from "~/subApps/sheet/constants";
import { Item, HitPointInfo, Creature } from "~/types";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
hpInfo: HitPointInfo;
creature?: Creature;
handleDamageUpdate?: (damageAmount: number) => void;
vibrationAmount?: number;
}
// TODO This hitpoints adjuster doesn't work as a generic component for vehicles yet.
export const HitPointsAdjuster: FC<Props> = ({
hpInfo,
creature,
handleDamageUpdate,
vibrationAmount = 15,
className,
...props
}) => {
const {
characterTheme,
protectionSuppliers,
deathSaveInfo,
ruleData,
characterActions,
actionUtils,
characterUtils,
helperUtils,
itemUtils,
limitedUseUtils,
modifierUtils,
ruleDataUtils,
creatureUtils,
} = useCharacterEngine();
const getInitialActiveProtectionSupplierKey = (): string | null => {
const foundSupplier = protectionSuppliers.find(
(supplier) =>
supplier.availabilityStatus ===
Constants.ProtectionAvailabilityStatusEnum.AVAILABLE
);
if (foundSupplier) {
return foundSupplier.key;
}
return null;
};
const [tickCount, setTickCount] = useState(0);
const [healingAmount, setHealingAmount] = useState<number | null>(null);
const [damageAmount, setDamageAmount] = useState<number | null>(null);
const [hpDifference, setHpDifference] = useState(
characterUtils.calculateHitPoints(hpInfo, 0)
);
const [isValueChanged, setIsValueChanged] = useState<boolean>(false);
const [activeProtectionSupplierKey, setActiveProtectionSupplierKey] =
useState<string | null>(getInitialActiveProtectionSupplierKey());
const damageInput = createRef<HTMLInputElement>();
const healingInput = createRef<HTMLInputElement>();
const dispatch = useDispatch();
/* --- Helper Functions --- */
const reset = (newTemp: number | null): void => {
if (hpInfo.tempHp !== newTemp && newTemp !== null) {
creature
? dispatch(
characterActions.creatureHitPointsSet(
creatureUtils.getMappingId(creature),
hpInfo.removedHp,
newTemp
)
)
: dispatch(characterActions.hitPointsSet(hpInfo.removedHp, newTemp));
}
setTickCount(0);
setHealingAmount(0);
setDamageAmount(0);
setIsValueChanged(false);
setActiveProtectionSupplierKey(getInitialActiveProtectionSupplierKey());
};
const onKeyUpSave = (evt: React.KeyboardEvent<HTMLInputElement>): void => {
if (evt.key === "Enter") {
onClickSave();
}
};
// onKeyDown must be used for the Escape key to reset the adjuster because the
// Sidebar uses onKeyDown to close when the Escape key is pressed.
const onKeyDownCancel = (
evt: React.KeyboardEvent<HTMLInputElement>
): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
if (evt.key === "Escape") {
reset(hpInfo.tempHp);
}
};
/* --- On Input Change Functions --- */
const onChangeHealing = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setTickCount(0);
setHealingAmount(
helperUtils.clampInt(
helperUtils.parseInputInt(evt.target.value, HP_DAMAGE_TAKEN_VALUE.MIN),
HP_DAMAGE_TAKEN_VALUE.MIN,
HP_DAMAGE_TAKEN_VALUE.MAX
)
);
setDamageAmount(0);
setIsValueChanged(true);
};
const onChangeDamage = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setTickCount(0);
setHealingAmount(0);
setDamageAmount(
helperUtils.clampInt(
helperUtils.parseInputInt(evt.target.value, HP_DAMAGE_TAKEN_VALUE.MIN),
HP_DAMAGE_TAKEN_VALUE.MIN,
HP_DAMAGE_TAKEN_VALUE.MAX
) * -1
);
setIsValueChanged(true);
};
/* --- On Click Functions --- */
const onClickIncrease = (): void => {
setHealingAmount(
healingAmount !== null && damageAmount === 0 ? healingAmount + 1 : 0
);
setDamageAmount(
damageAmount !== null && damageAmount < 0 ? damageAmount + 1 : 0
);
setIsValueChanged(true);
if (navigator.vibrate) {
navigator.vibrate(vibrationAmount);
}
};
const onClickDecrease = (): void => {
setHealingAmount(
healingAmount !== null && healingAmount > 0 ? healingAmount - 1 : 0
);
setDamageAmount(
damageAmount !== null && healingAmount === 0 ? damageAmount - 1 : 0
);
setIsValueChanged(true);
if (navigator.vibrate) {
navigator.vibrate(vibrationAmount);
}
};
const onClickSave = (): void => {
const { newTemp, startHp, newHp } = hpDifference;
let protectedHpResult: number | null = null;
if (!creature) {
if (
startHp !== 0 &&
newHp === 0 &&
activeProtectionSupplierKey !== null
) {
let foundActiveSupplier = protectionSuppliers.find(
(supplier) => supplier.key === activeProtectionSupplierKey
);
if (foundActiveSupplier) {
protectedHpResult = foundActiveSupplier.setHpValue;
switch (foundActiveSupplier.type) {
case Constants.ProtectionSupplierTypeEnum.ITEM: {
const itemData = foundActiveSupplier.data as Item;
const item = ItemManager.getItem(
itemUtils.getMappingId(itemData)
);
const numberUsed = item.getNumberUsed();
if (numberUsed !== null) {
item.handleItemLimitedUseSet(numberUsed + 1);
}
break;
}
case Constants.ProtectionSupplierTypeEnum.RACIAL_TRAIT:
case Constants.ProtectionSupplierTypeEnum.CLASS_FEATURE:
case Constants.ProtectionSupplierTypeEnum.FEAT: {
let action = foundActiveSupplier.action;
if (action !== null) {
let limitedUse = actionUtils.getLimitedUse(action);
const id = actionUtils.getId(action);
const entityTypeId = actionUtils.getEntityTypeId(action);
const dataOriginType = actionUtils.getDataOriginType(action);
if (
limitedUse !== null &&
id !== null &&
entityTypeId !== null
) {
const numberUsed = limitedUseUtils.getNumberUsed(limitedUse);
dispatch(
characterActions.actionUseSet(
id,
entityTypeId,
numberUsed + 1,
dataOriginType
)
);
}
}
break;
}
default:
// not implemented
}
}
}
}
let removedHp: number = 0;
if (protectedHpResult === null) {
removedHp = hpInfo.totalHp - newHp;
} else {
removedHp = hpInfo.totalHp - protectedHpResult;
}
creature
? dispatch(
characterActions.creatureHitPointsSet(
creatureUtils.getMappingId(creature),
removedHp,
newTemp
)
)
: dispatch(characterActions.hitPointsSet(removedHp, newTemp));
const damage: number =
damageAmount === null
? 0
: Math.min(
tickCount +
(healingAmount === null ? 0 : healingAmount) +
damageAmount,
0
);
if (startHp === 0 && !creature) {
if (newHp > 0) {
dispatch(characterActions.deathSavesSet(0, 0));
} else if (damage !== 0 && protectedHpResult === null) {
dispatch(
characterActions.deathSavesSet(
Math.min(
ruleDataUtils.getMaxDeathsavesFail(ruleData),
deathSaveInfo.failCount + 1
),
Math.min(
ruleDataUtils.getMaxDeathsavesSuccess(ruleData),
deathSaveInfo.successCount
)
)
);
reset(hpInfo.tempHp);
}
}
if (startHp === newHp) {
reset(newTemp);
}
};
const onClickCancel = (): void => {
reset(hpInfo.tempHp);
};
/* --- useEffects --- */
useEffect(() => {
const difference: number =
(healingAmount === null ? 0 : healingAmount) +
(damageAmount === null ? 0 : damageAmount) +
tickCount;
setHpDifference(characterUtils.calculateHitPoints(hpInfo, difference));
}, [healingAmount, damageAmount, tickCount, hpInfo]);
useEffect(() => {
reset(hpInfo.tempHp);
}, [hpInfo, protectionSuppliers]);
useEffect(() => {
if (handleDamageUpdate) {
const damageValue: number =
damageAmount === null
? 0
: Math.min(
tickCount +
(healingAmount === null ? 0 : healingAmount) +
damageAmount,
0
);
handleDamageUpdate(damageValue);
}
}, [tickCount, healingAmount, damageAmount]);
/* --- Render Functions --- */
const renderProtectionInfo = (): React.ReactNode => {
const { startHp, newHp } = hpDifference;
if (
!isValueChanged ||
startHp === 0 ||
newHp !== 0 ||
protectionSuppliers.length === 0
) {
return null;
}
return (
<>
{protectionSuppliers.map((protectionSupplier) => {
if (
protectionSupplier.availabilityStatus !==
Constants.ProtectionAvailabilityStatusEnum.AVAILABLE
) {
return null;
}
let dataOrigin = modifierUtils.getDataOrigin(
protectionSupplier.modifier
);
let isEnabled =
activeProtectionSupplierKey !== null &&
protectionSupplier.key === activeProtectionSupplierKey;
//TODO remove if check and ItemName => use DataOriginName when EntityDataOriginLookup is available to be passed as a prop to DataOriginName
let nameNode: React.ReactNode = null;
if (
protectionSupplier.type ===
Constants.ProtectionSupplierTypeEnum.ITEM
) {
nameNode = <ItemName item={protectionSupplier.data as Item} />;
} else {
nameNode = (
<DataOriginName dataOrigin={dataOrigin} theme={characterTheme} />
);
}
return (
<div
className={clsx([styles.row, styles.protectionNotice])}
key={protectionSupplier.key}
>
<Checkbox
checked={isEnabled}
themed
darkMode={characterTheme.isDarkMode}
id={`${protectionSupplier.key}-checkbox`}
onClick={() => {
isEnabled = !isEnabled;
setActiveProtectionSupplierKey(
isEnabled ? protectionSupplier.key : null
);
}}
/>
<label htmlFor={`${protectionSupplier.key}-checkbox`}>
Instead of dropping to 0 hit points, use{" "}
<strong>{nameNode}</strong> to set hit points to{" "}
<strong>{protectionSupplier.setHpValue}</strong>
</label>
</div>
);
})}
</>
);
};
return (
<div
className={clsx([styles.column, styles.container, className])}
{...props}
>
<div className={clsx([styles.row, styles.container])}>
<div className={clsx([styles.column, styles.inputsContainer])}>
<div
className={clsx([styles.inputContainer, styles.healingContainer])}
>
<label
htmlFor="healing-input"
className={clsx([styles.inputLabel, styles.positiveColor])}
>
Healing
</label>
<input
ref={healingInput}
type="number"
id="healing-input"
className={styles.input}
value={
healingAmount === null
? ""
: Math.max(
tickCount +
healingAmount +
(damageAmount === null ? 0 : damageAmount),
0
)
}
min={HP_DAMAGE_TAKEN_VALUE.MIN}
max={HP_DAMAGE_TAKEN_VALUE.MAX}
onChange={onChangeHealing}
onKeyUp={onKeyUpSave}
onKeyDown={onKeyDownCancel}
/>
</div>
<div className={clsx([styles.row, styles.newValues])}>
<div
className={clsx([
styles.column,
hpDifference.newHp > hpDifference.startHp &&
styles.positiveColor,
hpDifference.newHp < hpDifference.startHp &&
styles.negativeColor,
])}
>
<span className={styles.label}>New HP</span>
<span className={styles.value} data-testid="new-hp">
{hpDifference.newHp}
</span>
</div>
{hpInfo.tempHp !== null && hpInfo.tempHp > 0 && (
<div
className={clsx([
styles.column,
hpDifference.newTemp > hpInfo.tempHp && styles.positiveColor,
hpDifference.newTemp < hpInfo.tempHp && styles.negativeColor,
])}
>
<span className={styles.label}>New Temp</span>
<span className={styles.value} data-testid="new-temp-hp">
{hpDifference.newTemp}
</span>
</div>
)}
</div>
<div
className={clsx([styles.inputContainer, styles.damageContainer])}
>
<label
htmlFor="damage-input"
className={clsx([styles.inputLabel, styles.negativeColor])}
>
Damage
</label>
<input
ref={damageInput}
type="number"
id="damage-input"
className={styles.input}
value={
damageAmount === null
? ""
: Math.abs(
Math.min(
tickCount +
(healingAmount === null ? 0 : healingAmount) +
damageAmount,
0
)
)
}
min={HP_DAMAGE_TAKEN_VALUE.MIN}
max={HP_DAMAGE_TAKEN_VALUE.MAX}
onChange={onChangeDamage}
onKeyUp={onKeyUpSave}
onKeyDown={onKeyDownCancel}
/>
</div>
</div>
<div className={clsx([styles.column, styles.modifyButtons])}>
<Button
onClick={onClickIncrease}
themed
size="xx-small"
className={styles.increase}
aria-label="Increase Hit Points"
/>
<Button
onClick={onClickDecrease}
themed
size="xx-small"
className={styles.decrease}
aria-label="Decrease Hit Points"
/>
</div>
</div>
{!creature && renderProtectionInfo()}
{isValueChanged && (
<div className={clsx([styles.row, styles.applyButtons])}>
<Button onClick={onClickSave} size="xx-small" themed>
Apply Changes
</Button>
<Button
onClick={onClickCancel}
variant="outline"
size="xx-small"
themed
>
Cancel
</Button>
</div>
)}
</div>
);
};
@@ -0,0 +1,104 @@
import { FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { Constants } from "@dndbeyond/character-rules-engine";
import { HtmlContent } from "~/components/HtmlContent";
import { RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
import { toastMessageActions } from "~/tools/js/Shared/actions";
import { ShortModelInfoContract } from "~/types";
import { HitPointsSummary } from "../../../HitPointsBox/HitPointsSummary";
import { DeathSavesManager } from "./DeathSavesManager/DeathSavesManager";
import { HitPointsAdjuster } from "./HitPointsAdjuster";
import { HitPointsOverrides } from "./HitPointsOverrides";
import { RestoreLifeManager } from "./RestoreLifeManager/RestoreLifeManager";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const HitPointsManagePane: FC<Props> = ({ className, ...props }) => {
const { hpInfo, deathCause, ruleData, ruleDataUtils, characterActions } =
useCharacterEngine();
const dispatch = useDispatch();
const [damage, setDamage] = useState<number>(0);
const handleDamageUpdate = (damageValue: number) => {
setDamage(damageValue);
};
const onRestoreToLife = (restoreType: ShortModelInfoContract): void => {
const restoreChoice = restoreType.name === "Full" ? "full" : "1";
dispatch(characterActions.restoreLife(restoreType.id));
dispatch(
toastMessageActions.toastSuccess(
"Character Restored to Life",
`You have been restored to life with ${restoreChoice} HP.`
)
);
};
const renderDeathSavesRules = (): React.ReactNode => {
if (
hpInfo.remainingHp > 0 ||
(deathCause !== Constants.DeathCauseEnum.NONE &&
deathCause !== Constants.DeathCauseEnum.DEATHSAVES)
) {
return null;
}
const deathSavesRule = ruleDataUtils.getRule(
RuleKeyEnum.DEATH_SAVING_THROWS,
ruleData
);
return (
<div>
<Heading>Death Saving Throws Rules</Heading>
<HtmlContent
html={deathSavesRule?.description ? deathSavesRule.description : ""}
withoutTooltips
/>
</div>
);
};
return (
<div className={className} {...props}>
<Header>HP Management</Header>
<div className={styles.container}>
{hpInfo.remainingHp <= 0 &&
(deathCause === Constants.DeathCauseEnum.NONE ||
deathCause === Constants.DeathCauseEnum.DEATHSAVES) && (
<DeathSavesManager damageValue={damage} className={styles.border} />
)}
{deathCause !== Constants.DeathCauseEnum.NONE && (
<RestoreLifeManager
onSave={onRestoreToLife}
className={styles.border}
/>
)}
<HitPointsSummary
hpInfo={hpInfo}
showOriginalMax
showPermanentInputs
className={styles.border}
/>
<HitPointsAdjuster
hpInfo={hpInfo}
handleDamageUpdate={handleDamageUpdate}
className={styles.border}
/>
<HitPointsOverrides className={styles.border} />
{hpInfo.remainingHp <= 0 &&
(deathCause === Constants.DeathCauseEnum.NONE ||
deathCause === Constants.DeathCauseEnum.DEATHSAVES) &&
renderDeathSavesRules()}
</div>
</div>
);
};
@@ -0,0 +1,150 @@
import clsx from "clsx";
import { createRef, FC, HTMLAttributes, useState } from "react";
import { useDispatch } from "react-redux";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import {
HP_BONUS_VALUE,
HP_DAMAGE_TAKEN_VALUE,
HP_OVERRIDE_MAX_VALUE,
HP_TEMP_VALUE,
} from "~/subApps/sheet/constants";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const HitPointsOverrides: FC<Props> = ({ className, ...props }) => {
const { characterActions, hpInfo, ruleData, ruleDataUtils, helperUtils } =
useCharacterEngine();
const dispatch = useDispatch();
const [maxModifier, setMaxModifier] = useState<number | null>(hpInfo.bonusHp);
const [maxOverride, setMaxOverride] = useState<number | null>(
hpInfo.overrideHp
);
const maxModifierInput = createRef<HTMLInputElement>();
const maxOverrideInput = createRef<HTMLInputElement>();
/* --- Helper functions --- */
const updateDamageTaken = (maxHp: number) => {
if (hpInfo.totalHp !== null) {
let maxDiff = hpInfo.totalHp - maxHp;
if (maxDiff > 0) {
let newRemovedHp = Math.max(
HP_DAMAGE_TAKEN_VALUE.MIN,
hpInfo.removedHp - maxDiff
);
if (newRemovedHp !== hpInfo.removedHp) {
dispatch(
characterActions.hitPointsSet(
newRemovedHp,
hpInfo.tempHp ?? HP_TEMP_VALUE.MIN
)
);
}
}
}
};
const onKeyUp = (
func: any,
evt: React.KeyboardEvent<HTMLInputElement>
): void => {
if (evt.key === "Enter") {
func(evt);
}
};
/* --- Max hit point modifier functions --- */
const onChangeMaxModifier = (evt: React.ChangeEvent<HTMLInputElement>) => {
setMaxModifier(helperUtils.parseInputInt(evt.target.value));
};
const onBlurMaxModifier = (evt: React.FocusEvent<HTMLInputElement>) => {
let value = helperUtils.parseInputInt(evt.target.value);
if (value !== null) {
value = helperUtils.clampInt(
value,
HP_BONUS_VALUE.MIN,
HP_BONUS_VALUE.MAX
);
}
if (value !== null) {
updateDamageTaken(hpInfo.baseTotalHp + value);
}
if (value !== hpInfo.bonusHp) {
dispatch(characterActions.bonusHitPointsSet(value));
}
setMaxModifier(value);
};
/* --- Max hit point override functions --- */
const onChangeMaxOverride = (evt: React.ChangeEvent<HTMLInputElement>) => {
setMaxOverride(helperUtils.parseInputInt(evt.target.value));
};
const onBlurMaxOverride = (evt: React.FocusEvent<HTMLInputElement>) => {
let value = helperUtils.parseInputInt(evt.target.value);
if (value !== null) {
value = helperUtils.clampInt(
value,
ruleDataUtils.getMinimumHpTotal(ruleData),
HP_OVERRIDE_MAX_VALUE
);
}
if (value !== null) {
updateDamageTaken(value);
}
if (value !== hpInfo.overrideHp) {
//TODO fix when it can accept null
dispatch(characterActions.overrideHitPointsSet(value as number));
}
setMaxOverride(value);
};
return (
<div className={clsx([styles.row, styles.overrides, className])} {...props}>
<label className={clsx([styles.column, styles.override, styles.label])}>
Max HP Modifier
<input
ref={maxModifierInput}
className={styles.input}
type="number"
value={maxModifier ?? ""}
min={HP_BONUS_VALUE.MIN}
max={HP_BONUS_VALUE.MAX}
onChange={onChangeMaxModifier}
onBlur={onBlurMaxModifier}
onKeyUp={onKeyUp.bind(this, onBlurMaxModifier)}
placeholder="--"
/>
</label>
<label className={clsx([styles.column, styles.override, styles.label])}>
Override Max HP
<input
ref={maxOverrideInput}
className={styles.input}
type="number"
value={maxOverride ?? ""}
min={ruleDataUtils.getMinimumHpTotal(ruleData)}
max={HP_OVERRIDE_MAX_VALUE}
onChange={onChangeMaxOverride}
onBlur={onBlurMaxOverride}
onKeyUp={onKeyUp.bind(this, onBlurMaxOverride)}
placeholder="--"
/>
</label>
</div>
);
};
@@ -0,0 +1,84 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useState } from "react";
import {
ExclusiveCheckbox,
TypeScriptUtils,
} from "@dndbeyond/character-components/es";
import { Button } from "~/components/Button";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { ShortModelInfoContract } from "~/types";
import { Heading } from "../../../components/Heading";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
onSave?: (restoreType: ShortModelInfoContract) => void;
}
export const RestoreLifeManager: FC<Props> = ({
onSave,
className,
...props
}) => {
const { characterTheme, ruleData, ruleDataUtils } = useCharacterEngine();
const [activeChoice, setActiveChoice] = useState<number | null>(null);
const onClickRestore = () => {
if (onSave && activeChoice !== null) {
let restoreTypes = ruleDataUtils.getRestoreTypes(ruleData);
onSave(restoreTypes[activeChoice]);
}
};
const onClickReset = () => {
setActiveChoice(null);
};
const onSelection = (slotIdx: number) => {
setActiveChoice(slotIdx);
};
const renderRestoreLifeChoices = (): React.ReactNode => {
const restoreLifeChoices: Array<string> = ruleDataUtils
.getRestoreTypes(ruleData)
.map((type) => type.description)
.filter(TypeScriptUtils.isNotNullOrUndefined);
return (
<ExclusiveCheckbox
theme={characterTheme}
choices={restoreLifeChoices}
activeChoice={activeChoice}
onSelection={onSelection}
/>
);
};
const renderActions = (): React.ReactNode => {
if (activeChoice === null) {
return null;
}
return (
<div className={styles.actions}>
<Button onClick={onClickRestore} themed size="xx-small">
Restore Life
</Button>
<Button onClick={onClickReset} themed size="xx-small" variant="outline">
Reset
</Button>
</div>
);
};
return (
<div className={clsx([className])} {...props}>
<Heading className={styles.heading}>Restore Life</Heading>
{renderRestoreLifeChoices()}
{renderActions()}
</div>
);
};
@@ -0,0 +1,91 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { AdvantageIcon } from "@dndbeyond/character-components/es";
import { HtmlContent } from "~/components/HtmlContent";
import { NumberDisplay } from "~/components/NumberDisplay";
import { RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const InitiativePane: FC<Props> = ({ ...props }) => {
const { ruleDataUtils, initiativeScore } = useRuleData();
const {
ruleData,
processedInitiative,
hasInitiativeAdvantage,
characterTheme,
formatUtils,
} = useCharacterEngine();
const staticTotal = processedInitiative + initiativeScore.amount;
let rule = ruleDataUtils.getRule(RuleKeyEnum.INITIATIVE, ruleData);
return (
<div {...props}>
<Header>
{hasInitiativeAdvantage && (
<AdvantageIcon
theme={characterTheme}
title={"Advantage on Initiative"}
className={styles.advantageIcon}
/>
)}
Initiative{" "}
<span className={styles.modifier}>
(
<NumberDisplay
type="signed"
number={processedInitiative}
className={styles.signedNumber}
/>
)
</span>
</Header>
<div className={styles.container}>
<p>
Initiative scores can replace rolls at your DM's discretion. Your
initiative score equals {initiativeScore.amount} plus your DEX
modifier.
</p>
<div
className={clsx([
styles.score,
hasInitiativeAdvantage && styles.isAdvantage,
])}
>
<span className={styles.label}>Initiative Score: </span>
<span className={styles.amount}>{staticTotal}</span>
</div>
<div className={clsx([hasInitiativeAdvantage && styles.isAdvantage])}>
<span className={styles.labelSecondary}>
With Advantage (
{formatUtils.renderSignedNumber(initiativeScore.advantage)}):{" "}
</span>
<span className={styles.amount}>
{staticTotal + initiativeScore.advantage}
</span>
</div>
<div>
<span className={styles.labelSecondary}>
With Disadvantage (
{formatUtils.renderSignedNumber(initiativeScore.disadvantage)}):{" "}
</span>
<span className={styles.amount}>
{staticTotal + initiativeScore.disadvantage}
</span>
</div>
</div>
<HtmlContent
html={rule && rule.description ? rule.description : ""}
withoutTooltips
/>
</div>
);
};
@@ -0,0 +1,26 @@
import { FC, HTMLAttributes } from "react";
import { HtmlContent } from "~/components/HtmlContent";
import { RuleKeyEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const InspirationPane: FC<Props> = ({ ...props }) => {
const { ruleData } = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
let rule = ruleDataUtils.getRule(RuleKeyEnum.INSPIRATION, ruleData);
let description: string = "";
if (rule && rule.description) {
description = rule.description;
}
return (
<div {...props}>
<Header>Heroic Inspiration</Header>
<HtmlContent html={description} withoutTooltips />
</div>
);
};
@@ -0,0 +1,61 @@
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { characterActions } from "@dndbeyond/character-rules-engine/es";
import { Button } from "~/components/Button";
import { XpManager } from "~/components/XpManager";
import { Header } from "~/subApps/sheet/components/Sidebar/components/Header";
import { toastMessageActions } from "~/tools/js/Shared/actions";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {}
export const XpPane: FC<Props> = ({ ...props }) => {
const dispatch = useDispatch();
const [newXpTotal, setNewXpTotal] = useState<number | null>(null);
const [shouldReset, setShouldReset] = useState<boolean>(false);
useEffect(() => {
setShouldReset(newXpTotal === null);
}, [newXpTotal]);
const handleReset = (): void => {
setNewXpTotal(null);
};
const handleSave = (): void => {
if (newXpTotal !== null) {
setNewXpTotal(null);
dispatch(characterActions.xpSet(newXpTotal));
dispatch(
toastMessageActions.toastSuccess(
"Experience Points Updated",
"You have successfully updated your XP"
)
);
}
};
const handleXpUpdates = (newXpTotal: number): void => {
setNewXpTotal(newXpTotal);
};
return (
<div {...props}>
<Header>Manage XP</Header>
<XpManager handleXpUpdates={handleXpUpdates} shouldReset={shouldReset} />
{newXpTotal !== null && (
<div className={styles.buttonGroup}>
<Button themed variant="solid" size="x-small" onClick={handleSave}>
Apply Changes
</Button>
<Button themed onClick={handleReset} size="x-small" variant="outline">
Cancel
</Button>
</div>
)}
</div>
);
};
@@ -0,0 +1,18 @@
import { FC, HTMLAttributes } from "react";
type SvgProps = HTMLAttributes<SVGElement>;
export const AlignLeftIcon: FC<SvgProps> = (props) => (
<svg
viewBox="0 0 24 24"
width="1em"
height="1em"
aria-labelledby="alignLeftTitle"
{...props}
>
<title id="alignLeftTitle">Sidebar Align Left Icon</title>
<path d="M21,3V21H9.5V3H21m3-3H6.5V24H24V0Z" />
<rect x="0.5" y="0.5" width="5.91" height="23" />
<path d="M5.91,1V23H1V1H5.91m1-1H0V24H6.91V0Z" />
</svg>
);
@@ -0,0 +1,18 @@
import { FC, HTMLAttributes } from "react";
type SvgProps = HTMLAttributes<SVGElement>;
export const AlignRightIcon: FC<SvgProps> = (props) => (
<svg
viewBox="0 0 24 24"
width="1em"
height="1em"
aria-labelledby="alignRightTitle"
{...props}
>
<title id="alignRightTitle">Sidebar Align Right Icon</title>
<path d="M15.5,3V21H3V3H15.5m3-3H0V24H18.5V0Z" />
<rect x="17.59" y="0.5" width="5.91" height="23" />
<path d="M23,1V23H18.09V1H23m1-1H17.09V24H24V0Z" />
</svg>
);
@@ -0,0 +1,17 @@
import { FC, HTMLAttributes } from "react";
type SvgProps = HTMLAttributes<SVGElement>;
export const ArrowsLeftIcon: FC<SvgProps> = (props) => (
<svg
viewBox="0 0 16 16"
width="1em"
height="1em"
aria-labelledby="leftArrowsTitle"
{...props}
>
<title id="leftArrowsTitle">Left Arrows Icon</title>
<path d="M11,2.48,5,8l6,5.52a1.3,1.3,0,0,1-.21,2.12h0a2.25,2.25,0,0,1-2.68-.17L0,8,8.11.53A2.25,2.25,0,0,1,10.79.36h0A1.3,1.3,0,0,1,11,2.48Z" />
<polygon points="6.92 8 16 0 16 16 6.92 8" />
</svg>
);
@@ -0,0 +1,17 @@
import { FC, HTMLAttributes } from "react";
type SvgProps = HTMLAttributes<SVGElement>;
export const ArrowsRightIcon: FC<SvgProps> = (props) => (
<svg
viewBox="0 0 16 16"
width="1em"
height="1em"
aria-labelledby="rightArrowsTitle"
{...props}
>
<title id="rightArrowsTitle">Right Arrows Icon</title>
<path d="M5.21.36h0A2.25,2.25,0,0,1,7.89.53L16,8,7.89,15.47a2.25,2.25,0,0,1-2.68.17h0A1.3,1.3,0,0,1,5,13.52L11,8,5,2.48A1.3,1.3,0,0,1,5.21.36Z" />
<polygon points="9.09 8 0 0 0 16 9.09 8" />
</svg>
);
@@ -0,0 +1,28 @@
import { FC, HTMLAttributes } from "react";
type SvgProps = HTMLAttributes<SVGElement>;
export const FixedIcon: FC<SvgProps> = (props) => (
<svg
viewBox="0 0 24 24"
width="1em"
height="1em"
aria-labelledby="fixedTitle"
{...props}
>
<title id="fixedTitle">Sidebar Fixed Icon</title>
<path d="M15.5,3V21H3V3H15.5m3-3H0V24H18.5V0Z" />
<rect x="17.59" y="0.5" width="5.91" height="23" />
<path d="M23,1V23H18.09V1H23m1-1H17.09V24H24V0Z" />
<line
fill="none"
stroke="#000"
strokeMiterlimit={10}
strokeWidth="3px"
x1="12"
y1="5.5"
x2="12"
y2="19"
/>
</svg>
);
@@ -0,0 +1,19 @@
import { FC, HTMLAttributes } from "react";
type SvgProps = HTMLAttributes<SVGElement>;
export const OverlayIcon: FC<SvgProps> = (props) => (
<svg
viewBox="0 0 24 24"
width="1em"
height="1em"
aria-labelledby="overlayTitle"
{...props}
>
<title id="overlayTitle">Sidebar Overlay Icon</title>
<path d="M14.5,3V21H3V3H14.5m3-3H0V24H17.5V0Z" />
<rect x="17.59" y="0.5" width="5.91" height="23" />
<path d="M23,1V23H18.09V1H23m1-1H17.09V24H24V0Z" />
<polygon points="6.28 12.32 12.5 19.14 12.5 5.5 6.28 12.32" />
</svg>
);
@@ -0,0 +1,220 @@
import {
AbilityManager,
Constants,
} from "@dndbeyond/character-rules-engine/es";
export interface PaneIdentifiersCommonId {
id: string | number;
}
export interface PaneIdentifiersIntId {
id: number;
}
export interface PaneIdentifiersAction extends PaneIdentifiersCommonId {
entityTypeId: string | null;
}
export interface PaneIdentifiersClassSpell {
classId: number;
spellId: number;
castLevel?: number;
}
export interface PaneIdentifiersNote {
noteType: string;
}
export interface PaneIdentifiersClassFeature extends PaneIdentifiersCommonId {
classMappingId: number;
}
export interface PaneIdentifiersBasicAction extends PaneIdentifiersIntId {}
export interface PaneIdentifiersPreferenceHitPointConfirm
extends PaneIdentifiersIntId {}
export interface PaneIdentifiersPreferenceProgressionConfirm
extends PaneIdentifiersIntId {}
export interface PaneIdentifiersFeat extends PaneIdentifiersIntId {}
export interface PaneIdentifiersItem extends PaneIdentifiersCommonId {}
export interface PaneIdentifiersItem extends PaneIdentifiersCommonId {}
export interface PaneIdentifiersCustomItem extends PaneIdentifiersCommonId {}
export interface PaneIdentifiersCustomSkill extends PaneIdentifiersCommonId {}
export interface PaneIdentifiersCustomAction extends PaneIdentifiersCommonId {}
export interface PaneIdentifiersCharacterSpell extends PaneIdentifiersCommonId {
castLevel?: number;
}
export interface PaneIdentifiersRacialTrait extends PaneIdentifiersCommonId {}
export interface PaneIdentifiersAbilitySavingThrow
extends PaneIdentifiersCommonId {}
export interface PaneIdentifiersSkill extends PaneIdentifiersCommonId {}
export interface PaneIdentifiersCreature extends PaneIdentifiersCommonId {}
export interface PaneIdentifiersVehicle extends PaneIdentifiersCommonId {}
export interface PaneIdentifiersInfusionChoice
extends PaneIdentifiersCommonId {}
export interface PaneIdentifiersAbility {
ability: AbilityManager;
}
export interface PaneIdentifiersPreferenceOptionalClassFeaturesConfirm {
spellListIds: Array<number>;
newIsEnabled: boolean;
}
export interface PaneIdentifiersPreferenceOptionalOriginsConfirm {
spellListIds: Array<number>;
newIsEnabled: boolean;
}
export interface PaneIdentifiersVehicleComponent
extends PaneIdentifiersCommonId {
vehicleId: number;
}
export interface PaneIdentifiersVehicleActionStation
extends PaneIdentifiersCommonId {
vehicleId: number;
}
export interface PaneIdentifiersContainer {
containerDefinitionKey: string;
showAddItems?: boolean;
}
export interface PaneIdentifiersCurrencyContext {
containerDefinitionKeyContext: string | null;
}
export interface PaneIdentifiersSettingsContext {
context: string;
}
export interface PaneIdentifiersBlessing {
id: string;
}
export interface PaneIdentifiersTrait {
type: Constants.TraitTypeEnum;
}
export type PaneIdentifiers =
| PaneIdentifiersAction
| PaneIdentifiersClassSpell
| PaneIdentifiersNote
| PaneIdentifiersClassFeature
| PaneIdentifiersRacialTrait
| PaneIdentifiersItem
| PaneIdentifiersBasicAction
| PaneIdentifiersCharacterSpell
| PaneIdentifiersFeat
| PaneIdentifiersCustomAction
| PaneIdentifiersCustomItem
| PaneIdentifiersCustomSkill
| PaneIdentifiersAbility
| PaneIdentifiersSkill
| PaneIdentifiersAbilitySavingThrow
| PaneIdentifiersPreferenceHitPointConfirm
| PaneIdentifiersPreferenceOptionalClassFeaturesConfirm
| PaneIdentifiersPreferenceOptionalOriginsConfirm
| PaneIdentifiersPreferenceProgressionConfirm
| PaneIdentifiersCreature
| PaneIdentifiersVehicle
| PaneIdentifiersVehicleComponent
| PaneIdentifiersInfusionChoice
| PaneIdentifiersContainer
| PaneIdentifiersCurrencyContext
| PaneIdentifiersSettingsContext
| PaneIdentifiersBlessing
| PaneIdentifiersTrait;
export interface PaneComponentProperties {
forceDarkMode?: boolean;
isFullWidth?: boolean;
}
export type SidebarPaneComponent<T> = React.ComponentType<T>;
export interface PaneComponentInfo {
type: PaneComponentEnum;
identifiers?: PaneIdentifiers | null;
}
export enum PaneComponentEnum {
ERROR_404 = "ERROR_404",
ABILITY = "ABILITY",
ABILITY_SAVING_THROW = "ABILITY_SAVING_THROW",
ACTION = "ACTION",
ARMOR_MANAGE = "ARMOR_MANAGE",
BASIC_ACTION = "BASIC_ACTION",
BACKGROUND = "BACKGROUND",
BLESSING_DETAIL = "BLESSING_DETAIL",
CAMPAIGN = "CAMPAIGN",
CHARACTER_MANAGE = "CHARACTER_MANAGE",
CHARACTER_SPELL_DETAIL = "CHARACTER_SPELL_DETAIL",
CLASS_FEATURE_DETAIL = "CLASS_FEATURE_DETAIL",
CLASS_SPELL_DETAIL = "CLASS_SPELL_DETAIL",
CONDITION_MANAGE = "CONDITION_MANAGE",
CONTAINER = "CONTAINER",
CREATURE = "CREATURE",
CURRENCY = "CURRENCY",
CUSTOM_ACTIONS = "CUSTOM_ACTIONS",
CUSTOM_ACTION = "CUSTOM_ACTION",
CUSTOM_SKILL = "CUSTOM_SKILL",
DECORATE = "DECORATE",
DEFENSE_MANAGE = "DEFENSE_MANAGE",
DESCRIPTION = "DESCRIPTION",
ENCUMBRANCE = "ENCUMBRANCE",
EQUIPMENT_MANAGE = "EQUIPMENT_MANAGE",
EXPORT_PDF = "EXPORT_PDF",
EXTRA_MANAGE = "EXTRA_MANAGE",
FEAT_DETAIL = "FEAT_DETAIL",
FEATS_MANAGE = "FEATS_MANAGE",
GAME_LOG = "GAME_LOG",
HEALTH_MANAGE = "HEALTH_MANAGE",
INFUSION_CHOICE = "INFUSION_CHOICE",
INITIATIVE = "INITIATIVE",
INSPIRATION = "INSPIRATION",
ITEM_DETAIL = "ITEM_DETAIL",
LONG_REST = "LONG_REST",
NOTE_MANAGE = "NOTE_MANAGE",
POSSESSIONS_MANAGE = "POSSESSIONS_MANAGE",
PREFERENCES = "PREFERENCES",
PREFERENCES_HIT_POINT_CONFIRM = "PREFERENCES_HIT_POINT_CONFIRM",
PREFERENCES_OPTIONAL_CLASS_FEATURES_CONFIRM = "PREFERENCES_OPTIONAL_CLASS_FEATURES_CONFIRM",
PREFERENCES_OPTIONAL_ORIGINS_CONFIRM = "PREFERENCES_OPTIONAL_ORIGINS_CONFIRM",
PREFERENCES_PROGRESSION_CONFIRM = "PREFERENCES_PROGRESSION_CONFIRM",
PROFICIENCIES = "PROFICIENCIES",
PROFICIENCY_BONUS = "PROFICIENCY_BONUS",
SPECIES_TRAIT_DETAIL = "SPECIES_TRAIT_DETAIL",
SAVING_THROWS = "SAVING_THROWS",
SENSE_MANAGE = "SENSE_MANAGE",
SETTINGS = "SETTINGS",
SHARE_URL = "SHARE_URL",
SHORT_REST = "SHORT_REST",
SKILLS = "SKILLS",
SKILL = "SKILL",
SPEED_MANAGE = "SPEED_MANAGE",
SPELL_MANAGE = "SPELL_MANAGE",
STARTING_EQUIPMENT = "STARTING_EQUIPMENT",
TRAIT = "TRAIT",
VEHICLE = "VEHICLE",
VEHICLE_COMPONENT = "VEHICLE_COMPONENT",
VEHICLE_ACTION_STATION = "VEHICLE_ACTION_STATION",
XP = "XP",
}
export enum SidebarPlacementEnum {
FIXED = "fixed",
OVERLAY = "overlay",
}
export enum SidebarAlignmentEnum {
LEFT = "left",
RIGHT = "right",
}
export interface SidebarPositionInfo {
left: number | string;
right: number | string;
}