Grabbed dndbeyond's source code
``` ~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js ```
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
import React, { useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import { Tooltip } from "@dndbeyond/character-common-components/es";
|
||||
import {
|
||||
DiceComponentUtils,
|
||||
DigitalDiceWrapper,
|
||||
NoteComponents,
|
||||
SpellDamageEffect,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityLookup,
|
||||
ActivationUtils,
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
DataOriginRefData,
|
||||
EntityUtils,
|
||||
FormatUtils,
|
||||
RuleData,
|
||||
ScaledSpell,
|
||||
Spell,
|
||||
LeveledSpellManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
import {
|
||||
Dice,
|
||||
DiceEvent,
|
||||
DiceTools,
|
||||
IRollContext,
|
||||
RollRequest,
|
||||
RollType,
|
||||
} from "@dndbeyond/dice";
|
||||
import { GameLogContext } from "@dndbeyond/game-log-components";
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
import { SpellName } from "~/components/SpellName";
|
||||
import { TypeScriptUtils } from "../../utils";
|
||||
import SpellSlotChooser from "../SpellSlotChooser";
|
||||
import { ThemeButton } from "../common/Button";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
spell: ScaledSpell;
|
||||
castLevel: number;
|
||||
characterLevel: number;
|
||||
isSpellSlotAvailable: boolean;
|
||||
isPactSlotAvailable: boolean;
|
||||
doesSpellSlotExist: boolean;
|
||||
doesPactSlotExist: boolean;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
onClick?: (spell: Spell, castLevel: number) => void;
|
||||
onUse?: (
|
||||
useSpellSlot: boolean,
|
||||
usePactMagicSlot: boolean,
|
||||
dataOriginType: Constants.DataOriginTypeEnum,
|
||||
uses: number | null,
|
||||
mappingId: number | null,
|
||||
mappingTypeId: number | null
|
||||
) => void;
|
||||
showNotes: boolean;
|
||||
isInteractive: boolean;
|
||||
diceEnabled: boolean;
|
||||
theme: CharacterTheme;
|
||||
proficiencyBonus: number;
|
||||
rollContext: IRollContext;
|
||||
}
|
||||
|
||||
function SpellsSpell({
|
||||
className = "",
|
||||
spell: spellData,
|
||||
castLevel,
|
||||
characterLevel,
|
||||
isSpellSlotAvailable,
|
||||
isPactSlotAvailable,
|
||||
doesSpellSlotExist,
|
||||
doesPactSlotExist,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
dataOriginRefData,
|
||||
onClick,
|
||||
onUse,
|
||||
showNotes = true,
|
||||
isInteractive,
|
||||
diceEnabled,
|
||||
theme,
|
||||
proficiencyBonus,
|
||||
rollContext,
|
||||
}: Props) {
|
||||
const spell = useMemo(
|
||||
() => new LeveledSpellManager({ spell: spellData }),
|
||||
[spellData]
|
||||
);
|
||||
const [isSlotChooserOpen, setIsSlotChooserOpen] = React.useState(false);
|
||||
const [isCriticalHit, setIsCriticalHit] = React.useState(false);
|
||||
const spellsSpellNode = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleSpellClick = (evt: React.MouseEvent): void => {
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
evt.stopPropagation();
|
||||
|
||||
if (onClick) {
|
||||
onClick(spell.spell, castLevel);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisabledCastClick = (): void => {
|
||||
if (onClick) {
|
||||
onClick(spell.spell, castLevel);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpellSlotChooserOpen = (): void => {
|
||||
setIsSlotChooserOpen(true);
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
};
|
||||
|
||||
const handleSpellSlotChooserClose = (): void => {
|
||||
setIsSlotChooserOpen(false);
|
||||
};
|
||||
|
||||
const handleSpellSlotChosen = (): void => {
|
||||
spell.handleSpellUse(true, false);
|
||||
setIsSlotChooserOpen(false);
|
||||
};
|
||||
|
||||
const handlePactSlotChosen = (): void => {
|
||||
spell.handleSpellUse(false, true);
|
||||
setIsSlotChooserOpen(false);
|
||||
};
|
||||
|
||||
const handleRollResults = (result: RollRequest): void => {
|
||||
let wasCrit = DiceComponentUtils.isCriticalRoll(result);
|
||||
setIsCriticalHit(wasCrit);
|
||||
};
|
||||
|
||||
const diceEventHandler = useMemo(() => {
|
||||
if (!spellsSpellNode.current) {
|
||||
return () => {
|
||||
/* NOOP */
|
||||
};
|
||||
}
|
||||
return DiceComponentUtils.setupResetCritStateOnRoll(
|
||||
spell.getName(),
|
||||
spellsSpellNode.current
|
||||
);
|
||||
}, [spell]);
|
||||
// TODO: use mui click away listener
|
||||
const handleDocumentClick = useCallback((): void => {
|
||||
handleSpellSlotChooserClose();
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (isSlotChooserOpen) {
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
}
|
||||
|
||||
Dice.removeEventListener(DiceEvent.ROLL, diceEventHandler);
|
||||
},
|
||||
[isSlotChooserOpen, handleDocumentClick, diceEventHandler]
|
||||
);
|
||||
|
||||
// Todo: consider making a separate component for this
|
||||
const [{ messageTargetOptions, defaultMessageTargetOption, userId }] =
|
||||
useContext(GameLogContext);
|
||||
const renderAttackInfo = (): React.ReactNode => {
|
||||
let toHit: number | null = null;
|
||||
let saveDcValue: number | null = null;
|
||||
let saveDcLabel: string | null = null;
|
||||
let asPartOfWeaponAttack = spell.asPartOfWeaponAttack();
|
||||
let requiresAttackRoll = spell.getRequiresAttackRoll();
|
||||
let requiresSavingThrow = spell.getRequiresSavingThrow();
|
||||
if (requiresAttackRoll) {
|
||||
toHit = spell.getToHit();
|
||||
} else if (requiresSavingThrow) {
|
||||
saveDcValue = spell.getAttackSaveValue();
|
||||
saveDcLabel = spell.getSaveDcAbilityKey();
|
||||
}
|
||||
|
||||
if (!asPartOfWeaponAttack) {
|
||||
if (requiresAttackRoll && toHit !== null) {
|
||||
return (
|
||||
<div className="ct-spells-spell__tohit">
|
||||
<DigitalDiceWrapper
|
||||
diceNotation={DiceTools.CustomD20(toHit)}
|
||||
rollType={RollType.ToHit}
|
||||
rollAction={spell.getName()}
|
||||
diceEnabled={diceEnabled}
|
||||
themeColor={theme.themeColor}
|
||||
onRollResults={handleRollResults}
|
||||
rollContext={rollContext}
|
||||
rollTargetOptions={
|
||||
messageTargetOptions
|
||||
? Object.values(messageTargetOptions.entities).filter(
|
||||
TypeScriptUtils.isNotNullOrUndefined
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
rollTargetDefault={defaultMessageTargetOption}
|
||||
userId={Number(userId) || 0}
|
||||
>
|
||||
<NumberDisplay
|
||||
number={toHit}
|
||||
type="signed"
|
||||
/>
|
||||
</DigitalDiceWrapper>
|
||||
</div>
|
||||
);
|
||||
} else if (requiresSavingThrow) {
|
||||
return (
|
||||
<div className="ct-spells-spell__save">
|
||||
<span
|
||||
className={`ct-spells-spell__save-label ${
|
||||
theme?.isDarkMode ? "ct-spells-spell--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{saveDcLabel}
|
||||
</span>
|
||||
<span
|
||||
className={`ct-spells-spell__save-value ${
|
||||
theme?.isDarkMode ? "ct-spells-spell--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{saveDcValue}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="ct-spells-spell__empty-value">--</div>;
|
||||
};
|
||||
|
||||
let range = spell.getRange();
|
||||
let limitedUse = spell.getLimitedUse();
|
||||
let activation = spell.getActivation();
|
||||
let isScaled = spell.getCastLevel() !== spell.getLevel();
|
||||
let isAtWill = spell.isAtWill();
|
||||
const spellDataOriginType = spell.getDataOriginType();
|
||||
const spellDataOrigin = spell.getDataOrigin();
|
||||
|
||||
let limitedUseButtonText: React.ReactNode;
|
||||
if (limitedUse) {
|
||||
let maxUses = spell.getMaxUses();
|
||||
let consumedAmount = spell.getConsumedUses();
|
||||
if (
|
||||
maxUses === 1 &&
|
||||
spellDataOriginType !== Constants.DataOriginTypeEnum.ITEM
|
||||
) {
|
||||
limitedUseButtonText = "Use";
|
||||
} else {
|
||||
limitedUseButtonText = `${consumedAmount} ${
|
||||
spellDataOriginType === Constants.DataOriginTypeEnum.ITEM ? "C" : "U"
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
let usesSpellSlot = spell.getUsesSpellSlot();
|
||||
|
||||
let canCastSpell: boolean = true;
|
||||
if (limitedUse) {
|
||||
canCastSpell = spell.isLimitedUseAvailableAtScaledAmount();
|
||||
} else if (usesSpellSlot) {
|
||||
canCastSpell = isSpellSlotAvailable || isPactSlotAvailable;
|
||||
}
|
||||
|
||||
let scaledInfoNode: React.ReactNode;
|
||||
if (isScaled) {
|
||||
scaledInfoNode = (
|
||||
<span className="ct-spells-spell__scaled">
|
||||
<span className="ct-spells-spell__scaled-level">
|
||||
<span className="ct-spells-spell__scaled-level-number">
|
||||
{spell.getLevel()}
|
||||
</span>
|
||||
<span className="ct-spells-spell__scaled-level-ordinal">
|
||||
{FormatUtils.getOrdinalSuffix(spell.getLevel())}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
let combinedMetaItems: Array<React.ReactNode> = [];
|
||||
|
||||
if (spell.isLegacy()) {
|
||||
combinedMetaItems.push("Legacy");
|
||||
}
|
||||
|
||||
switch (spellDataOriginType) {
|
||||
case Constants.DataOriginTypeEnum.ITEM:
|
||||
combinedMetaItems.push(
|
||||
<ItemName item={spellDataOrigin.primary} showAttunement={false} />
|
||||
);
|
||||
break;
|
||||
default:
|
||||
combinedMetaItems.push(EntityUtils.getDataOriginName(spellDataOrigin));
|
||||
}
|
||||
|
||||
let expandedDataOriginRef = spell.getExpandedDataOriginRef();
|
||||
if (expandedDataOriginRef !== null) {
|
||||
combinedMetaItems.push(
|
||||
EntityUtils.getDataOriginRefName(expandedDataOriginRef, dataOriginRefData)
|
||||
);
|
||||
}
|
||||
|
||||
let castAsRitual = spell.isCastAsRitual();
|
||||
|
||||
let actionNode: React.ReactNode;
|
||||
if (castAsRitual) {
|
||||
actionNode = <span className="ct-spells-spell__as-ritual">As Ritual</span>;
|
||||
} else if (isAtWill) {
|
||||
actionNode = <span className="ct-spells-spell__at-will">At Will</span>;
|
||||
} else {
|
||||
actionNode = (
|
||||
<React.Fragment>
|
||||
<ThemeButton
|
||||
size={"small"}
|
||||
onClick={
|
||||
canCastSpell
|
||||
? () => spell.handleCastClick(handleSpellSlotChooserOpen)
|
||||
: handleDisabledCastClick
|
||||
}
|
||||
disabled={!canCastSpell}
|
||||
block={true}
|
||||
isInteractive={isInteractive}
|
||||
>
|
||||
{scaledInfoNode}
|
||||
{limitedUse ? limitedUseButtonText : "Cast"}
|
||||
</ThemeButton>
|
||||
{isSlotChooserOpen && canCastSpell && (
|
||||
<SpellSlotChooser
|
||||
onPactChoose={handlePactSlotChosen}
|
||||
onSpellChoose={handleSpellSlotChosen}
|
||||
isSpellSlotAvailable={isSpellSlotAvailable}
|
||||
isPactSlotAvailable={isPactSlotAvailable}
|
||||
doesSpellSlotExist={doesSpellSlotExist}
|
||||
doesPactSlotExist={doesPactSlotExist}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
let classNames: Array<string> = ["ct-spells-spell", className];
|
||||
if (isCriticalHit) {
|
||||
classNames.push("ct-spells-spell--crit");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames.join(" ")}
|
||||
onClick={handleSpellClick}
|
||||
ref={spellsSpellNode}
|
||||
>
|
||||
<div className="ct-spells-spell__action">{actionNode}</div>
|
||||
<div className="ct-spells-spell__name">
|
||||
<div
|
||||
className={`ct-spells-spell__label ${
|
||||
isScaled ? "ct-spells-spell__label--scaled" : ""
|
||||
}`}
|
||||
>
|
||||
<SpellName
|
||||
spell={spell.spell}
|
||||
showSpellLevel={false}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
/>
|
||||
</div>
|
||||
{combinedMetaItems.length > 0 && (
|
||||
<div
|
||||
className={`ct-spells-spell__meta ${
|
||||
theme.isDarkMode ? "ct-spells-spell__meta--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{combinedMetaItems.map((metaItem, idx) => (
|
||||
<span className="ct-spells-spell__meta-item" key={idx}>
|
||||
{metaItem}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activation !== null && (
|
||||
<div
|
||||
className={`ct-spells-spell__activation ${
|
||||
theme.isDarkMode ? "ct-spells-spell__activation--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
<Tooltip
|
||||
isDarkMode={theme.isDarkMode}
|
||||
title={ActivationUtils.renderCastingTime(
|
||||
activation,
|
||||
castAsRitual ? 10 : 0,
|
||||
ruleData
|
||||
)}
|
||||
tippyOpts={{ dynamicTitle: true }}
|
||||
>
|
||||
{ActivationUtils.renderCastingTimeAbbreviation(activation)}
|
||||
{castAsRitual && (
|
||||
<span className="ct-spells-spell__activation-extra">+10m</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{range !== null && (
|
||||
<div
|
||||
className={`ct-spells-spell__range ${
|
||||
theme?.isDarkMode ? "ct-spells-spell--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{!!range.origin &&
|
||||
range.origin !== Constants.SpellRangeTypeNameEnum.RANGED && (
|
||||
<span className="ct-spells-spell__range-origin">
|
||||
{range.origin}
|
||||
</span>
|
||||
)}
|
||||
{!!range.rangeValue && (
|
||||
<span className="ct-spells-spell__range-value">
|
||||
<NumberDisplay type="distanceInFt" number={range.rangeValue} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="ct-spells-spell__attacking">{renderAttackInfo()}</div>
|
||||
<div className="ct-spells-spell__damage">
|
||||
<SpellDamageEffect
|
||||
spell={spell.spell}
|
||||
characterLevel={characterLevel}
|
||||
castLevel={castLevel}
|
||||
ruleData={ruleData}
|
||||
diceEnabled={diceEnabled}
|
||||
theme={theme}
|
||||
isCriticalHit={isCriticalHit}
|
||||
rollContext={rollContext}
|
||||
/>
|
||||
</div>
|
||||
{showNotes && (
|
||||
<div className="ct-spells-spell__notes">
|
||||
<NoteComponents theme={theme} notes={spell.getNoteComponents()} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpellsSpell;
|
||||
@@ -0,0 +1,4 @@
|
||||
import SpellsSpell from "./SpellsSpell";
|
||||
|
||||
export default SpellsSpell;
|
||||
export { SpellsSpell };
|
||||
Reference in New Issue
Block a user