2023-08-10 15:56:36 -07:00
|
|
|
import React from "react";
|
|
|
|
import gql from "graphql-tag";
|
|
|
|
import { useQuery } from "@apollo/client";
|
|
|
|
import { ClassNames } from "@emotion/react";
|
|
|
|
import {
|
|
|
|
Box,
|
|
|
|
Button,
|
|
|
|
Flex,
|
|
|
|
Popover,
|
|
|
|
PopoverArrow,
|
|
|
|
PopoverContent,
|
|
|
|
PopoverTrigger,
|
|
|
|
Portal,
|
2024-01-29 02:10:44 -08:00
|
|
|
Tab,
|
|
|
|
Tabs,
|
|
|
|
TabList,
|
|
|
|
TabPanel,
|
|
|
|
TabPanels,
|
2023-08-10 15:56:36 -07:00
|
|
|
VisuallyHidden,
|
Move Expressions/Styles tab to the top/bottom depending on placement
On small screens, the PosePicker opens down, and we put the tabs on the
top, to be near the button.
On large screens, the PosePicker opens up, and we put the tabs on the
bottom, to be near the button.
Previously, we always set `placement="bottom-end"`, which on small
screens behaved as written, and on large screens there would not be
space to open downward so it would open upward instead.
Now, we set the placement explicitly based on a media breakpoint, and
we change the `flexDirection` of the tabs container on the same media
breakpoint.
2024-01-29 09:09:09 -08:00
|
|
|
useBreakpointValue,
|
2023-08-10 15:56:36 -07:00
|
|
|
useColorModeValue,
|
|
|
|
useTheme,
|
|
|
|
useToast,
|
2024-01-29 03:20:48 -08:00
|
|
|
useToken,
|
2023-08-10 15:56:36 -07:00
|
|
|
} from "@chakra-ui/react";
|
2024-01-30 05:34:20 -08:00
|
|
|
import { ChevronDownIcon } from "@chakra-ui/icons";
|
2023-08-10 15:56:36 -07:00
|
|
|
import { loadable } from "../util";
|
|
|
|
|
|
|
|
import { petAppearanceFragment } from "../components/useOutfitAppearance";
|
|
|
|
import getVisibleLayers from "../components/getVisibleLayers";
|
|
|
|
import { OutfitLayers } from "../components/OutfitPreview";
|
|
|
|
import SupportOnly from "./support/SupportOnly";
|
2024-01-29 03:20:48 -08:00
|
|
|
import { useAltStylesForSpecies } from "../loaders/alt-styles";
|
2023-08-10 15:56:36 -07:00
|
|
|
import useSupport from "./support/useSupport";
|
|
|
|
import { useLocalStorage } from "../util";
|
|
|
|
|
|
|
|
// From https://twemoji.twitter.com/, thank you!
|
|
|
|
import twemojiSmile from "../images/twemoji/smile.svg";
|
|
|
|
import twemojiCry from "../images/twemoji/cry.svg";
|
|
|
|
import twemojiSick from "../images/twemoji/sick.svg";
|
|
|
|
import twemojiSunglasses from "../images/twemoji/sunglasses.svg";
|
|
|
|
import twemojiQuestion from "../images/twemoji/question.svg";
|
|
|
|
import twemojiMasc from "../images/twemoji/masc.svg";
|
|
|
|
import twemojiFem from "../images/twemoji/fem.svg";
|
|
|
|
|
|
|
|
const PosePickerSupport = loadable(() => import("./support/PosePickerSupport"));
|
|
|
|
|
|
|
|
const PosePickerSupportSwitch = loadable(() =>
|
2023-10-24 16:45:49 -07:00
|
|
|
import("./support/PosePickerSupport").then((m) => m.PosePickerSupportSwitch),
|
2023-08-10 15:56:36 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
/**
|
|
|
|
* PosePicker shows the pet poses available on the current species/color, and
|
|
|
|
* lets the user choose which want they want!
|
|
|
|
*
|
|
|
|
* NOTE: This component is memoized with React.memo. It's relatively expensive
|
|
|
|
* to re-render on every outfit change - the contents update even if the
|
|
|
|
* popover is closed! This makes wearing/unwearing items noticeably
|
|
|
|
* slower on lower-power devices.
|
|
|
|
*
|
|
|
|
* So, instead of using `outfitState` like most components, we specify
|
|
|
|
* exactly which props we need, so that `React.memo` can see the changes
|
|
|
|
* that matter, and skip updates that don't.
|
|
|
|
*/
|
|
|
|
function PosePicker({
|
|
|
|
speciesId,
|
|
|
|
colorId,
|
|
|
|
pose,
|
|
|
|
appearanceId,
|
|
|
|
dispatchToOutfit,
|
|
|
|
onLockFocus,
|
|
|
|
onUnlockFocus,
|
|
|
|
...props
|
|
|
|
}) {
|
|
|
|
const initialFocusRef = React.useRef();
|
2024-01-29 03:20:48 -08:00
|
|
|
const posesQuery = usePoses(speciesId, colorId, pose);
|
|
|
|
const altStylesQuery = useAltStylesForSpecies(speciesId);
|
2023-08-10 15:56:36 -07:00
|
|
|
const [isInSupportMode, setIsInSupportMode] = useLocalStorage(
|
|
|
|
"DTIPosePickerIsInSupportMode",
|
2023-10-24 16:45:49 -07:00
|
|
|
false,
|
2023-08-10 15:56:36 -07:00
|
|
|
);
|
|
|
|
const { isSupportUser } = useSupport();
|
|
|
|
const toast = useToast();
|
|
|
|
|
2024-01-29 09:10:26 -08:00
|
|
|
const loading = posesQuery.loading || altStylesQuery.isLoading;
|
2024-01-29 03:20:48 -08:00
|
|
|
const error = posesQuery.error ?? altStylesQuery.error;
|
|
|
|
const poseInfos = posesQuery.poseInfos;
|
|
|
|
const altStyles = altStylesQuery.data ?? [];
|
|
|
|
|
Move Expressions/Styles tab to the top/bottom depending on placement
On small screens, the PosePicker opens down, and we put the tabs on the
top, to be near the button.
On large screens, the PosePicker opens up, and we put the tabs on the
bottom, to be near the button.
Previously, we always set `placement="bottom-end"`, which on small
screens behaved as written, and on large screens there would not be
space to open downward so it would open upward instead.
Now, we set the placement explicitly based on a media breakpoint, and
we change the `flexDirection` of the tabs container on the same media
breakpoint.
2024-01-29 09:09:09 -08:00
|
|
|
const placement = useBreakpointValue({ base: "bottom-end", md: "top-end" });
|
|
|
|
|
2023-08-10 15:56:36 -07:00
|
|
|
// Resize the Popover when we toggle support mode, because it probably will
|
|
|
|
// affect the content size.
|
|
|
|
React.useLayoutEffect(() => {
|
|
|
|
// HACK: To trigger a Popover resize, we simulate a window resize event,
|
|
|
|
// because Popover listens for window resizes to reposition itself.
|
|
|
|
// I've also filed an issue requesting an official API!
|
|
|
|
// https://github.com/chakra-ui/chakra-ui/issues/1853
|
|
|
|
window.dispatchEvent(new Event("resize"));
|
|
|
|
}, [isInSupportMode]);
|
|
|
|
|
|
|
|
// Generally, the app tries to never put us in an invalid pose state. But it
|
|
|
|
// can happen with direct URL navigation, or pet loading when modeling isn't
|
|
|
|
// updated! Let's do some recovery.
|
|
|
|
const selectedPoseIsAvailable = Object.values(poseInfos).some(
|
2023-10-24 16:45:49 -07:00
|
|
|
(pi) => pi.isSelected && pi.isAvailable,
|
2023-08-10 15:56:36 -07:00
|
|
|
);
|
|
|
|
const firstAvailablePose = Object.values(poseInfos).find(
|
2023-10-24 16:45:49 -07:00
|
|
|
(pi) => pi.isAvailable,
|
2023-08-10 15:56:36 -07:00
|
|
|
)?.pose;
|
|
|
|
React.useEffect(() => {
|
|
|
|
if (loading) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!selectedPoseIsAvailable) {
|
|
|
|
if (!firstAvailablePose) {
|
|
|
|
// TODO: I suppose this error would fit better in SpeciesColorPicker!
|
|
|
|
toast({
|
|
|
|
status: "error",
|
|
|
|
title: "Oops, we don't have data for this pet color!",
|
|
|
|
description:
|
|
|
|
"If it's new, this might be a modeling issue—try modeling it on " +
|
|
|
|
"Classic DTI first. Sorry!",
|
|
|
|
duration: null,
|
|
|
|
isClosable: true,
|
|
|
|
});
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
console.warn(
|
|
|
|
`Pose ${pose} not found for speciesId=${speciesId}, ` +
|
2023-10-24 16:45:49 -07:00
|
|
|
`colorId=${colorId}. Redirecting to pose ${firstAvailablePose}.`,
|
2023-08-10 15:56:36 -07:00
|
|
|
);
|
|
|
|
dispatchToOutfit({ type: "setPose", pose: firstAvailablePose });
|
|
|
|
}
|
|
|
|
}, [
|
|
|
|
loading,
|
|
|
|
selectedPoseIsAvailable,
|
|
|
|
firstAvailablePose,
|
|
|
|
speciesId,
|
|
|
|
colorId,
|
|
|
|
pose,
|
|
|
|
toast,
|
|
|
|
dispatchToOutfit,
|
|
|
|
]);
|
|
|
|
|
|
|
|
// This is a low-stakes enough control, where enough pairs don't have data
|
|
|
|
// anyway, that I think I want to just not draw attention to failures.
|
|
|
|
if (error) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2024-01-29 01:30:18 -08:00
|
|
|
const numStandardPoses = Object.values(poseInfos).filter(
|
|
|
|
(p) => p.isAvailable && STANDARD_POSES.includes(p.pose),
|
2023-08-10 15:56:36 -07:00
|
|
|
).length;
|
|
|
|
|
|
|
|
const onChange = (e) => {
|
|
|
|
dispatchToOutfit({ type: "setPose", pose: e.target.value });
|
|
|
|
};
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Popover
|
Move Expressions/Styles tab to the top/bottom depending on placement
On small screens, the PosePicker opens down, and we put the tabs on the
top, to be near the button.
On large screens, the PosePicker opens up, and we put the tabs on the
bottom, to be near the button.
Previously, we always set `placement="bottom-end"`, which on small
screens behaved as written, and on large screens there would not be
space to open downward so it would open upward instead.
Now, we set the placement explicitly based on a media breakpoint, and
we change the `flexDirection` of the tabs container on the same media
breakpoint.
2024-01-29 09:09:09 -08:00
|
|
|
placement={placement}
|
2023-08-10 15:56:36 -07:00
|
|
|
returnFocusOnClose
|
|
|
|
onOpen={onLockFocus}
|
|
|
|
onClose={onUnlockFocus}
|
|
|
|
initialFocusRef={initialFocusRef}
|
|
|
|
isLazy
|
|
|
|
lazyBehavior="keepMounted"
|
|
|
|
>
|
|
|
|
{({ isOpen }) => (
|
2024-01-29 01:39:41 -08:00
|
|
|
<>
|
|
|
|
<PopoverTrigger>
|
2024-01-30 05:34:20 -08:00
|
|
|
<PosePickerButton
|
|
|
|
pose={pose}
|
|
|
|
isOpen={isOpen}
|
|
|
|
loading={loading}
|
|
|
|
{...props}
|
|
|
|
/>
|
2024-01-29 01:39:41 -08:00
|
|
|
</PopoverTrigger>
|
|
|
|
<Portal>
|
|
|
|
<PopoverContent>
|
Move Expressions/Styles tab to the top/bottom depending on placement
On small screens, the PosePicker opens down, and we put the tabs on the
top, to be near the button.
On large screens, the PosePicker opens up, and we put the tabs on the
bottom, to be near the button.
Previously, we always set `placement="bottom-end"`, which on small
screens behaved as written, and on large screens there would not be
space to open downward so it would open upward instead.
Now, we set the placement explicitly based on a media breakpoint, and
we change the `flexDirection` of the tabs container on the same media
breakpoint.
2024-01-29 09:09:09 -08:00
|
|
|
<Tabs
|
|
|
|
size="sm"
|
|
|
|
variant="soft-rounded"
|
|
|
|
display="flex"
|
|
|
|
flexDirection={{ base: "column", md: "column-reverse" }}
|
|
|
|
paddingY="2"
|
|
|
|
gap="2"
|
2024-01-29 09:17:09 -08:00
|
|
|
// HACK: To only apply `initialFocusRef` to the selected input
|
|
|
|
// in the *active* tab, we just use `isLazy` to only *render*
|
|
|
|
// the active tab. We could also watch the tab state and set
|
|
|
|
// the ref accordingly!
|
|
|
|
isLazy
|
Move Expressions/Styles tab to the top/bottom depending on placement
On small screens, the PosePicker opens down, and we put the tabs on the
top, to be near the button.
On large screens, the PosePicker opens up, and we put the tabs on the
bottom, to be near the button.
Previously, we always set `placement="bottom-end"`, which on small
screens behaved as written, and on large screens there would not be
space to open downward so it would open upward instead.
Now, we set the placement explicitly based on a media breakpoint, and
we change the `flexDirection` of the tabs container on the same media
breakpoint.
2024-01-29 09:09:09 -08:00
|
|
|
>
|
|
|
|
<SupportOnly>
|
|
|
|
<TabList paddingX="2" paddingY="0">
|
|
|
|
<Tab width="50%">Expressions</Tab>
|
|
|
|
<Tab width="50%">Styles</Tab>
|
|
|
|
</TabList>
|
|
|
|
</SupportOnly>
|
2024-01-29 02:10:44 -08:00
|
|
|
<TabPanels position="relative">
|
Move Expressions/Styles tab to the top/bottom depending on placement
On small screens, the PosePicker opens down, and we put the tabs on the
top, to be near the button.
On large screens, the PosePicker opens up, and we put the tabs on the
bottom, to be near the button.
Previously, we always set `placement="bottom-end"`, which on small
screens behaved as written, and on large screens there would not be
space to open downward so it would open upward instead.
Now, we set the placement explicitly based on a media breakpoint, and
we change the `flexDirection` of the tabs container on the same media
breakpoint.
2024-01-29 09:09:09 -08:00
|
|
|
<TabPanel paddingX="4" paddingY="0">
|
2024-01-29 02:10:44 -08:00
|
|
|
{isInSupportMode ? (
|
|
|
|
<PosePickerSupport
|
|
|
|
speciesId={speciesId}
|
|
|
|
colorId={colorId}
|
|
|
|
pose={pose}
|
|
|
|
appearanceId={appearanceId}
|
|
|
|
initialFocusRef={initialFocusRef}
|
|
|
|
dispatchToOutfit={dispatchToOutfit}
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<>
|
|
|
|
<PosePickerTable
|
|
|
|
poseInfos={poseInfos}
|
|
|
|
onChange={onChange}
|
|
|
|
initialFocusRef={initialFocusRef}
|
|
|
|
/>
|
|
|
|
{numStandardPoses == 0 && (
|
|
|
|
<PosePickerEmptyExplanation />
|
|
|
|
)}
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
<SupportOnly>
|
Move Expressions/Styles tab to the top/bottom depending on placement
On small screens, the PosePicker opens down, and we put the tabs on the
top, to be near the button.
On large screens, the PosePicker opens up, and we put the tabs on the
bottom, to be near the button.
Previously, we always set `placement="bottom-end"`, which on small
screens behaved as written, and on large screens there would not be
space to open downward so it would open upward instead.
Now, we set the placement explicitly based on a media breakpoint, and
we change the `flexDirection` of the tabs container on the same media
breakpoint.
2024-01-29 09:09:09 -08:00
|
|
|
<Box position="absolute" top="1" left="3">
|
2024-01-29 02:10:44 -08:00
|
|
|
<PosePickerSupportSwitch
|
|
|
|
isChecked={isInSupportMode}
|
|
|
|
onChange={(e) => setIsInSupportMode(e.target.checked)}
|
|
|
|
/>
|
|
|
|
</Box>
|
|
|
|
</SupportOnly>
|
|
|
|
</TabPanel>
|
Move Expressions/Styles tab to the top/bottom depending on placement
On small screens, the PosePicker opens down, and we put the tabs on the
top, to be near the button.
On large screens, the PosePicker opens up, and we put the tabs on the
bottom, to be near the button.
Previously, we always set `placement="bottom-end"`, which on small
screens behaved as written, and on large screens there would not be
space to open downward so it would open upward instead.
Now, we set the placement explicitly based on a media breakpoint, and
we change the `flexDirection` of the tabs container on the same media
breakpoint.
2024-01-29 09:09:09 -08:00
|
|
|
<TabPanel paddingX="4" paddingY="0">
|
2024-01-29 09:17:09 -08:00
|
|
|
<StyleSelect
|
|
|
|
altStyles={altStyles}
|
|
|
|
initialFocusRef={initialFocusRef}
|
|
|
|
/>
|
2024-01-29 03:20:48 -08:00
|
|
|
<StyleExplanation />
|
|
|
|
</TabPanel>
|
2024-01-29 02:10:44 -08:00
|
|
|
</TabPanels>
|
|
|
|
</Tabs>
|
2024-01-29 01:39:41 -08:00
|
|
|
<PopoverArrow />
|
|
|
|
</PopoverContent>
|
|
|
|
</Portal>
|
|
|
|
</>
|
2023-08-10 15:56:36 -07:00
|
|
|
)}
|
|
|
|
</Popover>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-01-30 05:34:20 -08:00
|
|
|
function PosePickerButton({ pose, isOpen, loading, ...props }, ref) {
|
2024-01-29 01:39:41 -08:00
|
|
|
const theme = useTheme();
|
|
|
|
|
|
|
|
return (
|
|
|
|
<ClassNames>
|
|
|
|
{({ css, cx }) => (
|
|
|
|
<Button
|
|
|
|
variant="unstyled"
|
|
|
|
boxShadow="md"
|
|
|
|
d="flex"
|
|
|
|
alignItems="center"
|
|
|
|
justifyContent="center"
|
|
|
|
_focus={{ borderColor: "gray.50" }}
|
|
|
|
_hover={{ borderColor: "gray.50" }}
|
|
|
|
outline="initial"
|
2024-01-29 02:10:44 -08:00
|
|
|
fontSize="sm"
|
|
|
|
fontWeight="normal"
|
2024-01-30 05:34:20 -08:00
|
|
|
minWidth="12ch"
|
|
|
|
disabled={loading}
|
2024-01-29 01:39:41 -08:00
|
|
|
className={cx(
|
|
|
|
css`
|
|
|
|
border: 1px solid transparent !important;
|
2024-01-30 05:34:20 -08:00
|
|
|
color: ${theme.colors.gray["300"]};
|
|
|
|
cursor: ${loading ? "wait" : "pointer"} !important;
|
|
|
|
transition:
|
|
|
|
color 0.2s,
|
|
|
|
border-color 0.2s !important;
|
|
|
|
padding-left: 0.75em;
|
|
|
|
padding-right: 0.5em;
|
2024-01-29 01:39:41 -08:00
|
|
|
|
|
|
|
&:hover,
|
|
|
|
&.is-open {
|
|
|
|
border-color: ${theme.colors.gray["50"]} !important;
|
2024-01-30 05:34:20 -08:00
|
|
|
color: ${theme.colors.gray["50"]};
|
|
|
|
}
|
|
|
|
|
|
|
|
&:focus {
|
|
|
|
border-color: ${theme.colors.gray["50"]} !important;
|
|
|
|
box-shadow: ${theme.shadows.outline};
|
2024-01-29 01:39:41 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
&.is-open {
|
|
|
|
border-width: 2px !important;
|
|
|
|
}
|
|
|
|
`,
|
|
|
|
isOpen && "is-open",
|
|
|
|
)}
|
|
|
|
{...props}
|
|
|
|
ref={ref}
|
|
|
|
>
|
2024-01-30 05:34:20 -08:00
|
|
|
<EmojiImage src={getIcon(pose)} alt="Style" />
|
|
|
|
<Box width=".5em" />
|
|
|
|
{getLabel(pose)}
|
|
|
|
<Box width=".5em" />
|
|
|
|
<ChevronDownIcon />
|
2024-01-29 01:39:41 -08:00
|
|
|
</Button>
|
|
|
|
)}
|
|
|
|
</ClassNames>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
PosePickerButton = React.forwardRef(PosePickerButton);
|
|
|
|
|
2023-08-10 15:56:36 -07:00
|
|
|
function PosePickerTable({ poseInfos, onChange, initialFocusRef }) {
|
|
|
|
return (
|
|
|
|
<Box display="flex" flexDirection="column" alignItems="center">
|
|
|
|
<table width="100%">
|
|
|
|
<thead>
|
|
|
|
<tr>
|
|
|
|
<th />
|
|
|
|
<Cell as="th">
|
|
|
|
<EmojiImage src={twemojiSmile} alt="Happy" />
|
|
|
|
</Cell>
|
|
|
|
<Cell as="th">
|
|
|
|
<EmojiImage src={twemojiCry} alt="Sad" />
|
|
|
|
</Cell>
|
|
|
|
<Cell as="th">
|
|
|
|
<EmojiImage src={twemojiSick} alt="Sick" />
|
|
|
|
</Cell>
|
|
|
|
</tr>
|
|
|
|
</thead>
|
|
|
|
<tbody>
|
|
|
|
<tr>
|
|
|
|
<Cell as="th">
|
|
|
|
<EmojiImage src={twemojiMasc} alt="Masculine" />
|
|
|
|
</Cell>
|
|
|
|
<Cell as="td">
|
|
|
|
<PoseOption
|
|
|
|
poseInfo={poseInfos.happyMasc}
|
|
|
|
onChange={onChange}
|
|
|
|
inputRef={poseInfos.happyMasc.isSelected && initialFocusRef}
|
|
|
|
/>
|
|
|
|
</Cell>
|
|
|
|
<Cell as="td">
|
|
|
|
<PoseOption
|
|
|
|
poseInfo={poseInfos.sadMasc}
|
|
|
|
onChange={onChange}
|
|
|
|
inputRef={poseInfos.sadMasc.isSelected && initialFocusRef}
|
|
|
|
/>
|
|
|
|
</Cell>
|
|
|
|
<Cell as="td">
|
|
|
|
<PoseOption
|
|
|
|
poseInfo={poseInfos.sickMasc}
|
|
|
|
onChange={onChange}
|
|
|
|
inputRef={poseInfos.sickMasc.isSelected && initialFocusRef}
|
|
|
|
/>
|
|
|
|
</Cell>
|
|
|
|
</tr>
|
|
|
|
<tr>
|
|
|
|
<Cell as="th">
|
|
|
|
<EmojiImage src={twemojiFem} alt="Feminine" />
|
|
|
|
</Cell>
|
|
|
|
<Cell as="td">
|
|
|
|
<PoseOption
|
|
|
|
poseInfo={poseInfos.happyFem}
|
|
|
|
onChange={onChange}
|
|
|
|
inputRef={poseInfos.happyFem.isSelected && initialFocusRef}
|
|
|
|
/>
|
|
|
|
</Cell>
|
|
|
|
<Cell as="td">
|
|
|
|
<PoseOption
|
|
|
|
poseInfo={poseInfos.sadFem}
|
|
|
|
onChange={onChange}
|
|
|
|
inputRef={poseInfos.sadFem.isSelected && initialFocusRef}
|
|
|
|
/>
|
|
|
|
</Cell>
|
|
|
|
<Cell as="td">
|
|
|
|
<PoseOption
|
|
|
|
poseInfo={poseInfos.sickFem}
|
|
|
|
onChange={onChange}
|
|
|
|
inputRef={poseInfos.sickFem.isSelected && initialFocusRef}
|
|
|
|
/>
|
|
|
|
</Cell>
|
|
|
|
</tr>
|
|
|
|
</tbody>
|
|
|
|
</table>
|
|
|
|
{poseInfos.unconverted.isAvailable && (
|
|
|
|
<PoseOption
|
|
|
|
poseInfo={poseInfos.unconverted}
|
|
|
|
onChange={onChange}
|
|
|
|
inputRef={poseInfos.unconverted.isSelected && initialFocusRef}
|
|
|
|
size="sm"
|
|
|
|
label="Unconverted"
|
|
|
|
marginTop="2"
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function Cell({ children, as }) {
|
|
|
|
const Tag = as;
|
|
|
|
return (
|
|
|
|
<Tag>
|
|
|
|
<Flex justify="center" p="1">
|
|
|
|
{children}
|
|
|
|
</Flex>
|
|
|
|
</Tag>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const EMOTION_STRINGS = {
|
|
|
|
HAPPY_MASC: "Happy",
|
|
|
|
HAPPY_FEM: "Happy",
|
|
|
|
SAD_MASC: "Sad",
|
|
|
|
SAD_FEM: "Sad",
|
|
|
|
SICK_MASC: "Sick",
|
|
|
|
SICK_FEM: "Sick",
|
|
|
|
};
|
|
|
|
|
|
|
|
const GENDER_PRESENTATION_STRINGS = {
|
|
|
|
HAPPY_MASC: "Masculine",
|
|
|
|
SAD_MASC: "Masculine",
|
|
|
|
SICK_MASC: "Masculine",
|
|
|
|
HAPPY_FEM: "Feminine",
|
|
|
|
SAD_FEM: "Feminine",
|
|
|
|
SICK_FEM: "Feminine",
|
|
|
|
};
|
|
|
|
|
2024-01-29 01:30:18 -08:00
|
|
|
const STANDARD_POSES = Object.keys(EMOTION_STRINGS);
|
|
|
|
|
2023-08-10 15:56:36 -07:00
|
|
|
function PoseOption({
|
|
|
|
poseInfo,
|
|
|
|
onChange,
|
|
|
|
inputRef,
|
|
|
|
size = "md",
|
|
|
|
label,
|
|
|
|
...otherProps
|
|
|
|
}) {
|
|
|
|
const theme = useTheme();
|
|
|
|
const genderPresentationStr = GENDER_PRESENTATION_STRINGS[poseInfo.pose];
|
|
|
|
const emotionStr = EMOTION_STRINGS[poseInfo.pose];
|
|
|
|
|
|
|
|
let poseName =
|
|
|
|
poseInfo.pose === "UNCONVERTED"
|
|
|
|
? "Unconverted"
|
|
|
|
: `${emotionStr} and ${genderPresentationStr}`;
|
|
|
|
if (!poseInfo.isAvailable) {
|
|
|
|
poseName += ` (not modeled yet)`;
|
|
|
|
}
|
|
|
|
|
|
|
|
const borderColor = useColorModeValue(
|
|
|
|
theme.colors.green["600"],
|
2023-10-24 16:45:49 -07:00
|
|
|
theme.colors.green["300"],
|
2023-08-10 15:56:36 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<ClassNames>
|
|
|
|
{({ css, cx }) => (
|
|
|
|
<Box
|
|
|
|
as="label"
|
|
|
|
cursor="pointer"
|
|
|
|
display="flex"
|
|
|
|
alignItems="center"
|
|
|
|
borderColor={poseInfo.isSelected ? borderColor : "gray.400"}
|
|
|
|
boxShadow={label ? "md" : "none"}
|
|
|
|
borderWidth={label ? "1px" : "0"}
|
|
|
|
borderRadius={label ? "full" : "0"}
|
|
|
|
paddingRight={label ? "3" : "0"}
|
|
|
|
onClick={(e) => {
|
|
|
|
// HACK: We need the timeout to beat the popover's focus stealing!
|
|
|
|
const input = e.currentTarget.querySelector("input");
|
|
|
|
setTimeout(() => input.focus(), 0);
|
|
|
|
}}
|
|
|
|
{...otherProps}
|
|
|
|
>
|
|
|
|
<VisuallyHidden
|
|
|
|
as="input"
|
|
|
|
type="radio"
|
|
|
|
aria-label={poseName}
|
|
|
|
name="pose"
|
|
|
|
value={poseInfo.pose}
|
|
|
|
checked={poseInfo.isSelected}
|
|
|
|
disabled={!poseInfo.isAvailable}
|
|
|
|
onChange={onChange}
|
|
|
|
ref={inputRef || null}
|
|
|
|
/>
|
|
|
|
<Box
|
|
|
|
aria-hidden
|
|
|
|
borderRadius="full"
|
|
|
|
boxShadow="md"
|
|
|
|
overflow="hidden"
|
|
|
|
width={size === "sm" ? "30px" : "50px"}
|
|
|
|
height={size === "sm" ? "30px" : "50px"}
|
|
|
|
title={
|
|
|
|
poseInfo.isAvailable
|
|
|
|
? // A lil debug output, so that we can quickly identify glitched
|
|
|
|
// PetStates and manually mark them as glitched!
|
|
|
|
window.location.hostname.includes("localhost") &&
|
|
|
|
`#${poseInfo.id}`
|
|
|
|
: "Not modeled yet"
|
|
|
|
}
|
|
|
|
position="relative"
|
|
|
|
className={css`
|
|
|
|
transform: scale(0.8);
|
|
|
|
opacity: 0.8;
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
|
|
input:checked + & {
|
|
|
|
transform: scale(1);
|
|
|
|
opacity: 1;
|
|
|
|
}
|
|
|
|
`}
|
|
|
|
>
|
|
|
|
<Box
|
|
|
|
borderRadius="full"
|
|
|
|
position="absolute"
|
|
|
|
top="0"
|
|
|
|
bottom="0"
|
|
|
|
left="0"
|
|
|
|
right="0"
|
|
|
|
zIndex="2"
|
|
|
|
className={cx(
|
|
|
|
css`
|
|
|
|
border: 0px solid ${borderColor};
|
|
|
|
transition: border-width 0.2s;
|
|
|
|
|
|
|
|
&.not-available {
|
|
|
|
border-color: ${theme.colors.gray["500"]};
|
|
|
|
border-width: 1px;
|
|
|
|
}
|
|
|
|
|
|
|
|
input:checked + * & {
|
|
|
|
border-width: 1px;
|
|
|
|
}
|
|
|
|
|
|
|
|
input:focus + * & {
|
|
|
|
border-width: 3px;
|
|
|
|
}
|
|
|
|
`,
|
2023-10-24 16:45:49 -07:00
|
|
|
!poseInfo.isAvailable && "not-available",
|
2023-08-10 15:56:36 -07:00
|
|
|
)}
|
|
|
|
/>
|
|
|
|
{poseInfo.isAvailable ? (
|
|
|
|
<Box
|
|
|
|
width="100%"
|
|
|
|
height="100%"
|
|
|
|
transform={getTransform(poseInfo)}
|
|
|
|
>
|
|
|
|
<OutfitLayers visibleLayers={getVisibleLayers(poseInfo, [])} />
|
|
|
|
</Box>
|
|
|
|
) : (
|
|
|
|
<Flex align="center" justify="center" width="100%" height="100%">
|
|
|
|
<EmojiImage src={twemojiQuestion} boxSize={24} />
|
|
|
|
</Flex>
|
|
|
|
)}
|
|
|
|
</Box>
|
|
|
|
{label && (
|
|
|
|
<Box
|
|
|
|
marginLeft="2"
|
|
|
|
fontSize="xs"
|
|
|
|
fontWeight={poseInfo.isSelected ? "bold" : "normal"}
|
|
|
|
>
|
|
|
|
{label}
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
</ClassNames>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-01-29 01:39:41 -08:00
|
|
|
function PosePickerEmptyExplanation() {
|
|
|
|
return (
|
|
|
|
<Box
|
|
|
|
fontSize="xs"
|
|
|
|
fontStyle="italic"
|
|
|
|
textAlign="center"
|
|
|
|
opacity="0.7"
|
|
|
|
marginTop="2"
|
|
|
|
>
|
|
|
|
We're still working on labeling these! For now, we're just giving you one
|
2024-01-29 02:10:44 -08:00
|
|
|
of the expressions we have.
|
2024-01-29 01:39:41 -08:00
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-01-29 09:17:09 -08:00
|
|
|
function StyleSelect({ altStyles, initialFocusRef }) {
|
2024-01-29 03:20:48 -08:00
|
|
|
const [selectedStyleId, setSelectedStyleId] = React.useState(null);
|
|
|
|
|
|
|
|
const defaultStyle = { id: null, adjectiveName: "Default" };
|
|
|
|
|
2024-01-29 09:17:09 -08:00
|
|
|
const styles = [defaultStyle, ...altStyles];
|
|
|
|
|
2024-01-29 03:20:48 -08:00
|
|
|
return (
|
|
|
|
<Flex
|
|
|
|
as="form"
|
|
|
|
direction="column"
|
|
|
|
gap="2"
|
|
|
|
maxHeight="40vh"
|
|
|
|
padding="4px"
|
|
|
|
overflow="auto"
|
|
|
|
>
|
2024-01-29 09:17:09 -08:00
|
|
|
{styles.map((altStyle) => (
|
2024-01-29 03:20:48 -08:00
|
|
|
<StyleOption
|
|
|
|
key={altStyle.id}
|
|
|
|
altStyle={altStyle}
|
|
|
|
checked={selectedStyleId === altStyle.id}
|
|
|
|
onChange={setSelectedStyleId}
|
2024-01-29 09:17:09 -08:00
|
|
|
inputRef={selectedStyleId === altStyle.id ? initialFocusRef : null}
|
2024-01-29 03:20:48 -08:00
|
|
|
/>
|
|
|
|
))}
|
|
|
|
</Flex>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-01-29 09:17:09 -08:00
|
|
|
function StyleOption({ altStyle, checked, onChange, inputRef }) {
|
2024-01-29 03:20:48 -08:00
|
|
|
const theme = useTheme();
|
|
|
|
const selectedBorderColor = useColorModeValue(
|
|
|
|
theme.colors.green["600"],
|
|
|
|
theme.colors.green["300"],
|
|
|
|
);
|
|
|
|
const outlineShadow = useToken("shadows", "outline");
|
|
|
|
|
|
|
|
return (
|
|
|
|
<ClassNames>
|
|
|
|
{({ css, cx }) => (
|
|
|
|
<Box
|
|
|
|
as="label"
|
|
|
|
cursor="pointer"
|
|
|
|
onClick={(e) => {
|
|
|
|
// HACK: We need the timeout to beat the popover's focus stealing!
|
|
|
|
const input = e.currentTarget.querySelector("input");
|
|
|
|
setTimeout(() => input.focus(), 0);
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<VisuallyHidden
|
|
|
|
as="input"
|
|
|
|
type="radio"
|
|
|
|
name="style"
|
|
|
|
value={altStyle.id}
|
|
|
|
checked={checked}
|
|
|
|
onChange={(e) => onChange(altStyle.id)}
|
2024-01-29 09:17:09 -08:00
|
|
|
ref={inputRef}
|
2024-01-29 03:20:48 -08:00
|
|
|
/>
|
|
|
|
<Flex
|
|
|
|
alignItems="center"
|
|
|
|
gap="2"
|
|
|
|
borderRadius="md"
|
|
|
|
// HACK: Don't let the thumbnail image overlap the border
|
|
|
|
paddingLeft="2px"
|
|
|
|
paddingY="2px"
|
|
|
|
className={cx(css`
|
|
|
|
border: 2px solid transparent;
|
|
|
|
opacity: 0.8;
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
|
|
input:focus + & {
|
|
|
|
box-shadow: ${outlineShadow};
|
|
|
|
}
|
|
|
|
|
|
|
|
input:checked + & {
|
|
|
|
border-color: ${selectedBorderColor};
|
|
|
|
opacity: 1;
|
|
|
|
font-weight: bold;
|
|
|
|
}
|
|
|
|
`)}
|
|
|
|
>
|
|
|
|
{altStyle.thumbnailUrl ? (
|
|
|
|
<Box
|
|
|
|
as="img"
|
|
|
|
src={altStyle.thumbnailUrl}
|
|
|
|
alt="Item thumbnail"
|
|
|
|
width="40px"
|
|
|
|
height="40px"
|
|
|
|
loading="lazy"
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<Box width="40px" height="40px" />
|
|
|
|
)}
|
|
|
|
<Box width="2" />
|
|
|
|
{altStyle.adjectiveName}
|
|
|
|
</Flex>
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
</ClassNames>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function StyleExplanation() {
|
|
|
|
return (
|
|
|
|
<Box
|
|
|
|
fontSize="xs"
|
|
|
|
fontStyle="italic"
|
|
|
|
textAlign="center"
|
|
|
|
opacity="0.7"
|
|
|
|
marginTop="2"
|
|
|
|
>
|
|
|
|
"Alt Styles" are special NC items that override the pet's usual appearance
|
2024-01-29 05:36:48 -08:00
|
|
|
via the{" "}
|
|
|
|
<Box
|
|
|
|
as="a"
|
|
|
|
href="https://www.neopets.com/stylingchamber/"
|
|
|
|
target="_blank"
|
|
|
|
textDecoration="underline"
|
|
|
|
>
|
|
|
|
Styling Chamber
|
|
|
|
</Box>
|
|
|
|
. The pet's color doesn't have to match.
|
2024-01-29 04:36:48 -08:00
|
|
|
<SupportOnly>
|
|
|
|
<br />
|
|
|
|
WIP: Only Support staff see this tab for now! 💖
|
|
|
|
</SupportOnly>
|
2024-01-29 03:20:48 -08:00
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-08-10 15:56:36 -07:00
|
|
|
function EmojiImage({ src, alt, boxSize = 16 }) {
|
2023-08-10 16:17:44 -07:00
|
|
|
return <img src={src} alt={alt} width={boxSize} height={boxSize} />;
|
2023-08-10 15:56:36 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
function usePoses(speciesId, colorId, selectedPose) {
|
|
|
|
const { loading, error, data } = useQuery(
|
|
|
|
gql`
|
|
|
|
query PosePicker($speciesId: ID!, $colorId: ID!) {
|
|
|
|
happyMasc: petAppearance(
|
|
|
|
speciesId: $speciesId
|
|
|
|
colorId: $colorId
|
|
|
|
pose: HAPPY_MASC
|
|
|
|
) {
|
|
|
|
...PetAppearanceForPosePicker
|
|
|
|
}
|
|
|
|
sadMasc: petAppearance(
|
|
|
|
speciesId: $speciesId
|
|
|
|
colorId: $colorId
|
|
|
|
pose: SAD_MASC
|
|
|
|
) {
|
|
|
|
...PetAppearanceForPosePicker
|
|
|
|
}
|
|
|
|
sickMasc: petAppearance(
|
|
|
|
speciesId: $speciesId
|
|
|
|
colorId: $colorId
|
|
|
|
pose: SICK_MASC
|
|
|
|
) {
|
|
|
|
...PetAppearanceForPosePicker
|
|
|
|
}
|
|
|
|
happyFem: petAppearance(
|
|
|
|
speciesId: $speciesId
|
|
|
|
colorId: $colorId
|
|
|
|
pose: HAPPY_FEM
|
|
|
|
) {
|
|
|
|
...PetAppearanceForPosePicker
|
|
|
|
}
|
|
|
|
sadFem: petAppearance(
|
|
|
|
speciesId: $speciesId
|
|
|
|
colorId: $colorId
|
|
|
|
pose: SAD_FEM
|
|
|
|
) {
|
|
|
|
...PetAppearanceForPosePicker
|
|
|
|
}
|
|
|
|
sickFem: petAppearance(
|
|
|
|
speciesId: $speciesId
|
|
|
|
colorId: $colorId
|
|
|
|
pose: SICK_FEM
|
|
|
|
) {
|
|
|
|
...PetAppearanceForPosePicker
|
|
|
|
}
|
|
|
|
unconverted: petAppearance(
|
|
|
|
speciesId: $speciesId
|
|
|
|
colorId: $colorId
|
|
|
|
pose: UNCONVERTED
|
|
|
|
) {
|
|
|
|
...PetAppearanceForPosePicker
|
|
|
|
}
|
|
|
|
unknown: petAppearance(
|
|
|
|
speciesId: $speciesId
|
|
|
|
colorId: $colorId
|
|
|
|
pose: UNKNOWN
|
|
|
|
) {
|
|
|
|
...PetAppearanceForPosePicker
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
${petAppearanceForPosePickerFragment}
|
|
|
|
`,
|
2023-10-24 16:45:49 -07:00
|
|
|
{ variables: { speciesId, colorId }, onError: (e) => console.error(e) },
|
2023-08-10 15:56:36 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
const poseInfos = {
|
|
|
|
happyMasc: {
|
|
|
|
...data?.happyMasc,
|
|
|
|
pose: "HAPPY_MASC",
|
|
|
|
isAvailable: Boolean(data?.happyMasc),
|
|
|
|
isSelected: selectedPose === "HAPPY_MASC",
|
|
|
|
},
|
|
|
|
sadMasc: {
|
|
|
|
...data?.sadMasc,
|
|
|
|
pose: "SAD_MASC",
|
|
|
|
isAvailable: Boolean(data?.sadMasc),
|
|
|
|
isSelected: selectedPose === "SAD_MASC",
|
|
|
|
},
|
|
|
|
sickMasc: {
|
|
|
|
...data?.sickMasc,
|
|
|
|
pose: "SICK_MASC",
|
|
|
|
isAvailable: Boolean(data?.sickMasc),
|
|
|
|
isSelected: selectedPose === "SICK_MASC",
|
|
|
|
},
|
|
|
|
happyFem: {
|
|
|
|
...data?.happyFem,
|
|
|
|
pose: "HAPPY_FEM",
|
|
|
|
isAvailable: Boolean(data?.happyFem),
|
|
|
|
isSelected: selectedPose === "HAPPY_FEM",
|
|
|
|
},
|
|
|
|
sadFem: {
|
|
|
|
...data?.sadFem,
|
|
|
|
pose: "SAD_FEM",
|
|
|
|
isAvailable: Boolean(data?.sadFem),
|
|
|
|
isSelected: selectedPose === "SAD_FEM",
|
|
|
|
},
|
|
|
|
sickFem: {
|
|
|
|
...data?.sickFem,
|
|
|
|
pose: "SICK_FEM",
|
|
|
|
isAvailable: Boolean(data?.sickFem),
|
|
|
|
isSelected: selectedPose === "SICK_FEM",
|
|
|
|
},
|
|
|
|
unconverted: {
|
|
|
|
...data?.unconverted,
|
|
|
|
pose: "UNCONVERTED",
|
|
|
|
isAvailable: Boolean(data?.unconverted),
|
|
|
|
isSelected: selectedPose === "UNCONVERTED",
|
|
|
|
},
|
|
|
|
unknown: {
|
|
|
|
...data?.unknown,
|
|
|
|
pose: "UNKNOWN",
|
|
|
|
isAvailable: Boolean(data?.unknown),
|
|
|
|
isSelected: selectedPose === "UNKNOWN",
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
|
|
|
return { loading, error, poseInfos };
|
|
|
|
}
|
|
|
|
|
|
|
|
function getIcon(pose) {
|
|
|
|
if (["HAPPY_MASC", "HAPPY_FEM"].includes(pose)) {
|
|
|
|
return twemojiSmile;
|
|
|
|
} else if (["SAD_MASC", "SAD_FEM"].includes(pose)) {
|
|
|
|
return twemojiCry;
|
|
|
|
} else if (["SICK_MASC", "SICK_FEM"].includes(pose)) {
|
|
|
|
return twemojiSick;
|
|
|
|
} else if (pose === "UNCONVERTED") {
|
|
|
|
return twemojiSunglasses;
|
|
|
|
} else {
|
2024-01-30 05:06:26 -08:00
|
|
|
return twemojiSmile;
|
2024-01-29 02:10:44 -08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function getLabel(pose) {
|
|
|
|
if (pose === "HAPPY_MASC" || pose === "HAPPY_FEM") {
|
|
|
|
return "Happy";
|
|
|
|
} else if (pose === "SAD_MASC" || pose === "SAD_FEM") {
|
|
|
|
return "Sad";
|
|
|
|
} else if (pose === "SICK_MASC" || pose === "SICK_FEM") {
|
|
|
|
return "Sick";
|
|
|
|
} else if (pose === "UNCONVERTED") {
|
|
|
|
return "Classic UC";
|
|
|
|
} else {
|
|
|
|
return "Default";
|
2023-08-10 15:56:36 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function getTransform(poseInfo) {
|
|
|
|
const { pose, bodyId } = poseInfo;
|
|
|
|
if (pose === "UNCONVERTED") {
|
|
|
|
return transformsByBodyId.default;
|
|
|
|
}
|
|
|
|
if (bodyId in transformsByBodyId) {
|
|
|
|
return transformsByBodyId[bodyId];
|
|
|
|
}
|
|
|
|
return transformsByBodyId.default;
|
|
|
|
}
|
|
|
|
|
|
|
|
export const petAppearanceForPosePickerFragment = gql`
|
|
|
|
fragment PetAppearanceForPosePicker on PetAppearance {
|
|
|
|
id
|
|
|
|
bodyId
|
|
|
|
pose
|
|
|
|
...PetAppearanceForOutfitPreview
|
|
|
|
}
|
|
|
|
${petAppearanceFragment}
|
|
|
|
`;
|
|
|
|
|
|
|
|
const transformsByBodyId = {
|
|
|
|
93: "translate(-5px, 10px) scale(2.8)",
|
|
|
|
106: "translate(-8px, 8px) scale(2.9)",
|
|
|
|
47: "translate(-1px, 17px) scale(3)",
|
|
|
|
84: "translate(-21px, 22px) scale(3.2)",
|
|
|
|
146: "translate(2px, 15px) scale(3.3)",
|
|
|
|
250: "translate(-14px, 28px) scale(3.4)",
|
|
|
|
212: "translate(-4px, 8px) scale(2.9)",
|
|
|
|
74: "translate(-26px, 30px) scale(3.0)",
|
|
|
|
94: "translate(-4px, 8px) scale(3.1)",
|
|
|
|
132: "translate(-14px, 18px) scale(3.0)",
|
|
|
|
56: "translate(-7px, 24px) scale(2.9)",
|
|
|
|
90: "translate(-16px, 20px) scale(3.5)",
|
|
|
|
136: "translate(-11px, 18px) scale(3.0)",
|
|
|
|
138: "translate(-14px, 26px) scale(3.5)",
|
|
|
|
166: "translate(-13px, 24px) scale(3.1)",
|
|
|
|
119: "translate(-6px, 29px) scale(3.1)",
|
|
|
|
126: "translate(3px, 13px) scale(3.1)",
|
|
|
|
67: "translate(2px, 27px) scale(3.4)",
|
|
|
|
163: "translate(-7px, 16px) scale(3.1)",
|
|
|
|
147: "translate(-2px, 15px) scale(3.0)",
|
|
|
|
80: "translate(-2px, -17px) scale(3.0)",
|
|
|
|
117: "translate(-14px, 16px) scale(3.6)",
|
|
|
|
201: "translate(-16px, 16px) scale(3.2)",
|
|
|
|
51: "translate(-2px, 6px) scale(3.2)",
|
|
|
|
208: "translate(-3px, 6px) scale(3.7)",
|
|
|
|
196: "translate(-7px, 19px) scale(5.2)",
|
|
|
|
143: "translate(-16px, 20px) scale(3.5)",
|
|
|
|
150: "translate(-3px, 24px) scale(3.2)",
|
|
|
|
175: "translate(-9px, 15px) scale(3.4)",
|
|
|
|
173: "translate(3px, 57px) scale(4.4)",
|
|
|
|
199: "translate(-28px, 35px) scale(3.8)",
|
|
|
|
52: "translate(-8px, 33px) scale(3.5)",
|
|
|
|
109: "translate(-8px, -6px) scale(3.2)",
|
|
|
|
134: "translate(-14px, 14px) scale(3.1)",
|
|
|
|
95: "translate(-12px, 0px) scale(3.4)",
|
|
|
|
96: "translate(6px, 23px) scale(3.3)",
|
|
|
|
154: "translate(-20px, 25px) scale(3.6)",
|
|
|
|
55: "translate(-16px, 28px) scale(4.0)",
|
|
|
|
76: "translate(-8px, 11px) scale(3.0)",
|
|
|
|
156: "translate(2px, 12px) scale(3.5)",
|
|
|
|
78: "translate(-3px, 18px) scale(3.0)",
|
|
|
|
191: "translate(-18px, 46px) scale(4.4)",
|
|
|
|
187: "translate(-6px, 22px) scale(3.2)",
|
|
|
|
46: "translate(-2px, 19px) scale(3.4)",
|
|
|
|
178: "translate(-11px, 32px) scale(3.3)",
|
|
|
|
100: "translate(-13px, 23px) scale(3.3)",
|
|
|
|
130: "translate(-14px, 4px) scale(3.1)",
|
|
|
|
188: "translate(-9px, 24px) scale(3.5)",
|
|
|
|
257: "translate(-14px, 25px) scale(3.4)",
|
|
|
|
206: "translate(-7px, 4px) scale(3.6)",
|
|
|
|
101: "translate(-13px, 16px) scale(3.2)",
|
|
|
|
68: "translate(-2px, 13px) scale(3.2)",
|
|
|
|
182: "translate(-6px, 4px) scale(3.1)",
|
|
|
|
180: "translate(-15px, 22px) scale(3.6)",
|
|
|
|
306: "translate(1px, 14px) scale(3.1)",
|
|
|
|
default: "scale(2.5)",
|
|
|
|
};
|
|
|
|
|
|
|
|
export default React.memo(PosePicker);
|