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,3 @@
|
||||
export const DATA_SET = "appEnv.DATA_SET";
|
||||
export const MOBILE_SET = "appEnv.MOBILE_SET";
|
||||
export const DIMENSIONS_SET = "appEnv.DIMENSIONS_SET";
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AppEnvDimensionsState } from "../../stores/typings";
|
||||
import * as actionTypes from "./actionTypes";
|
||||
import {
|
||||
DataSetAction,
|
||||
DataSetPayload,
|
||||
DimensionsSetAction,
|
||||
MobileSetAction,
|
||||
} from "./typings";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param payload
|
||||
*/
|
||||
export function dataSet(payload: DataSetPayload): DataSetAction {
|
||||
return {
|
||||
type: actionTypes.DATA_SET,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param isMobile
|
||||
*/
|
||||
export function mobileSet(isMobile: boolean): MobileSetAction {
|
||||
return {
|
||||
type: actionTypes.MOBILE_SET,
|
||||
payload: {
|
||||
isMobile,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param dimensions
|
||||
*/
|
||||
export function dimensionsSet(
|
||||
dimensions: Omit<AppEnvDimensionsState, "styleSizeType">
|
||||
): DimensionsSetAction {
|
||||
return {
|
||||
type: actionTypes.DIMENSIONS_SET,
|
||||
payload: {
|
||||
dimensions,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as actionTypes from "./actionTypes";
|
||||
import * as actions from "./actions";
|
||||
import * as typings from "./typings";
|
||||
|
||||
export const appEnvActionTypes = actionTypes;
|
||||
export const appEnvActions = actions;
|
||||
export const appEnvTypings = typings;
|
||||
@@ -0,0 +1 @@
|
||||
export const ERROR_SET = "appInfo.ERROR_SET";
|
||||
@@ -0,0 +1,21 @@
|
||||
import AppErrorTypeEnum from "../../constants/AppErrorTypeEnum";
|
||||
import * as types from "./actionTypes";
|
||||
import { ErrorSetAction } from "./typings";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param appErrorType
|
||||
* @param errorId
|
||||
*/
|
||||
export function errorSet(
|
||||
appErrorType: AppErrorTypeEnum,
|
||||
errorId: string | null = null
|
||||
): ErrorSetAction {
|
||||
return {
|
||||
type: types.ERROR_SET,
|
||||
payload: {
|
||||
appErrorType: appErrorType,
|
||||
errorId,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as actionTypes from "./actionTypes";
|
||||
import * as actions from "./actions";
|
||||
import * as typings from "./typings";
|
||||
|
||||
export const appInfoActionTypes = actionTypes;
|
||||
export const appInfoActions = actions;
|
||||
export const appInfoTypings = typings;
|
||||
@@ -0,0 +1,51 @@
|
||||
export const ROLL_RESULT_GROUPS_LOAD = "rollResult.ROLL_RESULT_GROUPS_LOAD";
|
||||
export const ROLL_RESULT_GROUP_CREATE = "rollResult.ROLL_RESULT_GROUP_CREATE";
|
||||
export const ROLL_RESULT_GROUP_DESTROY = "rollResult.ROLL_RESULT_GROUP_DESTROY";
|
||||
|
||||
export const ROLL_RESULT_COMPONENT_GROUPS_SET =
|
||||
"rollResult.ROLL_RESULT_COMPONENT_GROUPS_SET";
|
||||
export const ROLL_RESULT_COMPONENT_GROUPS_SET_COMMIT =
|
||||
"rollResult.ROLL_RESULT_COMPONENT_GROUPS_SET_COMMIT";
|
||||
|
||||
export const ROLL_RESULT_GROUP_ADD = "rollResult.ROLL_RESULT_GROUP_ADD";
|
||||
export const ROLL_RESULT_GROUP_ADD_COMMIT =
|
||||
"rollResult.ROLL_RESULT_GROUP_ADD_COMMIT";
|
||||
|
||||
export const ROLL_RESULT_GROUP_REMOVE = "rollResult.ROLL_RESULT_GROUP_REMOVE";
|
||||
export const ROLL_RESULT_GROUP_REMOVE_COMMIT =
|
||||
"rollResult.ROLL_RESULT_GROUP_REMOVE_COMMIT";
|
||||
|
||||
export const ROLL_RESULT_GROUP_DICE_ROLLS_SET =
|
||||
"rollResult.ROLL_RESULT_GROUP_DICE_ROLLS_SET";
|
||||
export const ROLL_RESULT_GROUP_DICE_ROLLS_SET_COMMIT =
|
||||
"rollResult.ROLL_RESULT_GROUP_DICE_ROLLS_SET_COMMIT";
|
||||
|
||||
export const ROLL_RESULT_GROUP_ORDER_SET =
|
||||
"rollResult.ROLL_RESULT_GROUP_ORDER_SET";
|
||||
export const ROLL_RESULT_GROUP_ORDER_SET_COMMIT =
|
||||
"rollResult.ROLL_RESULT_GROUP_ORDER_SET_COMMIT";
|
||||
|
||||
export const ROLL_RESULT_GROUP_RESET = "rollResult.ROLL_RESULT_GROUP_RESET";
|
||||
export const ROLL_RESULT_GROUP_RESET_COMMIT =
|
||||
"rollResult.ROLL_RESULT_GROUP_RESET_COMMIT";
|
||||
|
||||
export const ROLL_RESULT_DICE_ROLL_SET = "rollResult.ROLL_RESULT_DICE_ROLL_SET";
|
||||
export const ROLL_RESULT_DICE_ROLL_SET_COMMIT =
|
||||
"rollResult.ROLL_RESULT_DICE_ROLL_SET_COMMIT";
|
||||
|
||||
export const ROLL_RESULT_DICE_ROLL_STATUS_SET =
|
||||
"rollResult.ROLL_RESULT_DICE_ROLL_STATUS_SET";
|
||||
|
||||
export const ROLL_RESULT_COMPONENT_GROUP_PERSIST =
|
||||
"rollResult.ROLL_RESULT_COMPONENT_GROUP_PERSIST";
|
||||
export const ROLL_RESULT_COMPONENT_PERSIST =
|
||||
"rollResult.ROLL_RESULT_COMPONENT_PERSIST";
|
||||
|
||||
export const ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_SET =
|
||||
"rollResult.ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_SET";
|
||||
export const ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_REMOVE =
|
||||
"rollResult.ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_REMOVE";
|
||||
export const ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_PERSIST =
|
||||
"rollResult.ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_PERSIST";
|
||||
export const ROLL_RESULT_COMPONENT_SIMULATED_DICE_ROLL_SET =
|
||||
"rollResult.ROLL_RESULT_COMPONENT_SIMULATED_DICE_ROLL_SET";
|
||||
@@ -0,0 +1,488 @@
|
||||
import {
|
||||
RollGroupContract,
|
||||
RollResultContract,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import * as types from "./actionTypes";
|
||||
import {
|
||||
RollResultGroupAddAction,
|
||||
RollResultGroupsLoadAction,
|
||||
RollResultGroupRemoveAction,
|
||||
RollResultGroupDiceRollsSetAction,
|
||||
RollResultComponentGroupsSetAction,
|
||||
RollResultGroupOrderSetAction,
|
||||
RollResultGroupCreateAction,
|
||||
RollResultGroupDestroyAction,
|
||||
RollResultComponentGroupsSetCommitAction,
|
||||
RollResultGroupAddCommitAction,
|
||||
RollResultGroupRemoveCommitAction,
|
||||
RollResultGroupDiceRollsSetCommitAction,
|
||||
RollResultGroupOrderSetCommitAction,
|
||||
RollResultGroupResetAction,
|
||||
RollResultGroupResetCommitAction,
|
||||
RollResultDiceRollSetAction,
|
||||
RollResultDiceRollSetCommitAction,
|
||||
RollResultDiceRollStatusSetAction,
|
||||
RollResultComponentSimulatedGroupsSetAction,
|
||||
RollResultComponentSimulatedGroupsPersistAction,
|
||||
RollResultComponentSimulatedDiceRollSetAction,
|
||||
RollResultComponentGroupPersistAction,
|
||||
RollResultComponentPersistAction,
|
||||
} from "./typings";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param simulatedGroupCount
|
||||
* @param simulatedRollResultCount
|
||||
*/
|
||||
export function rollResultGroupsLoad(
|
||||
componentKey: string,
|
||||
simulatedGroupCount: number | null,
|
||||
simulatedRollResultCount?: number
|
||||
): RollResultGroupsLoadAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUPS_LOAD,
|
||||
payload: {
|
||||
componentKey,
|
||||
simulatedGroupCount,
|
||||
simulatedRollResultCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param simulatedRollResultCount
|
||||
*/
|
||||
export function rollResultGroupCreate(
|
||||
componentKey: string,
|
||||
simulatedRollResultCount?: number
|
||||
): RollResultGroupCreateAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUP_CREATE,
|
||||
payload: {
|
||||
componentKey,
|
||||
simulatedRollResultCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
* @param nextGroupKey
|
||||
*/
|
||||
export function rollResultGroupDestroy(
|
||||
componentKey: string,
|
||||
groupKey: string,
|
||||
nextGroupKey: string | null
|
||||
): RollResultGroupDestroyAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUP_DESTROY,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
nextGroupKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groups
|
||||
*/
|
||||
export function rollResultComponentGroupsSet(
|
||||
componentKey: string,
|
||||
groups: Array<RollGroupContract>
|
||||
): RollResultComponentGroupsSetAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_COMPONENT_GROUPS_SET,
|
||||
payload: {
|
||||
componentKey,
|
||||
groups,
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groups
|
||||
*/
|
||||
export function rollResultComponentGroupsSetCommit(
|
||||
componentKey: string,
|
||||
groups: Array<RollGroupContract>
|
||||
): RollResultComponentGroupsSetCommitAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_COMPONENT_GROUPS_SET_COMMIT,
|
||||
payload: {
|
||||
componentKey,
|
||||
groups,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param group
|
||||
*/
|
||||
export function rollResultGroupAdd(
|
||||
group: RollGroupContract
|
||||
): RollResultGroupAddAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUP_ADD,
|
||||
payload: {
|
||||
group,
|
||||
componentKey: group.componentKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param group
|
||||
*/
|
||||
export function rollResultGroupAddCommit(
|
||||
group: RollGroupContract
|
||||
): RollResultGroupAddCommitAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUP_ADD_COMMIT,
|
||||
payload: {
|
||||
group,
|
||||
componentKey: group.componentKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
*/
|
||||
export function rollResultGroupRemove(
|
||||
componentKey: string,
|
||||
groupKey: string
|
||||
): RollResultGroupRemoveAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUP_REMOVE,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
*/
|
||||
export function rollResultGroupRemoveCommit(
|
||||
componentKey: string,
|
||||
groupKey: string
|
||||
): RollResultGroupRemoveCommitAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUP_REMOVE_COMMIT,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
* @param rollResults
|
||||
*/
|
||||
export function rollResultGroupDiceRollsSet(
|
||||
componentKey: string,
|
||||
groupKey: string,
|
||||
rollResults: Array<RollResultContract>
|
||||
): RollResultGroupDiceRollsSetAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUP_DICE_ROLLS_SET,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
rollResults,
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
* @param rollResults
|
||||
*/
|
||||
export function rollResultGroupDiceRollsSetCommit(
|
||||
componentKey: string,
|
||||
groupKey: string,
|
||||
rollResults: Array<RollResultContract>
|
||||
): RollResultGroupDiceRollsSetCommitAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUP_DICE_ROLLS_SET_COMMIT,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
rollResults,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
* @param nextGroupKey
|
||||
*/
|
||||
export function rollResultGroupOrderSet(
|
||||
componentKey: string,
|
||||
groupKey: string,
|
||||
nextGroupKey: string | null
|
||||
): RollResultGroupOrderSetAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUP_ORDER_SET,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
nextGroupKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
* @param nextGroupKey
|
||||
*/
|
||||
export function rollResultGroupOrderSetCommit(
|
||||
componentKey: string,
|
||||
groupKey: string,
|
||||
nextGroupKey: string | null
|
||||
): RollResultGroupOrderSetCommitAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUP_ORDER_SET_COMMIT,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
nextGroupKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
*/
|
||||
export function rollResultGroupReset(
|
||||
componentKey: string,
|
||||
groupKey: string,
|
||||
rollResults: Array<RollResultContract>
|
||||
): RollResultGroupResetAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUP_RESET,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
rollResults,
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
*/
|
||||
export function rollResultGroupResetCommit(
|
||||
componentKey: string,
|
||||
groupKey: string,
|
||||
rollResults: Array<RollResultContract>
|
||||
): RollResultGroupResetCommitAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_GROUP_RESET_COMMIT,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
rollResults,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param rollKey
|
||||
* @param properties
|
||||
* @param nextRollKey
|
||||
*/
|
||||
export function rollResultDiceRollSet(
|
||||
rollKey: string,
|
||||
properties: Omit<Partial<RollResultContract>, "rollKey">,
|
||||
nextRollKey: string | null
|
||||
): RollResultDiceRollSetAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_DICE_ROLL_SET,
|
||||
payload: {
|
||||
rollKey,
|
||||
properties,
|
||||
nextRollKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
* @param rollKey
|
||||
* @param properties
|
||||
*/
|
||||
export function rollResultDiceRollSetCommit(
|
||||
componentKey: string,
|
||||
groupKey: string,
|
||||
rollKey: string,
|
||||
properties: Omit<Partial<RollResultContract>, "rollKey">,
|
||||
nextRollKey: string | null
|
||||
): RollResultDiceRollSetCommitAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_DICE_ROLL_SET_COMMIT,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
rollKey,
|
||||
properties,
|
||||
nextRollKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param rollKey
|
||||
* @param loadingStatus
|
||||
*/
|
||||
export function rollResultDiceRollStatusSet(
|
||||
rollKey,
|
||||
loadingStatus
|
||||
): RollResultDiceRollStatusSetAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_DICE_ROLL_STATUS_SET,
|
||||
payload: {
|
||||
rollKey,
|
||||
loadingStatus,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
* @param rollResults
|
||||
*/
|
||||
export function rollResultComponentGroupPersist(
|
||||
componentKey: string,
|
||||
groupKey: string,
|
||||
rollResults: Array<RollResultContract>
|
||||
): RollResultComponentGroupPersistAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_COMPONENT_GROUP_PERSIST,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
rollResults,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
* @param rollKey
|
||||
* @param properties
|
||||
* @param nextRollKey
|
||||
*/
|
||||
export function rollResultComponentPersist(
|
||||
componentKey: string,
|
||||
groupKey: string,
|
||||
rollKey: string,
|
||||
properties: Partial<RollResultContract>,
|
||||
nextRollKey: string | null
|
||||
): RollResultComponentPersistAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_COMPONENT_PERSIST,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
rollKey,
|
||||
properties,
|
||||
nextRollKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groups
|
||||
*/
|
||||
export function rollResultComponentSimulatedGroupsSet(
|
||||
componentKey: string,
|
||||
groups: Array<RollGroupContract>
|
||||
): RollResultComponentSimulatedGroupsSetAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_SET,
|
||||
payload: {
|
||||
componentKey,
|
||||
groups,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
*/
|
||||
export function rollResultComponentSimulatedGroupsPersist(
|
||||
componentKey: string,
|
||||
groupKey?: string,
|
||||
rollKey?: string,
|
||||
properties?: Partial<RollResultContract>
|
||||
): RollResultComponentSimulatedGroupsPersistAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_COMPONENT_SIMULATED_GROUPS_PERSIST,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
rollKey,
|
||||
properties,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param componentKey
|
||||
* @param groupKey
|
||||
* @param rollKey
|
||||
* @param properties
|
||||
*/
|
||||
export function rollResultComponentSimulatedDiceRollSet(
|
||||
componentKey: string,
|
||||
groupKey: string,
|
||||
rollKey: string,
|
||||
properties: Omit<Partial<RollResultContract>, "rollKey">
|
||||
): RollResultComponentSimulatedDiceRollSetAction {
|
||||
return {
|
||||
type: types.ROLL_RESULT_COMPONENT_SIMULATED_DICE_ROLL_SET,
|
||||
payload: {
|
||||
componentKey,
|
||||
groupKey,
|
||||
rollKey,
|
||||
properties,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as actionTypes from "./actionTypes";
|
||||
import * as actions from "./actions";
|
||||
import * as typings from "./typings";
|
||||
|
||||
export const rollResultActionTypes = actionTypes;
|
||||
export const rollResultActions = actions;
|
||||
export const rollResultTypings = typings;
|
||||
@@ -0,0 +1,3 @@
|
||||
export const ADD_MESSAGE = "toastMessages.ADD_MESSAGE";
|
||||
export const REMOVE_MESSAGE = "toastMessages.REMOVE_MESSAGE";
|
||||
export const CLEAR_MESSAGES = "toastMessages.CLEAR_MESSAGES";
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ToastMessageMeta } from "../../stores/typings";
|
||||
import * as actionTypes from "./actionTypes";
|
||||
import {
|
||||
ToastErrorAction,
|
||||
ToastRemoveAction,
|
||||
ToastSuccessAction,
|
||||
} from "./typings";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param title
|
||||
* @param message
|
||||
* @param meta
|
||||
*/
|
||||
export function toastSuccess(
|
||||
title: string,
|
||||
message: string,
|
||||
meta?: ToastMessageMeta
|
||||
): ToastSuccessAction {
|
||||
return {
|
||||
type: actionTypes.ADD_MESSAGE,
|
||||
payload: {
|
||||
toast: {
|
||||
title,
|
||||
message,
|
||||
},
|
||||
meta: {
|
||||
level: "success",
|
||||
...meta,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param title
|
||||
* @param message
|
||||
* @param meta
|
||||
*/
|
||||
export function toastError(
|
||||
title: string,
|
||||
message: string,
|
||||
meta?: ToastMessageMeta
|
||||
): ToastErrorAction {
|
||||
return {
|
||||
type: actionTypes.ADD_MESSAGE,
|
||||
payload: {
|
||||
toast: {
|
||||
title,
|
||||
message,
|
||||
},
|
||||
meta: {
|
||||
level: "error",
|
||||
...meta,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
export function removeToast(id: number | string): ToastRemoveAction {
|
||||
return {
|
||||
type: actionTypes.REMOVE_MESSAGE,
|
||||
payload: {
|
||||
id,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as actionTypes from "./actionTypes";
|
||||
import * as actions from "./actions";
|
||||
import * as typings from "./typings";
|
||||
|
||||
export const toastMessageTypes = actionTypes;
|
||||
export const toastMessageActions = actions;
|
||||
export const toastMessageTypings = typings;
|
||||
@@ -0,0 +1,648 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
DamageTypeIcon,
|
||||
MarketplaceCta,
|
||||
AoeTypeIcon,
|
||||
ComponentConstants,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityLookup,
|
||||
AccessUtils,
|
||||
Action,
|
||||
ActionUtils,
|
||||
ActivationUtils,
|
||||
BaseInventoryContract,
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
DiceUtils,
|
||||
EntityUtils,
|
||||
EntityValueLookup,
|
||||
FormatUtils,
|
||||
HelperUtils,
|
||||
InfusionUtils,
|
||||
InventoryLookup,
|
||||
ItemUtils,
|
||||
LimitedUseUtils,
|
||||
ModelInfoContract,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
ValueUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { InfoItem } from "~/components/InfoItem";
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
|
||||
import EditorBox from "../EditorBox";
|
||||
import SlotManager from "../SlotManager";
|
||||
import SlotManagerLarge from "../SlotManagerLarge";
|
||||
import ValueEditor from "../ValueEditor";
|
||||
import { RemoveButton } from "../common/Button";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props {
|
||||
action: Action;
|
||||
ruleData: RuleData;
|
||||
onCustomDataUpdate?: (
|
||||
propertyKey: Constants.AdjustmentTypeEnum,
|
||||
value: any,
|
||||
source: any
|
||||
) => void;
|
||||
showCustomize?: boolean;
|
||||
entityValueLookup: EntityValueLookup;
|
||||
abilityLookup: AbilityLookup;
|
||||
inventoryLookup: InventoryLookup;
|
||||
isReadonly: boolean;
|
||||
largePoolMinAmount?: number;
|
||||
onLimitedUseSet?: (usedAmount: number) => void;
|
||||
onCustomizationsRemove?: () => void;
|
||||
proficiencyBonus: number;
|
||||
theme?: CharacterTheme;
|
||||
}
|
||||
|
||||
const infoItemProps = { role: "listItem", inline: true };
|
||||
|
||||
export const ActionDetail = ({
|
||||
showCustomize = true,
|
||||
largePoolMinAmount = 11,
|
||||
onCustomizationsRemove,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
action,
|
||||
onLimitedUseSet,
|
||||
proficiencyBonus,
|
||||
isReadonly,
|
||||
entityValueLookup,
|
||||
onCustomDataUpdate,
|
||||
theme,
|
||||
inventoryLookup,
|
||||
}: Props) => {
|
||||
const handleRemoveCustomizations = () => {
|
||||
if (onCustomizationsRemove) {
|
||||
onCustomizationsRemove();
|
||||
}
|
||||
};
|
||||
|
||||
const renderSmallAmountSlotPool = (): React.ReactNode => {
|
||||
let limitedUse = ActionUtils.getLimitedUse(action);
|
||||
if (!limitedUse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
||||
const maxUses = LimitedUseUtils.deriveMaxUses(
|
||||
limitedUse,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus
|
||||
);
|
||||
|
||||
return (
|
||||
<SlotManager
|
||||
used={numberUsed}
|
||||
available={maxUses}
|
||||
size={"small"}
|
||||
onSet={onLimitedUseSet}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLargeAmountSlotPool = (): React.ReactNode => {
|
||||
let limitedUse = ActionUtils.getLimitedUse(action);
|
||||
if (!limitedUse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
||||
const maxUses = LimitedUseUtils.deriveMaxUses(
|
||||
limitedUse,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus
|
||||
);
|
||||
|
||||
return (
|
||||
<SlotManagerLarge
|
||||
available={maxUses}
|
||||
used={numberUsed}
|
||||
onSet={onLimitedUseSet}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLimitedUses = (): React.ReactNode => {
|
||||
let limitedUse = ActionUtils.getLimitedUse(action);
|
||||
if (!limitedUse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let maxUses = LimitedUseUtils.deriveMaxUses(
|
||||
limitedUse,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus
|
||||
);
|
||||
if (!maxUses) {
|
||||
return null;
|
||||
}
|
||||
let numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
||||
let totalSlots: number = Math.max(maxUses, numberUsed);
|
||||
|
||||
return (
|
||||
<div className={styles.limitedUses}>
|
||||
<div className={styles.limitedUsesLabel}>Limited Use</div>
|
||||
{totalSlots >= largePoolMinAmount
|
||||
? renderLargeAmountSlotPool()
|
||||
: renderSmallAmountSlotPool()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCustomize = (): React.ReactNode => {
|
||||
const mappingId = ActionUtils.getMappingId(action);
|
||||
const mappingEntityTypeId = ActionUtils.getMappingEntityTypeId(action);
|
||||
|
||||
if (
|
||||
!showCustomize ||
|
||||
isReadonly ||
|
||||
mappingId === null ||
|
||||
mappingEntityTypeId === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let valueEditorComponents: Array<Constants.AdjustmentTypeEnum> = [
|
||||
Constants.AdjustmentTypeEnum.TO_HIT_OVERRIDE,
|
||||
Constants.AdjustmentTypeEnum.TO_HIT_BONUS,
|
||||
Constants.AdjustmentTypeEnum.FIXED_VALUE_BONUS,
|
||||
Constants.AdjustmentTypeEnum.DISPLAY_AS_ATTACK,
|
||||
Constants.AdjustmentTypeEnum.NAME_OVERRIDE,
|
||||
Constants.AdjustmentTypeEnum.NOTES,
|
||||
];
|
||||
|
||||
const isCustomized = ActionUtils.isCustomized(action);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
layoutType={"minimal"}
|
||||
header={`Customize${isCustomized ? "*" : ""}`}
|
||||
className={styles.customize}
|
||||
>
|
||||
<EditorBox>
|
||||
<ValueEditor
|
||||
dataLookup={ValueUtils.getEntityData(
|
||||
entityValueLookup,
|
||||
mappingId,
|
||||
mappingEntityTypeId
|
||||
)}
|
||||
onDataUpdate={onCustomDataUpdate}
|
||||
valueEditors={valueEditorComponents}
|
||||
ruleData={ruleData}
|
||||
labelOverrides={{
|
||||
[Constants.AdjustmentTypeEnum.NAME_OVERRIDE]: "Name",
|
||||
[Constants.AdjustmentTypeEnum.FIXED_VALUE_BONUS]: "Damage Bonus",
|
||||
}}
|
||||
defaultValues={{
|
||||
[Constants.AdjustmentTypeEnum.DISPLAY_AS_ATTACK]:
|
||||
ActionUtils.isDefaultDisplayAsAttack(action),
|
||||
}}
|
||||
layoutType={"compact"}
|
||||
/>
|
||||
<RemoveButton
|
||||
enableConfirm={true}
|
||||
size="medium"
|
||||
style="filled"
|
||||
disabled={!isCustomized}
|
||||
isInteractive={isCustomized}
|
||||
onClick={handleRemoveCustomizations}
|
||||
>
|
||||
{isCustomized ? "Remove" : "No"} Customizations
|
||||
</RemoveButton>
|
||||
</EditorBox>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
const getRangedInfoData = (): Array<React.ReactNode> => {
|
||||
let rangeInfo = ActionUtils.getRange(action);
|
||||
let attackRangeId = ActionUtils.getAttackRangeId(action);
|
||||
|
||||
let rangeAreas: Array<React.ReactNode> = [];
|
||||
if (
|
||||
rangeInfo !== null &&
|
||||
attackRangeId === Constants.AttackTypeRangeEnum.RANGED
|
||||
) {
|
||||
if (
|
||||
rangeInfo.origin &&
|
||||
rangeInfo.origin !== Constants.SpellRangeTypeEnum.RANGED
|
||||
) {
|
||||
let spellRangeType = RuleDataUtils.getSpellRangeType(
|
||||
rangeInfo.origin,
|
||||
ruleData
|
||||
);
|
||||
if (spellRangeType !== null) {
|
||||
rangeAreas.push(spellRangeType.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (rangeInfo.range) {
|
||||
rangeAreas.push(
|
||||
<React.Fragment>
|
||||
<NumberDisplay type="distanceInFt" number={rangeInfo.range} />
|
||||
{rangeInfo.longRange && <span>({rangeInfo.longRange})</span>}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (rangeInfo.aoeSize) {
|
||||
let aoeType: ModelInfoContract | null = null;
|
||||
if (rangeInfo.aoeType !== null) {
|
||||
aoeType = RuleDataUtils.getAoeType(rangeInfo.aoeType, ruleData);
|
||||
}
|
||||
rangeAreas.push(
|
||||
<React.Fragment>
|
||||
<NumberDisplay type="distanceInFt" number={rangeInfo.aoeSize} />
|
||||
{aoeType !== null && (
|
||||
<span className={styles.rangeShape}>
|
||||
<AoeTypeIcon
|
||||
className={styles.rangeIcon}
|
||||
type={
|
||||
FormatUtils.slugify(
|
||||
aoeType.name
|
||||
) as ComponentConstants.AoeTypePropType
|
||||
}
|
||||
themeMode={theme?.isDarkMode ? "gray" : "dark"}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return rangeAreas;
|
||||
//`
|
||||
};
|
||||
|
||||
const renderDamageProperties = (): React.ReactNode => {
|
||||
let damage = ActionUtils.getDamage(action);
|
||||
|
||||
let damageDisplay: React.ReactNode = null;
|
||||
if (damage.value !== null) {
|
||||
if (typeof damage.value === "number") {
|
||||
damageDisplay = damage.value;
|
||||
} else {
|
||||
damageDisplay = DiceUtils.renderDice(damage.value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{damageDisplay !== null && (
|
||||
<InfoItem label="Damage:" {...infoItemProps}>
|
||||
{damageDisplay}
|
||||
</InfoItem>
|
||||
)}
|
||||
{damage.type !== null && damage.type.name !== null && (
|
||||
<InfoItem label="Damage Type:" {...infoItemProps}>
|
||||
<DamageTypeIcon
|
||||
theme={theme}
|
||||
type={
|
||||
FormatUtils.slugify(
|
||||
damage.type.name
|
||||
) as ComponentConstants.DamageTypePropType
|
||||
}
|
||||
/>
|
||||
{damage.type.name}
|
||||
</InfoItem>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWeaponProperties = (): React.ReactNode => {
|
||||
let isProficient = ActionUtils.isProficient(action);
|
||||
let toHit = ActionUtils.getToHit(action);
|
||||
let attackRangeId = ActionUtils.getAttackRangeId(action);
|
||||
let statId = ActionUtils.getStatId(action);
|
||||
let requiresAttackRoll = ActionUtils.requiresAttackRoll(action);
|
||||
let requiresSavingThrow = ActionUtils.requiresSavingThrow(action);
|
||||
let activation = ActionUtils.getActivation(action);
|
||||
let notes = ActionUtils.getNotes(action);
|
||||
let attackSubtypeId = ActionUtils.getAttackSubtypeId(action);
|
||||
let saveStateId = ActionUtils.getSaveStatId(action);
|
||||
|
||||
let rangeAreas: Array<React.ReactNode> = [];
|
||||
if (attackRangeId === Constants.AttackTypeRangeEnum.RANGED) {
|
||||
rangeAreas = getRangedInfoData();
|
||||
} else {
|
||||
rangeAreas.push(
|
||||
<React.Fragment>
|
||||
<NumberDisplay
|
||||
type="distanceInFt"
|
||||
number={ActionUtils.getReach(action)}
|
||||
/>{" "}
|
||||
Reach
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.properties} role="list">
|
||||
{activation !== null && activation.activationType && (
|
||||
<InfoItem label="Action Type:" {...infoItemProps}>
|
||||
{ActivationUtils.renderActivation(activation, ruleData)}
|
||||
</InfoItem>
|
||||
)}
|
||||
{attackSubtypeId && (
|
||||
<InfoItem label="Attack Type:" {...infoItemProps}>
|
||||
{ActionUtils.getAttackSubtypeName(action)}
|
||||
</InfoItem>
|
||||
)}
|
||||
{requiresAttackRoll && (
|
||||
<InfoItem label="To Hit:" {...infoItemProps}>
|
||||
<NumberDisplay type="signed" number={toHit} />
|
||||
</InfoItem>
|
||||
)}
|
||||
{requiresSavingThrow && (
|
||||
<InfoItem label="Attack/Save:" {...infoItemProps}>
|
||||
{saveStateId !== null
|
||||
? RuleDataUtils.getStatNameById(saveStateId, ruleData)
|
||||
: ""}{" "}
|
||||
{ActionUtils.getAttackSaveValue(action)}
|
||||
</InfoItem>
|
||||
)}
|
||||
{renderDamageProperties()}
|
||||
{requiresAttackRoll && (
|
||||
<InfoItem label="Stat:" {...infoItemProps}>
|
||||
{statId === null
|
||||
? "--"
|
||||
: RuleDataUtils.getStatNameById(statId, ruleData)}
|
||||
</InfoItem>
|
||||
)}
|
||||
{rangeAreas.length > 0 && (
|
||||
<InfoItem label="Range/Area:" {...infoItemProps}>
|
||||
{rangeAreas.map((node, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{node}
|
||||
{idx + 1 < rangeAreas.length ? "/" : ""}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</InfoItem>
|
||||
)}
|
||||
{isProficient && (
|
||||
<InfoItem label="Proficient:" {...infoItemProps}>
|
||||
Yes
|
||||
</InfoItem>
|
||||
)}
|
||||
{notes && (
|
||||
<InfoItem label="Notes:" {...infoItemProps}>
|
||||
{notes}
|
||||
</InfoItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSpellProperties = (): React.ReactNode => {
|
||||
let isProficient = ActionUtils.isProficient(action);
|
||||
let toHit = ActionUtils.getToHit(action);
|
||||
let statId = ActionUtils.getStatId(action);
|
||||
let requiresAttackRoll = ActionUtils.requiresAttackRoll(action);
|
||||
let requiresSavingThrow = ActionUtils.requiresSavingThrow(action);
|
||||
let activation = ActionUtils.getActivation(action);
|
||||
let notes = ActionUtils.getNotes(action);
|
||||
let attackSubtypeId = ActionUtils.getAttackSubtypeId(action);
|
||||
let saveStateId = ActionUtils.getSaveStatId(action);
|
||||
|
||||
let rangeAreas = getRangedInfoData();
|
||||
return (
|
||||
<div className={styles.properties} role="list">
|
||||
{activation !== null && activation.activationType && (
|
||||
<InfoItem label="Casting Time:" {...infoItemProps}>
|
||||
{ActivationUtils.renderActivation(activation, ruleData)}
|
||||
</InfoItem>
|
||||
)}
|
||||
{attackSubtypeId && (
|
||||
<InfoItem label="Attack Type:">
|
||||
{ActionUtils.getAttackSubtypeName(action)}
|
||||
</InfoItem>
|
||||
)}
|
||||
{requiresAttackRoll && (
|
||||
<InfoItem label="To Hit:">
|
||||
<NumberDisplay type="signed" number={toHit} />
|
||||
</InfoItem>
|
||||
)}
|
||||
{requiresSavingThrow && (
|
||||
<InfoItem label="Attack/Save:">
|
||||
{saveStateId !== null
|
||||
? RuleDataUtils.getStatNameById(saveStateId, ruleData)
|
||||
: ""}{" "}
|
||||
{ActionUtils.getAttackSaveValue(action)}
|
||||
</InfoItem>
|
||||
)}
|
||||
{renderDamageProperties()}
|
||||
{requiresAttackRoll && (
|
||||
<InfoItem label="Stat:">
|
||||
{statId === null
|
||||
? "--"
|
||||
: RuleDataUtils.getStatNameById(statId, ruleData)}
|
||||
</InfoItem>
|
||||
)}
|
||||
{rangeAreas.length > 0 && (
|
||||
<InfoItem label="Range/Area:">
|
||||
{rangeAreas.map((node, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{node}
|
||||
{idx + 1 < rangeAreas.length ? "/" : ""}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</InfoItem>
|
||||
)}
|
||||
{isProficient && <InfoItem label="Proficient:">Yes</InfoItem>}
|
||||
{notes && <InfoItem label="Notes:">{notes}</InfoItem>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGeneralProperties = (): React.ReactNode => {
|
||||
let isProficient = ActionUtils.isProficient(action);
|
||||
let toHit = ActionUtils.getToHit(action);
|
||||
let statId = ActionUtils.getStatId(action);
|
||||
let requiresAttackRoll = ActionUtils.requiresAttackRoll(action);
|
||||
let requiresSavingThrow = ActionUtils.requiresSavingThrow(action);
|
||||
let activation = ActionUtils.getActivation(action);
|
||||
let notes = ActionUtils.getNotes(action);
|
||||
let attackRangeId = ActionUtils.getAttackRangeId(action);
|
||||
let saveStateId = ActionUtils.getSaveStatId(action);
|
||||
|
||||
let rangeAreas: Array<React.ReactNode> = [];
|
||||
if (attackRangeId === Constants.AttackTypeRangeEnum.RANGED) {
|
||||
rangeAreas = getRangedInfoData();
|
||||
} else {
|
||||
rangeAreas.push(
|
||||
<React.Fragment>
|
||||
<NumberDisplay
|
||||
type="distanceInFt"
|
||||
number={ActionUtils.getReach(action)}
|
||||
/>{" "}
|
||||
Reach
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.properties} role="list">
|
||||
{activation !== null && activation.activationType && (
|
||||
<InfoItem label={"Action Type:"} {...infoItemProps}>
|
||||
{ActivationUtils.renderActivation(activation, ruleData)}
|
||||
</InfoItem>
|
||||
)}
|
||||
{requiresAttackRoll && (
|
||||
<InfoItem label="To Hit:" {...infoItemProps}>
|
||||
<NumberDisplay type="signed" number={toHit} />
|
||||
</InfoItem>
|
||||
)}
|
||||
{requiresSavingThrow && (
|
||||
<InfoItem label="Attack/Save:" {...infoItemProps}>
|
||||
{saveStateId !== null
|
||||
? RuleDataUtils.getStatNameById(saveStateId, ruleData)
|
||||
: ""}{" "}
|
||||
{ActionUtils.getAttackSaveValue(action)}
|
||||
</InfoItem>
|
||||
)}
|
||||
{renderDamageProperties()}
|
||||
{requiresAttackRoll && (
|
||||
<InfoItem label="Stat:" {...infoItemProps}>
|
||||
{statId === null
|
||||
? "--"
|
||||
: RuleDataUtils.getStatNameById(statId, ruleData)}
|
||||
</InfoItem>
|
||||
)}
|
||||
{rangeAreas.length > 0 && (
|
||||
<InfoItem label="Range/Area:" {...infoItemProps}>
|
||||
{rangeAreas.map((node, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{node}
|
||||
{idx + 1 < rangeAreas.length ? "/" : ""}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</InfoItem>
|
||||
)}
|
||||
{isProficient && (
|
||||
<InfoItem label="Proficient:" {...infoItemProps}>
|
||||
Yes
|
||||
</InfoItem>
|
||||
)}
|
||||
{notes && (
|
||||
<InfoItem label="Notes:" {...infoItemProps}>
|
||||
{notes}
|
||||
</InfoItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderProperties = (): React.ReactNode => {
|
||||
const actionTypeId = ActionUtils.getActionTypeId(action);
|
||||
switch (actionTypeId) {
|
||||
case Constants.ActionTypeEnum.SPELL:
|
||||
return renderSpellProperties();
|
||||
case Constants.ActionTypeEnum.WEAPON:
|
||||
return renderWeaponProperties();
|
||||
case Constants.ActionTypeEnum.GENERAL:
|
||||
return renderGeneralProperties();
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderDescription = (): React.ReactNode => {
|
||||
let description = ActionUtils.getDescription(action);
|
||||
let dataOrigin = ActionUtils.getDataOrigin(action);
|
||||
let dataOriginType = ActionUtils.getDataOriginType(action);
|
||||
|
||||
if (!description) {
|
||||
description = EntityUtils.getPrimaryDescription(dataOrigin);
|
||||
}
|
||||
|
||||
if (dataOriginType === Constants.DataOriginTypeEnum.ITEM) {
|
||||
const itemContract = dataOrigin.primary as BaseInventoryContract;
|
||||
const itemMappingId = ItemUtils.getMappingId(itemContract);
|
||||
const item = HelperUtils.lookupDataOrFallback(
|
||||
inventoryLookup,
|
||||
itemMappingId
|
||||
);
|
||||
|
||||
if (item === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let infusion = ItemUtils.getInfusion(item);
|
||||
|
||||
if (
|
||||
infusion &&
|
||||
!AccessUtils.isAccessible(InfusionUtils.getAccessType(infusion))
|
||||
) {
|
||||
const sources = InfusionUtils.getSources(infusion);
|
||||
|
||||
let sourceNames: Array<string> = [];
|
||||
if (sources.length > 0) {
|
||||
sources.forEach((sourceMapping) => {
|
||||
let source = RuleDataUtils.getSourceDataInfo(
|
||||
sourceMapping.sourceId,
|
||||
ruleData
|
||||
);
|
||||
|
||||
if (source !== null && source.description !== null) {
|
||||
sourceNames.push(source.description);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sourceName: string | null =
|
||||
sourceNames.length > 0
|
||||
? FormatUtils.renderNonOxfordCommaList(sourceNames)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={styles.marketplaceCta}>
|
||||
<MarketplaceCta
|
||||
showImage={false}
|
||||
sourceName={sourceName}
|
||||
description={
|
||||
"To unlock this infusion, check out the Marketplace to view purchase options."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HtmlContent
|
||||
className={styles.description}
|
||||
html={description}
|
||||
withoutTooltips
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.details}>
|
||||
{renderLimitedUses()}
|
||||
{renderCustomize()}
|
||||
{renderProperties()}
|
||||
{renderDescription()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,403 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
CollapsibleHeading,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
CharClass,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
DataOriginRefData,
|
||||
EntityValueLookup,
|
||||
Spell,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import SpellManager from "../SpellManager";
|
||||
|
||||
interface Props {
|
||||
charClass: CharClass;
|
||||
activeSpells: Array<Spell>;
|
||||
isPrepareMaxed: boolean;
|
||||
isCantripsKnownMaxed: boolean;
|
||||
isSpellsKnownMaxed: boolean;
|
||||
hasMultipleSpellClasses: boolean;
|
||||
activeFeatureCantripCount: number;
|
||||
knownFeatureSpellCount: number;
|
||||
knownSpellCount: number;
|
||||
activeCantripsCount: number;
|
||||
preparedSpellCount: number;
|
||||
knownCantripsMax: number | null;
|
||||
knownSpellsMax: number | null;
|
||||
prepareMax: number | null;
|
||||
entityValueLookup: EntityValueLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
interface State {
|
||||
collapsedGroups: {
|
||||
activeSpells: boolean;
|
||||
spellbook: boolean;
|
||||
addSpells: boolean;
|
||||
};
|
||||
}
|
||||
export default function ClassSpellManager({
|
||||
charClass,
|
||||
activeSpells,
|
||||
isPrepareMaxed,
|
||||
isCantripsKnownMaxed,
|
||||
isSpellsKnownMaxed,
|
||||
activeFeatureCantripCount,
|
||||
knownFeatureSpellCount,
|
||||
knownSpellCount,
|
||||
activeCantripsCount,
|
||||
preparedSpellCount,
|
||||
knownCantripsMax,
|
||||
knownSpellsMax,
|
||||
prepareMax,
|
||||
entityValueLookup,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
hasMultipleSpellClasses = false,
|
||||
}: Props) {
|
||||
const isSpellbookSpellcaster = ClassUtils.isSpellbookSpellcaster(charClass);
|
||||
const isSpellbookEmpty = knownSpellCount === 0 && activeCantripsCount === 0;
|
||||
|
||||
const spellCastingLearningStyle = ClassUtils.getSpellCastingLearningStyle(charClass);
|
||||
const buttonAddText = Constants.SpellCastingLearningStyleAddText[spellCastingLearningStyle];
|
||||
const removeButtonText = Constants.SpellCastingLearningStyleRemoveText[spellCastingLearningStyle];
|
||||
const [state, setState] = useState({
|
||||
collapsedGroups: {
|
||||
activeSpells:
|
||||
hasMultipleSpellClasses || (isSpellbookSpellcaster && isSpellbookEmpty),
|
||||
spellbook:
|
||||
hasMultipleSpellClasses ||
|
||||
!isSpellbookSpellcaster ||
|
||||
(isSpellbookSpellcaster && !isSpellbookEmpty),
|
||||
addSpells: true,
|
||||
},
|
||||
});
|
||||
|
||||
function handleShowSpellGroup(key: keyof State["collapsedGroups"]): void {
|
||||
let resetState = {
|
||||
activeSpells: true,
|
||||
addSpells: true,
|
||||
spellbook: true,
|
||||
};
|
||||
setState({
|
||||
...state,
|
||||
collapsedGroups: {
|
||||
...resetState,
|
||||
[key]: !state.collapsedGroups[key],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function doesAvailableSpellsHaveNotifications(): boolean {
|
||||
if (knownCantripsMax !== null && activeCantripsCount > knownCantripsMax) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (prepareMax && preparedSpellCount > prepareMax) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (knownSpellsMax && knownSpellCount > knownSpellsMax) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderSpellListSpellStatus(): React.ReactNode {
|
||||
let featureSpellCount: number =
|
||||
activeFeatureCantripCount + knownFeatureSpellCount;
|
||||
let calloutNode: React.ReactNode;
|
||||
if (featureSpellCount > 0) {
|
||||
calloutNode = "*";
|
||||
}
|
||||
|
||||
let cantripsNode: React.ReactNode = "";
|
||||
let cantripsClsNames: Array<string> = [
|
||||
"ct-class-spell-manager__info-entry",
|
||||
"ct-class-spell-manager__info-entry--cantrips",
|
||||
];
|
||||
if (knownCantripsMax !== null) {
|
||||
if (activeCantripsCount === knownCantripsMax) {
|
||||
cantripsNode = `Cantrips: ${knownCantripsMax}`;
|
||||
} else {
|
||||
cantripsNode = `Cantrips: ${activeCantripsCount}/${knownCantripsMax}`;
|
||||
|
||||
if (activeCantripsCount > knownCantripsMax) {
|
||||
cantripsClsNames.push("ct-class-spell-manager__info-entry--exceeded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spellDisplayListType = spellCastingLearningStyle === Constants.SpellCastingLearningStyle.Prepared
|
||||
? "Prepared"
|
||||
: "Known";
|
||||
let spellsNode: React.ReactNode;
|
||||
let spellsExtraNode: React.ReactNode;
|
||||
let spellsClsNames: Array<string> = [
|
||||
"ct-class-spell-manager__info-entry",
|
||||
"ct-class-spell-manager__info-entry--spells",
|
||||
];
|
||||
if (prepareMax) {
|
||||
if (preparedSpellCount === prepareMax) {
|
||||
spellsNode = `Prepared Spells: ${preparedSpellCount}`;
|
||||
} else {
|
||||
spellsNode = `Prepared Spells: ${preparedSpellCount}/${prepareMax}`;
|
||||
|
||||
if (preparedSpellCount > prepareMax) {
|
||||
spellsClsNames.push("ct-class-spell-manager__info-entry--exceeded");
|
||||
}
|
||||
}
|
||||
|
||||
spellsExtraNode = (
|
||||
<span className="ct-class-spell-manager__info-entry-extra">
|
||||
({knownSpellCount} Known)
|
||||
</span>
|
||||
);
|
||||
} else if (knownSpellsMax) {
|
||||
if (knownSpellCount === knownSpellsMax) {
|
||||
spellsNode = `${spellDisplayListType} Spells: ${knownSpellCount}`;
|
||||
} else {
|
||||
spellsNode = `${spellDisplayListType} Spells: ${knownSpellCount}/${knownSpellsMax}`;
|
||||
|
||||
if (knownSpellCount > knownSpellsMax) {
|
||||
spellsClsNames.push("ct-class-spell-manager__info-entry--exceeded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-class-spell-manager__info">
|
||||
<div className={cantripsClsNames.join(" ")}>
|
||||
{cantripsNode}
|
||||
{calloutNode}
|
||||
</div>
|
||||
<div className={spellsClsNames.join(" ")}>
|
||||
{spellsNode}
|
||||
{spellsExtraNode}
|
||||
{calloutNode}
|
||||
</div>
|
||||
{featureSpellCount > 0 && (
|
||||
<div className="ct-class-spell-manager__info-features">
|
||||
*{featureSpellCount} spell
|
||||
{featureSpellCount !== 1 ? "s" : ""} included from class features.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderEmptyActiveSpells(): React.ReactNode {
|
||||
return (
|
||||
<div className="ct-class-spell-manager__empty">
|
||||
{ClassUtils.isSpellbookSpellcaster(charClass) && (
|
||||
<React.Fragment>
|
||||
You currently have no prepared spells.
|
||||
</React.Fragment>
|
||||
)}
|
||||
{ClassUtils.isPreparedSpellcaster(charClass) && (
|
||||
<React.Fragment>
|
||||
You currently have no prepared spells. Learn and prepare spells from
|
||||
your list of available spells below.
|
||||
</React.Fragment>
|
||||
)}
|
||||
{ClassUtils.isKnownSpellcaster(charClass) && (
|
||||
<React.Fragment>
|
||||
You currently have no known spells. Learn spells from your list of
|
||||
available spells below.
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderActiveSpells(): React.ReactNode {
|
||||
return (
|
||||
<div className="ct-class-spell-manager__active">
|
||||
<SpellManager
|
||||
charClassId={ClassUtils.getActiveId(charClass)}
|
||||
characterClassId={ClassUtils.getMappingId(charClass)}
|
||||
theme={theme}
|
||||
isPrepareMaxed={isPrepareMaxed}
|
||||
isCantripsKnownMaxed={isCantripsKnownMaxed}
|
||||
isSpellsKnownMaxed={isSpellsKnownMaxed}
|
||||
hasActiveSpells
|
||||
enableSpellRemove={!ClassUtils.isSpellbookSpellcaster(charClass)}
|
||||
enablePrepare={false}
|
||||
enableUnprepare={true}
|
||||
showFilters={false}
|
||||
addButtonText={buttonAddText}
|
||||
buttonActiveStyle="outline"
|
||||
buttonUnprepareText={removeButtonText}
|
||||
entityValueLookup={entityValueLookup}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
showCustomize={false}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderActiveSpellsGroup(): React.ReactNode {
|
||||
const { collapsedGroups } = state;
|
||||
|
||||
let heading: React.ReactNode = "Prepared Spells";
|
||||
if (ClassUtils.isKnownSpellcaster(charClass) && spellCastingLearningStyle !== Constants.SpellCastingLearningStyle.Prepared) {
|
||||
heading = "Known Spells";
|
||||
}
|
||||
|
||||
let headingNode: React.ReactNode = (
|
||||
<CollapsibleHeading>
|
||||
{heading}
|
||||
<span className="ct-class-spell-manager__heading-extra">
|
||||
({activeSpells.length})
|
||||
</span>
|
||||
</CollapsibleHeading>
|
||||
);
|
||||
|
||||
let headerNode: React.ReactNode = (
|
||||
<CollapsibleHeaderContent heading={headingNode} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
collapsed={collapsedGroups.activeSpells}
|
||||
header={headerNode}
|
||||
onChangeHandler={handleShowSpellGroup.bind(this, "activeSpells")}
|
||||
className="ct-class-spell-manager__group"
|
||||
>
|
||||
{activeSpells.length > 0
|
||||
? renderActiveSpells()
|
||||
: renderEmptyActiveSpells()}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSpellbook(): React.ReactNode {
|
||||
const { collapsedGroups } = state;
|
||||
|
||||
if (!ClassUtils.isSpellbookSpellcaster(charClass)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let headerNode: React.ReactNode = (
|
||||
<CollapsibleHeaderContent heading="Spellbook" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
collapsed={collapsedGroups.spellbook}
|
||||
header={headerNode}
|
||||
onChangeHandler={handleShowSpellGroup.bind(this, "spellbook")}
|
||||
className="ct-class-spell-manager__group"
|
||||
>
|
||||
{isSpellbookEmpty && (
|
||||
<div className="ct-class-spell-manager__empty">
|
||||
You currently have no known spells. Learn spells from your list of
|
||||
available spells below.
|
||||
</div>
|
||||
)}
|
||||
{!isSpellbookEmpty && (
|
||||
<React.Fragment>
|
||||
{renderSpellListSpellStatus()}
|
||||
<SpellManager
|
||||
charClassId={ClassUtils.getActiveId(charClass)}
|
||||
characterClassId={ClassUtils.getMappingId(charClass)}
|
||||
theme={theme}
|
||||
isPrepareMaxed={isPrepareMaxed}
|
||||
isCantripsKnownMaxed={isCantripsKnownMaxed}
|
||||
isSpellsKnownMaxed={isSpellsKnownMaxed}
|
||||
enableAdd={false}
|
||||
enableSpellRemove={false}
|
||||
addButtonText={buttonAddText}
|
||||
buttonUnprepareText={removeButtonText}
|
||||
entityValueLookup={entityValueLookup}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
showCustomize={false}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAddSpells(): React.ReactNode {
|
||||
const { collapsedGroups } = state;
|
||||
|
||||
let heading: React.ReactNode = "Add Spells";
|
||||
if (ClassUtils.isPreparedSpellcaster(charClass)) {
|
||||
heading = "Known Spells";
|
||||
}
|
||||
|
||||
let headingNode: React.ReactNode = (
|
||||
<CollapsibleHeading>
|
||||
{heading}
|
||||
{doesAvailableSpellsHaveNotifications() && (
|
||||
<div className="ct-class-spell-manager__notification">!</div>
|
||||
)}
|
||||
</CollapsibleHeading>
|
||||
);
|
||||
|
||||
let headerNode: React.ReactNode = (
|
||||
<CollapsibleHeaderContent heading={headingNode} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
collapsed={collapsedGroups.addSpells}
|
||||
header={headerNode}
|
||||
onChangeHandler={handleShowSpellGroup.bind(this, "addSpells")}
|
||||
className="ct-class-spell-manager__group"
|
||||
>
|
||||
{renderSpellListSpellStatus()}
|
||||
<SpellManager
|
||||
charClassId={ClassUtils.getActiveId(charClass)}
|
||||
characterClassId={ClassUtils.getMappingId(charClass)}
|
||||
theme={theme}
|
||||
isPrepareMaxed={isPrepareMaxed}
|
||||
isCantripsKnownMaxed={isCantripsKnownMaxed}
|
||||
isSpellsKnownMaxed={isSpellsKnownMaxed}
|
||||
shouldFetch
|
||||
enablePrepare={!ClassUtils.isSpellbookSpellcaster(charClass)}
|
||||
enableUnprepare={!ClassUtils.isSpellbookSpellcaster(charClass)}
|
||||
buttonUnprepareText={removeButtonText}
|
||||
addButtonText={buttonAddText}
|
||||
entityValueLookup={entityValueLookup}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
showExpandedType={true}
|
||||
showCustomize={false}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="ct-class-spell-manager">
|
||||
<div className="ct-class-spell-manager__header">
|
||||
<div className="ct-class-spell-manager__portrait">
|
||||
<img
|
||||
className="ct-class-spell-manager__portrait-img"
|
||||
src={ClassUtils.getPortraitUrl(charClass)}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-class-spell-manager__heading">
|
||||
{ClassUtils.getName(charClass)}
|
||||
</div>
|
||||
</div>
|
||||
{renderAddSpells()}
|
||||
{renderActiveSpellsGroup()}
|
||||
{renderSpellbook()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ClassSpellManager from "./ClassSpellManager";
|
||||
|
||||
export default ClassSpellManager;
|
||||
export { ClassSpellManager };
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ConditionLevelEffectLookup } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import SlotManager from "../SlotManager";
|
||||
import { ThemeButton } from "../common/Button";
|
||||
|
||||
interface Props {
|
||||
conditionName: string;
|
||||
levels: Array<number>;
|
||||
levelEffectLookup: ConditionLevelEffectLookup | null;
|
||||
levelOverrides: Record<number, string>;
|
||||
activeLevel: number | null;
|
||||
isInteractive: boolean;
|
||||
onLevelChange: (value: number | null) => void;
|
||||
}
|
||||
export default class ConditionLevelsTable extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
activeLevel: null,
|
||||
isInteractive: false,
|
||||
onLevelChange: null,
|
||||
levelOverrides: {},
|
||||
levelEffectLookup: null,
|
||||
};
|
||||
|
||||
handleLevelChange = (level: number | null, used: number): void => {
|
||||
const { onLevelChange, activeLevel } = this.props;
|
||||
|
||||
let value: number | null = level;
|
||||
if (level !== null && activeLevel === level) {
|
||||
value = level > 1 ? level - 1 : null;
|
||||
}
|
||||
|
||||
if (onLevelChange) {
|
||||
onLevelChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
renderEffect = (level: number): string => {
|
||||
const { levelEffectLookup, levelOverrides } = this.props;
|
||||
|
||||
if (levelOverrides.hasOwnProperty(level)) {
|
||||
return levelOverrides[level];
|
||||
}
|
||||
|
||||
let effect: string = "";
|
||||
if (levelEffectLookup !== null && levelEffectLookup.hasOwnProperty(level)) {
|
||||
effect = levelEffectLookup[level];
|
||||
}
|
||||
|
||||
return effect;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { conditionName, levels, activeLevel, isInteractive } = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-condition-levels-table">
|
||||
<table className="ct-condition-levels-table__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Applied</th>
|
||||
<th>Level</th>
|
||||
<th>Effect</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{levels.map((level, idx) => {
|
||||
let isActive: boolean = level === activeLevel;
|
||||
let isImplied: boolean =
|
||||
activeLevel !== null && level < activeLevel;
|
||||
|
||||
let classNames: Array<string> = [
|
||||
"ct-condition-levels-table__table-slot",
|
||||
];
|
||||
if (isActive) {
|
||||
classNames.push(
|
||||
"ct-condition-levels-table__table-slot--active"
|
||||
);
|
||||
}
|
||||
if (isImplied) {
|
||||
classNames.push(
|
||||
"ct-condition-levels-table__table-slot--implied"
|
||||
);
|
||||
}
|
||||
if (isInteractive) {
|
||||
classNames.push(
|
||||
"ct-condition-levels-table__table-slot--interactive"
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={`${level}-${idx}`}>
|
||||
<td className={classNames.join(" ")}>
|
||||
<SlotManager
|
||||
size="small"
|
||||
available={1}
|
||||
onSet={this.handleLevelChange.bind(this, level)}
|
||||
isInteractive={isInteractive}
|
||||
used={isActive || isImplied ? 1 : 0}
|
||||
/>
|
||||
</td>
|
||||
<td>{level}</td>
|
||||
<td className="left-align">{this.renderEffect(level)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{isInteractive && activeLevel !== null && (
|
||||
<div className="ct-condition-levels-table__actions">
|
||||
<div className="ct-condition-levels-table__actions-action">
|
||||
<ThemeButton
|
||||
size="small"
|
||||
style="outline"
|
||||
onClick={this.handleLevelChange.bind(this, null)}
|
||||
>
|
||||
Remove All {conditionName}
|
||||
</ThemeButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ConditionLevelsTable from "./ConditionLevelsTable";
|
||||
|
||||
export default ConditionLevelsTable;
|
||||
export { ConditionLevelsTable };
|
||||
@@ -0,0 +1,244 @@
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
CollapsibleHeading,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CampaignUtils,
|
||||
CharacterTheme,
|
||||
Container,
|
||||
ContainerUtils,
|
||||
InventoryManager,
|
||||
CharacterCurrencyContract,
|
||||
Item,
|
||||
ItemUtils,
|
||||
PartyInfo,
|
||||
RuleData,
|
||||
CoinManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
|
||||
import CurrencyCollapsible from "../../../CharacterSheet/components/CurrencyCollapsible";
|
||||
import { CurrencyErrorTypeEnum } from "../../containers/panes/CurrencyPane/CurrencyPaneConstants";
|
||||
import { CoinManagerContext } from "../../managers/CoinManagerContext";
|
||||
import { InventoryManagerContext } from "../../managers/InventoryManagerContext";
|
||||
import { CustomItemCreator } from "../CustomItemCreator";
|
||||
import EquipmentShop from "../EquipmentShop";
|
||||
import ItemDetail from "../ItemDetail";
|
||||
import { ItemSlotManager } from "../ItemSlotManager";
|
||||
import { ThemeButtonWithMenu } from "../common/Button";
|
||||
|
||||
interface Props {
|
||||
currentContainer: Container;
|
||||
theme: CharacterTheme;
|
||||
onItemMove?: (item: Item, containerDefinitionKey: string) => void;
|
||||
onItemEquip?: (item: Item, uses: number) => void;
|
||||
ruleData: RuleData;
|
||||
proficiencyBonus: number;
|
||||
inventory: Array<Item>;
|
||||
containers: Array<Container>;
|
||||
shopOpenInitially?: boolean;
|
||||
isReadonly: boolean;
|
||||
partyInfo: PartyInfo | null;
|
||||
inventoryManager: InventoryManager;
|
||||
coinManager: CoinManager;
|
||||
handleCurrencyChangeError: (
|
||||
currencyName: string,
|
||||
errorType: CurrencyErrorTypeEnum
|
||||
) => void;
|
||||
handleCurrencyAdjust: (
|
||||
coin: Partial<CharacterCurrencyContract>,
|
||||
multiplier: 1 | -1,
|
||||
containerDefinitionKey: string
|
||||
) => void;
|
||||
handleAmountSet: (
|
||||
containerDefinitionKey: string,
|
||||
key: keyof CharacterCurrencyContract,
|
||||
amount: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const ContainerActionsComponent: React.FC<Props> = ({
|
||||
currentContainer,
|
||||
theme,
|
||||
ruleData,
|
||||
onItemMove,
|
||||
onItemEquip,
|
||||
proficiencyBonus,
|
||||
inventory,
|
||||
containers,
|
||||
shopOpenInitially = false,
|
||||
isReadonly,
|
||||
partyInfo,
|
||||
inventoryManager,
|
||||
coinManager,
|
||||
handleCurrencyAdjust,
|
||||
handleAmountSet,
|
||||
handleCurrencyChangeError,
|
||||
}) => {
|
||||
let showEquipmentShop = true;
|
||||
if (
|
||||
ContainerUtils.isShared(currentContainer) &&
|
||||
partyInfo &&
|
||||
CampaignUtils.isSharingStateInactive(
|
||||
CampaignUtils.getSharingState(partyInfo)
|
||||
)
|
||||
) {
|
||||
showEquipmentShop = false;
|
||||
}
|
||||
|
||||
let headerNode: React.ReactNode = (
|
||||
<CollapsibleHeaderContent
|
||||
heading={
|
||||
<CollapsibleHeading>{`Contents (${inventory.length})`}</CollapsibleHeading>
|
||||
}
|
||||
callout={
|
||||
<div className="ct-equipment-manage-pane__callout">
|
||||
<NumberDisplay
|
||||
type="weightInLb"
|
||||
number={ContainerUtils.getWeightInfo(currentContainer).total}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ct-container-manager">
|
||||
{showEquipmentShop && (
|
||||
<Collapsible
|
||||
header="Add Items"
|
||||
initiallyCollapsed={!shopOpenInitially}
|
||||
overrideCollapsed={!shopOpenInitially}
|
||||
>
|
||||
<EquipmentShop
|
||||
limitAddToCurrentContainer={currentContainer}
|
||||
partyInfo={partyInfo}
|
||||
containers={containers}
|
||||
theme={theme}
|
||||
ruleData={ruleData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
<CustomItemCreator containers={[currentContainer]} />
|
||||
</Collapsible>
|
||||
)}
|
||||
{coinManager.canUseCointainers() && (
|
||||
<CurrencyCollapsible
|
||||
heading={`${ContainerUtils.getName(currentContainer)} Coin`}
|
||||
initiallyCollapsed={true}
|
||||
isReadonly={isReadonly}
|
||||
container={currentContainer}
|
||||
handleCurrencyChangeError={handleCurrencyChangeError}
|
||||
handleCurrencyAdjust={handleCurrencyAdjust}
|
||||
handleAmountSet={handleAmountSet}
|
||||
/>
|
||||
)}
|
||||
<Collapsible
|
||||
header={headerNode}
|
||||
initiallyCollapsed={shopOpenInitially}
|
||||
overrideCollapsed={shopOpenInitially}
|
||||
>
|
||||
<div className="ct-container-manager__inventory">
|
||||
{inventory.map((item, idx) => {
|
||||
const canEquip = inventoryManager.canEquipUnequipItem(item);
|
||||
const key = ItemUtils.getUniqueKey(item);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={`${key}-${idx}`}
|
||||
className="ct-container-manager__item"
|
||||
layoutType={"minimal"}
|
||||
header={
|
||||
<CollapsibleHeaderContent
|
||||
heading={
|
||||
<div className="ct-container-manager__item-header">
|
||||
<div className="ct-container-manager__item-header-action">
|
||||
<ItemSlotManager
|
||||
isUsed={!!ItemUtils.isEquipped(item)}
|
||||
isReadonly={isReadonly}
|
||||
canUse={canEquip}
|
||||
onSet={(uses) => {
|
||||
if (onItemEquip) {
|
||||
onItemEquip(item, uses);
|
||||
}
|
||||
}}
|
||||
theme={theme}
|
||||
useTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-container-manager__item-header-name">
|
||||
<ItemName item={item} showLegacy={true} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
callout={
|
||||
!isReadonly &&
|
||||
partyInfo &&
|
||||
inventoryManager.canMoveItem(item) ? (
|
||||
<ThemeButtonWithMenu
|
||||
showSingleOption={true}
|
||||
containerEl={
|
||||
document.querySelector(
|
||||
".ct-sidebar__portal"
|
||||
) as HTMLElement
|
||||
}
|
||||
groupedOptions={ContainerUtils.getGroupedOptions(
|
||||
ItemUtils.getContainerDefinitionKey(item),
|
||||
containers,
|
||||
"Move To:",
|
||||
CampaignUtils.getSharingState(partyInfo)
|
||||
)}
|
||||
buttonStyle="outline"
|
||||
onSelect={(definitionKey) => {
|
||||
if (onItemMove) {
|
||||
onItemMove(item, definitionKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Move
|
||||
</ThemeButtonWithMenu>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ItemDetail
|
||||
theme={theme}
|
||||
item={item}
|
||||
ruleData={ruleData}
|
||||
showCustomize={false}
|
||||
showImage={false}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
{ContainerUtils.getItemMappingIds(currentContainer).length === 0 && (
|
||||
<div className="ct-container-manager__empty">
|
||||
There are no items in your{" "}
|
||||
{ContainerUtils.getName(currentContainer)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ContainerActions = (props) => {
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
const { coinManager } = useContext(CoinManagerContext);
|
||||
return (
|
||||
<ContainerActionsComponent
|
||||
inventoryManager={inventoryManager}
|
||||
coinManager={coinManager}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContainerActions;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ContainerActions } from "./ContainerActions";
|
||||
|
||||
export default ContainerActions;
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from "react";
|
||||
|
||||
import { LightLinkOutSvg } from "@dndbeyond/character-components/es";
|
||||
|
||||
export enum CtaBannerOptions {
|
||||
NEW = "New",
|
||||
BETA = "Beta",
|
||||
ALPHA = "Alpha",
|
||||
}
|
||||
|
||||
interface Props {
|
||||
preferenceEnabled: boolean;
|
||||
className: string;
|
||||
onPreferenceClick: () => void;
|
||||
preferenceTitle: string;
|
||||
linkHref?: string;
|
||||
linkTitle?: string;
|
||||
icon?: React.ReactNode;
|
||||
borderColor?: string;
|
||||
switchColor?: string;
|
||||
backgroundImageUrl?: string;
|
||||
bannerTitle?: CtaBannerOptions;
|
||||
}
|
||||
export default class CtaPreferenceManager extends React.PureComponent<
|
||||
Props,
|
||||
{}
|
||||
> {
|
||||
static defaultProps = {
|
||||
className: "",
|
||||
};
|
||||
|
||||
handlePreferenceToggle = (evt: React.MouseEvent): void => {
|
||||
const { onPreferenceClick } = this.props;
|
||||
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
evt.stopPropagation();
|
||||
|
||||
onPreferenceClick();
|
||||
};
|
||||
|
||||
handlePreferenceSwitch = (evt: React.MouseEvent): void => {
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
evt.stopPropagation();
|
||||
//Needed to prevent the label from triggering the input onClick handler
|
||||
//https://stackoverflow.com/questions/24501497/why-the-onclick-element-will-trigger-twice-for-label-element
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
preferenceEnabled,
|
||||
className,
|
||||
linkHref,
|
||||
linkTitle,
|
||||
icon,
|
||||
borderColor,
|
||||
switchColor,
|
||||
backgroundImageUrl,
|
||||
bannerTitle,
|
||||
preferenceTitle,
|
||||
} = this.props;
|
||||
|
||||
const classNames: Array<string> = [className, "ct-cta-preference-manager"];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames.join(" ")}
|
||||
style={borderColor ? { borderColor } : undefined}
|
||||
>
|
||||
<div
|
||||
className="ct-cta-preference-manager__primary"
|
||||
onClick={this.handlePreferenceToggle}
|
||||
style={
|
||||
backgroundImageUrl
|
||||
? { backgroundImage: `url(${backgroundImageUrl})` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="ct-cta-preference-manager__primary-info">
|
||||
{bannerTitle && (
|
||||
<div className="ct-cta-preference-manager__primary-info-banner">
|
||||
{bannerTitle}
|
||||
</div>
|
||||
)}
|
||||
<div className="ct-cta-preference-manager__primary-info-title">
|
||||
{icon && (
|
||||
<div className="ct-cta-preference-manager__primary-info-icon">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="ct-cta-preference-manager__primary-info-label">
|
||||
{preferenceTitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="ct-cta-preference-manager__switch">
|
||||
<input
|
||||
className="ct-cta-preference-manager__switch-input"
|
||||
type="checkbox"
|
||||
checked={preferenceEnabled}
|
||||
onClick={this.handlePreferenceSwitch}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<span
|
||||
style={
|
||||
preferenceEnabled && switchColor
|
||||
? { backgroundColor: switchColor }
|
||||
: undefined
|
||||
}
|
||||
className="ct-cta-preference-manager__switch-slider"
|
||||
>
|
||||
{" "}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{linkHref && (
|
||||
<a
|
||||
className="ct-cta-preference-manager__link-out"
|
||||
href={linkHref}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="ct-cta-preference-manager__link-out-label">
|
||||
{linkTitle ? linkTitle : "Feedback Forum"}
|
||||
</span>
|
||||
<LightLinkOutSvg />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import CtaPreferenceManager, { CtaBannerOptions } from "./CtaPreferenceManager";
|
||||
|
||||
export default CtaPreferenceManager;
|
||||
|
||||
export { CtaPreferenceManager, CtaBannerOptions };
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useCallback, useContext, useState } from "react";
|
||||
|
||||
import { Collapsible } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
Container,
|
||||
ContainerUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { InventoryManagerContext } from "../../managers/InventoryManagerContext";
|
||||
import { AppNotificationUtils } from "../../utils";
|
||||
import CustomizeDataEditor from "../CustomizeDataEditor";
|
||||
import EditorBox from "../EditorBox";
|
||||
import SimpleQuantity from "../SimpleQuantity";
|
||||
import { ThemeButtonWithMenu } from "../common/Button";
|
||||
|
||||
interface Props {
|
||||
containers: Array<Container>;
|
||||
}
|
||||
|
||||
export const CustomItemCreator: React.FC<Props> = ({ containers }) => {
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
|
||||
const getInitialCustomItemState = () => ({
|
||||
cost: null,
|
||||
description: null,
|
||||
name: inventoryManager.getDefaultCustomItemName(),
|
||||
notes: null,
|
||||
weight: null,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
const [customItem, setCustomItemProps] = useState(
|
||||
getInitialCustomItemState()
|
||||
);
|
||||
|
||||
const handleOnSelectSuccess = useCallback(() => {
|
||||
AppNotificationUtils.dispatchSuccess(
|
||||
"Custom Item Added",
|
||||
`Added ${customItem.name}`
|
||||
);
|
||||
setCustomItemProps(getInitialCustomItemState());
|
||||
}, [getInitialCustomItemState, customItem]);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className="ct-equipment-manage-pane__custom"
|
||||
layoutType="minimal"
|
||||
header={"Add Custom Item"}
|
||||
>
|
||||
<EditorBox>
|
||||
<CustomizeDataEditor
|
||||
data={customItem}
|
||||
enableName={true}
|
||||
enableNotes={true}
|
||||
enableDescription={true}
|
||||
enableCost={true}
|
||||
enableWeight={true}
|
||||
maxNameLength={128}
|
||||
onDataUpdate={(data) => {
|
||||
setCustomItemProps({
|
||||
...customItem,
|
||||
...data,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="ct-custom-item-creator__actions">
|
||||
<div className="ct-custom-item-creator__action ct-custom-item-creator__action--amount">
|
||||
<SimpleQuantity
|
||||
quantity={customItem.quantity ?? 1}
|
||||
onUpdate={(quantity) => {
|
||||
setCustomItemProps({
|
||||
...customItem,
|
||||
quantity,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-custom-item-creator__action">
|
||||
<ThemeButtonWithMenu
|
||||
onSelect={(containerDefinitionKey) =>
|
||||
inventoryManager.handleCustomAdd(
|
||||
{ customItem, containerDefinitionKey },
|
||||
handleOnSelectSuccess
|
||||
)
|
||||
}
|
||||
groupedOptions={ContainerUtils.getGroupedOptions(
|
||||
null,
|
||||
containers,
|
||||
"Add To:",
|
||||
inventoryManager.getSharingState()
|
||||
)}
|
||||
>
|
||||
Add{" "}
|
||||
{customItem.quantity === 1
|
||||
? " Item"
|
||||
: ` ${customItem.quantity} Items`}
|
||||
</ThemeButtonWithMenu>
|
||||
</div>
|
||||
</div>
|
||||
</EditorBox>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomItemCreator;
|
||||
@@ -0,0 +1,977 @@
|
||||
/**
|
||||
* NOTE: This file is meant to be removed over time. This is an older editor that is still used,
|
||||
* but will be phased out eventually.
|
||||
*/
|
||||
import React from "react";
|
||||
|
||||
import { Checkbox, Select } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
HelperUtils,
|
||||
HtmlSelectOption,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import CustomizeDataEditorProperty from "./CustomizeDataEditorProperty";
|
||||
import CustomizeDataEditorPropertyLabel from "./CustomizeDataEditorPropertyLabel";
|
||||
import CustomizeDataEditorPropertyValue from "./CustomizeDataEditorPropertyValue";
|
||||
|
||||
const CUSTOMIZE_KEY = {
|
||||
ABILITY_MODIFIER_STAT: "abilityModifierStatId",
|
||||
ATTACK_SUBTYPE: "attackSubtype",
|
||||
ACTIVATION_TYPE: "activationType",
|
||||
ACTIVATION_TIME: "activationTime",
|
||||
AOE_SIZE: "aoeSize",
|
||||
AOE_TYPE: "aoeType",
|
||||
COST: "cost",
|
||||
DAMAGE_BONUS: "damageBonus",
|
||||
DAMAGE_TYPE: "damageTypeId",
|
||||
DESCRIPTION: "description",
|
||||
DICE_COUNT: "diceCount",
|
||||
DICE_TYPE: "diceType",
|
||||
DISPLAY_AS_ATTACK: "displayAsAttack",
|
||||
FIXED_VALUE: "fixedValue",
|
||||
IS_ADAMANTINE: "isAdamantine",
|
||||
IS_OFFHAND: "isOffhand",
|
||||
IS_PROFICIENT: "isProficient",
|
||||
IS_SILVER: "isSilvered",
|
||||
IS_MARTIAL_ARTS: "isMartialArts",
|
||||
LONG_RANGE: "longRange",
|
||||
MAGIC_BONUS: "magicBonus",
|
||||
MISC_BONUS: "miscBonus",
|
||||
NAME: "name",
|
||||
NOTES: "notes",
|
||||
OVERRIDE: "override",
|
||||
PROFICIENCY_LEVEL: "proficiencyLevel",
|
||||
RANGE: "range",
|
||||
RANGE_TYPE: "rangeId",
|
||||
SAVE_DC_BONUS: "saveDcBonus",
|
||||
SAVE_DC: "fixedSaveDc",
|
||||
SAVE_TYPE: "saveStatId",
|
||||
SNIPPET: "snippet",
|
||||
SPELL_RANGE_TYPE: "spellRangeType",
|
||||
STAT: "statId",
|
||||
TO_HIT_BONUS: "toHitBonus",
|
||||
TO_HIT: "toHit",
|
||||
WEIGHT: "weight",
|
||||
};
|
||||
|
||||
interface SelectEditorProps {
|
||||
label: string;
|
||||
propertyKey: string;
|
||||
defaultValue: number | null;
|
||||
options: Array<any>;
|
||||
onUpdate: (propertyKey: string, value: number | null) => void;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
interface SelectEditorState {
|
||||
value: number | null;
|
||||
}
|
||||
class SelectEditor extends React.PureComponent<
|
||||
SelectEditorProps,
|
||||
SelectEditorState
|
||||
> {
|
||||
static defaultProps = {
|
||||
options: [],
|
||||
};
|
||||
|
||||
constructor(props: SelectEditorProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.defaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
handleChange = (value: string): void => {
|
||||
const { propertyKey, onUpdate } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
let parsedValue = HelperUtils.parseInputInt(value);
|
||||
onUpdate(propertyKey, parsedValue);
|
||||
this.setState({
|
||||
value: parsedValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value } = this.state;
|
||||
const { propertyKey, label, options, isEnabled } = this.props;
|
||||
|
||||
if (!isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomizeDataEditorProperty propertyKey={propertyKey}>
|
||||
<CustomizeDataEditorPropertyValue>
|
||||
<Select
|
||||
placeholder="--"
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</CustomizeDataEditorPropertyValue>
|
||||
<CustomizeDataEditorPropertyLabel>
|
||||
{label}
|
||||
</CustomizeDataEditorPropertyLabel>
|
||||
</CustomizeDataEditorProperty>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface CheckboxEditorProps {
|
||||
label: string;
|
||||
propertyKey: string;
|
||||
initiallyEnabled: boolean;
|
||||
onUpdate: (propertyKey: string, value: boolean) => void;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
class CheckboxEditor extends React.PureComponent<CheckboxEditorProps> {
|
||||
handleChange = (isEnabled: boolean): void => {
|
||||
const { propertyKey, onUpdate } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, isEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { propertyKey, label, initiallyEnabled, isEnabled } = this.props;
|
||||
|
||||
if (!isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomizeDataEditorProperty propertyKey={propertyKey}>
|
||||
<CustomizeDataEditorPropertyValue>
|
||||
<Checkbox
|
||||
label={label}
|
||||
initiallyEnabled={initiallyEnabled}
|
||||
enabled={initiallyEnabled}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</CustomizeDataEditorPropertyValue>
|
||||
</CustomizeDataEditorProperty>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface NumberEditorProps {
|
||||
label: string;
|
||||
propertyKey: string;
|
||||
defaultValue: number | null;
|
||||
minimumValue: number | null;
|
||||
maximumValue: number | null;
|
||||
onUpdate: (propertyKey: string, value: number | null) => void;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
interface NumberEditorState {
|
||||
value: number | null;
|
||||
}
|
||||
class NumberEditor extends React.PureComponent<
|
||||
NumberEditorProps,
|
||||
NumberEditorState
|
||||
> {
|
||||
static defaultProps = {
|
||||
minimumValue: null,
|
||||
maximumValue: null,
|
||||
};
|
||||
|
||||
constructor(props: NumberEditorProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.defaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<NumberEditorProps>,
|
||||
prevState: Readonly<NumberEditorState>
|
||||
): void {
|
||||
const { defaultValue } = this.props;
|
||||
|
||||
if (defaultValue !== prevProps.defaultValue) {
|
||||
this.setState({
|
||||
value: defaultValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const { propertyKey, onUpdate, minimumValue, maximumValue } = this.props;
|
||||
|
||||
let parsedValue = HelperUtils.parseInputInt(evt.target.value);
|
||||
let newValue: number | null =
|
||||
parsedValue === null
|
||||
? null
|
||||
: HelperUtils.clampInt(parsedValue, minimumValue, maximumValue);
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, newValue);
|
||||
}
|
||||
this.setState({
|
||||
value: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
handleChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
this.setState({
|
||||
value: HelperUtils.parseInputInt(evt.target.value),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value } = this.state;
|
||||
const { propertyKey, label, minimumValue, maximumValue, isEnabled } =
|
||||
this.props;
|
||||
|
||||
if (!isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomizeDataEditorProperty propertyKey={propertyKey}>
|
||||
<CustomizeDataEditorPropertyValue>
|
||||
<input
|
||||
type="number"
|
||||
min={minimumValue ?? ""}
|
||||
max={maximumValue ?? ""}
|
||||
value={value ?? ""}
|
||||
onBlur={this.handleBlur}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</CustomizeDataEditorPropertyValue>
|
||||
<CustomizeDataEditorPropertyLabel>
|
||||
{label}
|
||||
</CustomizeDataEditorPropertyLabel>
|
||||
</CustomizeDataEditorProperty>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface TextAreaEditorProps {
|
||||
label: string;
|
||||
propertyKey: string;
|
||||
defaultValue: string | null;
|
||||
onUpdate: (propertyKey: string, value: string | null) => void;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
interface TextAreaEditorState {
|
||||
value: string;
|
||||
}
|
||||
class TextAreaEditor extends React.PureComponent<
|
||||
TextAreaEditorProps,
|
||||
TextAreaEditorState
|
||||
> {
|
||||
constructor(props: TextEditorProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.defaultValue ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<TextAreaEditorProps>,
|
||||
prevState: Readonly<TextAreaEditorState>
|
||||
): void {
|
||||
const { defaultValue } = this.props;
|
||||
|
||||
if (defaultValue !== prevProps.defaultValue) {
|
||||
this.setState({
|
||||
value: defaultValue ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur = (evt: React.FocusEvent<HTMLTextAreaElement>): void => {
|
||||
const { propertyKey, onUpdate } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, evt.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
handleChange = (evt: React.FocusEvent<HTMLTextAreaElement>): void => {
|
||||
const { value } = this.state;
|
||||
|
||||
if (value !== evt.target.value) {
|
||||
this.setState({
|
||||
value: evt.target.value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { propertyKey, label, isEnabled } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
if (!isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomizeDataEditorProperty propertyKey={propertyKey} isBlock={true}>
|
||||
<CustomizeDataEditorPropertyValue>
|
||||
<textarea
|
||||
value={value}
|
||||
onBlur={this.handleBlur}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</CustomizeDataEditorPropertyValue>
|
||||
<CustomizeDataEditorPropertyLabel>
|
||||
{label}
|
||||
</CustomizeDataEditorPropertyLabel>
|
||||
</CustomizeDataEditorProperty>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface TextEditorProps {
|
||||
label: string;
|
||||
propertyKey: string;
|
||||
defaultValue: string | null;
|
||||
onUpdate: (propertyKey: string, value: string | null) => void;
|
||||
isEnabled: boolean;
|
||||
maxLength: number | null;
|
||||
initialFocus?: boolean;
|
||||
}
|
||||
interface TextAreaState {
|
||||
value: string | null;
|
||||
}
|
||||
class TextEditor extends React.PureComponent<TextEditorProps, TextAreaState> {
|
||||
static defaultProps = {
|
||||
maxLength: null,
|
||||
};
|
||||
|
||||
constructor(props: TextEditorProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.defaultValue,
|
||||
};
|
||||
}
|
||||
inputRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
componentDidMount() {
|
||||
if (this.inputRef.current && this.props.initialFocus) {
|
||||
this.inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<TextEditorProps>,
|
||||
prevState: Readonly<TextAreaState>
|
||||
): void {
|
||||
const { defaultValue } = this.props;
|
||||
|
||||
if (defaultValue !== prevProps.defaultValue) {
|
||||
this.setState({
|
||||
value: defaultValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const { propertyKey, onUpdate } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, evt.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
handleChange = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const { value } = this.state;
|
||||
|
||||
if (value !== evt.target.value) {
|
||||
this.setState({
|
||||
value: evt.target.value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { propertyKey, label, isEnabled, maxLength } = this.props;
|
||||
|
||||
const { value } = this.state;
|
||||
|
||||
if (!isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomizeDataEditorProperty propertyKey={propertyKey} isBlock={true}>
|
||||
<CustomizeDataEditorPropertyValue>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={maxLength ?? undefined}
|
||||
value={value ?? ""}
|
||||
onBlur={this.handleBlur}
|
||||
onChange={this.handleChange}
|
||||
ref={this.inputRef}
|
||||
/>
|
||||
</CustomizeDataEditorPropertyValue>
|
||||
<CustomizeDataEditorPropertyLabel>
|
||||
{label}
|
||||
</CustomizeDataEditorPropertyLabel>
|
||||
</CustomizeDataEditorProperty>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type DataLookup = Record<string, any>;
|
||||
interface Props {
|
||||
data: DataLookup;
|
||||
onDataUpdate: (data: DataLookup) => void;
|
||||
|
||||
enableActivationType: boolean;
|
||||
enableActivationTime: boolean;
|
||||
enableAoeType: boolean;
|
||||
enableAoeSize: boolean;
|
||||
enableAttackSubtype: boolean;
|
||||
enableCost: boolean;
|
||||
enableDamageBonus: boolean;
|
||||
enableDamageType: boolean;
|
||||
enableDescription: boolean;
|
||||
enableDiceCount: boolean;
|
||||
enableDiceType: boolean;
|
||||
enableFixedValue: boolean;
|
||||
enableIsAdamantine: boolean;
|
||||
enableIsMartialArts: boolean;
|
||||
enableIsOffhand: boolean;
|
||||
enableIsProficient: boolean;
|
||||
enableIsSilver: boolean;
|
||||
enableLongRange: boolean;
|
||||
enableMagicBonus: boolean;
|
||||
enableMiscBonus: boolean;
|
||||
enableName: boolean;
|
||||
enableNotes: boolean;
|
||||
enableOverride: boolean;
|
||||
enableProficiencyLevel: boolean;
|
||||
enableRange: boolean;
|
||||
enableRangeType: boolean;
|
||||
enableSaveDcBonus: boolean;
|
||||
enableSaveDc: boolean;
|
||||
enableSaveType: boolean;
|
||||
enableSnippet: boolean;
|
||||
enableSpellRangeType: boolean;
|
||||
enableStat: boolean;
|
||||
enableToHitBonus: boolean;
|
||||
enableToHit: boolean;
|
||||
enableWeight: boolean;
|
||||
enableDisplayAsAttack: boolean;
|
||||
|
||||
maxNameLength: number;
|
||||
|
||||
fallbackValues: Record<string, any>;
|
||||
labelOverrides: Record<string, string>;
|
||||
|
||||
damageTypeOptions?: Array<HtmlSelectOption>;
|
||||
diceTypeOptions?: Array<HtmlSelectOption>;
|
||||
rangeOptions?: Array<HtmlSelectOption>;
|
||||
statOptions?: Array<HtmlSelectOption>;
|
||||
attackSubtypeOptions?: Array<HtmlSelectOption>;
|
||||
activationTypeOptions?: Array<HtmlSelectOption>;
|
||||
spellRangeTypeOptions?: Array<HtmlSelectOption>;
|
||||
aoeTypeOptions?: Array<HtmlSelectOption>;
|
||||
proficiencyLevelOptions?: Array<HtmlSelectOption>;
|
||||
|
||||
className: Array<string>;
|
||||
}
|
||||
interface State {
|
||||
data: DataLookup;
|
||||
}
|
||||
export default class CustomizeDataEditor extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
enableActivationTime: false,
|
||||
enableActivationType: false,
|
||||
enableAoeType: false,
|
||||
enableAoeSize: false,
|
||||
enableAttackSubtype: false,
|
||||
|
||||
enableDamageBonus: false,
|
||||
enableName: false,
|
||||
enableNotes: false,
|
||||
enableSnippet: false,
|
||||
enableToHitBonus: false,
|
||||
enableToHit: false,
|
||||
enableDisplayAsAttack: false,
|
||||
|
||||
enableCost: false,
|
||||
enableIsAdamantine: false,
|
||||
enableDescription: false,
|
||||
enableIsMartialArts: false,
|
||||
enableIsOffhand: false,
|
||||
enableIsProficient: false,
|
||||
enableIsSilver: false,
|
||||
enableSaveDcBonus: false,
|
||||
enableSaveDc: false,
|
||||
enableSaveType: false,
|
||||
enableSpellRangeType: false,
|
||||
|
||||
enableWeight: false,
|
||||
|
||||
enableProficiencyLevel: false,
|
||||
enableMagicBonus: false,
|
||||
enableMiscBonus: false,
|
||||
enableOverride: false,
|
||||
|
||||
enableRange: false,
|
||||
enableLongRange: false,
|
||||
enableRangeType: false,
|
||||
enableStat: false,
|
||||
enableDiceCount: false,
|
||||
enableDiceType: false,
|
||||
enableFixedValue: false,
|
||||
enableDamageType: false,
|
||||
|
||||
maxNameLength: 256,
|
||||
|
||||
fallbackValues: {},
|
||||
labelOverrides: {},
|
||||
|
||||
className: "",
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
data: props.data,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { data } = this.props;
|
||||
|
||||
if (data !== prevProps.data) {
|
||||
this.setState({
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleDataUpdate = (key: string, value: any): void => {
|
||||
const { onDataUpdate } = this.props;
|
||||
|
||||
this.setState((prevState: State) => {
|
||||
let newData: DataLookup = {
|
||||
...prevState.data,
|
||||
[key]: value,
|
||||
};
|
||||
|
||||
if (onDataUpdate) {
|
||||
onDataUpdate(newData);
|
||||
}
|
||||
|
||||
return {
|
||||
data: newData,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
getPropertyDefaultValue = (key: string, fallback: any = null): any => {
|
||||
if (this.state.data.hasOwnProperty(key) && this.state.data[key] !== null) {
|
||||
return this.state.data[key];
|
||||
}
|
||||
if (this.props.fallbackValues.hasOwnProperty(key)) {
|
||||
return this.props.fallbackValues[key];
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
getPropertyLabelOverride = (key: string, fallback: any): any => {
|
||||
if (this.props.labelOverrides.hasOwnProperty(key)) {
|
||||
return this.props.labelOverrides[key];
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
enableActivationTime,
|
||||
enableActivationType,
|
||||
enableAoeSize,
|
||||
enableAoeType,
|
||||
enableAttackSubtype,
|
||||
enableCost,
|
||||
enableDisplayAsAttack,
|
||||
enableDamageBonus,
|
||||
enableDamageType,
|
||||
enableDescription,
|
||||
enableDiceCount,
|
||||
enableDiceType,
|
||||
enableFixedValue,
|
||||
enableIsAdamantine,
|
||||
enableIsMartialArts,
|
||||
enableIsOffhand,
|
||||
enableIsProficient,
|
||||
enableIsSilver,
|
||||
enableLongRange,
|
||||
enableMagicBonus,
|
||||
enableMiscBonus,
|
||||
enableName,
|
||||
enableNotes,
|
||||
enableOverride,
|
||||
enableProficiencyLevel,
|
||||
enableRange,
|
||||
enableRangeType,
|
||||
enableStat,
|
||||
enableSaveDcBonus,
|
||||
enableSaveDc,
|
||||
enableSaveType,
|
||||
enableSnippet,
|
||||
enableSpellRangeType,
|
||||
enableToHitBonus,
|
||||
enableToHit,
|
||||
enableWeight,
|
||||
damageTypeOptions,
|
||||
diceTypeOptions,
|
||||
rangeOptions,
|
||||
statOptions,
|
||||
attackSubtypeOptions,
|
||||
activationTypeOptions,
|
||||
spellRangeTypeOptions,
|
||||
aoeTypeOptions,
|
||||
proficiencyLevelOptions,
|
||||
className,
|
||||
maxNameLength,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={`ct-customize-data-editor ${className}`}>
|
||||
<div className="ct-customize-data-editor__properties">
|
||||
<SelectEditor
|
||||
propertyKey={CUSTOMIZE_KEY.ATTACK_SUBTYPE}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.ATTACK_SUBTYPE
|
||||
)}
|
||||
label="Attack Type"
|
||||
options={attackSubtypeOptions}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableAttackSubtype}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.TO_HIT}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.TO_HIT)}
|
||||
label="To Hit Override"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableToHitBonus}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.TO_HIT_BONUS}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.TO_HIT_BONUS
|
||||
)}
|
||||
label="To Hit Bonus"
|
||||
minimumValue={1}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableToHit}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.OVERRIDE}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.OVERRIDE)}
|
||||
label="Override"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableOverride}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.MAGIC_BONUS}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.MAGIC_BONUS
|
||||
)}
|
||||
label="Magic Bonus"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableMagicBonus}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.MISC_BONUS}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.MISC_BONUS
|
||||
)}
|
||||
label="Misc Bonus"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableMiscBonus}
|
||||
/>
|
||||
<SelectEditor
|
||||
propertyKey={CUSTOMIZE_KEY.RANGE_TYPE}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.RANGE_TYPE
|
||||
)}
|
||||
label="Range"
|
||||
options={rangeOptions}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableRangeType}
|
||||
/>
|
||||
<SelectEditor
|
||||
propertyKey={CUSTOMIZE_KEY.STAT}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.STAT)}
|
||||
label="Stat"
|
||||
options={statOptions}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableStat}
|
||||
/>
|
||||
<SelectEditor
|
||||
propertyKey={CUSTOMIZE_KEY.PROFICIENCY_LEVEL}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.PROFICIENCY_LEVEL
|
||||
)}
|
||||
label="Proficiency Level"
|
||||
options={proficiencyLevelOptions}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableProficiencyLevel}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.DICE_COUNT}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.DICE_COUNT
|
||||
)}
|
||||
minimumValue={1}
|
||||
label="Dice Count"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableDiceCount}
|
||||
/>
|
||||
<SelectEditor
|
||||
propertyKey={CUSTOMIZE_KEY.DICE_TYPE}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.DICE_TYPE)}
|
||||
label="Die Type"
|
||||
options={diceTypeOptions}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableDiceType}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.FIXED_VALUE}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.FIXED_VALUE
|
||||
)}
|
||||
minimumValue={1}
|
||||
label={this.getPropertyLabelOverride(
|
||||
CUSTOMIZE_KEY.FIXED_VALUE,
|
||||
"Additional Bonus"
|
||||
)}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableFixedValue}
|
||||
/>
|
||||
<SelectEditor
|
||||
propertyKey={CUSTOMIZE_KEY.DAMAGE_TYPE}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.DAMAGE_TYPE
|
||||
)}
|
||||
label="Damage Type"
|
||||
options={damageTypeOptions}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableDamageType}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.DAMAGE_BONUS}
|
||||
minimumValue={1}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.DAMAGE_BONUS
|
||||
)}
|
||||
label="Damage Bonus"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableDamageBonus}
|
||||
/>
|
||||
<SelectEditor
|
||||
propertyKey={CUSTOMIZE_KEY.SAVE_TYPE}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.SAVE_TYPE)}
|
||||
label="Save Type"
|
||||
options={statOptions}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableSaveType}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.SAVE_DC}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.SAVE_DC)}
|
||||
minimumValue={0}
|
||||
label="Fixed Save DC"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableSaveDc}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.SAVE_DC_BONUS}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.SAVE_DC_BONUS
|
||||
)}
|
||||
label="DC Bonus"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableSaveDcBonus}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.COST}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.COST)}
|
||||
minimumValue={0}
|
||||
label="Cost Override"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableCost}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.WEIGHT}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.WEIGHT)}
|
||||
minimumValue={0}
|
||||
label="Weight Override"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableWeight}
|
||||
/>
|
||||
<SelectEditor
|
||||
propertyKey={CUSTOMIZE_KEY.SPELL_RANGE_TYPE}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.SPELL_RANGE_TYPE
|
||||
)}
|
||||
label="Spell Range Type"
|
||||
options={spellRangeTypeOptions}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableSpellRangeType}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.RANGE}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.RANGE)}
|
||||
minimumValue={0}
|
||||
label="Range"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableRange}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.LONG_RANGE}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.LONG_RANGE
|
||||
)}
|
||||
minimumValue={0}
|
||||
label="Long Range"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableLongRange}
|
||||
/>
|
||||
<SelectEditor
|
||||
propertyKey={CUSTOMIZE_KEY.AOE_TYPE}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.AOE_TYPE)}
|
||||
label="AoE Type"
|
||||
options={aoeTypeOptions}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableAoeType}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.AOE_SIZE}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.AOE_SIZE)}
|
||||
minimumValue={0}
|
||||
label="AoE Size"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableAoeSize}
|
||||
/>
|
||||
<SelectEditor
|
||||
propertyKey={CUSTOMIZE_KEY.ACTIVATION_TYPE}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.ACTIVATION_TYPE
|
||||
)}
|
||||
label="Activation Type"
|
||||
options={activationTypeOptions}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableActivationType}
|
||||
/>
|
||||
<NumberEditor
|
||||
propertyKey={CUSTOMIZE_KEY.ACTIVATION_TIME}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.ACTIVATION_TIME
|
||||
)}
|
||||
minimumValue={0}
|
||||
label="Activation Time"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableActivationTime}
|
||||
/>
|
||||
<CheckboxEditor
|
||||
propertyKey={CUSTOMIZE_KEY.IS_MARTIAL_ARTS}
|
||||
initiallyEnabled={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.IS_MARTIAL_ARTS
|
||||
)}
|
||||
label="Affected by Martial Arts"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableIsMartialArts}
|
||||
/>
|
||||
<CheckboxEditor
|
||||
propertyKey={CUSTOMIZE_KEY.IS_PROFICIENT}
|
||||
initiallyEnabled={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.IS_PROFICIENT
|
||||
)}
|
||||
label="Proficient"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableIsProficient}
|
||||
/>
|
||||
<CheckboxEditor
|
||||
propertyKey={CUSTOMIZE_KEY.IS_OFFHAND}
|
||||
initiallyEnabled={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.IS_OFFHAND
|
||||
)}
|
||||
label="Dual Wield"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableIsOffhand}
|
||||
/>
|
||||
<CheckboxEditor
|
||||
propertyKey={CUSTOMIZE_KEY.IS_SILVER}
|
||||
initiallyEnabled={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.IS_SILVER
|
||||
)}
|
||||
label="Silvered"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableIsSilver}
|
||||
/>
|
||||
<CheckboxEditor
|
||||
propertyKey={CUSTOMIZE_KEY.IS_ADAMANTINE}
|
||||
initiallyEnabled={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.IS_ADAMANTINE
|
||||
)}
|
||||
label="Adamantine"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableIsAdamantine}
|
||||
/>
|
||||
<CheckboxEditor
|
||||
propertyKey={CUSTOMIZE_KEY.DISPLAY_AS_ATTACK}
|
||||
initiallyEnabled={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.DISPLAY_AS_ATTACK
|
||||
)}
|
||||
label="Display as Attack"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableDisplayAsAttack}
|
||||
/>
|
||||
<TextEditor
|
||||
propertyKey={CUSTOMIZE_KEY.NAME}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.NAME)}
|
||||
label="Name"
|
||||
maxLength={maxNameLength}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableName}
|
||||
initialFocus={true}
|
||||
/>
|
||||
<TextEditor
|
||||
propertyKey={CUSTOMIZE_KEY.NOTES}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.NOTES)}
|
||||
label="Notes"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableNotes}
|
||||
/>
|
||||
<TextAreaEditor
|
||||
propertyKey={CUSTOMIZE_KEY.SNIPPET}
|
||||
defaultValue={this.getPropertyDefaultValue(CUSTOMIZE_KEY.SNIPPET)}
|
||||
label="Snippet"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableSnippet}
|
||||
/>
|
||||
<TextAreaEditor
|
||||
propertyKey={CUSTOMIZE_KEY.DESCRIPTION}
|
||||
defaultValue={this.getPropertyDefaultValue(
|
||||
CUSTOMIZE_KEY.DESCRIPTION
|
||||
)}
|
||||
label="Description"
|
||||
onUpdate={this.handleDataUpdate}
|
||||
isEnabled={enableDescription}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
import { FormatUtils } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
isBlock: boolean;
|
||||
propertyKey: string;
|
||||
}
|
||||
export default class CustomizeDataEditorProperty extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
isBlock: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isBlock, propertyKey } = this.props;
|
||||
|
||||
let classNames: Array<string> = [
|
||||
"ct-customize-data-editor__property",
|
||||
`ct-customize-data-editor__property--${FormatUtils.slugify(propertyKey)}`,
|
||||
];
|
||||
if (isBlock) {
|
||||
classNames.push("ct-customize-data-editor__property--block");
|
||||
}
|
||||
|
||||
return <div className={classNames.join(" ")}>{this.props.children}</div>;
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import CustomizeDataEditorProperty from "./CustomizeDataEditorProperty";
|
||||
|
||||
export default CustomizeDataEditorProperty;
|
||||
export { CustomizeDataEditorProperty };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class CustomizeDataEditorPropertyLabel extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-customize-data-editor__property-label">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import CustomizeDataEditorPropertyLabel from "./CustomizeDataEditorPropertyLabel";
|
||||
|
||||
export default CustomizeDataEditorPropertyLabel;
|
||||
export { CustomizeDataEditorPropertyLabel };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class CustomizeDataEditorPropertyValue extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-customize-data-editor__property-value">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import CustomizeDataEditorPropertyValue from "./CustomizeDataEditorPropertyValue";
|
||||
|
||||
export default CustomizeDataEditorPropertyValue;
|
||||
export { CustomizeDataEditorPropertyValue };
|
||||
@@ -0,0 +1,12 @@
|
||||
import CustomizeDataEditor from "./CustomizeDataEditor";
|
||||
import CustomizeDataEditorProperty from "./CustomizeDataEditorProperty";
|
||||
import CustomizeDataEditorPropertyLabel from "./CustomizeDataEditorPropertyLabel";
|
||||
import CustomizeDataEditorPropertyValue from "./CustomizeDataEditorPropertyValue";
|
||||
|
||||
export default CustomizeDataEditor;
|
||||
export {
|
||||
CustomizeDataEditor,
|
||||
CustomizeDataEditorProperty,
|
||||
CustomizeDataEditorPropertyLabel,
|
||||
CustomizeDataEditorPropertyValue,
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AdvantageIcon,
|
||||
DarkModeNegativeBonusNegativeSvg,
|
||||
DarkModePositiveBonusPositiveSvg,
|
||||
DataOriginName,
|
||||
DisadvantageIcon,
|
||||
NegativeBonusNegativeSvg,
|
||||
PositiveBonusPositiveSvg,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
DiceAdjustment,
|
||||
DataOrigin,
|
||||
Constants,
|
||||
FormatUtils,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
CharacterTheme,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
diceAdjustment: DiceAdjustment;
|
||||
ruleData: RuleData;
|
||||
showDataOrigin: boolean;
|
||||
onDataOriginClick?: (dataOrigin: DataOrigin) => void;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class DiceAdjustmentSummary extends React.PureComponent<
|
||||
Props,
|
||||
{}
|
||||
> {
|
||||
static defaultProps = {
|
||||
showDataOrigin: true,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
diceAdjustment,
|
||||
ruleData,
|
||||
showDataOrigin,
|
||||
onDataOriginClick,
|
||||
theme,
|
||||
} = this.props;
|
||||
const { statId, restriction, type, rollType, amount, dataOrigin } =
|
||||
diceAdjustment;
|
||||
|
||||
let abilityNode: React.ReactNode;
|
||||
if (statId) {
|
||||
abilityNode = (
|
||||
<React.Fragment>
|
||||
on{" "}
|
||||
<span
|
||||
className={`ct-dice-adjustment-summary__description--ability ${
|
||||
theme?.isDarkMode
|
||||
? "ct-dice-adjustment-summary__description--ability--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{RuleDataUtils.getStatNameById(statId, ruleData)}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else if (rollType === Constants.DiceAdjustmentRollTypeEnum.DEATH_SAVE) {
|
||||
abilityNode = <React.Fragment>on Death Saves</React.Fragment>;
|
||||
} else {
|
||||
if (
|
||||
!restriction &&
|
||||
rollType === Constants.DiceAdjustmentRollTypeEnum.SAVE
|
||||
) {
|
||||
abilityNode = <React.Fragment>on saves</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
let saveAmount: number | null = null;
|
||||
let IconComponent: React.ComponentType<any> | null = null;
|
||||
switch (type) {
|
||||
case Constants.DiceAdjustmentTypeEnum.BONUS:
|
||||
if (amount) {
|
||||
saveAmount = amount;
|
||||
if (amount >= 0) {
|
||||
IconComponent = theme?.isDarkMode
|
||||
? DarkModePositiveBonusPositiveSvg
|
||||
: PositiveBonusPositiveSvg;
|
||||
} else {
|
||||
IconComponent = theme?.isDarkMode
|
||||
? DarkModeNegativeBonusNegativeSvg
|
||||
: NegativeBonusNegativeSvg;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case Constants.DiceAdjustmentTypeEnum.ADVANTAGE:
|
||||
IconComponent = AdvantageIcon;
|
||||
break;
|
||||
case Constants.DiceAdjustmentTypeEnum.DISADVANTAGE:
|
||||
IconComponent = DisadvantageIcon;
|
||||
break;
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
|
||||
let dataOriginNode: React.ReactNode;
|
||||
if (showDataOrigin) {
|
||||
dataOriginNode = (
|
||||
<DataOriginName
|
||||
dataOrigin={dataOrigin}
|
||||
onClick={onDataOriginClick}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-dice-adjustment-summary">
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
theme={theme}
|
||||
className="ct-dice-adjustment-summary__icon"
|
||||
/>
|
||||
)}
|
||||
{typeof saveAmount === "number" && (
|
||||
<span className="ct-dice-adjustment-summary__value">
|
||||
{saveAmount}
|
||||
</span>
|
||||
)}
|
||||
{abilityNode && (
|
||||
<span className="ct-dice-adjustment-summary__description">
|
||||
{abilityNode}
|
||||
</span>
|
||||
)}
|
||||
{restriction !== null && restriction.length > 0 && (
|
||||
<span className="ct-dice-adjustment-summary__restriction">
|
||||
{FormatUtils.lowerCaseLetters(restriction.trim(), 0)}
|
||||
</span>
|
||||
)}
|
||||
{showDataOrigin && dataOriginNode && (
|
||||
<span className="ct-dice-adjustment-summary__data-origin">
|
||||
({dataOriginNode})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import DiceAdjustmentSummary from "./DiceAdjustmentSummary";
|
||||
|
||||
export default DiceAdjustmentSummary;
|
||||
export { DiceAdjustmentSummary };
|
||||
@@ -0,0 +1,13 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
className?: string,
|
||||
}
|
||||
export default class EditorBox extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const { children, className } = this.props;
|
||||
|
||||
return <div className={clsx(["ct-editor-box", className])}>{children}</div>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import EditorBox from "./EditorBox";
|
||||
|
||||
export default EditorBox;
|
||||
export { EditorBox };
|
||||
@@ -0,0 +1,386 @@
|
||||
import axios, { Canceler } from "axios";
|
||||
import React from "react";
|
||||
import { useContext } from "react";
|
||||
|
||||
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
Container,
|
||||
ContainerUtils,
|
||||
CharacterTheme,
|
||||
Item,
|
||||
RuleData,
|
||||
PartyInfo,
|
||||
InventoryManager,
|
||||
ItemManager,
|
||||
ContainerManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { ItemFilter } from "~/components/ItemFilter";
|
||||
|
||||
import { InventoryManagerContext } from "../../managers/InventoryManagerContext";
|
||||
import { AppLoggerUtils } from "../../utils";
|
||||
import { ThemeButton } from "../common/Button";
|
||||
import { EquipmentShopItem } from "./EquipmentShopItem";
|
||||
|
||||
interface Props {
|
||||
items?: Array<Item>;
|
||||
pageSize: number;
|
||||
// CAN THIS GO AS WELL!?
|
||||
ruleData: RuleData;
|
||||
proficiencyBonus: number;
|
||||
containers: Array<Container>;
|
||||
theme: CharacterTheme;
|
||||
limitAddToCurrentContainer: Container | null;
|
||||
partyInfo: PartyInfo | null;
|
||||
inventoryManager: InventoryManager;
|
||||
}
|
||||
interface State {
|
||||
query: "";
|
||||
shoppeContainer: ContainerManager | null;
|
||||
filteredItems: Array<Item>;
|
||||
currentPage: number;
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
filterTypes: Array<string>;
|
||||
filterQuery: string;
|
||||
filterProficient: boolean;
|
||||
filterBasic: boolean;
|
||||
filterMagic: boolean;
|
||||
filterContainer: boolean;
|
||||
filterSourceCategories: Array<number>;
|
||||
}
|
||||
export class EquipmentShop extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
pageSize: 12,
|
||||
limitAddToCurrentContainer: null,
|
||||
};
|
||||
|
||||
// TODO: I am going to hold off on this
|
||||
|
||||
loadItemsCanceler: null | Canceler = null;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
query: "",
|
||||
shoppeContainer: null,
|
||||
filteredItems: props.items ? props.items : [],
|
||||
currentPage: 0,
|
||||
loading: false,
|
||||
loaded: false,
|
||||
filterTypes: [],
|
||||
filterQuery: "",
|
||||
filterProficient: false,
|
||||
filterBasic: false,
|
||||
filterMagic: false,
|
||||
filterContainer: false,
|
||||
filterSourceCategories: [],
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { inventoryManager } = this.props;
|
||||
|
||||
if (inventoryManager) {
|
||||
this.setState({
|
||||
loading: true,
|
||||
});
|
||||
|
||||
// promise or callback vs async/await? geeze
|
||||
inventoryManager
|
||||
.getInventoryShoppe({
|
||||
onSuccess: (shoppeContainer: ContainerManager) => {
|
||||
this.setState({
|
||||
shoppeContainer,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
additionalApiConfig: {
|
||||
cancelToken: new axios.CancelToken((c) => {
|
||||
this.loadItemsCanceler = c;
|
||||
}),
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
this.loadItemsCanceler = null;
|
||||
return res;
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
} else {
|
||||
this.setState({
|
||||
loaded: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.loadItemsCanceler !== null) {
|
||||
this.loadItemsCanceler();
|
||||
}
|
||||
}
|
||||
|
||||
isFiltered = (): boolean => {
|
||||
const {
|
||||
filterQuery,
|
||||
filterTypes,
|
||||
filterProficient,
|
||||
filterMagic,
|
||||
filterBasic,
|
||||
filterContainer,
|
||||
filterSourceCategories,
|
||||
} = this.state;
|
||||
|
||||
if (filterProficient || filterMagic || filterBasic || filterContainer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filterQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filterTypes.length || filterSourceCategories.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
handlePageMore = (): void => {
|
||||
this.setState((prevState) => ({
|
||||
currentPage: prevState.currentPage + 1,
|
||||
}));
|
||||
};
|
||||
|
||||
handleQueryChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
this.setState({
|
||||
filterQuery: evt.target.value,
|
||||
currentPage: 0,
|
||||
});
|
||||
};
|
||||
|
||||
handleFilterItemType = (type: string): void => {
|
||||
this.setState((prevState: State) => ({
|
||||
filterTypes: prevState.filterTypes.includes(type)
|
||||
? prevState.filterTypes.filter((t) => t !== type)
|
||||
: [...prevState.filterTypes, type],
|
||||
currentPage: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
handleSourceCategoryClick = (categoryId: number): void => {
|
||||
this.setState((prevState: State) => ({
|
||||
filterSourceCategories: prevState.filterSourceCategories.includes(
|
||||
categoryId
|
||||
)
|
||||
? prevState.filterSourceCategories.filter((cat) => cat !== categoryId)
|
||||
: [...prevState.filterSourceCategories, categoryId],
|
||||
currentPage: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
handleToggleFilter = (type: string): void => {
|
||||
switch (type) {
|
||||
case "Proficient":
|
||||
this.handleProficientToggle();
|
||||
break;
|
||||
case "Common":
|
||||
this.handleBasicToggle();
|
||||
break;
|
||||
case "Magical":
|
||||
this.handleMagicToggle();
|
||||
break;
|
||||
case "Container":
|
||||
this.handleContainerToggle();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
handleProficientToggle = (): void => {
|
||||
this.setState((prevState: State) => ({
|
||||
filterProficient: !prevState.filterProficient,
|
||||
currentPage: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
handleBasicToggle = (): void => {
|
||||
this.setState((prevState: State) => ({
|
||||
filterBasic: !prevState.filterBasic,
|
||||
currentPage: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
handleMagicToggle = (): void => {
|
||||
this.setState((prevState: State) => ({
|
||||
filterMagic: !prevState.filterMagic,
|
||||
currentPage: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
handleContainerToggle = (): void => {
|
||||
this.setState((prevState: State) => ({
|
||||
filterContainer: !prevState.filterContainer,
|
||||
currentPage: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
renderPager = (
|
||||
filteredItems: Array<ItemManager>,
|
||||
totalItems: number
|
||||
): React.ReactNode => {
|
||||
if (!this.isFiltered()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filteredItems.length >= totalItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-equipment-shop__pager">
|
||||
<ThemeButton block={true} onClick={this.handlePageMore}>
|
||||
Load More
|
||||
</ThemeButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderPagedListing = (filteredItems: Array<ItemManager>): React.ReactNode => {
|
||||
const { currentPage } = this.state;
|
||||
const {
|
||||
pageSize,
|
||||
ruleData,
|
||||
proficiencyBonus,
|
||||
containers,
|
||||
theme,
|
||||
limitAddToCurrentContainer,
|
||||
partyInfo,
|
||||
} = this.props;
|
||||
|
||||
if (!this.isFiltered()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pagedFilteredItems: Array<ItemManager> = filteredItems.slice(
|
||||
0,
|
||||
(currentPage + 1) * pageSize
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ct-equipment-shop__items">
|
||||
{pagedFilteredItems.length ? (
|
||||
pagedFilteredItems.map((item, idx) => {
|
||||
let filteredContainers = containers;
|
||||
const isContainer = item.isContainer();
|
||||
const isPack = item.isPack();
|
||||
if (isContainer || isPack) {
|
||||
filteredContainers = containers.filter(
|
||||
(container) =>
|
||||
ContainerUtils.isCharacterContainer(container) ||
|
||||
ContainerUtils.isPartyContainer(container)
|
||||
);
|
||||
} else if (limitAddToCurrentContainer) {
|
||||
filteredContainers = [limitAddToCurrentContainer];
|
||||
}
|
||||
return (
|
||||
<EquipmentShopItem
|
||||
key={`${item.getDefinitionId()}-${idx}`}
|
||||
containers={filteredContainers}
|
||||
theme={theme}
|
||||
item={item}
|
||||
ruleData={ruleData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
partyInfo={partyInfo}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="ct-equipment-shop__empty">No Results Found</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderUi = (): React.ReactNode => {
|
||||
const {
|
||||
filterTypes,
|
||||
filterQuery,
|
||||
filterProficient,
|
||||
filterBasic,
|
||||
filterMagic,
|
||||
filterContainer,
|
||||
filterSourceCategories,
|
||||
currentPage,
|
||||
} = this.state;
|
||||
const { pageSize } = this.props;
|
||||
|
||||
const itemData = this.state.shoppeContainer?.getInventoryItems({
|
||||
filterOptions: {
|
||||
filterTypes,
|
||||
filterQuery,
|
||||
filterProficient,
|
||||
filterBasic,
|
||||
filterMagic,
|
||||
filterContainer,
|
||||
filterSourceCategories,
|
||||
},
|
||||
paginationOptions: {
|
||||
currentPage,
|
||||
pageSize,
|
||||
},
|
||||
isShoppe: true,
|
||||
});
|
||||
|
||||
const items = itemData?.items ?? [];
|
||||
const totalItems = itemData?.totalItems ?? 0;
|
||||
const sourceCategories = itemData?.sourceCategories || [];
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ItemFilter
|
||||
filterQuery={filterQuery}
|
||||
onQueryChange={(value) =>
|
||||
this.setState({
|
||||
filterQuery: value,
|
||||
})
|
||||
}
|
||||
filterTypes={filterTypes}
|
||||
sourceCategories={sourceCategories}
|
||||
onFilterButtonClick={this.handleFilterItemType}
|
||||
onCheckboxChange={this.handleToggleFilter}
|
||||
onSourceCategoryClick={this.handleSourceCategoryClick}
|
||||
filterSourceCategories={filterSourceCategories}
|
||||
filterProficient={filterProficient}
|
||||
filterBasic={filterBasic}
|
||||
filterMagic={filterMagic}
|
||||
filterContainer={filterContainer}
|
||||
themed
|
||||
/>
|
||||
{this.renderPagedListing(items)}
|
||||
{this.renderPager(items, totalItems)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
renderLoading = (): React.ReactNode => {
|
||||
return <LoadingPlaceholder />;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loaded, loading } = this.state;
|
||||
|
||||
return (
|
||||
<div className="ct-equipment-shop">
|
||||
{loading && this.renderLoading()}
|
||||
{!loading && loaded && this.renderUi()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function EquipmentShopContainer(props) {
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
return <EquipmentShop inventoryManager={inventoryManager} {...props} />;
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CampaignUtils,
|
||||
Container,
|
||||
ContainerUtils,
|
||||
CharacterTheme,
|
||||
HelperUtils,
|
||||
Item,
|
||||
ItemUtils,
|
||||
RuleData,
|
||||
MenuOption,
|
||||
PartyInfo,
|
||||
Constants,
|
||||
ItemManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { ItemName } from "~/components/ItemName";
|
||||
|
||||
import { AppNotificationUtils } from "../../../utils";
|
||||
import ItemDetail from "../../ItemDetail";
|
||||
import { ThemeButton, ThemeButtonWithMenu } from "../../common/Button";
|
||||
|
||||
interface Props {
|
||||
item: ItemManager;
|
||||
enableAdd: boolean;
|
||||
minAddAmount: number;
|
||||
maxAddAmount: number;
|
||||
ruleData: RuleData;
|
||||
proficiencyBonus: number;
|
||||
containers: Array<Container>;
|
||||
theme: CharacterTheme;
|
||||
partyInfo: PartyInfo | null;
|
||||
}
|
||||
interface State {
|
||||
amount: number | null;
|
||||
}
|
||||
export default class EquipmentShopItem extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
enableAdd: true,
|
||||
minAddAmount: 1,
|
||||
maxAddAmount: 10,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
amount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
handleAmountChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
this.setState({
|
||||
amount: HelperUtils.parseInputInt(evt.target.value),
|
||||
});
|
||||
};
|
||||
|
||||
handleAmountBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const { minAddAmount, maxAddAmount } = this.props;
|
||||
|
||||
const parsedValue = HelperUtils.parseInputInt(evt.target.value);
|
||||
const clampedValue = HelperUtils.clampInt(
|
||||
parsedValue === null ? 0 : parsedValue,
|
||||
minAddAmount,
|
||||
maxAddAmount
|
||||
);
|
||||
|
||||
this.setState({
|
||||
amount: clampedValue,
|
||||
});
|
||||
};
|
||||
|
||||
handleDecrementCountClick = (): void => {
|
||||
const { minAddAmount } = this.props;
|
||||
|
||||
this.setState((prevState, props) => ({
|
||||
amount: Math.max(
|
||||
(prevState.amount === null ? 0 : prevState.amount) - 1,
|
||||
minAddAmount
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
handleIncrementCountClick = (): void => {
|
||||
this.setState((prevState, props) => ({
|
||||
amount: (prevState.amount === null ? 0 : prevState.amount) + 1,
|
||||
}));
|
||||
};
|
||||
|
||||
handleAmountKeyUp = (evt: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
const { minAddAmount, maxAddAmount } = this.props;
|
||||
|
||||
const parsedValue = HelperUtils.parseInputInt(
|
||||
(evt.target as HTMLInputElement).value
|
||||
);
|
||||
const clampedValue = HelperUtils.clampInt(
|
||||
parsedValue === null ? 0 : parsedValue,
|
||||
minAddAmount,
|
||||
maxAddAmount
|
||||
);
|
||||
|
||||
this.setState({
|
||||
amount: clampedValue,
|
||||
});
|
||||
};
|
||||
|
||||
handleAdd = (containerDefinitionKey?: string): void => {
|
||||
const { amount: stateAmount } = this.state;
|
||||
const { item, minAddAmount } = this.props;
|
||||
|
||||
const amount = stateAmount === null ? minAddAmount : stateAmount;
|
||||
item.handleAdd(
|
||||
{ amount, containerDefinitionKey },
|
||||
AppNotificationUtils.handleItemAddAccepted.bind(this, item.item, amount),
|
||||
AppNotificationUtils.handleItemAddRejected.bind(this, item.item, amount)
|
||||
);
|
||||
};
|
||||
|
||||
renderHeader = (
|
||||
item: ItemManager,
|
||||
metaItems: Array<string>
|
||||
): React.ReactNode => {
|
||||
const { containers, partyInfo } = this.props;
|
||||
|
||||
return (
|
||||
<CollapsibleHeaderContent
|
||||
heading={<ItemName item={item.item} />}
|
||||
metaItems={metaItems}
|
||||
callout={
|
||||
<ThemeButtonWithMenu
|
||||
groupedOptions={ContainerUtils.getGroupedOptions(
|
||||
null,
|
||||
containers,
|
||||
"Add To:",
|
||||
partyInfo
|
||||
? CampaignUtils.getSharingState(partyInfo)
|
||||
: Constants.PartyInventorySharingStateEnum.OFF
|
||||
)}
|
||||
onSelect={this.handleAdd}
|
||||
>
|
||||
Add
|
||||
</ThemeButtonWithMenu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { amount } = this.state;
|
||||
const {
|
||||
item,
|
||||
minAddAmount,
|
||||
maxAddAmount,
|
||||
ruleData,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
containers,
|
||||
partyInfo,
|
||||
} = this.props;
|
||||
|
||||
const totalAmount: number =
|
||||
(amount === null ? 1 : amount) * item.getBundleSize();
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
layoutType={"minimal"}
|
||||
header={this.renderHeader(item, item.getMetaText())}
|
||||
className="ct-equipment-shop__item"
|
||||
>
|
||||
<ItemDetail
|
||||
theme={theme}
|
||||
item={item.item}
|
||||
ruleData={ruleData}
|
||||
showCustomize={false}
|
||||
showActions={false}
|
||||
showImage={false}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
<div className="ct-equipment-shop__item-actions">
|
||||
<div className="ct-equipment-shop__item-action">
|
||||
<div className="ct-equipment-shop__item-amount">
|
||||
<div className="ct-equipment-shop__item-amount-label">
|
||||
Amount to add
|
||||
</div>
|
||||
<div className="ct-equipment-shop__item-amount-controls">
|
||||
<div className="ct-equipment-shop__item-amount-decrease">
|
||||
<ThemeButton
|
||||
className="button-action-decrease button-action-decrease--small"
|
||||
onClick={this.handleDecrementCountClick}
|
||||
disabled={amount !== null && amount <= minAddAmount}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-equipment-shop__item-amount-value">
|
||||
<input
|
||||
type="number"
|
||||
value={amount === null ? "" : amount}
|
||||
className="character-input ct-equipment-shop__item-amount-input"
|
||||
onChange={this.handleAmountChange}
|
||||
onBlur={this.handleAmountBlur}
|
||||
onKeyUp={this.handleAmountKeyUp}
|
||||
min={minAddAmount}
|
||||
max={maxAddAmount}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-equipment-shop__item-amount-increase">
|
||||
<ThemeButton
|
||||
className="button-action-increase button-action-increase--small"
|
||||
onClick={this.handleIncrementCountClick}
|
||||
disabled={amount !== null && amount >= maxAddAmount}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ct-equipment-shop__item-action">
|
||||
<ThemeButtonWithMenu
|
||||
onSelect={(definitionKey) => this.handleAdd(definitionKey)}
|
||||
containerEl={
|
||||
document.querySelector(".ct-sidebar__portal") as HTMLElement
|
||||
}
|
||||
groupedOptions={ContainerUtils.getGroupedOptions(
|
||||
null,
|
||||
containers,
|
||||
"Add To:",
|
||||
partyInfo
|
||||
? CampaignUtils.getSharingState(partyInfo)
|
||||
: Constants.PartyInventorySharingStateEnum.OFF
|
||||
)}
|
||||
>
|
||||
Add {totalAmount === 1 ? " Item" : ` ${totalAmount} Items`}
|
||||
</ThemeButtonWithMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import EquipmentShop from "./EquipmentShop";
|
||||
import EquipmentShopItem from "./EquipmentShopItem";
|
||||
|
||||
export default EquipmentShop;
|
||||
export { EquipmentShop, EquipmentShopItem };
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
|
||||
import { Link } from "~/components/Link";
|
||||
|
||||
import config from "../../../config";
|
||||
import { ErrorUtils } from "../../utils";
|
||||
|
||||
interface Props {}
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
errorId: string | null;
|
||||
}
|
||||
export default class ErrorBoundary extends React.PureComponent<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
errorId: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return {
|
||||
hasError: true,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, componentInfo: React.ErrorInfo) {
|
||||
console.error("ERROR >>>", error);
|
||||
ErrorUtils.dispatchException(error, {
|
||||
componentInfo,
|
||||
});
|
||||
}
|
||||
|
||||
handleBack = (): void => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
renderErrorUi = (): React.ReactNode => {
|
||||
const { errorId } = this.state;
|
||||
|
||||
return (
|
||||
<div className="ct-error-boundary">
|
||||
<div className="ct-error-boundary__header">
|
||||
<div className="ct-error-boundary__heading">
|
||||
A beholder has appeared and ruined everything
|
||||
</div>
|
||||
</div>
|
||||
<div className="ct-error-boundary__content">
|
||||
<p>
|
||||
The error that summoned it has been logged and the D&D Beyond
|
||||
team will hit the forge to make repairs.{" "}
|
||||
<Link href="#" onClick={this.handleBack}>
|
||||
Back to Previous Page
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="ct-error-boundary__reporting">
|
||||
{errorId !== null && (
|
||||
<p>
|
||||
Error Code:{" "}
|
||||
<span className="ct-error-boundary__code">{errorId}</span>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Version:{" "}
|
||||
<span className="ct-error-boundary__code">{config.version}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// You can render any custom fallback UI
|
||||
return this.renderErrorUi();
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
|
||||
export default ErrorBoundary;
|
||||
export { ErrorBoundary };
|
||||
@@ -0,0 +1,69 @@
|
||||
import { orderBy } from "lodash";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
CharacterTheme,
|
||||
ExtraManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { ExtraRow } from "../ExtraRow";
|
||||
|
||||
interface Props {
|
||||
extras: Array<ExtraManager>;
|
||||
showNotes: boolean;
|
||||
onShow?: (extra: ExtraManager) => void;
|
||||
onStatusChange?: (extra: ExtraManager, newStatus: boolean) => void;
|
||||
isReadonly: boolean;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
export default class ExtraList extends React.PureComponent<Props, {}> {
|
||||
static defaultProps = {
|
||||
showNotes: true,
|
||||
isReadonly: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { extras, showNotes, onShow, onStatusChange, isReadonly, theme } =
|
||||
this.props;
|
||||
|
||||
let orderedExtras: Array<ExtraManager> = orderBy(extras, (extra) =>
|
||||
extra.getName()
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ct-extra-list">
|
||||
<div className="ct-extra-list__row-header">
|
||||
<div className="ct-extra-list__col ct-extra-list__col--preview" />
|
||||
<div className="ct-extra-list__col ct-extra-list__col--primary">
|
||||
Name
|
||||
</div>
|
||||
<div className="ct-extra-list__col ct-extra-list__col--ac">AC</div>
|
||||
<div className="ct-extra-list__col ct-extra-list__col--hp">
|
||||
Hit Points
|
||||
</div>
|
||||
<div className="ct-extra-list__col ct-extra-list__col--speed">
|
||||
Speed
|
||||
</div>
|
||||
{showNotes && (
|
||||
<div className="ct-extra-list__col ct-extra-list__col--notes">
|
||||
Notes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ct-extra-list__items">
|
||||
{orderedExtras.map((extra, idx) => (
|
||||
<ExtraRow
|
||||
theme={theme}
|
||||
key={`${idx}-${extra.getUniqueKey()}`}
|
||||
extra={extra}
|
||||
onShow={onShow}
|
||||
onStatusChange={onStatusChange}
|
||||
showNotes={showNotes}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ExtraList from "./ExtraList";
|
||||
|
||||
export default ExtraList;
|
||||
export { ExtraList };
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Tooltip } from "@dndbeyond/character-common-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
ExtraManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
extra: ExtraManager;
|
||||
onClick?: (extra: ExtraManager) => void;
|
||||
className: string;
|
||||
theme?: CharacterTheme;
|
||||
}
|
||||
export default class ExtraName extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
className: "",
|
||||
};
|
||||
|
||||
handleClick = (evt: React.MouseEvent): void => {
|
||||
const { onClick, extra } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
|
||||
onClick(extra);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { extra, className, theme } = this.props;
|
||||
|
||||
let title: string = "Customized";
|
||||
|
||||
switch (extra.getExtraType()) {
|
||||
case Constants.ExtraTypeEnum.CREATURE:
|
||||
title = `Creature is ${title}`;
|
||||
break;
|
||||
case Constants.ExtraTypeEnum.VEHICLE:
|
||||
title = `Vehicle is ${title}`;
|
||||
break;
|
||||
default:
|
||||
//not implemented
|
||||
}
|
||||
|
||||
let isCustomized = extra.isCustomized();
|
||||
let classNames: Array<string> = [className, "ddbc-extra-name"];
|
||||
|
||||
return (
|
||||
<span className={classNames.join(" ")} onClick={this.handleClick}>
|
||||
{extra.getName()}
|
||||
{isCustomized && (
|
||||
<Tooltip
|
||||
isDarkMode={theme?.isDarkMode}
|
||||
title={title}
|
||||
className="ddbc-extra-name__customized"
|
||||
>
|
||||
*
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ExtraName from "./ExtraName";
|
||||
|
||||
export default ExtraName;
|
||||
export { ExtraName };
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, { FC, HTMLAttributes } from "react";
|
||||
|
||||
import { Tooltip } from "@dndbeyond/character-common-components/es";
|
||||
import { NoteComponents } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
Creature,
|
||||
ExtraManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
|
||||
import ExtraName from "../ExtraName";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
extra: ExtraManager;
|
||||
onShow?: (extra: ExtraManager) => void;
|
||||
onStatusChange?: (extra: ExtraManager, newStatus: boolean) => void;
|
||||
showNotes: boolean;
|
||||
isReadonly: boolean;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
|
||||
export const ExtraRow: FC<Props> = ({
|
||||
extra,
|
||||
onShow,
|
||||
onStatusChange,
|
||||
showNotes,
|
||||
isReadonly = false,
|
||||
theme,
|
||||
...props
|
||||
}) => {
|
||||
const { hpInfo } = useCharacterEngine();
|
||||
|
||||
let avatarUrl = extra.getAvatarUrl();
|
||||
if (!avatarUrl) {
|
||||
avatarUrl = "";
|
||||
}
|
||||
const name = extra.getName();
|
||||
|
||||
const handleClick = (evt: React.MouseEvent): void => {
|
||||
if (onShow) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onShow(extra);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = (): void => {
|
||||
if (onStatusChange) {
|
||||
onStatusChange(extra, true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = (): void => {
|
||||
if (onStatusChange) {
|
||||
onStatusChange(extra, false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMetaItems = (): React.ReactNode => {
|
||||
let metaItems: Array<string> = [];
|
||||
|
||||
const sizeInfo = extra.getSizeInfo();
|
||||
let size: string = "";
|
||||
if (sizeInfo !== null && sizeInfo.name !== null) {
|
||||
size = sizeInfo.name;
|
||||
}
|
||||
|
||||
let type = extra.getType();
|
||||
let typeName: string = "";
|
||||
if (type) {
|
||||
typeName = type;
|
||||
}
|
||||
|
||||
let description: string = `${size} ${typeName.toLowerCase()}`.trim();
|
||||
if (description) {
|
||||
metaItems.push(description);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ct-extra-row__meta ${
|
||||
theme.isDarkMode ? "ct-extra-row__meta--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{metaItems.map((metaItem, idx) => (
|
||||
<span key={idx} className="ct-extra-row__meta-item">
|
||||
{metaItem}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHitPoints = (): React.ReactNode => {
|
||||
let useOwnerHp = false;
|
||||
if (extra.isCreature()) {
|
||||
const extraData = extra.getExtraData() as Creature;
|
||||
if (extraData) {
|
||||
useOwnerHp = extraData.useOwnerHp;
|
||||
}
|
||||
}
|
||||
|
||||
const hitPointInfo = useOwnerHp ? hpInfo : extra.getHitPointInfo().data;
|
||||
const showTooltip = useOwnerHp
|
||||
? false
|
||||
: extra.getHitPointInfo().showTooltip;
|
||||
|
||||
let contentNode: React.ReactNode = null;
|
||||
if (hitPointInfo !== null) {
|
||||
let totalHp: number = hitPointInfo.totalHp ? hitPointInfo.totalHp : 0;
|
||||
let remainingHp: number = hitPointInfo.remainingHp
|
||||
? hitPointInfo.remainingHp
|
||||
: 0;
|
||||
let tempHp: number = hitPointInfo.tempHp ? hitPointInfo.tempHp : 0;
|
||||
|
||||
const current: number = remainingHp + tempHp;
|
||||
const total: number = totalHp + tempHp;
|
||||
|
||||
contentNode = (
|
||||
<React.Fragment>
|
||||
<span className="ct-extra-row__hp-value ct-extra-row__hp-value--current">
|
||||
{current}
|
||||
</span>
|
||||
<span className="ct-extra-row__hp-sep">/</span>
|
||||
<span className="ct-extra-row__hp-value ct-extra-row__hp-value--total">
|
||||
{total}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
contentNode = (
|
||||
<span className="ct-extra-row__hp-value ct-extra-row__hp-value--current">
|
||||
--
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
let classNames: Array<string> = ["ct-extra-row__hp"];
|
||||
if (
|
||||
hitPointInfo !== null &&
|
||||
hitPointInfo.tempHp !== null &&
|
||||
hitPointInfo.tempHp > 0
|
||||
) {
|
||||
classNames.push("ct-extra-row__hp--has-temp");
|
||||
}
|
||||
|
||||
if (theme.isDarkMode) {
|
||||
classNames.push("ct-extra-row__hp--dark-mode");
|
||||
}
|
||||
return (
|
||||
<div className={classNames.join(" ")}>
|
||||
{contentNode}
|
||||
{showTooltip && renderAdditionalInfoTooltip()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSpeed = (): React.ReactNode => {
|
||||
const movementInfo = extra.getMovementInfo();
|
||||
|
||||
const { data, label, showTooltip } = movementInfo;
|
||||
|
||||
let contentNode: React.ReactNode = null;
|
||||
if (data !== null) {
|
||||
contentNode = (
|
||||
<React.Fragment>
|
||||
<div className="ct-extra-row__speed-value">
|
||||
<NumberDisplay type="distanceInFt" number={data.speed} />
|
||||
{showTooltip && renderAdditionalInfoTooltip()}
|
||||
</div>
|
||||
{data.movementId !== Constants.MovementTypeEnum.WALK && (
|
||||
<div className="ct-extra-row__speed-callout">{label}</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
contentNode = (
|
||||
<div className="ct-extra-row__speed-value">
|
||||
--
|
||||
{showTooltip && renderAdditionalInfoTooltip()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ct-extra-row__speed ${
|
||||
theme.isDarkMode ? "ct-extra-row__speed--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{contentNode}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderArmorClass = (): React.ReactNode => {
|
||||
let armorClassInfo = extra.getArmorClassInfo();
|
||||
|
||||
let armorClassText: string | number = "--";
|
||||
if (armorClassInfo.value !== null) {
|
||||
armorClassText = armorClassInfo.value;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{armorClassText}
|
||||
{armorClassInfo.showTooltip && renderAdditionalInfoTooltip()}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAdditionalInfoTooltip = (): React.ReactNode => {
|
||||
return (
|
||||
<Tooltip
|
||||
isDarkMode={theme.isDarkMode}
|
||||
title={"View the full description for additional info"}
|
||||
className={`ct-extra-row__tooltip ${
|
||||
theme.isDarkMode ? "ct-extra-row__tooltip--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
*
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ct-extra-row" onClick={handleClick}>
|
||||
<div className="ct-extra-row__preview">
|
||||
{avatarUrl && (
|
||||
<img
|
||||
className="ct-extra-row__img"
|
||||
src={avatarUrl}
|
||||
alt={`${name} preview`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="ct-extra-row__primary">
|
||||
<div
|
||||
className={`ct-extra-row__name ${
|
||||
theme.isDarkMode ? "ct-extra-row__name--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
<ExtraName theme={theme} extra={extra} />
|
||||
</div>
|
||||
{renderMetaItems()}
|
||||
</div>
|
||||
<div
|
||||
className={`ct-extra-row__ac ${
|
||||
theme.isDarkMode ? "ct-extra-row__ac--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
{renderArmorClass()}
|
||||
</div>
|
||||
{renderHitPoints()}
|
||||
{renderSpeed()}
|
||||
{showNotes && (
|
||||
<div className="ct-extra-row__notes">
|
||||
<NoteComponents theme={theme} notes={extra.getNoteComponents()} />
|
||||
</div>
|
||||
)}
|
||||
{/*<div className="ct-extra-row__action">
|
||||
<SlotManager
|
||||
used={isActive ? 1 : 0}
|
||||
available={1}
|
||||
size="small"
|
||||
onUse={this.handleActivate}
|
||||
onClear={this.handleDeactivate}
|
||||
isInteractive={!isReadonly}
|
||||
/>
|
||||
</div>*/}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
CharacterHitPointChange,
|
||||
CharacterUtils,
|
||||
HelperUtils,
|
||||
HitPointInfo,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import ThemeButton from "../common/Button/ThemeButton";
|
||||
|
||||
interface Props {
|
||||
hitPointInfo: HitPointInfo;
|
||||
enableVibration: boolean;
|
||||
onSave?: (amount: number) => void;
|
||||
onCancel?: () => void;
|
||||
vibrationAmount: number;
|
||||
}
|
||||
interface State {
|
||||
tickCount: number;
|
||||
healingAmount: number | null;
|
||||
damageAmount: number | null;
|
||||
isDirtyHitPoints: boolean;
|
||||
}
|
||||
export default class HealthAdjuster extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
enableVibration: true,
|
||||
vibrationAmount: 15,
|
||||
};
|
||||
|
||||
defaultState = {
|
||||
tickCount: 0,
|
||||
healingAmount: 0,
|
||||
damageAmount: 0,
|
||||
isDirtyHitPoints: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
...this.defaultState,
|
||||
};
|
||||
}
|
||||
|
||||
reset = (): void => {
|
||||
this.setState(this.defaultState);
|
||||
};
|
||||
|
||||
getDirtyHitPointsChange = (): number => {
|
||||
const { tickCount, healingAmount, damageAmount } = this.state;
|
||||
|
||||
let healingAmountValue: number = healingAmount === null ? 0 : healingAmount;
|
||||
let damageAmountValue: number = damageAmount === null ? 0 : damageAmount;
|
||||
|
||||
return tickCount + healingAmountValue + damageAmountValue;
|
||||
};
|
||||
|
||||
calculateHitPoints = (): CharacterHitPointChange => {
|
||||
const { hitPointInfo } = this.props;
|
||||
|
||||
return CharacterUtils.calculateHitPoints(
|
||||
hitPointInfo,
|
||||
this.getDirtyHitPointsChange()
|
||||
);
|
||||
};
|
||||
|
||||
handleIncrease = (): void => {
|
||||
const { enableVibration, vibrationAmount } = this.props;
|
||||
|
||||
this.setState((prevState: State) => {
|
||||
let newHealing: number | null = prevState.healingAmount;
|
||||
let newDamage: number | null = prevState.damageAmount;
|
||||
|
||||
if (prevState.damageAmount === null || prevState.damageAmount === 0) {
|
||||
newHealing =
|
||||
prevState.healingAmount === null ? 1 : prevState.healingAmount + 1;
|
||||
newDamage = 0;
|
||||
} else if (prevState.damageAmount < 0) {
|
||||
newDamage = prevState.damageAmount + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
healingAmount: newHealing,
|
||||
damageAmount: newDamage,
|
||||
isDirtyHitPoints: true,
|
||||
};
|
||||
});
|
||||
|
||||
if (enableVibration && navigator.vibrate) {
|
||||
navigator.vibrate(vibrationAmount);
|
||||
}
|
||||
};
|
||||
|
||||
handleDecrease = (): void => {
|
||||
const { enableVibration, vibrationAmount } = this.props;
|
||||
|
||||
this.setState((prevState: State) => {
|
||||
let newHealing: number | null = prevState.healingAmount;
|
||||
let newDamage: number | null = prevState.damageAmount;
|
||||
|
||||
if (prevState.healingAmount === null || prevState.healingAmount === 0) {
|
||||
newDamage =
|
||||
prevState.damageAmount === null ? -1 : prevState.damageAmount - 1;
|
||||
newHealing = 0;
|
||||
} else if (prevState.healingAmount > 0) {
|
||||
newHealing = prevState.healingAmount - 1;
|
||||
}
|
||||
|
||||
return {
|
||||
healingAmount: newHealing,
|
||||
damageAmount: newDamage,
|
||||
isDirtyHitPoints: true,
|
||||
};
|
||||
});
|
||||
|
||||
if (enableVibration && navigator.vibrate) {
|
||||
navigator.vibrate(vibrationAmount);
|
||||
}
|
||||
};
|
||||
|
||||
handleTickChange = (tickCount: number): void => {
|
||||
const { enableVibration, vibrationAmount } = this.props;
|
||||
|
||||
this.setState({
|
||||
tickCount,
|
||||
isDirtyHitPoints: true,
|
||||
});
|
||||
|
||||
if (enableVibration && navigator.vibrate) {
|
||||
navigator.vibrate(vibrationAmount);
|
||||
}
|
||||
};
|
||||
|
||||
handleHealingSet = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
let value = HelperUtils.parseInputInt(evt.target.value);
|
||||
|
||||
this.setState({
|
||||
tickCount: 0,
|
||||
healingAmount: value === null ? value : HelperUtils.clampInt(value, 0),
|
||||
damageAmount: 0,
|
||||
isDirtyHitPoints: true,
|
||||
});
|
||||
};
|
||||
|
||||
handleDamageSet = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
let value = HelperUtils.parseInputInt(evt.target.value);
|
||||
|
||||
this.setState({
|
||||
tickCount: 0,
|
||||
healingAmount: 0,
|
||||
damageAmount:
|
||||
value === null ? value : HelperUtils.clampInt(value, 0) * -1,
|
||||
isDirtyHitPoints: true,
|
||||
});
|
||||
};
|
||||
|
||||
handleSave = (): void => {
|
||||
const { onSave } = this.props;
|
||||
|
||||
if (onSave) {
|
||||
onSave(this.getDirtyHitPointsChange());
|
||||
}
|
||||
|
||||
this.reset();
|
||||
};
|
||||
|
||||
handleCancel = (): void => {
|
||||
const { onCancel } = this.props;
|
||||
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
|
||||
this.reset();
|
||||
};
|
||||
|
||||
renderActions = (): React.ReactNode => {
|
||||
const { isDirtyHitPoints } = this.state;
|
||||
|
||||
if (!isDirtyHitPoints) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-health-adjuster__actions">
|
||||
<div className="ct-health-adjuster__action">
|
||||
<ThemeButton onClick={this.handleSave}>Apply Changes</ThemeButton>
|
||||
</div>
|
||||
<div className="ct-health-adjuster__action">
|
||||
<ThemeButton onClick={this.handleCancel} style="outline">
|
||||
Cancel
|
||||
</ThemeButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { healingAmount, damageAmount } = this.state;
|
||||
const { hitPointInfo } = this.props;
|
||||
|
||||
let healingDisplay: React.ReactText =
|
||||
healingAmount === null ? "" : Math.max(this.getDirtyHitPointsChange(), 0);
|
||||
let damageDisplay: React.ReactText =
|
||||
damageAmount === null
|
||||
? ""
|
||||
: Math.abs(Math.min(this.getDirtyHitPointsChange(), 0));
|
||||
|
||||
const { newTemp, newHp } = this.calculateHitPoints();
|
||||
|
||||
let hpDiffClassNames: Array<string> = ["ct-health-adjuster__new"];
|
||||
if (newHp > hitPointInfo.remainingHp) {
|
||||
hpDiffClassNames.push("ct-health-adjuster__status--positive");
|
||||
} else if (newHp < hitPointInfo.remainingHp) {
|
||||
hpDiffClassNames.push("ct-health-adjuster__status--negative");
|
||||
}
|
||||
|
||||
let tempDiffClassNames: Array<string> = ["ct-health-adjuster__new"];
|
||||
if (hitPointInfo.tempHp !== null) {
|
||||
if (newTemp > hitPointInfo.tempHp) {
|
||||
tempDiffClassNames.push("ct-health-adjuster__status--positive");
|
||||
} else if (newTemp < hitPointInfo.tempHp) {
|
||||
tempDiffClassNames.push("ct-health-adjuster__status--negative");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-health-adjuster">
|
||||
<div className="ct-health-adjuster__controls">
|
||||
<div className="ct-health-adjuster__details">
|
||||
<div className="ct-health-adjuster__healing">
|
||||
<div className="ct-health-adjuster__healing-label">Healing</div>
|
||||
<div className="ct-health-adjuster__healing-value">
|
||||
<input
|
||||
className="ct-health-adjuster__healing-input"
|
||||
type="number"
|
||||
value={healingDisplay}
|
||||
onChange={this.handleHealingSet}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ct-health-adjuster__updates">
|
||||
<div className={hpDiffClassNames.join(" ")}>
|
||||
<div className="ct-health-adjuster__new-label">New HP</div>
|
||||
<div className="ct-health-adjuster__new-value">{newHp}</div>
|
||||
</div>
|
||||
{hitPointInfo.tempHp !== null && hitPointInfo.tempHp > 0 && (
|
||||
<div className={tempDiffClassNames.join(" ")}>
|
||||
<div className="ct-health-adjuster__new-label">New Temp</div>
|
||||
<div className="ct-health-adjuster__new-value">{newTemp}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ct-health-adjuster__damage">
|
||||
<div className="ct-health-adjuster__damage-label">Damage</div>
|
||||
<div className="ct-health-adjuster__damage-value">
|
||||
<input
|
||||
className="ct-health-adjuster__damage-input"
|
||||
type="number"
|
||||
value={damageDisplay}
|
||||
onChange={this.handleDamageSet}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ct-health-adjuster__buttons">
|
||||
<div className="ct-health-adjuster__button ct-health-adjuster__button--increase">
|
||||
<ThemeButton
|
||||
className="action-increase"
|
||||
onClick={this.handleIncrease}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-health-adjuster__button ct-health-adjuster__button--decrease">
|
||||
<ThemeButton
|
||||
className="action-decrease"
|
||||
onClick={this.handleDecrease}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{this.renderActions()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import HealthAdjuster from "./HealthAdjuster";
|
||||
|
||||
export default HealthAdjuster;
|
||||
export { HealthAdjuster };
|
||||
@@ -0,0 +1,625 @@
|
||||
import axios, { Canceler } from "axios";
|
||||
import { orderBy } from "lodash";
|
||||
import * as React from "react";
|
||||
|
||||
import { LoadingPlaceholder, Select } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
ApiAdapterPromise,
|
||||
ApiAdapterRequestConfig,
|
||||
ApiAdapterUtils,
|
||||
ApiResponse,
|
||||
Constants,
|
||||
ContainerManager,
|
||||
DefinitionPool,
|
||||
DefinitionPoolUtils,
|
||||
DefinitionUtils,
|
||||
EntitledEntity,
|
||||
HelperUtils,
|
||||
HtmlSelectOption,
|
||||
HtmlSelectOptionGroup,
|
||||
InfusionChoice,
|
||||
InfusionChoiceUtils,
|
||||
InfusionDefinitionContract,
|
||||
InfusionUtils,
|
||||
InventoryManager,
|
||||
Item,
|
||||
ItemUtils,
|
||||
KnownInfusionUtils,
|
||||
Modifier,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
TypeValueLookup,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
|
||||
import DataLoadingStatusEnum from "../../constants/DataLoadingStatusEnum";
|
||||
import { AppLoggerUtils, TypeScriptUtils } from "../../utils";
|
||||
|
||||
type OnInfusionChangeFunc = (
|
||||
choiceKey: string,
|
||||
infusionId: string | null,
|
||||
oldInfusionId: string | null,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
|
||||
interface Props {
|
||||
infusionChoices: Array<InfusionChoice>;
|
||||
contextLevel: number | null;
|
||||
globalModifiers: Array<Modifier>;
|
||||
typeValueLookup: TypeValueLookup;
|
||||
ruleData: RuleData;
|
||||
definitionPool: DefinitionPool;
|
||||
knownInfusionLookup: Record<string, InfusionChoice>;
|
||||
knownReplicatedItems: Array<string>;
|
||||
onInfusionChoiceItemChangePromise?: (
|
||||
infusionChoiceKey: string,
|
||||
infusionId: string,
|
||||
itemDefinitionKey: string,
|
||||
itemName: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onInfusionChoiceItemDestroyPromise?: (
|
||||
infusionChoiceKey: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onInfusionChoiceChangePromise?: (
|
||||
infusionChoiceKey: string,
|
||||
infusionId: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onInfusionChoiceDestroyPromise?: (
|
||||
infusionChoiceKey: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
onInfusionChoiceCreatePromise?: (
|
||||
infusionChoiceKey: string,
|
||||
infusionId: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
loadAvailableInfusions: (
|
||||
additionalConfig?: Partial<ApiAdapterRequestConfig>
|
||||
) => ApiAdapterPromise<
|
||||
ApiResponse<EntitledEntity<InfusionDefinitionContract>>
|
||||
>;
|
||||
onDefinitionsLoaded?: (
|
||||
definitions: Array<InfusionDefinitionContract>,
|
||||
accessTypes: Record<string, number>
|
||||
) => void;
|
||||
inventoryManager: InventoryManager;
|
||||
}
|
||||
interface State {
|
||||
equipment: Array<Item>;
|
||||
loadingStatus: DataLoadingStatusEnum;
|
||||
}
|
||||
class InfusionChoiceManager extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
loadAvailableInfusions: null,
|
||||
};
|
||||
|
||||
loadItemsCanceler: null | Canceler = null;
|
||||
loadInfusionsCanceler: null | Canceler = null;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
equipment: [],
|
||||
loadingStatus: DataLoadingStatusEnum.NOT_LOADED,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { loadingStatus } = this.state;
|
||||
const {
|
||||
infusionChoices,
|
||||
inventoryManager,
|
||||
loadAvailableInfusions,
|
||||
globalModifiers,
|
||||
typeValueLookup,
|
||||
ruleData,
|
||||
onDefinitionsLoaded,
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
infusionChoices.length &&
|
||||
loadingStatus === DataLoadingStatusEnum.NOT_LOADED
|
||||
) {
|
||||
this.setState({
|
||||
loadingStatus: DataLoadingStatusEnum.LOADING,
|
||||
});
|
||||
|
||||
// TODO fix typings "any" once axios fixes how all can have multiple return types
|
||||
axios
|
||||
.all<any>([
|
||||
inventoryManager.getInventoryShoppe({
|
||||
onSuccess: (shoppeContainer: ContainerManager) => {
|
||||
this.setState({
|
||||
equipment: shoppeContainer
|
||||
.getInventoryItems()
|
||||
.items.map((item) => item.getItem()),
|
||||
loadingStatus: DataLoadingStatusEnum.LOADED,
|
||||
});
|
||||
},
|
||||
additionalApiConfig: {
|
||||
cancelToken: new axios.CancelToken((c) => {
|
||||
this.loadItemsCanceler = c;
|
||||
}),
|
||||
},
|
||||
}),
|
||||
loadAvailableInfusions({
|
||||
cancelToken: new axios.CancelToken((c) => {
|
||||
this.loadInfusionsCanceler = c;
|
||||
}),
|
||||
}),
|
||||
])
|
||||
.then(([equipmentResponse, infusionResponse]) => {
|
||||
//do nothing with equipmentResponse since we moved converted to the inventoryManager which sets state on onsuccess
|
||||
|
||||
let infusionData =
|
||||
ApiAdapterUtils.getResponseData<
|
||||
EntitledEntity<InfusionDefinitionContract>
|
||||
>(infusionResponse);
|
||||
if (
|
||||
infusionData &&
|
||||
infusionData.definitionData.length > 0 &&
|
||||
onDefinitionsLoaded
|
||||
) {
|
||||
onDefinitionsLoaded(
|
||||
infusionData.definitionData,
|
||||
infusionData.accessTypes
|
||||
);
|
||||
}
|
||||
|
||||
this.loadItemsCanceler = null;
|
||||
this.loadInfusionsCanceler = null;
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.loadItemsCanceler !== null) {
|
||||
this.loadItemsCanceler();
|
||||
}
|
||||
if (this.loadInfusionsCanceler !== null) {
|
||||
this.loadInfusionsCanceler();
|
||||
}
|
||||
}
|
||||
|
||||
getItemKeyParts = (itemKey: string): Array<string> => {
|
||||
return itemKey.split("|");
|
||||
};
|
||||
|
||||
getItemKey = (id: number, name: string): string => {
|
||||
return [
|
||||
DefinitionUtils.hack__generateDefinitionKey(
|
||||
Constants.FUTURE_ITEM_DEFINITION_TYPE,
|
||||
id
|
||||
),
|
||||
name,
|
||||
].join("|");
|
||||
};
|
||||
|
||||
handleChoiceItemChangePromise = (
|
||||
infusionChoice: InfusionChoice,
|
||||
itemKey: string | null,
|
||||
oldItemKey: string | null,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
): void => {
|
||||
const {
|
||||
onInfusionChoiceItemChangePromise,
|
||||
onInfusionChoiceItemDestroyPromise,
|
||||
} = this.props;
|
||||
|
||||
const choiceKey = InfusionChoiceUtils.getKey(infusionChoice);
|
||||
|
||||
if (choiceKey === null) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemKey) {
|
||||
if (onInfusionChoiceItemChangePromise) {
|
||||
const knownInfusion =
|
||||
InfusionChoiceUtils.getKnownInfusion(infusionChoice);
|
||||
if (knownInfusion === null) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
const simulatedInfusion =
|
||||
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
|
||||
if (simulatedInfusion === null) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
const infusionId = InfusionUtils.getId(simulatedInfusion);
|
||||
if (infusionId === null) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
const itemKeyParts = this.getItemKeyParts(itemKey);
|
||||
|
||||
onInfusionChoiceItemChangePromise(
|
||||
choiceKey,
|
||||
infusionId,
|
||||
itemKeyParts[0],
|
||||
itemKeyParts[1],
|
||||
accept,
|
||||
reject
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (onInfusionChoiceItemDestroyPromise) {
|
||||
onInfusionChoiceItemDestroyPromise(choiceKey, accept, reject);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleChoiceChangePromise = (
|
||||
choiceKey: string,
|
||||
infusionId: string | null,
|
||||
oldInfusionId: string | null,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
): void => {
|
||||
const { onInfusionChoiceChangePromise, onInfusionChoiceDestroyPromise } =
|
||||
this.props;
|
||||
|
||||
if (infusionId) {
|
||||
if (onInfusionChoiceChangePromise) {
|
||||
onInfusionChoiceChangePromise(choiceKey, infusionId, accept, reject);
|
||||
}
|
||||
} else {
|
||||
if (onInfusionChoiceDestroyPromise) {
|
||||
onInfusionChoiceDestroyPromise(choiceKey, accept, reject);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleChoiceCreatePromise = (
|
||||
choiceKey: string,
|
||||
infusionId: string,
|
||||
oldInfusionId: string | null,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => {
|
||||
const { onInfusionChoiceCreatePromise } = this.props;
|
||||
|
||||
if (onInfusionChoiceCreatePromise) {
|
||||
onInfusionChoiceCreatePromise(choiceKey, infusionId, accept, reject);
|
||||
}
|
||||
};
|
||||
|
||||
renderInfusionReplicateItemChoice = (
|
||||
infusionChoice: InfusionChoice
|
||||
): React.ReactNode => {
|
||||
const { equipment, loadingStatus } = this.state;
|
||||
const { contextLevel, knownReplicatedItems } = this.props;
|
||||
|
||||
let knownInfusion = InfusionChoiceUtils.getKnownInfusion(infusionChoice);
|
||||
if (knownInfusion === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let simulatedInfusion =
|
||||
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
|
||||
if (
|
||||
simulatedInfusion === null ||
|
||||
InfusionUtils.getType(simulatedInfusion) !==
|
||||
Constants.InfusionTypeEnum.REPLICATE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
const classNames: Array<string> = [
|
||||
"ct-infusion-choice-manager__choice",
|
||||
"ct-infusion-choice-manager__choice--child",
|
||||
];
|
||||
|
||||
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
|
||||
contentNode = <LoadingPlaceholder />;
|
||||
} else {
|
||||
let groups: Array<HtmlSelectOptionGroup> = [];
|
||||
let infusableItems: Array<Item> = equipment.filter((item) => {
|
||||
const levelInfusionGranted = ItemUtils.getLevelInfusionGranted(item);
|
||||
const itemDefinitionKey = ItemUtils.getDefinitionKey(item);
|
||||
let chosenItemDefinitionKey: string | null = null;
|
||||
if (knownInfusion !== null) {
|
||||
chosenItemDefinitionKey =
|
||||
KnownInfusionUtils.getItemDefinitionKey(knownInfusion);
|
||||
}
|
||||
const hasKnownReplicatedItem: boolean =
|
||||
knownReplicatedItems.includes(itemDefinitionKey);
|
||||
if (
|
||||
hasKnownReplicatedItem &&
|
||||
chosenItemDefinitionKey === itemDefinitionKey
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
!hasKnownReplicatedItem &&
|
||||
levelInfusionGranted !== null &&
|
||||
contextLevel !== null &&
|
||||
contextLevel >= levelInfusionGranted
|
||||
);
|
||||
});
|
||||
|
||||
let orderedInfusableItems = orderBy(infusableItems, (item) =>
|
||||
ItemUtils.getName(item)
|
||||
);
|
||||
|
||||
orderedInfusableItems.forEach((item) => {
|
||||
const levelInfusionGranted = ItemUtils.getLevelInfusionGranted(item);
|
||||
|
||||
if (levelInfusionGranted === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!groups.hasOwnProperty(levelInfusionGranted)) {
|
||||
groups[levelInfusionGranted] = {
|
||||
optGroupLabel: `Level ${levelInfusionGranted}`,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
const definitionName = ItemUtils.getDefinitionName(item);
|
||||
groups[levelInfusionGranted].options.push({
|
||||
label: definitionName,
|
||||
value: this.getItemKey(
|
||||
ItemUtils.getId(item),
|
||||
definitionName === null ? "" : definitionName
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
groups.forEach((group) => {
|
||||
group.options = orderBy(
|
||||
group.options,
|
||||
(option: HtmlSelectOption) => option.label
|
||||
);
|
||||
});
|
||||
|
||||
let selectedValue: string | null = null;
|
||||
let knownItemId = KnownInfusionUtils.getItemId(knownInfusion);
|
||||
let knownItemName = KnownInfusionUtils.getItemName(knownInfusion);
|
||||
if (knownItemId && knownItemName) {
|
||||
selectedValue = this.getItemKey(knownItemId, knownItemName);
|
||||
}
|
||||
|
||||
if (selectedValue === null) {
|
||||
classNames.push("ct-infusion-choice-manager__choice--todo");
|
||||
}
|
||||
|
||||
contentNode = (
|
||||
<Select
|
||||
options={groups}
|
||||
onChangePromise={this.handleChoiceItemChangePromise.bind(
|
||||
this,
|
||||
infusionChoice
|
||||
)}
|
||||
value={selectedValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={classNames.join(" ")}>{contentNode}</div>;
|
||||
};
|
||||
|
||||
renderInfusionChoice = (infusionChoice: InfusionChoice): React.ReactNode => {
|
||||
const { ruleData, knownInfusionLookup, definitionPool, contextLevel } =
|
||||
this.props;
|
||||
|
||||
// TODO check typing once definition pool is updated
|
||||
let infusionDefinitions = DefinitionPoolUtils.getTypedDefinitionList(
|
||||
Constants.DefinitionTypeEnum.INFUSION,
|
||||
definitionPool,
|
||||
true
|
||||
);
|
||||
let infusionOptions: Array<HtmlSelectOption> = infusionDefinitions
|
||||
.map((infusionDefinition) =>
|
||||
InfusionUtils.simulateInfusion(
|
||||
DefinitionUtils.getDefinitionKey(infusionDefinition),
|
||||
definitionPool
|
||||
)
|
||||
)
|
||||
.filter(TypeScriptUtils.isNotNullOrUndefined)
|
||||
.filter((infusion) => {
|
||||
// if the infusion allows duplicates just let it through
|
||||
if (InfusionUtils.getAllowDuplicates(infusion)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const infusionDefinitionKey = InfusionUtils.getDefinitionKey(infusion);
|
||||
let knownInfusion =
|
||||
InfusionChoiceUtils.getKnownInfusion(infusionChoice);
|
||||
// if choice has the infusion, show it in the list
|
||||
if (
|
||||
knownInfusion !== null &&
|
||||
KnownInfusionUtils.getDefinitionKey(knownInfusion) ===
|
||||
infusionDefinitionKey
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if its already selected somewhere else and is here, it shouldnt be shown
|
||||
if (
|
||||
infusionDefinitionKey !== null &&
|
||||
HelperUtils.lookupDataOrFallback(
|
||||
knownInfusionLookup,
|
||||
infusionDefinitionKey
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!InfusionUtils.validateIsAvailableByContextLevel(
|
||||
infusion,
|
||||
contextLevel
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((infusion) => {
|
||||
let label: string | null = InfusionUtils.getName(infusion);
|
||||
|
||||
const sources = InfusionUtils.getSources(infusion);
|
||||
|
||||
const hasAllToggleableSources: boolean = sources.every(
|
||||
(sourceMapping) => {
|
||||
const sourceDataInfo = RuleDataUtils.getSourceDataInfo(
|
||||
sourceMapping.sourceId,
|
||||
ruleData
|
||||
);
|
||||
return sourceDataInfo?.sourceCategory?.isToggleable;
|
||||
}
|
||||
);
|
||||
|
||||
if (sources.length && hasAllToggleableSources) {
|
||||
const calloutSources: Array<string> = sources
|
||||
.map((sourceMapping) => {
|
||||
const sourceDataInfo = RuleDataUtils.getSourceDataInfo(
|
||||
sourceMapping.sourceId,
|
||||
ruleData
|
||||
);
|
||||
return sourceDataInfo?.name ?? null;
|
||||
})
|
||||
.filter(TypeScriptUtils.isNotNullOrUndefined);
|
||||
|
||||
label = `${calloutSources.join(", ")} • ${label}`;
|
||||
}
|
||||
|
||||
const infusionId = InfusionUtils.getId(infusion);
|
||||
return {
|
||||
label,
|
||||
value: infusionId ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
let knownInfusion = InfusionChoiceUtils.getKnownInfusion(infusionChoice);
|
||||
|
||||
//TODO this typing is weird because of the binding that will happen further down... may need to split this into more components to remove the bind and make more sense
|
||||
let onChangePromise: OnInfusionChangeFunc = this.handleChoiceCreatePromise;
|
||||
if (knownInfusion !== null) {
|
||||
onChangePromise = this.handleChoiceChangePromise;
|
||||
}
|
||||
|
||||
let warningNode: React.ReactNode;
|
||||
let descriptionNode: React.ReactNode;
|
||||
const classNames: Array<string> = ["ct-infusion-choice-manager__choice"];
|
||||
if (knownInfusion === null) {
|
||||
classNames.push("ct-infusion-choice-manager__choice--todo");
|
||||
} else {
|
||||
const simulatedInfusion =
|
||||
KnownInfusionUtils.getSimulatedInfusion(knownInfusion);
|
||||
if (simulatedInfusion !== null) {
|
||||
const description = InfusionUtils.getDescription(simulatedInfusion);
|
||||
if (description !== null) {
|
||||
descriptionNode = (
|
||||
<HtmlContent
|
||||
className="ct-infusion-choice-manager__choice-description"
|
||||
html={description}
|
||||
withoutTooltips
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!InfusionUtils.validateIsAvailableByContextLevel(
|
||||
simulatedInfusion,
|
||||
contextLevel
|
||||
)
|
||||
) {
|
||||
classNames.push("ct-infusion-choice-manager__choice--warning");
|
||||
warningNode = (
|
||||
<div className="ct-infusion-choice-manager__choice-warning">
|
||||
<strong>{InfusionUtils.getName(simulatedInfusion)}</strong> is not
|
||||
a valid choice because it requires level{" "}
|
||||
<strong>{InfusionUtils.getLevel(simulatedInfusion)}</strong>.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let knownInfusionDefinitionKey: string | null = null;
|
||||
if (knownInfusion !== null) {
|
||||
knownInfusionDefinitionKey =
|
||||
KnownInfusionUtils.getDefinitionKey(knownInfusion);
|
||||
}
|
||||
|
||||
const choiceKey = InfusionChoiceUtils.getKey(infusionChoice);
|
||||
return (
|
||||
<div
|
||||
className={classNames.join(" ")}
|
||||
key={choiceKey === null ? "" : choiceKey}
|
||||
>
|
||||
<Select
|
||||
options={infusionOptions}
|
||||
placeholder={`- Choose a Level ${InfusionChoiceUtils.getLevel(
|
||||
infusionChoice
|
||||
)} Option -`}
|
||||
onChangePromise={onChangePromise.bind(this, choiceKey)}
|
||||
value={
|
||||
knownInfusionDefinitionKey === null
|
||||
? null
|
||||
: DefinitionUtils.getDefinitionKeyId(knownInfusionDefinitionKey)
|
||||
}
|
||||
/>
|
||||
{this.renderInfusionReplicateItemChoice(infusionChoice)}
|
||||
{warningNode}
|
||||
{descriptionNode}
|
||||
</div>
|
||||
);
|
||||
//`
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loadingStatus } = this.state;
|
||||
const { infusionChoices } = this.props;
|
||||
|
||||
if (!infusionChoices.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let contentNode: React.ReactNode;
|
||||
if (loadingStatus !== DataLoadingStatusEnum.LOADED) {
|
||||
contentNode = <LoadingPlaceholder />;
|
||||
} else {
|
||||
contentNode = (
|
||||
<React.Fragment>
|
||||
{infusionChoices.map((infusionChoice) =>
|
||||
this.renderInfusionChoice(infusionChoice)
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-infusion-choice-manager">
|
||||
<div className="ct-infusion-choice-manager__header">
|
||||
Infusion Choices
|
||||
</div>
|
||||
{contentNode}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default InfusionChoiceManager;
|
||||
@@ -0,0 +1,4 @@
|
||||
import InfusionChoiceManager from "./InfusionChoiceManager";
|
||||
|
||||
export default InfusionChoiceManager;
|
||||
export { InfusionChoiceManager };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
import ItemDetail from "./ItemDetail";
|
||||
|
||||
export default ItemDetail;
|
||||
export { ItemDetail };
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Constants,
|
||||
DefinitionUtils,
|
||||
Item,
|
||||
ItemUtils,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HelperTextAccordion } from "~/components/HelperTextAccordion";
|
||||
|
||||
interface Props {
|
||||
ruleData: RuleData;
|
||||
item: Item;
|
||||
}
|
||||
|
||||
export const ItemListInformationCollapsible = ({ ruleData, item }: Props) => {
|
||||
const definitionKey = DefinitionUtils.hack__generateDefinitionKey(
|
||||
ItemUtils.getEntityTypeId(item),
|
||||
ItemUtils.getId(item)
|
||||
);
|
||||
const builderText = RuleDataUtils.getBuilderHelperTextByDefinitionKeys(
|
||||
[definitionKey],
|
||||
ruleData,
|
||||
Constants.DisplayConfigurationTypeEnum.ITEM
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ct-item-list-information-collapsible">
|
||||
<HelperTextAccordion builderHelperText={builderText} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ItemListInformationCollapsible } from "./ItemListInformationCollapsible";
|
||||
|
||||
export default ItemListInformationCollapsible;
|
||||
export { ItemListInformationCollapsible };
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
|
||||
import { Tooltip } from "@dndbeyond/character-common-components/es";
|
||||
import { CharacterTheme } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import SlotManager from "../SlotManager";
|
||||
|
||||
interface Props {
|
||||
isUsed: boolean;
|
||||
isReadonly?: boolean;
|
||||
canUse: boolean;
|
||||
onSet?: (uses: number) => void;
|
||||
theme: CharacterTheme;
|
||||
useTooltip?: boolean;
|
||||
showEmptySlot?: boolean;
|
||||
tooltipTitle?: string;
|
||||
}
|
||||
export const ItemSlotManager: React.FC<Props> = ({
|
||||
isUsed,
|
||||
isReadonly = false,
|
||||
canUse,
|
||||
onSet,
|
||||
theme,
|
||||
useTooltip = true,
|
||||
showEmptySlot = true,
|
||||
tooltipTitle,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ct-item-slot-manager">
|
||||
{canUse ? (
|
||||
<SlotManager
|
||||
size="small"
|
||||
used={isUsed ? 1 : 0}
|
||||
available={1}
|
||||
onSet={onSet}
|
||||
isInteractive={!isReadonly}
|
||||
/>
|
||||
) : showEmptySlot ? (
|
||||
<Tooltip
|
||||
enabled={useTooltip}
|
||||
title={tooltipTitle ?? ""}
|
||||
isDarkMode={theme.isDarkMode}
|
||||
>
|
||||
<div className="ct-item-slot-manager--empty">--</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemSlotManager;
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
|
||||
import { AnimatedLoadingRingSvg } from "@dndbeyond/character-components/es";
|
||||
|
||||
interface Props {
|
||||
isFinished: boolean;
|
||||
hideDelay: number;
|
||||
}
|
||||
interface State {
|
||||
isVisible: boolean;
|
||||
}
|
||||
export default class LoadingBlocker extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
hideDelay: 2000,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isVisible: !props.isFinished,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { isFinished, hideDelay } = this.props;
|
||||
|
||||
if (!prevProps.isFinished && isFinished) {
|
||||
setTimeout(() => {
|
||||
this.setState({
|
||||
isVisible: false,
|
||||
});
|
||||
}, hideDelay);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isVisible } = this.state;
|
||||
const { isFinished } = this.props;
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ct-loading-blocker ${
|
||||
isFinished ? " ct-loading-blocker--finished" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="ct-loading-blocker__logo" />
|
||||
<AnimatedLoadingRingSvg className="ct-loading-blocker__anim" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import LoadingBlocker from "./LoadingBlocker";
|
||||
|
||||
export default LoadingBlocker;
|
||||
export { LoadingBlocker };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { sortBy } from "lodash";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
ClassSpellListSpellsLookup,
|
||||
BaseSpell,
|
||||
HelperUtils,
|
||||
ClassSpellListSpellInfo,
|
||||
ClassUtils,
|
||||
SpellUtils,
|
||||
FormatUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { SpellName } from "~/components/SpellName";
|
||||
|
||||
//This uses the common SpellName component due to builder modals not playing nice with the dataOriginRefData
|
||||
//would revert back to the character-components SpellName if we ever changed the builder modals
|
||||
|
||||
interface Props {
|
||||
spellListIds: Array<number>;
|
||||
classSpellListSpellsLookup: ClassSpellListSpellsLookup;
|
||||
}
|
||||
export default class SimpleClassSpellList extends React.PureComponent<
|
||||
Props,
|
||||
{}
|
||||
> {
|
||||
getClassSpellsLookupByClassName = (): Record<string, Array<BaseSpell>> => {
|
||||
const { spellListIds, classSpellListSpellsLookup } = this.props;
|
||||
|
||||
return spellListIds.reduce((acc, id) => {
|
||||
const classSpellListSpellInfos = HelperUtils.lookupDataOrFallback(
|
||||
classSpellListSpellsLookup,
|
||||
id
|
||||
);
|
||||
classSpellListSpellInfos?.forEach((info: ClassSpellListSpellInfo) => {
|
||||
const charClassName = ClassUtils.getName(info.charClass);
|
||||
if (charClassName) {
|
||||
if (!acc[charClassName]) {
|
||||
acc[charClassName] = [];
|
||||
}
|
||||
|
||||
acc[charClassName].push(info.spell);
|
||||
}
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
render() {
|
||||
const classSpellsLookup = this.getClassSpellsLookupByClassName();
|
||||
|
||||
return (
|
||||
<div className="ct-simple-class-spell-list">
|
||||
{Object.keys(classSpellsLookup).map((charClassName) => {
|
||||
const sortedSpells = sortBy(classSpellsLookup[charClassName], [
|
||||
(spell) => SpellUtils.getLevel(spell),
|
||||
(spell) => SpellUtils.getName(spell),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ct-simple-class-spell-list__class"
|
||||
key={charClassName}
|
||||
>
|
||||
<strong>{charClassName}</strong>
|
||||
{sortedSpells.map((spell) => {
|
||||
return (
|
||||
<div
|
||||
className="ct-simple-class-spell-list__class-spell"
|
||||
key={`${charClassName}-${SpellUtils.getUniqueKey(spell)}`}
|
||||
>
|
||||
<SpellName spell={spell} showLegacy={true} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import React from "react";
|
||||
|
||||
import { HelperUtils } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { ThemeButton } from "../common/Button";
|
||||
|
||||
interface Props {
|
||||
quantity: number;
|
||||
minimum: number;
|
||||
maximum: number | null;
|
||||
label: string;
|
||||
onUpdate?: (quantity: number) => void;
|
||||
isReadonly: boolean;
|
||||
}
|
||||
interface State {
|
||||
quantity: number;
|
||||
newQuantity: number | null;
|
||||
}
|
||||
export default class SimpleQuantity extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
label: "Quantity",
|
||||
minimum: 0,
|
||||
maximum: null,
|
||||
isReadonly: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
quantity: props.quantity,
|
||||
newQuantity: props.quantity,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>,
|
||||
snapshot?: any
|
||||
): void {
|
||||
const { quantity } = this.props;
|
||||
|
||||
if (quantity !== prevState.quantity) {
|
||||
this.setState({
|
||||
quantity,
|
||||
newQuantity: quantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdate = (): void => {
|
||||
const { newQuantity } = this.state;
|
||||
const { onUpdate, quantity } = this.props;
|
||||
|
||||
if (newQuantity !== quantity && onUpdate) {
|
||||
onUpdate(newQuantity === null ? 0 : newQuantity);
|
||||
}
|
||||
};
|
||||
|
||||
handleAmountChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
this.setState({
|
||||
newQuantity: HelperUtils.parseInputInt(evt.target.value, null),
|
||||
});
|
||||
};
|
||||
|
||||
handleAmountKeyUp = (evt: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
const { minimum, maximum } = this.props;
|
||||
|
||||
if (evt.key === "Enter") {
|
||||
const parsedValue = HelperUtils.parseInputInt(
|
||||
(evt.target as HTMLInputElement).value
|
||||
);
|
||||
const clampedValue = HelperUtils.clampInt(
|
||||
parsedValue ?? minimum,
|
||||
minimum,
|
||||
maximum
|
||||
);
|
||||
this.setState(
|
||||
{
|
||||
quantity: clampedValue,
|
||||
newQuantity: clampedValue,
|
||||
},
|
||||
this.handleUpdate
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleAmountBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const { minimum, maximum } = this.props;
|
||||
|
||||
const parsedValue = HelperUtils.parseInputInt(evt.target.value);
|
||||
const clampedValue = HelperUtils.clampInt(
|
||||
parsedValue ?? minimum,
|
||||
minimum,
|
||||
maximum
|
||||
);
|
||||
this.setState(
|
||||
{
|
||||
quantity: clampedValue,
|
||||
newQuantity: clampedValue,
|
||||
},
|
||||
this.handleUpdate
|
||||
);
|
||||
};
|
||||
|
||||
handleIncrementClick = () => {
|
||||
const { quantity, isReadonly } = this.props;
|
||||
|
||||
if (!isReadonly) {
|
||||
let newValue: number = quantity + 1;
|
||||
this.setState(
|
||||
{
|
||||
quantity: newValue,
|
||||
newQuantity: newValue,
|
||||
},
|
||||
this.handleUpdate
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleDecrementClick = () => {
|
||||
const { quantity, isReadonly } = this.props;
|
||||
|
||||
if (!isReadonly) {
|
||||
let newValue: number = quantity - 1;
|
||||
this.setState(
|
||||
{
|
||||
quantity: newValue,
|
||||
newQuantity: newValue,
|
||||
},
|
||||
this.handleUpdate
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { newQuantity } = this.state;
|
||||
const { label, quantity, minimum, maximum, isReadonly } = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-simple-quantity">
|
||||
<div className="ct-simple-quantity__label">{label}</div>
|
||||
<div className="ct-simple-quantity__controls">
|
||||
<div className="ct-simple-quantity__decrease">
|
||||
<ThemeButton
|
||||
className="button-action-decrease button-action-decrease--small"
|
||||
onClick={this.handleDecrementClick}
|
||||
disabled={minimum !== null && quantity <= minimum}
|
||||
size="small"
|
||||
isInteractive={!isReadonly}
|
||||
aria-label={"decrease quantity"}
|
||||
data-testid={"decrease-quantity"}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-simple-quantity__value">
|
||||
<input
|
||||
type="number"
|
||||
value={newQuantity === null ? "" : newQuantity}
|
||||
className="character-input ct-simple-quantity__input"
|
||||
onChange={this.handleAmountChange}
|
||||
onBlur={this.handleAmountBlur}
|
||||
onKeyUp={this.handleAmountKeyUp}
|
||||
min={0}
|
||||
readOnly={isReadonly}
|
||||
data-testid={"change-quantity"}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-simple-quantity__increase">
|
||||
<ThemeButton
|
||||
className="button-action-increase button-action-increase--small"
|
||||
onClick={this.handleIncrementClick}
|
||||
size="small"
|
||||
disabled={maximum !== null && quantity >= maximum}
|
||||
isInteractive={!isReadonly}
|
||||
aria-label={"increase quantity"}
|
||||
data-testid={"increase-quantity"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SimpleQuantity from "./SimpleQuantity";
|
||||
|
||||
export default SimpleQuantity;
|
||||
export { SimpleQuantity };
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
used: number;
|
||||
available: number;
|
||||
onSet?: (used: number) => void;
|
||||
size: "normal" | "small";
|
||||
isInteractive: boolean;
|
||||
}
|
||||
export default class SlotManager extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
size: "normal",
|
||||
isInteractive: true,
|
||||
};
|
||||
|
||||
handleClick = (isUsed: boolean, evt: React.MouseEvent): void => {
|
||||
let { onSet, used, available, isInteractive } = this.props;
|
||||
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
evt.stopPropagation();
|
||||
|
||||
if (!onSet || !isInteractive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUsed) {
|
||||
onSet(Math.max(used - 1, 0));
|
||||
} else {
|
||||
onSet(Math.min(used + 1, available));
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
let { available, used, size, isInteractive } = this.props;
|
||||
|
||||
let slotElements: Array<React.ReactNode> = [];
|
||||
|
||||
let totalSlots: number = Math.max(available, used);
|
||||
for (let i = 0; i < totalSlots; i++) {
|
||||
let isUsed = i < used;
|
||||
|
||||
let slotClassNames: Array<string> = ["ct-slot-manager__slot"];
|
||||
if (isUsed) {
|
||||
slotClassNames.push("ct-slot-manager__slot--used");
|
||||
}
|
||||
if (isInteractive) {
|
||||
slotClassNames.push("ct-slot-manager__slot--interactive");
|
||||
}
|
||||
if (i >= available) {
|
||||
slotClassNames.push("ct-slot-manager__slot--over-used");
|
||||
}
|
||||
|
||||
slotElements.push(
|
||||
<div
|
||||
role="checkbox"
|
||||
aria-checked={isUsed}
|
||||
aria-label={"use"}
|
||||
className={slotClassNames.join(" ")}
|
||||
key={i}
|
||||
onClick={this.handleClick.bind(this, isUsed)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`ct-slot-manager ct-slot-manager--size-${size}`}>
|
||||
{slotElements}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SlotManager from "./SlotManager";
|
||||
|
||||
export default SlotManager;
|
||||
export { SlotManager };
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from "react";
|
||||
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
|
||||
import { ThemeButton } from "../common/Button";
|
||||
|
||||
interface Props {
|
||||
used: number;
|
||||
available: number;
|
||||
label: string;
|
||||
onSet?: (usedAmount: number) => void;
|
||||
isReadonly: boolean;
|
||||
isInteractive: boolean;
|
||||
}
|
||||
interface State {
|
||||
startUsed: number;
|
||||
newUsed: number;
|
||||
}
|
||||
export default class SlotManagerLarge extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
label: "",
|
||||
isInteractive: true,
|
||||
isReadonly: false,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
startUsed: props.used,
|
||||
newUsed: props.used,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>,
|
||||
snapshot?: any
|
||||
): void {
|
||||
const { used } = this.props;
|
||||
|
||||
if (used !== prevState.startUsed) {
|
||||
this.setState({
|
||||
startUsed: used,
|
||||
newUsed: used,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleSetClick = (): void => {
|
||||
const { newUsed } = this.state;
|
||||
const { onSet } = this.props;
|
||||
|
||||
if (onSet) {
|
||||
onSet(newUsed);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
startUsed: newUsed,
|
||||
});
|
||||
};
|
||||
|
||||
handleResetClick = (): void => {
|
||||
const { startUsed } = this.state;
|
||||
|
||||
this.setState({
|
||||
newUsed: startUsed,
|
||||
});
|
||||
};
|
||||
|
||||
handleUseClick = (): void => {
|
||||
const { isReadonly } = this.props;
|
||||
|
||||
if (!isReadonly) {
|
||||
this.setState((prevState, props) => ({
|
||||
newUsed: Math.min(prevState.newUsed + 1, props.available),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handleGainClick = (): void => {
|
||||
const { isReadonly } = this.props;
|
||||
|
||||
if (!isReadonly) {
|
||||
this.setState((prevState, props) => ({
|
||||
newUsed: Math.max(prevState.newUsed - 1, 0),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { newUsed, startUsed } = this.state;
|
||||
const { label, available, isInteractive } = this.props;
|
||||
|
||||
const usedDiff: number = startUsed - newUsed;
|
||||
|
||||
let classNames: Array<string> = ["ct-slot-manager-large__values"];
|
||||
if (usedDiff > 0) {
|
||||
classNames.push("ct-slot-manager-large__values--gain");
|
||||
} else if (usedDiff < 0) {
|
||||
classNames.push("ct-slot-manager-large__values--use");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-slot-manager-large">
|
||||
<div className={classNames.join(" ")}>
|
||||
{label && <div className="ct-slot-manager-large__label">{label}</div>}
|
||||
<div className="ct-slot-manager-large__value-control ct-slot-manager-large__value-control--use">
|
||||
<ThemeButton
|
||||
size="small"
|
||||
className={"button-action-decrease button-action-decrease--small"}
|
||||
onClick={this.handleUseClick}
|
||||
disabled={newUsed === available}
|
||||
isInteractive={isInteractive}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-slot-manager-large__value ct-slot-manager-large__value--cur">
|
||||
{available - newUsed}
|
||||
</div>
|
||||
<div className="ct-slot-manager-large__value-control ct-slot-manager-large__value-control--gain">
|
||||
<ThemeButton
|
||||
size="small"
|
||||
className={"button-action-increase button-action-increase--small"}
|
||||
onClick={this.handleGainClick}
|
||||
disabled={newUsed === 0}
|
||||
isInteractive={isInteractive}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{usedDiff !== 0 && (
|
||||
<div className="ct-slot-manager-large__diff">
|
||||
<div className="ct-slot-manager-large__diff-explain">
|
||||
<NumberDisplay type="signed" number={usedDiff} />
|
||||
</div>
|
||||
<div className="ct-slot-manager-large__diff-actions">
|
||||
<ThemeButton
|
||||
className="ct-slot-manager-large__diff-action"
|
||||
size={"small"}
|
||||
onClick={this.handleSetClick}
|
||||
>
|
||||
Confirm
|
||||
</ThemeButton>
|
||||
<ThemeButton
|
||||
className="ct-slot-manager-large__diff-action"
|
||||
size={"small"}
|
||||
style={"outline"}
|
||||
onClick={this.handleResetClick}
|
||||
>
|
||||
Clear
|
||||
</ThemeButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SlotManagerLarge from "./SlotManagerLarge";
|
||||
|
||||
export default SlotManagerLarge;
|
||||
export { SlotManagerLarge };
|
||||
@@ -0,0 +1,965 @@
|
||||
import React, { useContext } from "react";
|
||||
import { DispatchProp } from "react-redux";
|
||||
|
||||
import {
|
||||
ComponentConstants,
|
||||
DamageTypeIcon,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
AbilityLookup,
|
||||
BaseInventoryContract,
|
||||
Constants,
|
||||
DiceUtils,
|
||||
EntityUtils,
|
||||
FormatUtils,
|
||||
ItemUtils,
|
||||
LimitedUseUtils,
|
||||
ModifierUtils,
|
||||
Spell,
|
||||
SpellCasterInfo,
|
||||
SpellSlotContract,
|
||||
SpellUtils,
|
||||
characterActions,
|
||||
ApiRequestHelpers,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
CharacterTheme,
|
||||
InventoryManager,
|
||||
ItemManager,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { toastMessageActions } from "../../actions/toastMessage";
|
||||
import { InventoryManagerContext } from "../../managers/InventoryManagerContext";
|
||||
import { ThemeButton } from "../common/Button";
|
||||
|
||||
function canCastAtLevel(level, available) {
|
||||
return available.indexOf(level) > -1;
|
||||
}
|
||||
|
||||
function isCastAvailableAtLevel(level, available) {
|
||||
return available.indexOf(level) > -1;
|
||||
}
|
||||
|
||||
// TODO dispatch needs to be factored out
|
||||
interface Props extends DispatchProp {
|
||||
characterLevel: number;
|
||||
spell: Spell;
|
||||
spellSlots: Array<SpellSlotContract>;
|
||||
pactMagicSlots: Array<SpellSlotContract>;
|
||||
castableSpellLevels: Array<number>;
|
||||
castablePactMagicLevels: Array<number>;
|
||||
availableSpellLevels: Array<number>;
|
||||
availablePactMagicLevels: Array<number>;
|
||||
initialCastLevel: number | null;
|
||||
spellCasterInfo: SpellCasterInfo;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme?: CharacterTheme;
|
||||
inventoryManager: InventoryManager;
|
||||
}
|
||||
interface State {
|
||||
castLevel: number;
|
||||
castableLevelsIdx: number;
|
||||
castableLevels: Array<number>;
|
||||
minLevel: number;
|
||||
maxLevel: number;
|
||||
}
|
||||
export class SpellCaster extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
initialCastLevel: null,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = this.getStartingCastState(props);
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>,
|
||||
snapshot?: any
|
||||
): void {
|
||||
const { spell } = this.props;
|
||||
|
||||
if (
|
||||
SpellUtils.getUniqueKey(spell) !==
|
||||
SpellUtils.getUniqueKey(prevProps.spell)
|
||||
) {
|
||||
this.setState({
|
||||
...this.getStartingCastState(this.props),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getStartingCastState = (props: Props): State => {
|
||||
const { spell, initialCastLevel, spellCasterInfo, ruleData } = props;
|
||||
|
||||
let { startLevel, endLevel } = SpellUtils.getCastLevelRange(
|
||||
spell,
|
||||
spellCasterInfo,
|
||||
ruleData
|
||||
);
|
||||
|
||||
const castableLevels = SpellUtils.getCastableLevels(
|
||||
spell,
|
||||
spellCasterInfo,
|
||||
ruleData,
|
||||
initialCastLevel
|
||||
);
|
||||
const castLevel = SpellUtils.getMinCastLevel(
|
||||
spell,
|
||||
spellCasterInfo,
|
||||
ruleData,
|
||||
initialCastLevel
|
||||
);
|
||||
const castableLevelsIdx = castableLevels.findIndex(
|
||||
(level) => level === castLevel
|
||||
);
|
||||
|
||||
return {
|
||||
castLevel,
|
||||
castableLevelsIdx,
|
||||
castableLevels,
|
||||
minLevel: startLevel,
|
||||
maxLevel: endLevel,
|
||||
};
|
||||
};
|
||||
|
||||
getSpellLevelUses = (
|
||||
spellSlots: Array<SpellSlotContract>,
|
||||
level: number
|
||||
): number => {
|
||||
const spellSlot = spellSlots.find((slot) => slot.level === level);
|
||||
|
||||
if (spellSlot) {
|
||||
return spellSlot.used;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
handleIncreaseCastLevel = (): void => {
|
||||
this.setState((prevState, props) => ({
|
||||
castableLevelsIdx: prevState.castableLevelsIdx + 1,
|
||||
castLevel: prevState.castableLevels[prevState.castableLevelsIdx + 1],
|
||||
}));
|
||||
};
|
||||
|
||||
handleDecreaseCastLevel = (): void => {
|
||||
this.setState((prevState, props) => ({
|
||||
castableLevelsIdx: prevState.castableLevelsIdx - 1,
|
||||
castLevel: prevState.castableLevels[prevState.castableLevelsIdx - 1],
|
||||
}));
|
||||
};
|
||||
|
||||
handleCastSpellSlot = (): void => {
|
||||
this.handleSpellUse(true, false);
|
||||
};
|
||||
|
||||
handleCastPactMagicSlot = (): void => {
|
||||
this.handleSpellUse(false, true);
|
||||
};
|
||||
|
||||
// TODO dispatch needs to be factored out
|
||||
handleSpellUse = (useSpellSlot: boolean, usePactMagicSlot: boolean): void => {
|
||||
const { castLevel, minLevel } = this.state;
|
||||
const {
|
||||
dispatch,
|
||||
spell,
|
||||
spellSlots,
|
||||
pactMagicSlots,
|
||||
isReadonly,
|
||||
inventoryManager,
|
||||
} = this.props;
|
||||
|
||||
if (isReadonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataOrigin = SpellUtils.getDataOrigin(spell);
|
||||
const dataOriginType = SpellUtils.getDataOriginType(spell);
|
||||
|
||||
let limitedUse = SpellUtils.getLimitedUse(spell);
|
||||
let mappingId: number | null = null;
|
||||
let mappingTypeId: number | null = null;
|
||||
if (limitedUse) {
|
||||
let numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
||||
let uses = SpellUtils.getConsumedLimitedUse(spell, castLevel - minLevel);
|
||||
|
||||
switch (dataOriginType) {
|
||||
case Constants.DataOriginTypeEnum.ITEM:
|
||||
//TODO itemManager: make a way to get the item with just he mapping id and lookups?
|
||||
|
||||
mappingId = ItemUtils.getMappingId(
|
||||
dataOrigin.primary as BaseInventoryContract
|
||||
);
|
||||
mappingTypeId = ItemUtils.getMappingEntityTypeId(
|
||||
dataOrigin.primary as BaseInventoryContract
|
||||
);
|
||||
const itemData = dataOrigin.primary as BaseInventoryContract;
|
||||
const item = ItemManager.getItem(ItemUtils.getMappingId(itemData));
|
||||
item.handleItemLimitedUseSet(numberUsed + uses);
|
||||
dispatch(
|
||||
toastMessageActions.toastSuccess(
|
||||
"Item Spell Cast",
|
||||
`Cast ${SpellUtils.getName(
|
||||
spell
|
||||
)} from ${EntityUtils.getDataOriginName(dataOrigin)}`
|
||||
)
|
||||
);
|
||||
break;
|
||||
default:
|
||||
mappingId = SpellUtils.getMappingId(spell);
|
||||
mappingTypeId = SpellUtils.getMappingEntityTypeId(spell);
|
||||
if (mappingId !== null && mappingTypeId !== null) {
|
||||
dispatch(
|
||||
characterActions.spellUseSet(
|
||||
mappingId,
|
||||
mappingTypeId,
|
||||
numberUsed + uses,
|
||||
dataOriginType
|
||||
)
|
||||
);
|
||||
dispatch(
|
||||
toastMessageActions.toastSuccess(
|
||||
"Limited Use Spell Cast",
|
||||
`Cast ${SpellUtils.getName(spell)} with limited use`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (useSpellSlot) {
|
||||
let castLevelKey =
|
||||
ApiRequestHelpers.getSpellLevelSpellSlotRequestsDataKey(castLevel);
|
||||
if (castLevelKey !== null) {
|
||||
dispatch(
|
||||
characterActions.spellLevelSpellSlotsSet({
|
||||
[castLevelKey]: this.getSpellLevelUses(spellSlots, castLevel) + 1,
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
toastMessageActions.toastSuccess(
|
||||
"Spell Cast",
|
||||
`Cast ${SpellUtils.getName(spell)} in spell slot level ${castLevel}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (usePactMagicSlot) {
|
||||
let castLevelKey =
|
||||
ApiRequestHelpers.getSpellLevelPactMagicRequestsDataKey(castLevel);
|
||||
if (castLevelKey !== null) {
|
||||
dispatch(
|
||||
characterActions.spellLevelPactMagicSlotsSet({
|
||||
[castLevelKey]:
|
||||
this.getSpellLevelUses(pactMagicSlots, castLevel) + 1,
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
toastMessageActions.toastSuccess(
|
||||
"Spell Cast",
|
||||
`Cast ${SpellUtils.getName(spell)} in a pact magic slot`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleCast = (): void => {
|
||||
const { castLevel } = this.state;
|
||||
const { spell, spellCasterInfo } = this.props;
|
||||
|
||||
let usesSpellSlot = SpellUtils.getUsesSpellSlot(spell);
|
||||
let limitedUse = SpellUtils.getLimitedUse(spell);
|
||||
|
||||
const isSpellSlotAvailable = canCastAtLevel(
|
||||
castLevel,
|
||||
spellCasterInfo.castableSpellLevels
|
||||
);
|
||||
const isPactSlotAvailable = canCastAtLevel(
|
||||
castLevel,
|
||||
spellCasterInfo.castablePactMagicLevels
|
||||
);
|
||||
|
||||
if (usesSpellSlot) {
|
||||
if (limitedUse) {
|
||||
if (SpellUtils.isValidToUsePactSlots(spell)) {
|
||||
if (isPactSlotAvailable) {
|
||||
this.handleSpellUse(false, true);
|
||||
}
|
||||
} else {
|
||||
if (isSpellSlotAvailable) {
|
||||
this.handleSpellUse(true, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isSpellSlotAvailable) {
|
||||
this.handleSpellUse(true, false);
|
||||
} else if (isPactSlotAvailable) {
|
||||
this.handleSpellUse(false, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.handleSpellUse(false, false);
|
||||
}
|
||||
};
|
||||
|
||||
renderLimitedUseDetails = (): React.ReactNode => {
|
||||
const { spell, ruleData, abilityLookup, proficiencyBonus } = this.props;
|
||||
const limitedUse = SpellUtils.getLimitedUse(spell);
|
||||
|
||||
if (!limitedUse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxUses = LimitedUseUtils.deriveMaxUses(
|
||||
limitedUse,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus
|
||||
);
|
||||
const resetType = LimitedUseUtils.getResetType(limitedUse);
|
||||
|
||||
if (resetType === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let limitedUseType: string = "Use";
|
||||
if (
|
||||
SpellUtils.getDataOriginType(spell) === Constants.DataOriginTypeEnum.ITEM
|
||||
) {
|
||||
limitedUseType = "Charge";
|
||||
}
|
||||
|
||||
let resetDisplay: string = "";
|
||||
switch (resetType) {
|
||||
case Constants.LimitedUseResetTypeEnum.SHORT_REST:
|
||||
case Constants.LimitedUseResetTypeEnum.LONG_REST:
|
||||
resetDisplay = `${
|
||||
maxUses === 1 ? "Once" : maxUses
|
||||
} per ${RuleDataUtils.getLimitedUseResetTypeName(resetType, ruleData)}`;
|
||||
break;
|
||||
default:
|
||||
resetDisplay = `${maxUses} ${limitedUseType}${
|
||||
maxUses !== 1 ? "s" : ""
|
||||
}, Reset: ${RuleDataUtils.getLimitedUseResetTypeName(
|
||||
resetType,
|
||||
ruleData
|
||||
)}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return <div className="ct-spell-caster__limited">{resetDisplay}</div>;
|
||||
};
|
||||
|
||||
renderCasting = (): React.ReactNode => {
|
||||
const { castLevel, minLevel, maxLevel, castableLevelsIdx, castableLevels } =
|
||||
this.state;
|
||||
const {
|
||||
spell,
|
||||
castableSpellLevels,
|
||||
castablePactMagicLevels,
|
||||
spellSlots,
|
||||
pactMagicSlots,
|
||||
availableSpellLevels,
|
||||
availablePactMagicLevels,
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
spellCasterInfo,
|
||||
isReadonly,
|
||||
proficiencyBonus,
|
||||
} = this.props;
|
||||
|
||||
const limitedUse = SpellUtils.getLimitedUse(spell);
|
||||
const isAtWill = SpellUtils.validateIsAtWill(spell, castLevel);
|
||||
const castAsRitual = SpellUtils.isCastAsRitual(spell);
|
||||
|
||||
let castLevelNode: React.ReactNode;
|
||||
let castActionsNode: React.ReactNode;
|
||||
|
||||
if (castAsRitual) {
|
||||
castActionsNode = "As Ritual";
|
||||
} else if (isAtWill) {
|
||||
castActionsNode = "At Will";
|
||||
} else {
|
||||
if (limitedUse) {
|
||||
const maxUses = LimitedUseUtils.deriveMaxUses(
|
||||
limitedUse,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
proficiencyBonus
|
||||
);
|
||||
const numberUsed = LimitedUseUtils.getNumberUsed(limitedUse);
|
||||
const consumedAmount = SpellUtils.getConsumedLimitedUse(
|
||||
spell,
|
||||
castLevel - minLevel
|
||||
);
|
||||
const numberRemaining: number = maxUses - numberUsed;
|
||||
let limitedUseButtonText: React.ReactNode;
|
||||
let showRemainingCount: boolean = true;
|
||||
|
||||
const dataOriginType = SpellUtils.getDataOriginType(spell);
|
||||
|
||||
if (maxUses === 1) {
|
||||
if (dataOriginType === Constants.DataOriginTypeEnum.ITEM) {
|
||||
limitedUseButtonText = "1 Charge";
|
||||
} else {
|
||||
limitedUseButtonText = "Use";
|
||||
showRemainingCount = false;
|
||||
}
|
||||
} else {
|
||||
limitedUseButtonText = `${consumedAmount} ${
|
||||
dataOriginType === Constants.DataOriginTypeEnum.ITEM
|
||||
? "Charge"
|
||||
: "Use"
|
||||
}${consumedAmount !== 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
const isLimitedUseAvailable =
|
||||
SpellUtils.validateIsLimitedUseAvailableAtScaledAmount(
|
||||
spell,
|
||||
castLevel,
|
||||
castLevel - minLevel,
|
||||
abilityLookup,
|
||||
ruleData,
|
||||
spellCasterInfo,
|
||||
proficiencyBonus
|
||||
);
|
||||
const usesSpellSlots = SpellUtils.getUsesSpellSlot(spell);
|
||||
|
||||
let spellSlotsAvailable: number | null = null;
|
||||
let spellSlotName: string = "";
|
||||
if (usesSpellSlots) {
|
||||
if (SpellUtils.isValidToUsePactSlots(spell)) {
|
||||
spellSlotsAvailable = SpellUtils.getCastLevelAvailableCount(
|
||||
castLevel,
|
||||
pactMagicSlots
|
||||
);
|
||||
spellSlotName = "Pact Slot";
|
||||
} else {
|
||||
spellSlotsAvailable = SpellUtils.getCastLevelAvailableCount(
|
||||
castLevel,
|
||||
spellSlots
|
||||
);
|
||||
spellSlotName = "Spell Slot";
|
||||
}
|
||||
}
|
||||
|
||||
castActionsNode = (
|
||||
<React.Fragment>
|
||||
<div className="ct-spell-caster__casting-action">
|
||||
<ThemeButton
|
||||
disabled={!isLimitedUseAvailable}
|
||||
isInteractive={!isReadonly}
|
||||
onClick={this.handleCast}
|
||||
className="ct-spell-caster__casting-action-button--limited"
|
||||
>
|
||||
{usesSpellSlots ? `${spellSlotName}, ` : ""}
|
||||
{limitedUseButtonText}
|
||||
{usesSpellSlots && (
|
||||
<span className="ct-spell-caster__casting-action-count ct-spell-caster__casting-action-count--spellcasting">
|
||||
{spellSlotsAvailable}
|
||||
</span>
|
||||
)}
|
||||
{showRemainingCount && (
|
||||
<span className="ct-spell-caster__casting-action-count ct-spell-caster__casting-action-count--limited-use">
|
||||
{numberRemaining}
|
||||
</span>
|
||||
)}
|
||||
</ThemeButton>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
const canSpellCastAtLevel = canCastAtLevel(
|
||||
castLevel,
|
||||
castableSpellLevels
|
||||
);
|
||||
const canPactMagicCastAtLevel = canCastAtLevel(
|
||||
castLevel,
|
||||
castablePactMagicLevels
|
||||
);
|
||||
const isSpellCastAvailableAtLevel = isCastAvailableAtLevel(
|
||||
castLevel,
|
||||
availableSpellLevels
|
||||
);
|
||||
const isPactMagicCastAvailableAtLevel = isCastAvailableAtLevel(
|
||||
castLevel,
|
||||
availablePactMagicLevels
|
||||
);
|
||||
const spellCastLevelAvailableCount =
|
||||
SpellUtils.getCastLevelAvailableCount(castLevel, spellSlots);
|
||||
const pactMagicCastLevelAvailableCount =
|
||||
SpellUtils.getCastLevelAvailableCount(castLevel, pactMagicSlots);
|
||||
|
||||
castActionsNode = (
|
||||
<React.Fragment>
|
||||
{isSpellCastAvailableAtLevel && (
|
||||
<div className="ct-spell-caster__casting-action">
|
||||
<ThemeButton
|
||||
disabled={!canSpellCastAtLevel}
|
||||
isInteractive={!isReadonly}
|
||||
onClick={this.handleCastSpellSlot}
|
||||
>
|
||||
Spell Slot
|
||||
<span className="ct-spell-caster__casting-action-count ct-spell-caster__casting-action-count--spellcasting">
|
||||
{spellCastLevelAvailableCount}
|
||||
</span>
|
||||
</ThemeButton>
|
||||
</div>
|
||||
)}
|
||||
{isPactMagicCastAvailableAtLevel && (
|
||||
<div className="ct-spell-caster__casting-action">
|
||||
<ThemeButton
|
||||
disabled={!canPactMagicCastAtLevel}
|
||||
onClick={this.handleCastPactMagicSlot}
|
||||
>
|
||||
Pact Slot
|
||||
<span className="ct-spell-caster__casting-action-count ct-spell-caster__casting-action-count--spellcasting">
|
||||
{pactMagicCastLevelAvailableCount}
|
||||
</span>
|
||||
</ThemeButton>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!SpellUtils.isCantrip(spell)) {
|
||||
castLevelNode = (
|
||||
<div className="ct-spell-caster__casting-level">
|
||||
<span className="ct-spell-caster__casting-level-label">Level</span>
|
||||
{minLevel !== maxLevel && (
|
||||
<span className="ct-spell-caster__casting-level-action">
|
||||
<ThemeButton
|
||||
disabled={castableLevelsIdx === 0}
|
||||
onClick={this.handleDecreaseCastLevel}
|
||||
className="ct-button--decrease"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`ct-spell-caster__casting-level-current ct-spell-caster__casting-level-current--${
|
||||
minLevel === maxLevel ? "nocontrols" : "controls"
|
||||
}`}
|
||||
>
|
||||
{FormatUtils.renderSpellLevelAbbreviation(castLevel)}
|
||||
</span>
|
||||
{minLevel !== maxLevel && (
|
||||
<span className="ct-spell-caster__casting-level-action">
|
||||
<ThemeButton
|
||||
disabled={castableLevelsIdx + 1 === castableLevels.length}
|
||||
onClick={this.handleIncreaseCastLevel}
|
||||
className="ct-button--increase"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
//`
|
||||
|
||||
return (
|
||||
<div className="ct-spell-caster__casting">
|
||||
<div className="ct-spell-caster__casting-label">Cast</div>
|
||||
<div className="ct-spell-caster__casting-actions">
|
||||
{castActionsNode}
|
||||
</div>
|
||||
{castLevelNode}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderAtHigherLevels = (): React.ReactNode => {
|
||||
const { castLevel } = this.state;
|
||||
const { spell, characterLevel } = this.props;
|
||||
|
||||
const atHigherLevels = SpellUtils.getAtHigherLevels(spell);
|
||||
if (!atHigherLevels) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scaleType = SpellUtils.getScaleType(spell);
|
||||
|
||||
const {
|
||||
additionalAttacks,
|
||||
additionalTargets,
|
||||
areaOfEffect,
|
||||
duration,
|
||||
creatures,
|
||||
special,
|
||||
range,
|
||||
} = atHigherLevels;
|
||||
|
||||
const level = SpellUtils.getLevel(spell);
|
||||
|
||||
let additionalAttacksDisplay: React.ReactNode;
|
||||
if (additionalAttacks !== null) {
|
||||
const additionalAttackInfo = SpellUtils.getSpellScaledAtHigher(
|
||||
spell,
|
||||
scaleType,
|
||||
additionalAttacks,
|
||||
characterLevel,
|
||||
castLevel
|
||||
);
|
||||
if (additionalAttackInfo) {
|
||||
const additionalAttackCount =
|
||||
level === 0
|
||||
? additionalAttackInfo.totalCount + 1
|
||||
: additionalAttackInfo.totalCount;
|
||||
const additionalAttackLabel = level === 0 ? "Total" : "Additional";
|
||||
additionalAttacksDisplay = (
|
||||
<div className="ct-spell-caster__higher ct-spell-caster__higher--attacks">
|
||||
{additionalAttackLabel} {additionalAttackInfo.description}:{" "}
|
||||
{additionalAttackCount}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let additionalTargetsDisplay: React.ReactNode;
|
||||
if (additionalTargets !== null) {
|
||||
const additionalTargetInfo = SpellUtils.getSpellScaledAtHigher(
|
||||
spell,
|
||||
scaleType,
|
||||
additionalTargets,
|
||||
characterLevel,
|
||||
castLevel
|
||||
);
|
||||
if (additionalTargetInfo) {
|
||||
additionalTargetsDisplay = (
|
||||
<div className="ct-spell-caster__higher ct-spell-caster__higher--targets">
|
||||
<div className="ct-spell-caster__higher-targets-info">
|
||||
{additionalTargetInfo.targets} Additional Target
|
||||
{additionalTargetInfo.targets === 1 ? "" : "s"}
|
||||
</div>
|
||||
<div className="ct-spell-caster__higher-targets-description">
|
||||
{additionalTargetInfo.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let aoeDisplay: React.ReactNode;
|
||||
if (areaOfEffect !== null) {
|
||||
const aoeInfo = SpellUtils.getSpellScaledAtHigher(
|
||||
spell,
|
||||
scaleType,
|
||||
areaOfEffect,
|
||||
characterLevel,
|
||||
castLevel
|
||||
);
|
||||
if (aoeInfo && aoeInfo.extendedAoe !== null) {
|
||||
aoeDisplay = (
|
||||
<div className="ct-spell-caster__higher ct-spell-caster__higher--aoe">
|
||||
Extended Area: {FormatUtils.renderDistance(aoeInfo.extendedAoe)}{" "}
|
||||
{aoeInfo.description}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let rangeDisplay: React.ReactNode;
|
||||
if (range !== null) {
|
||||
const rangeInfo = SpellUtils.getSpellScaledAtHigher(
|
||||
spell,
|
||||
scaleType,
|
||||
range,
|
||||
characterLevel,
|
||||
castLevel
|
||||
);
|
||||
if (rangeInfo && rangeInfo.range) {
|
||||
rangeDisplay = (
|
||||
<div className="ct-spell-caster__higher ct-spell-caster__higher--range">
|
||||
Extended Range: {FormatUtils.renderDistance(rangeInfo.range)}{" "}
|
||||
{rangeInfo.description}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let durationDisplay: React.ReactNode;
|
||||
if (duration !== null) {
|
||||
const durationInfo = SpellUtils.getSpellScaledAtHigher(
|
||||
spell,
|
||||
scaleType,
|
||||
duration,
|
||||
characterLevel,
|
||||
castLevel
|
||||
);
|
||||
if (durationInfo) {
|
||||
durationDisplay = (
|
||||
<div className="ct-spell-caster__higher ct-spell-caster__higher--duration">
|
||||
Extended Duration: {durationInfo.description}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let creaturesDisplay: React.ReactNode;
|
||||
if (creatures !== null) {
|
||||
const creatureInfo = SpellUtils.getSpellScaledAtHigher(
|
||||
spell,
|
||||
scaleType,
|
||||
creatures,
|
||||
characterLevel,
|
||||
castLevel
|
||||
);
|
||||
if (creatureInfo) {
|
||||
creaturesDisplay = (
|
||||
<div className="ct-spell-caster__higher ct-spell-caster__higher--creatures">
|
||||
Creatures: {creatureInfo.description}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let specialDisplay: React.ReactNode;
|
||||
if (special !== null) {
|
||||
const specialInfo = SpellUtils.getSpellScaledAtHigher(
|
||||
spell,
|
||||
scaleType,
|
||||
special,
|
||||
characterLevel,
|
||||
castLevel
|
||||
);
|
||||
if (specialInfo) {
|
||||
specialDisplay = (
|
||||
<div className="ct-spell-caster__higher ct-spell-caster__higher--special">
|
||||
Special: {specialInfo.description}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!additionalAttacksDisplay &&
|
||||
!additionalTargetsDisplay &&
|
||||
!aoeDisplay &&
|
||||
!durationDisplay &&
|
||||
!creaturesDisplay &&
|
||||
!specialDisplay &&
|
||||
!rangeDisplay
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-spell-caster__higher-items">
|
||||
{additionalAttacksDisplay}
|
||||
{additionalTargetsDisplay}
|
||||
{rangeDisplay}
|
||||
{aoeDisplay}
|
||||
{durationDisplay}
|
||||
{creaturesDisplay}
|
||||
{specialDisplay}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { castLevel } = this.state;
|
||||
const { characterLevel, spell, theme } = this.props;
|
||||
|
||||
const modifiers = SpellUtils.getModifiers(spell);
|
||||
|
||||
const damageModifiers = modifiers.filter((modifier) =>
|
||||
ModifierUtils.isSpellDamageModifier(modifier)
|
||||
);
|
||||
let damageNode: React.ReactNode;
|
||||
if (damageModifiers.length) {
|
||||
damageNode = (
|
||||
<div className="ct-spell-caster__modifiers ct-spell-caster__modifiers--damages">
|
||||
{damageModifiers.map((modifier) => {
|
||||
const atHigherLevels = ModifierUtils.getAtHigherLevels(modifier);
|
||||
const id = ModifierUtils.getId(modifier);
|
||||
const restriction = ModifierUtils.getRestriction(modifier);
|
||||
const friendlySubtypeName =
|
||||
ModifierUtils.getFriendlySubtypeName(modifier);
|
||||
const atHigherDamage = SpellUtils.getSpellScaledAtHigher(
|
||||
spell,
|
||||
atHigherLevels ? SpellUtils.getScaleType(spell) : null,
|
||||
atHigherLevels && atHigherLevels.points
|
||||
? atHigherLevels.points
|
||||
: [],
|
||||
characterLevel,
|
||||
castLevel
|
||||
);
|
||||
const scaledDamageDie = SpellUtils.getSpellFinalScaledDie(
|
||||
spell,
|
||||
modifier,
|
||||
atHigherDamage
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ct-spell-caster__modifier ct-spell-caster__modifier--damage"
|
||||
key={id ? id : ""}
|
||||
>
|
||||
{scaledDamageDie && (
|
||||
<span className="ct-spell-caster__modifier-amount">
|
||||
{DiceUtils.renderDice(scaledDamageDie)}
|
||||
</span>
|
||||
)}
|
||||
{friendlySubtypeName !== null && (
|
||||
<span className="ct-spell-caster__modifier-effect">
|
||||
<DamageTypeIcon
|
||||
theme={theme}
|
||||
type={
|
||||
FormatUtils.slugify(
|
||||
friendlySubtypeName
|
||||
) as ComponentConstants.DamageTypePropType
|
||||
}
|
||||
/>{" "}
|
||||
Damage
|
||||
</span>
|
||||
)}
|
||||
{!!restriction && (
|
||||
<span className="ct-spell-caster__modifier-restriction">
|
||||
({restriction})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hitPointHealingModifiers = modifiers.filter((modifier) =>
|
||||
ModifierUtils.isSpellHealingHitPointsModifier(modifier)
|
||||
);
|
||||
const tempHitPointHealingModifiers = modifiers.filter((modifier) =>
|
||||
ModifierUtils.isSpellHealingTempHitPointsModifier(modifier)
|
||||
);
|
||||
let healingNode: React.ReactNode;
|
||||
if (
|
||||
hitPointHealingModifiers.length ||
|
||||
tempHitPointHealingModifiers.length
|
||||
) {
|
||||
healingNode = (
|
||||
<div className="ct-spell-caster__modifiers ct-spell-caster__modifiers--healing">
|
||||
{hitPointHealingModifiers.map((modifier) => {
|
||||
const atHigherLevels = ModifierUtils.getAtHigherLevels(modifier);
|
||||
const id = ModifierUtils.getId(modifier);
|
||||
const restriction = ModifierUtils.getRestriction(modifier);
|
||||
const atHigherHealing = SpellUtils.getSpellScaledAtHigher(
|
||||
spell,
|
||||
atHigherLevels ? SpellUtils.getScaleType(spell) : null,
|
||||
atHigherLevels && atHigherLevels.points
|
||||
? atHigherLevels.points
|
||||
: [],
|
||||
characterLevel,
|
||||
castLevel
|
||||
);
|
||||
const scaledHealingDie = SpellUtils.getSpellFinalScaledDie(
|
||||
spell,
|
||||
modifier,
|
||||
atHigherHealing,
|
||||
castLevel
|
||||
);
|
||||
const isHealingDieAdditional =
|
||||
SpellUtils.hack__isHealingDieAdditionalBonusFixedValue(
|
||||
damageModifiers,
|
||||
spell,
|
||||
scaledHealingDie
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="ct-spell-caster__modifier ct-spell-caster__modifier--hp"
|
||||
key={id ? id : ""}
|
||||
>
|
||||
<span className="ct-spell-caster__modifier-amount">
|
||||
Regain {isHealingDieAdditional && "Additional"}{" "}
|
||||
{scaledHealingDie && DiceUtils.renderDice(scaledHealingDie)}{" "}
|
||||
Hit Points
|
||||
</span>
|
||||
{!!restriction && (
|
||||
<span className="ct-spell-caster__modifier-restriction">
|
||||
({restriction})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tempHitPointHealingModifiers.map((modifier) => {
|
||||
const atHigherLevels = ModifierUtils.getAtHigherLevels(modifier);
|
||||
const id = ModifierUtils.getId(modifier);
|
||||
const restriction = ModifierUtils.getRestriction(modifier);
|
||||
const atHigherHealing = SpellUtils.getSpellScaledAtHigher(
|
||||
spell,
|
||||
atHigherLevels ? SpellUtils.getScaleType(spell) : null,
|
||||
atHigherLevels && atHigherLevels.points
|
||||
? atHigherLevels.points
|
||||
: [],
|
||||
characterLevel,
|
||||
castLevel
|
||||
);
|
||||
const scaledHealingDie = SpellUtils.getSpellFinalScaledDie(
|
||||
spell,
|
||||
modifier,
|
||||
atHigherHealing,
|
||||
castLevel
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="ct-spell-caster__modifier ct-spell-caster__modifier--temp"
|
||||
key={id ? id : ""}
|
||||
>
|
||||
<span className="ct-spell-caster__modifier-amount">
|
||||
Regain{" "}
|
||||
{scaledHealingDie && DiceUtils.renderDice(scaledHealingDie)}{" "}
|
||||
Temp Hit Points
|
||||
</span>
|
||||
{!!restriction && (
|
||||
<span className="ct-spell-caster__modifier-restriction">
|
||||
({restriction})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const castingNode = this.renderCasting();
|
||||
const limitedUseNode = this.renderLimitedUseDetails();
|
||||
const atHigherLevelsNode = this.renderAtHigherLevels();
|
||||
|
||||
if (
|
||||
!castingNode &&
|
||||
!limitedUseNode &&
|
||||
!damageNode &&
|
||||
!healingNode &&
|
||||
!atHigherLevelsNode
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-spell-caster">
|
||||
{castingNode}
|
||||
{limitedUseNode}
|
||||
{damageNode}
|
||||
{healingNode}
|
||||
{atHigherLevelsNode}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SpellCasterContainer = (props) => {
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
return <SpellCaster inventoryManager={inventoryManager} {...props} />;
|
||||
};
|
||||
|
||||
export default SpellCasterContainer;
|
||||
@@ -0,0 +1,4 @@
|
||||
import SpellCaster from "./SpellCaster";
|
||||
|
||||
export default SpellCaster;
|
||||
export { SpellCaster };
|
||||
@@ -0,0 +1,475 @@
|
||||
import React from "react";
|
||||
|
||||
import { Tooltip } from "@dndbeyond/character-common-components/es";
|
||||
import {
|
||||
Collapsible,
|
||||
AoeTypeIcon,
|
||||
ComponentConstants,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
ActivationUtils,
|
||||
AnySimpleDataType,
|
||||
CharacterTheme,
|
||||
CharacterUtils,
|
||||
CharacterValuesContract,
|
||||
Constants,
|
||||
DurationUtils,
|
||||
EntityValueLookup,
|
||||
FormatUtils,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
SourceMappingContract,
|
||||
Spell,
|
||||
SpellCasterInfo,
|
||||
SpellUtils,
|
||||
ValueUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { HtmlContent } from "~/components/HtmlContent";
|
||||
import { InfoItem } from "~/components/InfoItem";
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
import { Reference } from "~/components/Reference";
|
||||
import { TagGroup } from "~/components/TagGroup";
|
||||
|
||||
import SpellDetailCaster from "../../containers/SpellDetailCaster";
|
||||
import EditorBox from "../EditorBox";
|
||||
import ValueEditor from "../ValueEditor";
|
||||
import { RemoveButton, ThemeButton } from "../common/Button";
|
||||
|
||||
interface Props {
|
||||
spell: Spell;
|
||||
enableCaster: boolean;
|
||||
castAsRitual: boolean;
|
||||
initialCastLevel: number | null;
|
||||
isPreparedMaxed: boolean;
|
||||
onRemove?: () => void;
|
||||
onPrepareToggle?: () => void;
|
||||
onCustomDataUpdate?: (
|
||||
key: number,
|
||||
value: AnySimpleDataType,
|
||||
source: string | null
|
||||
) => void;
|
||||
onCustomizationsRemove?: () => void;
|
||||
showCustomize: boolean;
|
||||
showActions: boolean;
|
||||
entityValueLookup?: EntityValueLookup;
|
||||
spellCasterInfo: SpellCasterInfo;
|
||||
ruleData: RuleData;
|
||||
isReadonly: boolean;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
isCustomizeClosed?: boolean;
|
||||
onCustomizeClick?: (isCollapsed: boolean) => void;
|
||||
}
|
||||
export default class SpellDetail extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
initialCastLevel: null,
|
||||
isPreparedMaxed: true,
|
||||
enableCaster: true,
|
||||
showActions: true,
|
||||
showCustomize: true,
|
||||
castAsRitual: false,
|
||||
spellCasterInfo: {},
|
||||
isReadonly: false,
|
||||
};
|
||||
|
||||
handleRemoveCustomizations = () => {
|
||||
const { onCustomizationsRemove } = this.props;
|
||||
|
||||
if (onCustomizationsRemove) {
|
||||
onCustomizationsRemove();
|
||||
}
|
||||
};
|
||||
|
||||
renderCustomize = (): React.ReactNode => {
|
||||
const {
|
||||
spell,
|
||||
showCustomize,
|
||||
onCustomDataUpdate,
|
||||
entityValueLookup,
|
||||
ruleData,
|
||||
isReadonly,
|
||||
isCustomizeClosed,
|
||||
onCustomizeClick,
|
||||
} = this.props;
|
||||
|
||||
const dataOrigin = SpellUtils.getDataOrigin(spell);
|
||||
const dataOriginType = SpellUtils.getDataOriginType(spell);
|
||||
|
||||
if (
|
||||
!entityValueLookup ||
|
||||
!showCustomize ||
|
||||
!dataOrigin ||
|
||||
(dataOrigin && dataOriginType === Constants.DataOriginTypeEnum.ITEM) ||
|
||||
isReadonly
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayAsAttack = SpellUtils.isDisplayAsAttack(spell);
|
||||
const initialDisplayAsAttack: boolean =
|
||||
displayAsAttack ||
|
||||
(SpellUtils.isAttack(spell) &&
|
||||
(displayAsAttack || displayAsAttack === null));
|
||||
|
||||
let customizationValues: Array<Constants.AdjustmentTypeEnum> = [
|
||||
Constants.AdjustmentTypeEnum.TO_HIT_OVERRIDE,
|
||||
Constants.AdjustmentTypeEnum.TO_HIT_BONUS,
|
||||
Constants.AdjustmentTypeEnum.FIXED_VALUE_BONUS,
|
||||
Constants.AdjustmentTypeEnum.SAVE_DC_OVERRIDE,
|
||||
Constants.AdjustmentTypeEnum.SAVE_DC_BONUS,
|
||||
];
|
||||
|
||||
if (!SpellUtils.asPartOfWeaponAttack(spell)) {
|
||||
customizationValues.push(Constants.AdjustmentTypeEnum.DISPLAY_AS_ATTACK);
|
||||
}
|
||||
|
||||
customizationValues.push(Constants.AdjustmentTypeEnum.NAME_OVERRIDE);
|
||||
customizationValues.push(Constants.AdjustmentTypeEnum.NOTES);
|
||||
|
||||
let customizationLabelOverrides: Partial<
|
||||
Record<Constants.AdjustmentTypeEnum, string>
|
||||
> = {
|
||||
[Constants.AdjustmentTypeEnum.NAME_OVERRIDE]: "Name",
|
||||
[Constants.AdjustmentTypeEnum.FIXED_VALUE_BONUS]: "Damage Bonus",
|
||||
[Constants.AdjustmentTypeEnum.SAVE_DC_OVERRIDE]: "DC Override",
|
||||
[Constants.AdjustmentTypeEnum.SAVE_DC_BONUS]: "DC Bonus",
|
||||
};
|
||||
|
||||
let customizationDefaults: Partial<
|
||||
Record<Constants.AdjustmentTypeEnum, any>
|
||||
> = {
|
||||
[Constants.AdjustmentTypeEnum.DISPLAY_AS_ATTACK]: initialDisplayAsAttack,
|
||||
};
|
||||
|
||||
let [contextId, contextTypeId] = SpellUtils.deriveExpandedContextIds(spell);
|
||||
const mappingId = SpellUtils.getMappingId(spell);
|
||||
const mappingEntityTypeId = SpellUtils.getMappingEntityTypeId(spell);
|
||||
let dataLookup: Record<number, CharacterValuesContract> = {};
|
||||
if (mappingId !== null && mappingEntityTypeId !== null) {
|
||||
dataLookup = ValueUtils.getEntityData(
|
||||
entityValueLookup,
|
||||
ValueUtils.hack__toString(mappingId),
|
||||
ValueUtils.hack__toString(mappingEntityTypeId),
|
||||
ValueUtils.hack__toString(contextId),
|
||||
ValueUtils.hack__toString(contextTypeId)
|
||||
);
|
||||
}
|
||||
|
||||
const isCustomized = SpellUtils.isCustomized(spell);
|
||||
|
||||
return (
|
||||
<div className="ct-spell-detail__customize">
|
||||
<Collapsible
|
||||
layoutType={"minimal"}
|
||||
header={`Customize${isCustomized ? "*" : ""}`}
|
||||
collapsed={isCustomizeClosed}
|
||||
onChangeHandler={onCustomizeClick}
|
||||
>
|
||||
<EditorBox>
|
||||
<ValueEditor
|
||||
dataLookup={dataLookup}
|
||||
onDataUpdate={onCustomDataUpdate}
|
||||
valueEditors={customizationValues}
|
||||
ruleData={ruleData}
|
||||
labelOverrides={customizationLabelOverrides}
|
||||
defaultValues={customizationDefaults}
|
||||
layoutType={"compact"}
|
||||
/>
|
||||
<RemoveButton
|
||||
enableConfirm={true}
|
||||
size="medium"
|
||||
style="filled"
|
||||
disabled={!isCustomized}
|
||||
isInteractive={isCustomized}
|
||||
onClick={this.handleRemoveCustomizations}
|
||||
>
|
||||
{isCustomized ? "Remove" : "No"} Customizations
|
||||
</RemoveButton>
|
||||
</EditorBox>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
//`
|
||||
};
|
||||
|
||||
renderActions = (): React.ReactNode => {
|
||||
const { spell, isPreparedMaxed, onRemove, onPrepareToggle, showActions } =
|
||||
this.props;
|
||||
|
||||
const dataOrigin = SpellUtils.getDataOrigin(spell);
|
||||
const dataOriginType = SpellUtils.getDataOriginType(spell);
|
||||
|
||||
if (
|
||||
!showActions ||
|
||||
!dataOrigin ||
|
||||
(dataOrigin && dataOriginType !== Constants.DataOriginTypeEnum.CLASS)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPrepared = SpellUtils.isPrepared(spell);
|
||||
const showRemove = SpellUtils.canRemove(spell);
|
||||
const showPrepare =
|
||||
SpellUtils.canPrepare(spell) && !SpellUtils.isAlwaysPrepared(spell);
|
||||
|
||||
if (!showRemove && !showPrepare) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-spell-detail__actions">
|
||||
{showPrepare && (
|
||||
<div className="ct-spell-detail__action">
|
||||
<ThemeButton
|
||||
size="small"
|
||||
style={isPrepared ? "" : "outline"}
|
||||
onClick={onPrepareToggle}
|
||||
stopPropagation={true}
|
||||
disabled={isPreparedMaxed && !isPrepared}
|
||||
>
|
||||
{isPrepared ? "Prepared" : "Prepare"}
|
||||
</ThemeButton>
|
||||
</div>
|
||||
)}
|
||||
{showRemove && (
|
||||
<div className="ct-spell-detail__action">
|
||||
<RemoveButton onClick={onRemove} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderCaster = (): React.ReactNode => {
|
||||
const {
|
||||
spell,
|
||||
initialCastLevel,
|
||||
enableCaster,
|
||||
castAsRitual,
|
||||
spellCasterInfo,
|
||||
proficiencyBonus,
|
||||
} = this.props;
|
||||
|
||||
if (!enableCaster || castAsRitual || !SpellUtils.isActive(spell)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SpellDetailCaster
|
||||
spell={spell}
|
||||
initialCastLevel={initialCastLevel}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
{...spellCasterInfo}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { spell, ruleData, theme } = this.props;
|
||||
let { castAsRitual } = this.props;
|
||||
|
||||
castAsRitual = castAsRitual || SpellUtils.isCastAsRitual(spell);
|
||||
|
||||
const attackSaveValue = SpellUtils.getAttackSaveValue(spell);
|
||||
const notes = SpellUtils.getNotes(spell);
|
||||
const range = SpellUtils.getRange(spell);
|
||||
const additionalDescription = SpellUtils.getAdditionalDescription(spell);
|
||||
const level = SpellUtils.getLevel(spell);
|
||||
const components = SpellUtils.getComponents(spell);
|
||||
const componentsDescription = SpellUtils.getComponentsDescription(spell);
|
||||
const duration = SpellUtils.getDuration(spell);
|
||||
const school = SpellUtils.getSchool(spell);
|
||||
const description = SpellUtils.getDescription(spell);
|
||||
const version = SpellUtils.getVersion(spell);
|
||||
const requiresSavingThrow = SpellUtils.getRequiresSavingThrow(spell);
|
||||
const tags = SpellUtils.getTags(spell);
|
||||
const activation = SpellUtils.getActivation(spell);
|
||||
const castingTimeDescription = SpellUtils.getCastingTimeDescription(spell);
|
||||
let sourceId: number | null = null;
|
||||
let sourcePage: number | null = null;
|
||||
let filteredSources = SpellUtils.getSources(spell).filter(
|
||||
CharacterUtils.isPrimarySource
|
||||
);
|
||||
if (filteredSources.length) {
|
||||
let primarySource: SourceMappingContract = filteredSources[0];
|
||||
sourceId = primarySource.sourceId;
|
||||
sourcePage = primarySource.pageNumber;
|
||||
}
|
||||
|
||||
let rangeAreas: Array<React.ReactNode> = [];
|
||||
if (range) {
|
||||
if (
|
||||
range.origin &&
|
||||
range.origin !== Constants.SpellRangeTypeNameEnum.RANGED
|
||||
) {
|
||||
rangeAreas.push(range.origin);
|
||||
}
|
||||
if (range.rangeValue) {
|
||||
rangeAreas.push(
|
||||
<NumberDisplay type="distanceInFt" number={range.rangeValue} />
|
||||
);
|
||||
}
|
||||
if (range.aoeValue) {
|
||||
rangeAreas.push(
|
||||
<React.Fragment>
|
||||
<NumberDisplay type="distanceInFt" number={range.aoeValue} />
|
||||
<span className="ct-spell-detail__range-shape">
|
||||
<AoeTypeIcon
|
||||
className="ct-spell-detail__range-icon"
|
||||
type={
|
||||
FormatUtils.slugify(
|
||||
range.aoeType
|
||||
) as ComponentConstants.AoeTypePropType
|
||||
}
|
||||
themeMode={theme.isDarkMode ? "gray" : "dark"}
|
||||
/>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let componentsNode: React.ReactNode;
|
||||
if (components) {
|
||||
componentsNode = (
|
||||
<span className="ct-spell-detail__components">
|
||||
{components.map((componentId, idx) => {
|
||||
let componentInfo = RuleDataUtils.getSpellComponentInfo(
|
||||
componentId,
|
||||
ruleData
|
||||
);
|
||||
if (componentInfo === null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={componentId}>
|
||||
<Tooltip
|
||||
isDarkMode={theme.isDarkMode}
|
||||
title={componentInfo.name ? componentInfo.name : ""}
|
||||
>
|
||||
{componentInfo.shortName}
|
||||
</Tooltip>
|
||||
{idx + 1 < components.length ? ", " : ""}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
let castingTimeDisplay: React.ReactNode;
|
||||
if (activation) {
|
||||
castingTimeDisplay = ActivationUtils.renderCastingTime(
|
||||
activation,
|
||||
castAsRitual ? 10 : 0,
|
||||
ruleData
|
||||
);
|
||||
}
|
||||
|
||||
const infoItemProps = { role: "listitem", inline: true };
|
||||
const getSourceInfo = (sourceId) =>
|
||||
sourceId ? RuleDataUtils.getSourceDataInfo(sourceId, ruleData) : null;
|
||||
|
||||
return (
|
||||
<div className="ct-spell-detail">
|
||||
<div className="ct-spell-detail__level-school">
|
||||
{SpellUtils.isLegacy(spell) && (
|
||||
<span className="ct-spell-detail__level-school-item">
|
||||
Legacy •{" "}
|
||||
</span>
|
||||
)}
|
||||
<span className="ct-spell-detail__level-school-item">
|
||||
{SpellUtils.isCantrip(spell)
|
||||
? school
|
||||
: FormatUtils.renderSpellLevelName(level)}
|
||||
</span>
|
||||
<span className="ct-spell-detail__level-school-item">
|
||||
{SpellUtils.isCantrip(spell)
|
||||
? FormatUtils.renderSpellLevelName(level)
|
||||
: school}
|
||||
</span>
|
||||
</div>
|
||||
{this.renderCaster()}
|
||||
{this.renderCustomize()}
|
||||
<div className="ct-spell-detail__properties" role="list">
|
||||
<InfoItem label="Casting Time:" {...infoItemProps}>
|
||||
{castingTimeDisplay}
|
||||
{castingTimeDescription ? `, ${castingTimeDescription}` : ""}
|
||||
</InfoItem>
|
||||
<InfoItem label="Range/Area:" {...infoItemProps}>
|
||||
{rangeAreas.map((node, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{node}
|
||||
{idx + 1 < rangeAreas.length ? "/" : ""}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</InfoItem>
|
||||
<InfoItem label="Components:" {...infoItemProps}>
|
||||
{componentsNode}
|
||||
{componentsDescription && (
|
||||
<span className="ct-spell-detail__components-description">
|
||||
({componentsDescription})
|
||||
</span>
|
||||
)}
|
||||
</InfoItem>
|
||||
{duration !== null && (
|
||||
<InfoItem label="Duration:" {...infoItemProps}>
|
||||
{DurationUtils.renderDuration(
|
||||
duration,
|
||||
SpellUtils.getConcentration(spell)
|
||||
)}
|
||||
</InfoItem>
|
||||
)}
|
||||
{requiresSavingThrow && (
|
||||
<InfoItem label="Attack/Save:" {...infoItemProps}>
|
||||
{SpellUtils.getSaveDcAbilityShortName(spell, ruleData)}{" "}
|
||||
{attackSaveValue}
|
||||
</InfoItem>
|
||||
)}
|
||||
{version !== null && (
|
||||
<InfoItem label="Version:" {...infoItemProps}>
|
||||
{version}
|
||||
</InfoItem>
|
||||
)}
|
||||
{notes && (
|
||||
<InfoItem label="Notes:" {...infoItemProps}>
|
||||
{notes}
|
||||
</InfoItem>
|
||||
)}
|
||||
{sourceId !== null && (
|
||||
<InfoItem label="Source:" {...infoItemProps}>
|
||||
<Reference
|
||||
name={getSourceInfo(sourceId)?.description || ""}
|
||||
page={sourcePage}
|
||||
isDarkMode={theme?.isDarkMode}
|
||||
/>
|
||||
</InfoItem>
|
||||
)}
|
||||
</div>
|
||||
{description !== null && (
|
||||
<HtmlContent
|
||||
className="ct-spell-detail__description"
|
||||
html={description}
|
||||
withoutTooltips
|
||||
/>
|
||||
)}
|
||||
{additionalDescription && (
|
||||
<HtmlContent
|
||||
className="ct-spell-detail__additional-description"
|
||||
html={additionalDescription}
|
||||
withoutTooltips
|
||||
/>
|
||||
)}
|
||||
{this.renderActions()}
|
||||
{tags.length > 0 && (
|
||||
<TagGroup
|
||||
className="ct-spell-detail__tags"
|
||||
label="Tags"
|
||||
tags={tags}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SpellDetail from "./SpellDetail";
|
||||
|
||||
export default SpellDetail;
|
||||
export { SpellDetail };
|
||||
@@ -0,0 +1,264 @@
|
||||
import { sortBy } from "lodash";
|
||||
import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { LoadingPlaceholder } from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
DataOriginRefData,
|
||||
EntityValueLookup,
|
||||
rulesEngineSelectors,
|
||||
SimpleSourceCategoryContract,
|
||||
SpellManager as SpellManagerType,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { Link } from "~/components/Link";
|
||||
import { SpellFilter } from "~/components/SpellFilter";
|
||||
|
||||
import DataLoadingStatusEnum from "../../constants/DataLoadingStatusEnum";
|
||||
import { SpellsManagerContext } from "../../managers/SpellsManagerContext";
|
||||
import { FilterUtils } from "../../utils";
|
||||
import SpellManagerItem from "./SpellManagerItem";
|
||||
|
||||
interface Props {
|
||||
charClassId: number;
|
||||
characterClassId: number;
|
||||
shouldFetch?: boolean;
|
||||
hasActiveSpells?: boolean;
|
||||
|
||||
isPrepareMaxed: boolean;
|
||||
isCantripsKnownMaxed: boolean;
|
||||
isSpellsKnownMaxed: boolean;
|
||||
addButtonText: string;
|
||||
enableAdd?: boolean;
|
||||
enablePrepare?: boolean;
|
||||
enableUnprepare?: boolean;
|
||||
enableSpellRemove?: boolean;
|
||||
showFilters?: boolean;
|
||||
showExpandedType?: boolean;
|
||||
showCustomize: boolean;
|
||||
buttonActiveStyle?: string;
|
||||
buttonUnprepareText?: string;
|
||||
entityValueLookup: EntityValueLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
|
||||
// TODO: migrate this all into here
|
||||
export default function SpellManagerContainer({
|
||||
showFilters = true,
|
||||
enableAdd = true,
|
||||
enableSpellRemove = true,
|
||||
showExpandedType = true,
|
||||
enablePrepare = true,
|
||||
enableUnprepare = true,
|
||||
shouldFetch = false,
|
||||
hasActiveSpells = false,
|
||||
charClassId,
|
||||
// TODO: get from manager?
|
||||
characterClassId,
|
||||
isPrepareMaxed,
|
||||
isCantripsKnownMaxed,
|
||||
isSpellsKnownMaxed,
|
||||
addButtonText,
|
||||
buttonActiveStyle,
|
||||
buttonUnprepareText,
|
||||
entityValueLookup,
|
||||
dataOriginRefData,
|
||||
showCustomize,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
}: Props) {
|
||||
const ruleData = useSelector(rulesEngineSelectors.getRuleData);
|
||||
const spellCasterInfo = useSelector(rulesEngineSelectors.getSpellCasterInfo);
|
||||
const { spellsManager } = useContext(SpellsManagerContext);
|
||||
|
||||
const [availableSpells, setAvailableSpells] = useState<
|
||||
Array<SpellManagerType>
|
||||
>([]);
|
||||
const [spells, setSpells] = useState<Array<SpellManagerType>>([]);
|
||||
const [sourceCategories, setSourceCategories] = useState<
|
||||
Array<SimpleSourceCategoryContract>
|
||||
>([]);
|
||||
const [loadingStatus, setLoadingStatus] = useState(
|
||||
DataLoadingStatusEnum.LOADING
|
||||
);
|
||||
const [filterLevels, setFilterLevels] = useState<Array<number>>([]);
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
const [filterSourceCategories, setFilterSourceCategories] = useState<
|
||||
Array<number>
|
||||
>([]);
|
||||
|
||||
const getData = useCallback(
|
||||
async function getData() {
|
||||
let classSpellMap = await spellsManager.getSpellShoppe();
|
||||
if (!classSpellMap[charClassId] || shouldFetch) {
|
||||
classSpellMap = await spellsManager.getSpellShoppe(true);
|
||||
}
|
||||
setSpells(
|
||||
hasActiveSpells
|
||||
? classSpellMap[charClassId].activeSpells
|
||||
: classSpellMap[charClassId].knownSpells
|
||||
);
|
||||
setAvailableSpells(
|
||||
shouldFetch ? classSpellMap[charClassId].availableSpells : []
|
||||
);
|
||||
setSourceCategories(classSpellMap[charClassId].sourceCategories);
|
||||
setLoadingStatus(DataLoadingStatusEnum.LOADED);
|
||||
},
|
||||
[spellsManager, hasActiveSpells, shouldFetch, charClassId]
|
||||
);
|
||||
useEffect(() => {
|
||||
getData();
|
||||
}, [getData]);
|
||||
|
||||
function getCombinedSpells(): Array<SpellManagerType> {
|
||||
// const remainingLazySpells = availableSpells.filter((spell) => !knownSpellIds.includes(spell.deriveKnownKey()));
|
||||
|
||||
return sortBy(
|
||||
[...availableSpells, ...spells],
|
||||
[
|
||||
(spell) => spell.getLevel(),
|
||||
(spell) => spell.getName(),
|
||||
(spell) => spell.getExpandedDataOriginRef() !== null,
|
||||
(spell) => spell.getUniqueKey(),
|
||||
]
|
||||
);
|
||||
}
|
||||
function getFilteredSpells(
|
||||
combinedSpells: Array<SpellManagerType>
|
||||
): Array<SpellManagerType> {
|
||||
return combinedSpells.filter((spell) => {
|
||||
if (filterSourceCategories.length !== 0) {
|
||||
const sourceCategoryId = spell.getPrimarySourceCategoryId();
|
||||
if (!filterSourceCategories.includes(sourceCategoryId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
filterLevels.length !== 0 &&
|
||||
!filterLevels.includes(spell.getLevel())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
filterQuery !== "" &&
|
||||
!FilterUtils.doesQueryMatchData(filterQuery, spell.getName())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function handleFilterSpellLevel(level: number): void {
|
||||
setFilterLevels(
|
||||
filterLevels.includes(level)
|
||||
? filterLevels.filter((l) => l !== level)
|
||||
: [...filterLevels, level]
|
||||
);
|
||||
}
|
||||
|
||||
function handleSourceCategoryClick(categoryId: number): void {
|
||||
setFilterSourceCategories(
|
||||
filterSourceCategories.includes(categoryId)
|
||||
? filterSourceCategories.filter((cat) => cat !== categoryId)
|
||||
: [...filterSourceCategories, categoryId]
|
||||
);
|
||||
}
|
||||
|
||||
function onSuccess() {
|
||||
getData();
|
||||
}
|
||||
function onFailure() {
|
||||
getData();
|
||||
}
|
||||
|
||||
function renderUi(): React.ReactNode {
|
||||
const combinedSpells = getCombinedSpells();
|
||||
const filteredSpells = getFilteredSpells(combinedSpells);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{showFilters && (
|
||||
<React.Fragment>
|
||||
<SpellFilter
|
||||
spells={combinedSpells.map((manager) => manager.getSpell())}
|
||||
sourceCategories={sourceCategories}
|
||||
filterQuery={filterQuery}
|
||||
filterLevels={filterLevels}
|
||||
onLevelFilterClick={handleFilterSpellLevel}
|
||||
onQueryChange={setFilterQuery}
|
||||
onSourceCategoryClick={handleSourceCategoryClick}
|
||||
filterSourceCategories={filterSourceCategories}
|
||||
themed
|
||||
/>
|
||||
<div className="ct-character-tools__marketplace-callout">
|
||||
Looking for something not in the list below? Unlock all official
|
||||
options in the <Link href="/marketplace">Marketplace</Link>.
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{filteredSpells.length === 0 && <div>No Results Found</div>}
|
||||
{filteredSpells.map((spell, idx) => (
|
||||
<SpellManagerItem
|
||||
theme={theme}
|
||||
spell={spell}
|
||||
key={`${spell.getId()}-${idx}`}
|
||||
onPrepare={() => {
|
||||
spell.handlePrepare({ characterClassId }, onSuccess, onFailure);
|
||||
}}
|
||||
onUnprepare={() => {
|
||||
spell.handleUnprepare({ characterClassId }, onSuccess, onFailure);
|
||||
}}
|
||||
onRemove={() => {
|
||||
spell.handleRemove({ characterClassId }, onSuccess, onFailure);
|
||||
}}
|
||||
onAdd={() => {
|
||||
spell.handleAdd({ characterClassId }, onSuccess, onFailure);
|
||||
}}
|
||||
isPrepareMaxed={isPrepareMaxed}
|
||||
isCantripsKnownMaxed={isCantripsKnownMaxed}
|
||||
isSpellsKnownMaxed={isSpellsKnownMaxed}
|
||||
addButtonText={addButtonText}
|
||||
enableAdd={enableAdd}
|
||||
enablePrepare={enablePrepare}
|
||||
enableUnprepare={enableUnprepare}
|
||||
enableSpellRemove={enableSpellRemove}
|
||||
buttonUnprepareText={buttonUnprepareText}
|
||||
buttonActiveStyle={buttonActiveStyle}
|
||||
spellCasterInfo={spellCasterInfo}
|
||||
ruleData={ruleData}
|
||||
entityValueLookup={entityValueLookup}
|
||||
showExpandedType={showExpandedType ?? true}
|
||||
showCustomize={showCustomize}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function renderLoading(): React.ReactNode {
|
||||
return <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
let content: React.ReactNode;
|
||||
switch (loadingStatus) {
|
||||
case DataLoadingStatusEnum.LOADED:
|
||||
content = renderUi();
|
||||
break;
|
||||
|
||||
case DataLoadingStatusEnum.LOADING:
|
||||
default:
|
||||
content = renderLoading();
|
||||
break;
|
||||
}
|
||||
|
||||
return <div className="ct-spell-manager">{content}</div>;
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleHeaderContent,
|
||||
} from "@dndbeyond/character-components/es";
|
||||
import {
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
DataOriginRefData,
|
||||
EntityValueLookup,
|
||||
RuleData,
|
||||
SpellCasterInfo,
|
||||
SpellManager,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { SpellName } from "~/components/SpellName";
|
||||
|
||||
import SpellDetail from "../../SpellDetail";
|
||||
import { RemoveButton, ThemeButton } from "../../common/Button";
|
||||
|
||||
interface Props {
|
||||
spell: SpellManager;
|
||||
buttonUnprepareText?: string;
|
||||
buttonActiveStyle?: string;
|
||||
enableSpellRemove: boolean;
|
||||
isPrepareMaxed: boolean;
|
||||
isCantripsKnownMaxed: boolean;
|
||||
isSpellsKnownMaxed: boolean;
|
||||
addButtonText: string;
|
||||
enableAdd: boolean;
|
||||
enablePrepare: boolean;
|
||||
enableUnprepare: boolean;
|
||||
showExpandedType: boolean;
|
||||
showCustomize: boolean;
|
||||
onPrepare: () => void;
|
||||
onUnprepare: () => void;
|
||||
onRemove: () => void;
|
||||
onAdd: () => void;
|
||||
spellCasterInfo: SpellCasterInfo;
|
||||
ruleData: RuleData;
|
||||
entityValueLookup: EntityValueLookup;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
proficiencyBonus: number;
|
||||
theme: CharacterTheme;
|
||||
}
|
||||
|
||||
export default function SpellManagerItem({
|
||||
spell,
|
||||
isPrepareMaxed,
|
||||
isCantripsKnownMaxed,
|
||||
isSpellsKnownMaxed,
|
||||
showExpandedType,
|
||||
showCustomize,
|
||||
onPrepare,
|
||||
onUnprepare,
|
||||
onRemove,
|
||||
onAdd,
|
||||
spellCasterInfo,
|
||||
ruleData,
|
||||
entityValueLookup,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
theme,
|
||||
enableSpellRemove = true,
|
||||
enableAdd = true,
|
||||
enablePrepare = true,
|
||||
enableUnprepare = true,
|
||||
buttonUnprepareText = "Prepared",
|
||||
addButtonText = "Prepare",
|
||||
buttonActiveStyle = "",
|
||||
}: Props) {
|
||||
function handlePrepareToggle(): void {
|
||||
if (spell.isPrepared()) {
|
||||
onUnprepare();
|
||||
} else {
|
||||
onPrepare();
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove(): void {
|
||||
onRemove();
|
||||
}
|
||||
|
||||
function handleAdd(): void {
|
||||
onAdd();
|
||||
}
|
||||
|
||||
function renderButtons(classNames: Array<string>): React.ReactNode {
|
||||
const alwaysPrepared = spell.isAlwaysPrepared();
|
||||
const isPrepared = spell.isPrepared();
|
||||
const canRemove = spell.canRemove();
|
||||
const canPrepare = spell.canPrepare();
|
||||
const canAdd = spell.canAdd();
|
||||
const isCantrip = spell.isCantrip();
|
||||
|
||||
let isAddDisabled: boolean = false;
|
||||
if (isCantrip && isCantripsKnownMaxed) {
|
||||
isAddDisabled = true;
|
||||
}
|
||||
if (!isCantrip && isSpellsKnownMaxed) {
|
||||
isAddDisabled = true;
|
||||
}
|
||||
|
||||
let showPrepare: boolean =
|
||||
!alwaysPrepared && canPrepare && (enablePrepare || enableUnprepare);
|
||||
|
||||
let showRemove: boolean =
|
||||
canRemove &&
|
||||
(enableSpellRemove || (spell.isSpellbookCaster() && isCantrip));
|
||||
|
||||
if (isCantrip) {
|
||||
addButtonText = Constants.SpellCastingLearningStyleAddText[Constants.SpellCastingLearningStyle.Learned];
|
||||
buttonUnprepareText = Constants.SpellCastingLearningStyleRemoveText[Constants.SpellCastingLearningStyle.Learned];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")}>
|
||||
{alwaysPrepared && (
|
||||
<div className="ct-spell-manager__spell-always">Always Prepared</div>
|
||||
)}
|
||||
{showPrepare && (
|
||||
<ThemeButton
|
||||
size="small"
|
||||
disabled={isPrepareMaxed && !isPrepared}
|
||||
style={isPrepared ? buttonActiveStyle : "outline"}
|
||||
onClick={handlePrepareToggle}
|
||||
stopPropagation={true}
|
||||
>
|
||||
{isPrepared ? buttonUnprepareText : addButtonText}
|
||||
</ThemeButton>
|
||||
)}
|
||||
{showRemove && (
|
||||
<RemoveButton onClick={handleRemove} style={buttonActiveStyle}>
|
||||
{buttonUnprepareText}
|
||||
</RemoveButton>
|
||||
)}
|
||||
{enableAdd && canAdd && (
|
||||
<ThemeButton
|
||||
size="small"
|
||||
onClick={handleAdd}
|
||||
disabled={isAddDisabled}
|
||||
style={"outline"}
|
||||
stopPropagation={true}
|
||||
>
|
||||
{addButtonText}
|
||||
</ThemeButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderHeader(): React.ReactNode {
|
||||
return (
|
||||
<CollapsibleHeaderContent
|
||||
heading={
|
||||
<SpellName
|
||||
spell={spell.spell}
|
||||
showExpandedType={showExpandedType}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
showLegacy={true}
|
||||
/>
|
||||
}
|
||||
callout={renderButtons(["ct-spell-manager__spell-header-actions"])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
layoutType={"minimal"}
|
||||
header={renderHeader()}
|
||||
className="ct-spell-manager__spell"
|
||||
>
|
||||
<SpellDetail
|
||||
theme={theme}
|
||||
spell={spell.spell}
|
||||
isPreparedMaxed={isPrepareMaxed}
|
||||
enableCaster={false}
|
||||
spellCasterInfo={spellCasterInfo}
|
||||
ruleData={ruleData}
|
||||
onPrepareToggle={handlePrepareToggle}
|
||||
onRemove={handleRemove}
|
||||
entityValueLookup={entityValueLookup}
|
||||
showCustomize={showCustomize}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
/>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SpellManagerItem from "./SpellManagerItem";
|
||||
|
||||
export default SpellManagerItem;
|
||||
export { SpellManagerItem };
|
||||
@@ -0,0 +1,5 @@
|
||||
import SpellManager from "./SpellManager";
|
||||
import SpellManagerItem from "./SpellManagerItem";
|
||||
|
||||
export default SpellManager;
|
||||
export { SpellManager, SpellManagerItem };
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
|
||||
import { ThemeButton } from "../common/Button";
|
||||
|
||||
interface Props {
|
||||
onPactChoose: () => void;
|
||||
onSpellChoose: () => void;
|
||||
isSpellSlotAvailable: boolean;
|
||||
isPactSlotAvailable: boolean;
|
||||
doesSpellSlotExist: boolean;
|
||||
doesPactSlotExist: boolean;
|
||||
}
|
||||
export default class SpellSlotChooser extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const {
|
||||
onPactChoose,
|
||||
onSpellChoose,
|
||||
doesSpellSlotExist,
|
||||
doesPactSlotExist,
|
||||
isSpellSlotAvailable,
|
||||
isPactSlotAvailable,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-spells-slot-chooser">
|
||||
{doesSpellSlotExist && (
|
||||
<ThemeButton
|
||||
onClick={onSpellChoose}
|
||||
size={"small"}
|
||||
disabled={!isSpellSlotAvailable}
|
||||
>
|
||||
Spell Slot
|
||||
</ThemeButton>
|
||||
)}
|
||||
{doesPactSlotExist && (
|
||||
<ThemeButton
|
||||
onClick={onPactChoose}
|
||||
size={"small"}
|
||||
disabled={!isPactSlotAvailable}
|
||||
>
|
||||
Pact Slot
|
||||
</ThemeButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SpellSlotChooser from "./SpellSlotChooser";
|
||||
|
||||
export default SpellSlotChooser;
|
||||
export { SpellSlotChooser };
|
||||
@@ -0,0 +1,238 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AbilityLookup,
|
||||
CharacterTheme,
|
||||
Constants,
|
||||
DataOriginRefData,
|
||||
ExperienceInfo,
|
||||
RuleData,
|
||||
ScaledSpell,
|
||||
Spell,
|
||||
SpellCasterInfo,
|
||||
SpellSlotContract,
|
||||
SpellUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
import { IRollContext } from "@dndbeyond/dice";
|
||||
|
||||
import SpellsSpell from "../SpellsSpell";
|
||||
|
||||
interface Props {
|
||||
spells: Array<ScaledSpell>;
|
||||
level: number;
|
||||
spellCasterInfo: SpellCasterInfo;
|
||||
ruleData: RuleData;
|
||||
abilityLookup: AbilityLookup;
|
||||
xpInfo: ExperienceInfo;
|
||||
onSpellClick?: (spell: Spell, castLevel: number) => void;
|
||||
onSpellSlotChange?: (castLevel: number, changeAmount: number) => void;
|
||||
onPactSlotChange?: (castLevel: number, changeAmount: number) => void;
|
||||
onSpellLimitedUseSet?: (
|
||||
mappingId: number,
|
||||
mappingTypeId: number,
|
||||
uses: number,
|
||||
dataOriginType: Constants.DataOriginTypeEnum
|
||||
) => void;
|
||||
onItemLimitedUseSet?: (
|
||||
mappingId: number,
|
||||
mappingTypeId: number,
|
||||
uses: number
|
||||
) => void;
|
||||
showNotes: boolean;
|
||||
isInteractive: boolean;
|
||||
diceEnabled: boolean;
|
||||
theme: CharacterTheme;
|
||||
dataOriginRefData: DataOriginRefData;
|
||||
proficiencyBonus: number;
|
||||
rollContext: IRollContext;
|
||||
}
|
||||
export default class SpellsLevel extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
showNotes: true,
|
||||
diceEnabled: false,
|
||||
};
|
||||
|
||||
handleSpellSlotChange = (changeAmount: number): void => {
|
||||
const { onSpellSlotChange, level } = this.props;
|
||||
|
||||
if (onSpellSlotChange) {
|
||||
onSpellSlotChange(level, changeAmount);
|
||||
}
|
||||
};
|
||||
|
||||
handlePactSlotChange = (changeAmount: number): void => {
|
||||
const { onPactSlotChange, level } = this.props;
|
||||
|
||||
if (onPactSlotChange) {
|
||||
onPactSlotChange(level, changeAmount);
|
||||
}
|
||||
};
|
||||
|
||||
handleSpellClick = (spell: Spell, castLevel: number): void => {
|
||||
const { onSpellClick } = this.props;
|
||||
|
||||
if (onSpellClick) {
|
||||
onSpellClick(spell, castLevel);
|
||||
}
|
||||
};
|
||||
|
||||
handleSpellUse = (
|
||||
useSpellSlot: boolean,
|
||||
usePactMagicSlot: boolean,
|
||||
dataOriginType: Constants.DataOriginTypeEnum,
|
||||
uses: number | null,
|
||||
mappingId: number | null,
|
||||
mappingTypeId: number | null
|
||||
): void => {
|
||||
const { onSpellLimitedUseSet, onItemLimitedUseSet } = this.props;
|
||||
|
||||
if (useSpellSlot) {
|
||||
this.handleSpellSlotChange(1);
|
||||
}
|
||||
if (usePactMagicSlot) {
|
||||
this.handlePactSlotChange(1);
|
||||
}
|
||||
if (uses !== null && mappingId !== null && mappingTypeId !== null) {
|
||||
switch (dataOriginType) {
|
||||
case Constants.DataOriginTypeEnum.ITEM:
|
||||
if (onItemLimitedUseSet) {
|
||||
onItemLimitedUseSet(mappingId, mappingTypeId, uses);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (onSpellLimitedUseSet) {
|
||||
onSpellLimitedUseSet(
|
||||
mappingId,
|
||||
mappingTypeId,
|
||||
uses,
|
||||
dataOriginType
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getSpellSlotInfo = (): SpellSlotContract | null => {
|
||||
const { spellCasterInfo, level } = this.props;
|
||||
const { spellSlots } = spellCasterInfo;
|
||||
|
||||
let spellSlotInfo = spellSlots.find(
|
||||
(spellSlotGroup) => spellSlotGroup.level === level
|
||||
);
|
||||
return spellSlotInfo ? spellSlotInfo : null;
|
||||
};
|
||||
|
||||
getPactSlotInfo = (): SpellSlotContract | null => {
|
||||
const { spellCasterInfo, level } = this.props;
|
||||
const { pactMagicSlots } = spellCasterInfo;
|
||||
|
||||
let spellSlotInfo = pactMagicSlots.find(
|
||||
(pactSlotGroup) => pactSlotGroup.level === level
|
||||
);
|
||||
return spellSlotInfo ? spellSlotInfo : null;
|
||||
};
|
||||
|
||||
renderSpell = (spell: ScaledSpell): React.ReactNode => {
|
||||
const {
|
||||
ruleData,
|
||||
abilityLookup,
|
||||
xpInfo,
|
||||
level,
|
||||
showNotes,
|
||||
isInteractive,
|
||||
diceEnabled,
|
||||
theme,
|
||||
dataOriginRefData,
|
||||
proficiencyBonus,
|
||||
rollContext,
|
||||
} = this.props;
|
||||
|
||||
let spellSlotLevel = this.getSpellSlotInfo();
|
||||
let pactMagicLevel = this.getPactSlotInfo();
|
||||
let doesSpellSlotExist: boolean =
|
||||
!!spellSlotLevel && spellSlotLevel.available > 0;
|
||||
let doesPactSlotExist: boolean =
|
||||
!!pactMagicLevel && pactMagicLevel.available > 0;
|
||||
|
||||
let isSpellSlotAvailable: boolean = false;
|
||||
if (doesSpellSlotExist && spellSlotLevel) {
|
||||
isSpellSlotAvailable = spellSlotLevel.used < spellSlotLevel.available;
|
||||
}
|
||||
|
||||
let isPactSlotAvailable: boolean = false;
|
||||
if (doesPactSlotExist && pactMagicLevel) {
|
||||
isPactSlotAvailable = pactMagicLevel.used < pactMagicLevel.available;
|
||||
}
|
||||
|
||||
return (
|
||||
<SpellsSpell
|
||||
key={SpellUtils.getUniqueKey(spell)}
|
||||
spell={spell}
|
||||
abilityLookup={abilityLookup}
|
||||
ruleData={ruleData}
|
||||
castLevel={level}
|
||||
characterLevel={xpInfo.currentLevel}
|
||||
onClick={this.handleSpellClick}
|
||||
onUse={this.handleSpellUse}
|
||||
doesSpellSlotExist={doesSpellSlotExist}
|
||||
doesPactSlotExist={doesPactSlotExist}
|
||||
isSpellSlotAvailable={isSpellSlotAvailable}
|
||||
isPactSlotAvailable={isPactSlotAvailable}
|
||||
showNotes={showNotes}
|
||||
isInteractive={isInteractive}
|
||||
diceEnabled={diceEnabled}
|
||||
theme={theme}
|
||||
dataOriginRefData={dataOriginRefData}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
rollContext={rollContext}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { spells, showNotes, theme } = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-spells-level">
|
||||
<div
|
||||
className={`ct-spells-level__spells-row-header ${
|
||||
theme.isDarkMode
|
||||
? "ct-spells-level__spells-row-header--dark-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--action" />
|
||||
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--name">
|
||||
Name
|
||||
</div>
|
||||
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--activation">
|
||||
Time
|
||||
</div>
|
||||
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--range">
|
||||
Range
|
||||
</div>
|
||||
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--tohit">
|
||||
Hit / DC
|
||||
</div>
|
||||
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--damage">
|
||||
Effect
|
||||
</div>
|
||||
{showNotes && (
|
||||
<div className="ct-spells-level__spells-col ct-spells-level__spells-col--notes">
|
||||
Notes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ct-spells-level__spells-content">
|
||||
{spells.length ? (
|
||||
spells.map((spell) => this.renderSpell(spell))
|
||||
) : (
|
||||
<div className="ct-spells-level__empty">
|
||||
No Spells Match the Current Filter
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SpellsLevel from "./SpellsLevel";
|
||||
|
||||
export default SpellsLevel;
|
||||
export { SpellsLevel };
|
||||
@@ -0,0 +1,211 @@
|
||||
import React from "react";
|
||||
|
||||
import { Tooltip } from "@dndbeyond/character-common-components/es";
|
||||
import {
|
||||
ClassUtils,
|
||||
SpellCasterCastingEntry,
|
||||
SpellCasterInfo,
|
||||
SpellSlotContract,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { NumberDisplay } from "~/components/NumberDisplay";
|
||||
|
||||
import SlotManager from "../SlotManager";
|
||||
|
||||
interface Props {
|
||||
level: number;
|
||||
spellCasterInfo: SpellCasterInfo;
|
||||
onSpellSlotSet?: (level: number, uses: number) => void;
|
||||
onPactSlotSet?: (level: number, uses: number) => void;
|
||||
showSlots: boolean;
|
||||
showCastingInfo: boolean;
|
||||
isInteractive: boolean;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
export default class SpellsLevelCasting extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
level: 1,
|
||||
showSlots: true,
|
||||
showCastingInfo: true,
|
||||
isInteractive: true,
|
||||
};
|
||||
|
||||
handleSpellSlotSet = (uses: number): void => {
|
||||
const { onSpellSlotSet, level } = this.props;
|
||||
|
||||
if (onSpellSlotSet) {
|
||||
onSpellSlotSet(level, uses);
|
||||
}
|
||||
};
|
||||
|
||||
handlePactSlotSet = (uses: number): void => {
|
||||
const { onPactSlotSet, level } = this.props;
|
||||
|
||||
if (onPactSlotSet) {
|
||||
onPactSlotSet(level, uses);
|
||||
}
|
||||
};
|
||||
|
||||
getSpellSlotInfo = (): SpellSlotContract | null => {
|
||||
const { spellCasterInfo, level } = this.props;
|
||||
const { spellSlots } = spellCasterInfo;
|
||||
|
||||
let spellSlotInfo = spellSlots.find(
|
||||
(spellSlotGroup) => spellSlotGroup.level === level
|
||||
);
|
||||
return spellSlotInfo ? spellSlotInfo : null;
|
||||
};
|
||||
|
||||
getPactSlotInfo = (): SpellSlotContract | null => {
|
||||
const { spellCasterInfo, level } = this.props;
|
||||
const { pactMagicSlots } = spellCasterInfo;
|
||||
|
||||
let spellSlotInfo = pactMagicSlots.find(
|
||||
(pactSlotGroup) => pactSlotGroup.level === level
|
||||
);
|
||||
return spellSlotInfo ? spellSlotInfo : null;
|
||||
};
|
||||
|
||||
hasLevelSlots = (): boolean => {
|
||||
let spellSlotLevel = this.getSpellSlotInfo();
|
||||
let pactMagicLevel = this.getPactSlotInfo();
|
||||
|
||||
return !!(spellSlotLevel || pactMagicLevel);
|
||||
};
|
||||
|
||||
hasCastingInfo = (): boolean => {
|
||||
const { spellCasterInfo } = this.props;
|
||||
const { castingInfo } = spellCasterInfo;
|
||||
|
||||
return !!(
|
||||
castingInfo.modifiers.length ||
|
||||
castingInfo.spellAttacks.length ||
|
||||
castingInfo.saveDcs.length
|
||||
);
|
||||
};
|
||||
|
||||
renderLevelSlots = (): React.ReactNode => {
|
||||
const { isInteractive, isDarkMode } = this.props;
|
||||
|
||||
let spellSlotLevel = this.getSpellSlotInfo();
|
||||
let pactMagicLevel = this.getPactSlotInfo();
|
||||
|
||||
if (!this.hasLevelSlots()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-spells-level-casting__slot-groups">
|
||||
{pactMagicLevel && (
|
||||
<div className="ct-spells-level-casting__slot-group ct-spells-level-casting__slot-group--pact">
|
||||
<div className="ct-spells-level-casting__slot-group-manager">
|
||||
<SlotManager
|
||||
onSet={this.handlePactSlotSet}
|
||||
available={pactMagicLevel.available}
|
||||
used={pactMagicLevel.used}
|
||||
size={"small"}
|
||||
isInteractive={isInteractive}
|
||||
/>
|
||||
</div>
|
||||
<div className="ct-spells-level-casting__slot-group-label">
|
||||
Pact
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{spellSlotLevel && (
|
||||
<div className="ct-spells-level-casting__slot-group ct-spells-level-casting__slot-group--spells">
|
||||
<div className="ct-spells-level-casting__slot-group-manager">
|
||||
<SlotManager
|
||||
onSet={this.handleSpellSlotSet}
|
||||
available={spellSlotLevel.available}
|
||||
used={spellSlotLevel.used}
|
||||
size={"small"}
|
||||
isInteractive={isInteractive}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`ct-spells-level-casting__slot-group-label ${
|
||||
isDarkMode ? "ct-spells-level-casting--dark-mode" : ""
|
||||
}`}
|
||||
>
|
||||
Slots
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderCastingInfoGroup = (
|
||||
label: React.ReactNode,
|
||||
entries: Array<SpellCasterCastingEntry>,
|
||||
isSignedNumber: boolean = true
|
||||
): React.ReactNode => {
|
||||
const { isDarkMode } = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-spells-level-casting__info-group">
|
||||
<div className="ct-spells-level-casting__info-items">
|
||||
{entries.map((entry) => {
|
||||
if (entry.value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tooltip = entry.sources
|
||||
.map((charClass) => ClassUtils.getName(charClass))
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={tooltip}
|
||||
className="ct-spells-level-casting__info-item"
|
||||
key={entry.value}
|
||||
tippyOpts={{ dynamicTitle: true }}
|
||||
isDarkMode={isDarkMode}
|
||||
>
|
||||
{isSignedNumber ? (
|
||||
<NumberDisplay type="signed" number={entry.value} />
|
||||
) : (
|
||||
entry.value
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="ct-spells-level-casting__info-label">{label}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderCastingInfo = (): React.ReactNode => {
|
||||
const { spellCasterInfo } = this.props;
|
||||
const { castingInfo } = spellCasterInfo;
|
||||
|
||||
if (!this.hasCastingInfo()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-spells-level-casting__info">
|
||||
{this.renderCastingInfoGroup("Modifier", castingInfo.modifiers)}
|
||||
{this.renderCastingInfoGroup("Spell Attack", castingInfo.spellAttacks)}
|
||||
{this.renderCastingInfoGroup("Save DC", castingInfo.saveDcs, false)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { showSlots, showCastingInfo } = this.props;
|
||||
|
||||
if (!this.hasCastingInfo() && !this.hasLevelSlots()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-spells-level-casting">
|
||||
{showSlots && this.renderLevelSlots()}
|
||||
{showCastingInfo && this.renderCastingInfo()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SpellsLevelCasting from "./SpellsLevelCasting";
|
||||
|
||||
export default SpellsLevelCasting;
|
||||
export { SpellsLevelCasting };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
import { FormatUtils } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
className: string;
|
||||
}
|
||||
export default class Subsection extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
className: "",
|
||||
};
|
||||
|
||||
render() {
|
||||
const { name, children, className } = this.props;
|
||||
|
||||
let classNames: Array<string> = [className, "ct-subsection"];
|
||||
if (name) {
|
||||
classNames.push(`ct-subsection--${FormatUtils.slugify(name)}`);
|
||||
}
|
||||
|
||||
return <div className={classNames.join(" ")}>{children}</div>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
export default class SubsectionFooter extends React.PureComponent {
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
|
||||
return <div className="ct-subsection__footer">{children}</div>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SubsectionFooter from "./SubsectionFooter";
|
||||
|
||||
export default SubsectionFooter;
|
||||
export { SubsectionFooter };
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
export default class SubsectionHeader extends React.PureComponent {
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
|
||||
return <div className="ct-subsection__header">{children}</div>;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
export default class SubsectionHeaderContent extends React.PureComponent {
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
|
||||
return <div className="ct-subsection__header-content">{children}</div>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SubsectionHeaderContent from "./SubsectionHeaderContent";
|
||||
|
||||
export default SubsectionHeaderContent;
|
||||
export { SubsectionHeaderContent };
|
||||
@@ -0,0 +1,12 @@
|
||||
import Subsection from "./Subsection";
|
||||
import SubsectionFooter from "./SubsectionFooter";
|
||||
import SubsectionHeader from "./SubsectionHeader";
|
||||
import SubsectionHeaderContent from "./SubsectionHeaderContent";
|
||||
|
||||
export default Subsection;
|
||||
export {
|
||||
Subsection,
|
||||
SubsectionFooter,
|
||||
SubsectionHeader,
|
||||
SubsectionHeaderContent,
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
|
||||
import { BackgroundCharacteristicContract } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { ThemeButton } from "../common/Button";
|
||||
|
||||
interface Props {
|
||||
suggestions: Array<BackgroundCharacteristicContract>;
|
||||
tableLabel: React.ReactNode;
|
||||
dieLabel: React.ReactNode;
|
||||
useButtonLabel: React.ReactNode;
|
||||
randomizeButtonLabel: React.ReactNode;
|
||||
onSuggestionUse?: (
|
||||
idx: number,
|
||||
diceRoll: number,
|
||||
description: string | null
|
||||
) => void;
|
||||
}
|
||||
export default class SuggestionTable extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
useButtonLabel: "+ Add",
|
||||
randomizeButtonLabel: "Random",
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
handleSuggestionUse = (
|
||||
idx: number,
|
||||
diceRoll: number,
|
||||
description: string | null
|
||||
): void => {
|
||||
const { onSuggestionUse } = this.props;
|
||||
|
||||
if (onSuggestionUse) {
|
||||
onSuggestionUse(idx, diceRoll, description);
|
||||
}
|
||||
};
|
||||
|
||||
handleRandomClick = (): void => {
|
||||
const { onSuggestionUse, suggestions } = this.props;
|
||||
|
||||
if (onSuggestionUse) {
|
||||
let randomIdx: number = Math.floor(Math.random() * suggestions.length);
|
||||
let randomSuggestion: BackgroundCharacteristicContract =
|
||||
suggestions[randomIdx];
|
||||
onSuggestionUse(
|
||||
randomIdx,
|
||||
randomSuggestion.diceRoll,
|
||||
randomSuggestion.description
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
suggestions,
|
||||
dieLabel,
|
||||
tableLabel,
|
||||
useButtonLabel,
|
||||
randomizeButtonLabel,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-suggestions-table">
|
||||
<table className="ct-suggestions-table__table">
|
||||
<thead>
|
||||
<tr className="ct-suggestions-table__header">
|
||||
<th className="ct-suggestions-table__header-col ct-suggestions-table__col--die">
|
||||
{dieLabel}
|
||||
</th>
|
||||
<th className="ct-suggestions-table__header-col ct-suggestions-table__col--desc">
|
||||
{tableLabel}
|
||||
</th>
|
||||
<th className="ct-suggestions-table__header-col ct-suggestions-table__col--action">
|
||||
<ThemeButton size="small" onClick={this.handleRandomClick}>
|
||||
{randomizeButtonLabel}
|
||||
</ThemeButton>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<tr className="ct-suggestions-table__item" key={idx}>
|
||||
<td className="ct-suggestions-table__item-col ct-suggestions-table__col--die">
|
||||
{suggestion.diceRoll}
|
||||
</td>
|
||||
<td className="ct-suggestions-table__item-col ct-suggestions-table__col--desc">
|
||||
{suggestion.description}
|
||||
</td>
|
||||
<td className="ct-suggestions-table__item-col ct-suggestions-table__col--action">
|
||||
<ThemeButton
|
||||
size="small"
|
||||
onClick={this.handleSuggestionUse.bind(
|
||||
this,
|
||||
idx,
|
||||
suggestion.diceRoll,
|
||||
suggestion.description
|
||||
)}
|
||||
>
|
||||
{useButtonLabel}
|
||||
</ThemeButton>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SuggestionTable from "./SuggestionTable";
|
||||
|
||||
export default SuggestionTable;
|
||||
export { SuggestionTable };
|
||||
@@ -0,0 +1,357 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Constants,
|
||||
EntityAdjustmentConstraintContract,
|
||||
RuleData,
|
||||
RuleDataUtils,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import {
|
||||
SIGNED_32BIT_INT_MAX_VALUE,
|
||||
SIGNED_32BIT_INT_MIN_VALUE,
|
||||
} from "~/subApps/sheet/constants";
|
||||
|
||||
import ValueEditorCheckboxProperty from "./ValueEditorCheckboxProperty";
|
||||
import ValueEditorNumberProperty from "./ValueEditorNumberProperty";
|
||||
import ValueEditorSelectProperty from "./ValueEditorSelectProperty";
|
||||
import ValueEditorTextProperty from "./ValueEditorTextProperty";
|
||||
|
||||
interface ConstraintProps {
|
||||
minimumValue?: number;
|
||||
maximumValue?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dataLookup: Partial<Record<Constants.AdjustmentTypeEnum, any>>;
|
||||
valueEditors: Array<number>; // TODO deal with enums not working with switch statement
|
||||
labelOverrides: Partial<Record<Constants.AdjustmentTypeEnum, string>>;
|
||||
optionRestrictions: Partial<Record<Constants.AdjustmentTypeEnum, Array<any>>>;
|
||||
defaultValues: Partial<Record<Constants.AdjustmentTypeEnum, any>>;
|
||||
layoutType: "standard" | "compact";
|
||||
onDataUpdate?: (
|
||||
propertyKey: Constants.AdjustmentTypeEnum,
|
||||
value: any,
|
||||
source: any
|
||||
) => void;
|
||||
ruleData: RuleData;
|
||||
className: string;
|
||||
}
|
||||
interface State {
|
||||
dataLookup: Partial<Record<Constants.AdjustmentTypeEnum, any>>;
|
||||
}
|
||||
export default class ValueEditor extends React.PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
className: "",
|
||||
labelOverrides: {},
|
||||
optionRestrictions: {},
|
||||
defaultValues: {},
|
||||
layoutType: "standard",
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = this.generateStateData(props);
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { dataLookup } = this.props;
|
||||
|
||||
if (dataLookup !== prevProps.dataLookup) {
|
||||
this.setState(this.generateStateData(this.props));
|
||||
}
|
||||
}
|
||||
|
||||
generateStateData = (props: Props): State => {
|
||||
const { dataLookup } = props;
|
||||
|
||||
return {
|
||||
dataLookup,
|
||||
};
|
||||
};
|
||||
|
||||
getNumberConstraintProps = (
|
||||
constraintLookup: Record<number, EntityAdjustmentConstraintContract>
|
||||
): ConstraintProps => {
|
||||
let constraintProps: ConstraintProps = {};
|
||||
if (constraintLookup[Constants.AdjustmentConstraintTypeEnum.MINIMUM]) {
|
||||
constraintProps.minimumValue = this.getConstraintValue(
|
||||
Constants.AdjustmentConstraintTypeEnum.MINIMUM,
|
||||
constraintLookup
|
||||
);
|
||||
} else {
|
||||
constraintProps.minimumValue = SIGNED_32BIT_INT_MIN_VALUE;
|
||||
}
|
||||
if (constraintLookup[Constants.AdjustmentConstraintTypeEnum.MAXIMUM]) {
|
||||
constraintProps.maximumValue = this.getConstraintValue(
|
||||
Constants.AdjustmentConstraintTypeEnum.MAXIMUM,
|
||||
constraintLookup
|
||||
);
|
||||
} else {
|
||||
constraintProps.maximumValue = SIGNED_32BIT_INT_MAX_VALUE;
|
||||
}
|
||||
return constraintProps;
|
||||
};
|
||||
|
||||
getConstraintValue = (
|
||||
constraintTypeId: Constants.AdjustmentConstraintTypeEnum,
|
||||
constraintLookup: Record<number, EntityAdjustmentConstraintContract>,
|
||||
fallback: any = null
|
||||
): any => {
|
||||
let constraint = constraintLookup[constraintTypeId];
|
||||
if (constraint) {
|
||||
return constraint.value;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
getPropertyValue = (key: Constants.AdjustmentTypeEnum): any => {
|
||||
const { dataLookup, defaultValues } = this.props;
|
||||
|
||||
let property = dataLookup[key];
|
||||
if (property) {
|
||||
return property.value;
|
||||
}
|
||||
|
||||
let defaultValue = defaultValues[key];
|
||||
if (defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
getPropertySource = (key: Constants.AdjustmentTypeEnum): string | null => {
|
||||
const { dataLookup } = this.state;
|
||||
let property = dataLookup[key];
|
||||
if (property) {
|
||||
return property.notes;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
restrictOptions = <T extends any>(
|
||||
key: Constants.AdjustmentTypeEnum,
|
||||
options: Array<T>
|
||||
): Array<T> => {
|
||||
const { optionRestrictions } = this.props;
|
||||
|
||||
let typeOptionRestrictions = optionRestrictions[key];
|
||||
|
||||
if (!typeOptionRestrictions || typeOptionRestrictions.length === 0) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return options.filter((option) => {
|
||||
if (!typeOptionRestrictions) {
|
||||
return true;
|
||||
}
|
||||
// @ts-ignore
|
||||
return typeOptionRestrictions.includes(option.value);
|
||||
});
|
||||
};
|
||||
|
||||
handleDataUpdate = (
|
||||
key: Constants.AdjustmentTypeEnum,
|
||||
value: any,
|
||||
source: any
|
||||
): void => {
|
||||
const { onDataUpdate } = this.props;
|
||||
|
||||
this.setState((prevState: State): State => {
|
||||
let processedSource: string | null = source === "" ? null : source;
|
||||
if (
|
||||
onDataUpdate &&
|
||||
(this.getPropertyValue(key) !== value ||
|
||||
this.getPropertySource(key) !== processedSource)
|
||||
) {
|
||||
onDataUpdate(key, value, processedSource);
|
||||
}
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
dataLookup: {
|
||||
...prevState.dataLookup,
|
||||
[key]: {
|
||||
value,
|
||||
notes: processedSource,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { valueEditors, className, ruleData, labelOverrides, layoutType } =
|
||||
this.props;
|
||||
|
||||
let enableSource: boolean = layoutType === "standard";
|
||||
let layoutClass: string = layoutType === "compact" ? "compact" : "standard";
|
||||
|
||||
let classNames: Array<string> = [
|
||||
"ct-value-editor",
|
||||
`ct-value-editor--${layoutClass}`,
|
||||
className,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={classNames.join(" ")}>
|
||||
{valueEditors.map((valueTypeId) => {
|
||||
let valueDataTypeId = RuleDataUtils.getAdjustmentDataType(
|
||||
valueTypeId,
|
||||
ruleData
|
||||
);
|
||||
let constraintLookup = RuleDataUtils.getAdjustmentConstraintLookup(
|
||||
valueTypeId,
|
||||
ruleData
|
||||
);
|
||||
let valueName: string = "";
|
||||
if (labelOverrides.hasOwnProperty(valueTypeId)) {
|
||||
valueName = labelOverrides[valueTypeId];
|
||||
} else {
|
||||
let adjustmentName = RuleDataUtils.getAdjustmentName(
|
||||
valueTypeId,
|
||||
ruleData
|
||||
);
|
||||
if (adjustmentName) {
|
||||
valueName = adjustmentName;
|
||||
}
|
||||
}
|
||||
switch (valueDataTypeId) {
|
||||
case Constants.AdjustmentDataTypeEnum.STRING:
|
||||
return (
|
||||
<ValueEditorTextProperty
|
||||
key={valueTypeId}
|
||||
propertyKey={valueTypeId}
|
||||
defaultValue={this.getPropertyValue(valueTypeId)}
|
||||
label={valueName}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
enableSource={enableSource}
|
||||
initialFocus={
|
||||
valueTypeId === Constants.AdjustmentTypeEnum.NAME_OVERRIDE
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case Constants.AdjustmentDataTypeEnum.BOOLEAN:
|
||||
return (
|
||||
<ValueEditorCheckboxProperty
|
||||
key={valueTypeId}
|
||||
propertyKey={valueTypeId}
|
||||
initiallyEnabled={this.getPropertyValue(valueTypeId)}
|
||||
defaultSource={this.getPropertySource(valueTypeId)}
|
||||
label={valueName}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
enableSource={enableSource}
|
||||
/>
|
||||
);
|
||||
|
||||
case Constants.AdjustmentDataTypeEnum.INTEGER:
|
||||
let baseProps = {
|
||||
key: valueTypeId,
|
||||
propertyKey: valueTypeId,
|
||||
defaultValue: this.getPropertyValue(valueTypeId),
|
||||
defaultSource: this.getPropertySource(valueTypeId),
|
||||
label: valueName,
|
||||
onUpdate: this.handleDataUpdate,
|
||||
enableSource: enableSource,
|
||||
};
|
||||
|
||||
switch (valueTypeId) {
|
||||
case Constants.AdjustmentTypeEnum.SKILL_STAT_OVERRIDE:
|
||||
return (
|
||||
<ValueEditorSelectProperty
|
||||
{...baseProps}
|
||||
options={RuleDataUtils.getStatOptions(ruleData)}
|
||||
/>
|
||||
);
|
||||
|
||||
case Constants.AdjustmentTypeEnum.DICE_TYPE_OVERRIDE:
|
||||
return (
|
||||
<ValueEditorSelectProperty
|
||||
{...baseProps}
|
||||
options={RuleDataUtils.getDieTypeOptions(ruleData)}
|
||||
/>
|
||||
);
|
||||
|
||||
case Constants.AdjustmentTypeEnum.SKILL_PROFICIENCY_LEVEL:
|
||||
case Constants.AdjustmentTypeEnum
|
||||
.SAVING_THROW_PROFICIENCY_LEVEL:
|
||||
return (
|
||||
<ValueEditorSelectProperty
|
||||
{...baseProps}
|
||||
options={RuleDataUtils.getProficiencyLevelOptions(
|
||||
ruleData
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case Constants.AdjustmentTypeEnum.CREATURE_ALIGNMENT:
|
||||
return (
|
||||
<ValueEditorSelectProperty
|
||||
{...baseProps}
|
||||
options={RuleDataUtils.getAlignmentOptions(ruleData)}
|
||||
/>
|
||||
);
|
||||
|
||||
case Constants.AdjustmentTypeEnum.CREATURE_SIZE:
|
||||
return (
|
||||
<ValueEditorSelectProperty
|
||||
{...baseProps}
|
||||
options={RuleDataUtils.getCreatureSizeOptions(ruleData)}
|
||||
/>
|
||||
);
|
||||
|
||||
case Constants.AdjustmentTypeEnum.CREATURE_TYPE_OVERRIDE:
|
||||
return (
|
||||
<ValueEditorSelectProperty
|
||||
{...baseProps}
|
||||
options={this.restrictOptions(
|
||||
valueTypeId,
|
||||
RuleDataUtils.getMonsterTypeOptions(ruleData)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
let constraintProps =
|
||||
this.getNumberConstraintProps(constraintLookup);
|
||||
return (
|
||||
<ValueEditorNumberProperty
|
||||
{...baseProps}
|
||||
{...constraintProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case Constants.AdjustmentDataTypeEnum.DECIMAL:
|
||||
let constraintProps =
|
||||
this.getNumberConstraintProps(constraintLookup);
|
||||
return (
|
||||
<ValueEditorNumberProperty
|
||||
key={valueTypeId}
|
||||
step={0.01}
|
||||
propertyKey={valueTypeId}
|
||||
defaultValue={this.getPropertyValue(valueTypeId)}
|
||||
defaultSource={this.getPropertySource(valueTypeId)}
|
||||
label={valueName}
|
||||
onUpdate={this.handleDataUpdate}
|
||||
enableSource={enableSource}
|
||||
{...constraintProps}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
// not implemented
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import React from "react";
|
||||
|
||||
import { Checkbox } from "@dndbeyond/character-components/es";
|
||||
import { Constants } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import ValueEditorProperty from "../ValueEditorProperty";
|
||||
import ValueEditorPropertySource from "../ValueEditorPropertySource";
|
||||
import ValueEditorPropertyValue from "../ValueEditorPropertyValue";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
propertyKey: Constants.AdjustmentTypeEnum;
|
||||
initiallyEnabled: boolean;
|
||||
defaultSource: string | null;
|
||||
onUpdate?: (
|
||||
propertyKey: Constants.AdjustmentTypeEnum,
|
||||
isEnabled: boolean,
|
||||
source: string | null
|
||||
) => void;
|
||||
enableSource: boolean;
|
||||
}
|
||||
interface State {
|
||||
value: boolean;
|
||||
source: string | null;
|
||||
}
|
||||
export default class ValueEditorCheckboxProperty extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
enableSource: true,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.initiallyEnabled,
|
||||
source: props.defaultSource,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { initiallyEnabled } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
if (
|
||||
initiallyEnabled !== prevProps.initiallyEnabled &&
|
||||
initiallyEnabled !== value
|
||||
) {
|
||||
this.setState({
|
||||
value: initiallyEnabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = (isEnabled: boolean): void => {
|
||||
const { source } = this.state;
|
||||
const { propertyKey, onUpdate } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, isEnabled, source);
|
||||
}
|
||||
};
|
||||
|
||||
handleSourceUpdate = (source: string | null): void => {
|
||||
const { value } = this.state;
|
||||
const { propertyKey, onUpdate } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, value, source);
|
||||
}
|
||||
this.setState({
|
||||
source,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
propertyKey,
|
||||
label,
|
||||
initiallyEnabled,
|
||||
defaultSource,
|
||||
enableSource,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<ValueEditorProperty
|
||||
className="ct-value-editor__property--checkbox"
|
||||
propertyKey={propertyKey}
|
||||
>
|
||||
<ValueEditorPropertyValue>
|
||||
<Checkbox
|
||||
stopPropagation={true}
|
||||
label={label}
|
||||
initiallyEnabled={initiallyEnabled}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</ValueEditorPropertyValue>
|
||||
{enableSource && (
|
||||
<ValueEditorPropertySource
|
||||
onUpdate={this.handleSourceUpdate}
|
||||
defaultValue={defaultSource}
|
||||
/>
|
||||
)}
|
||||
</ValueEditorProperty>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ValueEditorCheckboxProperty from "./ValueEditorCheckboxProperty";
|
||||
|
||||
export default ValueEditorCheckboxProperty;
|
||||
export { ValueEditorCheckboxProperty };
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import React from "react";
|
||||
|
||||
import { Constants, HelperUtils } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import ValueEditorProperty from "../ValueEditorProperty";
|
||||
import ValueEditorPropertyLabel from "../ValueEditorPropertyLabel";
|
||||
import ValueEditorPropertySource from "../ValueEditorPropertySource";
|
||||
import ValueEditorPropertyValue from "../ValueEditorPropertyValue";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
propertyKey: Constants.AdjustmentTypeEnum;
|
||||
defaultValue: number | null;
|
||||
defaultSource: string | null;
|
||||
minimumValue?: number;
|
||||
maximumValue?: number;
|
||||
onUpdate?: (
|
||||
propertyKey: Constants.AdjustmentTypeEnum,
|
||||
value: number | null,
|
||||
source: string | null
|
||||
) => void;
|
||||
enableSource: boolean;
|
||||
step: number;
|
||||
}
|
||||
interface State {
|
||||
value: number | null;
|
||||
source: string | null;
|
||||
}
|
||||
export default class ValueEditorNumberProperty extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
enableSource: true,
|
||||
step: 1,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.defaultValue,
|
||||
source: props.defaultSource,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { defaultValue } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
if (defaultValue !== prevProps.defaultValue && defaultValue !== value) {
|
||||
this.setState({
|
||||
value: defaultValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getDataValue = (value: number | null): number | null => {
|
||||
const { minimumValue, maximumValue } = this.props;
|
||||
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return HelperUtils.clampInt(
|
||||
value,
|
||||
minimumValue ? minimumValue : null,
|
||||
maximumValue
|
||||
);
|
||||
};
|
||||
|
||||
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const { source } = this.state;
|
||||
const { propertyKey, onUpdate, step } = this.props;
|
||||
|
||||
let newValue = this.getDataValue(
|
||||
step < 1
|
||||
? HelperUtils.parseInputFloat(evt.target.value)
|
||||
: HelperUtils.parseInputInt(evt.target.value)
|
||||
);
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, newValue, source);
|
||||
}
|
||||
this.setState({
|
||||
value: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
handleChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const { step } = this.props;
|
||||
this.setState({
|
||||
value:
|
||||
step < 1
|
||||
? HelperUtils.parseInputFloat(evt.target.value)
|
||||
: HelperUtils.parseInputInt(evt.target.value),
|
||||
});
|
||||
};
|
||||
|
||||
handleSourceUpdate = (source: string | null): void => {
|
||||
const { value } = this.state;
|
||||
const { propertyKey, onUpdate } = this.props;
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(propertyKey, this.getDataValue(value), source);
|
||||
}
|
||||
this.setState({
|
||||
source,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value } = this.state;
|
||||
const {
|
||||
propertyKey,
|
||||
label,
|
||||
minimumValue,
|
||||
maximumValue,
|
||||
defaultSource,
|
||||
enableSource,
|
||||
step,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<ValueEditorProperty
|
||||
className="ct-value-editor__property--number"
|
||||
propertyKey={propertyKey}
|
||||
>
|
||||
<ValueEditorPropertyLabel>{label}</ValueEditorPropertyLabel>
|
||||
<ValueEditorPropertyValue>
|
||||
<input
|
||||
className="ct-value-editor__property-input"
|
||||
type="number"
|
||||
min={minimumValue}
|
||||
max={maximumValue}
|
||||
step={step}
|
||||
value={value === null ? "" : value}
|
||||
onBlur={this.handleBlur}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</ValueEditorPropertyValue>
|
||||
{enableSource && (
|
||||
<ValueEditorPropertySource
|
||||
onUpdate={this.handleSourceUpdate}
|
||||
defaultValue={defaultSource}
|
||||
/>
|
||||
)}
|
||||
</ValueEditorProperty>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ValueEditorNumberProperty from "./ValueEditorNumberProperty";
|
||||
|
||||
export default ValueEditorNumberProperty;
|
||||
export { ValueEditorNumberProperty };
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
isBlock: boolean;
|
||||
propertyKey: number;
|
||||
className: string;
|
||||
}
|
||||
export default class ValueEditorProperty extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
isBlock: false,
|
||||
className: "",
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, isBlock, propertyKey } = this.props;
|
||||
|
||||
let classNames: Array<string> = [
|
||||
className,
|
||||
"ct-value-editor__property",
|
||||
`ct-value-editor__property--${propertyKey}`,
|
||||
];
|
||||
if (isBlock) {
|
||||
classNames.push("ct-value-editor__property--block");
|
||||
}
|
||||
|
||||
return <div className={classNames.join(" ")}>{this.props.children}</div>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ValueEditorProperty from "./ValueEditorProperty";
|
||||
|
||||
export default ValueEditorProperty;
|
||||
export { ValueEditorProperty };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class ValueEditorPropertyLabel extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-value-editor__property-label">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ValueEditorPropertyLabel from "./ValueEditorPropertyLabel";
|
||||
|
||||
export default ValueEditorPropertyLabel;
|
||||
export { ValueEditorPropertyLabel };
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
defaultValue: string | null;
|
||||
placeholder: string;
|
||||
onUpdate?: (value: string) => void;
|
||||
}
|
||||
interface State {
|
||||
value: string | null;
|
||||
}
|
||||
export default class ValueEditorPropertySource extends React.PureComponent<
|
||||
Props,
|
||||
State
|
||||
> {
|
||||
static defaultProps = {
|
||||
placeholder: "Enter Source Notes...",
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.defaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
prevProps: Readonly<Props>,
|
||||
prevState: Readonly<State>
|
||||
): void {
|
||||
const { defaultValue } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
if (defaultValue !== prevProps.defaultValue && defaultValue !== value) {
|
||||
this.setState({
|
||||
value: defaultValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
this.setState({
|
||||
value: evt.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
handleBlur = (evt: React.FocusEvent<HTMLInputElement>): void => {
|
||||
const { onUpdate } = this.props;
|
||||
|
||||
let value = evt.target.value;
|
||||
if (onUpdate) {
|
||||
onUpdate(value);
|
||||
}
|
||||
this.setState({
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value } = this.state;
|
||||
const { placeholder } = this.props;
|
||||
|
||||
return (
|
||||
<div className="ct-value-editor__property-source">
|
||||
<input
|
||||
className="ct-value-editor__property-input"
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
onChange={this.handleChange}
|
||||
onBlur={this.handleBlur}
|
||||
value={value === null ? "" : value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ValueEditorPropertySource from "./ValueEditorPropertySource";
|
||||
|
||||
export default ValueEditorPropertySource;
|
||||
export { ValueEditorPropertySource };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
export default class ValueEditorPropertyValue extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="ct-value-editor__property-value">
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ValueEditorPropertyValue from "./ValueEditorPropertyValue";
|
||||
|
||||
export default ValueEditorPropertyValue;
|
||||
export { ValueEditorPropertyValue };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user