Matchu
ddfdd5fc11
This is an important workflow for people doing art stuff, I'm told! They used to use the Classic DTI broken image UI for this, but now that that's uhh Fully Gone, let's add this more explicitly!
741 lines
22 KiB
JavaScript
741 lines
22 KiB
JavaScript
import React from "react";
|
|
import { ClassNames } from "@emotion/react";
|
|
import {
|
|
Box,
|
|
Button,
|
|
DarkMode,
|
|
Flex,
|
|
FormControl,
|
|
FormHelperText,
|
|
FormLabel,
|
|
HStack,
|
|
IconButton,
|
|
ListItem,
|
|
Menu,
|
|
MenuItem,
|
|
MenuList,
|
|
Popover,
|
|
PopoverArrow,
|
|
PopoverBody,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
Portal,
|
|
Stack,
|
|
Switch,
|
|
Tooltip,
|
|
UnorderedList,
|
|
useClipboard,
|
|
useToast,
|
|
} from "@chakra-ui/react";
|
|
import {
|
|
ArrowBackIcon,
|
|
CheckIcon,
|
|
ChevronDownIcon,
|
|
DownloadIcon,
|
|
LinkIcon,
|
|
SettingsIcon,
|
|
} from "@chakra-ui/icons";
|
|
import { MdPause, MdPlayArrow } from "react-icons/md";
|
|
|
|
import { getBestImageUrlForLayer } from "../components/OutfitPreview";
|
|
import HTML5Badge, { layerUsesHTML5 } from "../components/HTML5Badge";
|
|
import PosePicker from "./PosePicker";
|
|
import SpeciesColorPicker from "../components/SpeciesColorPicker";
|
|
import { loadImage, loadable, useLocalStorage } from "../util";
|
|
import useCurrentUser from "../components/useCurrentUser";
|
|
import useOutfitAppearance from "../components/useOutfitAppearance";
|
|
import OutfitKnownGlitchesBadge from "./OutfitKnownGlitchesBadge";
|
|
import usePreferArchive from "../components/usePreferArchive";
|
|
|
|
const LoadableLayersInfoModal = loadable(() => import("./LayersInfoModal"));
|
|
|
|
/**
|
|
* OutfitControls is the set of controls layered over the outfit preview, to
|
|
* control things like species/color and sharing links!
|
|
*/
|
|
function OutfitControls({
|
|
outfitState,
|
|
dispatchToOutfit,
|
|
showAnimationControls,
|
|
appearance,
|
|
}) {
|
|
const [focusIsLocked, setFocusIsLocked] = React.useState(false);
|
|
const onLockFocus = React.useCallback(
|
|
() => setFocusIsLocked(true),
|
|
[setFocusIsLocked]
|
|
);
|
|
const onUnlockFocus = React.useCallback(
|
|
() => setFocusIsLocked(false),
|
|
[setFocusIsLocked]
|
|
);
|
|
|
|
// HACK: As of 1.0.0-rc.0, Chakra's `toast` function rebuilds unnecessarily,
|
|
// which triggers unnecessary rebuilds of the `onSpeciesColorChange`
|
|
// callback, which causes the `React.memo` on `SpeciesColorPicker` to
|
|
// fail, which harms performance. But it seems to work just fine if we
|
|
// hold onto the first copy of the function we get! :/
|
|
const _toast = useToast();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
const toast = React.useMemo(() => _toast, []);
|
|
|
|
const onSpeciesColorChange = React.useCallback(
|
|
(species, color, isValid, closestPose) => {
|
|
if (isValid) {
|
|
dispatchToOutfit({
|
|
type: "setSpeciesAndColor",
|
|
speciesId: species.id,
|
|
colorId: color.id,
|
|
pose: closestPose,
|
|
});
|
|
} else {
|
|
// NOTE: This shouldn't be possible to trigger, because the
|
|
// `stateMustAlwaysBeValid` prop should prevent it. But we have
|
|
// it as a fallback, just in case!
|
|
toast({
|
|
title: `We haven't seen a ${color.name} ${species.name} before! 😓`,
|
|
status: "warning",
|
|
});
|
|
}
|
|
},
|
|
[dispatchToOutfit, toast]
|
|
);
|
|
|
|
const maybeUnlockFocus = (e) => {
|
|
// We lock focus when a touch-device user taps the area. When they tap
|
|
// empty space, we treat that as a toggle and release the focus lock.
|
|
if (e.target === e.currentTarget) {
|
|
onUnlockFocus();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ClassNames>
|
|
{({ css, cx }) => (
|
|
<OutfitControlsContextMenu outfitState={outfitState}>
|
|
<Box
|
|
role="group"
|
|
pos="absolute"
|
|
left="0"
|
|
right="0"
|
|
top="0"
|
|
bottom="0"
|
|
height="100%" // Required for Safari to size the grid correctly
|
|
padding={{ base: 2, lg: 6 }}
|
|
display="grid"
|
|
overflow="auto"
|
|
gridTemplateAreas={`"back play-pause sharing"
|
|
"space space space"
|
|
"picker picker picker"`}
|
|
gridTemplateRows="auto minmax(1rem, 1fr) auto"
|
|
className={cx(
|
|
css`
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
|
|
&:focus-within,
|
|
&.focus-is-locked {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Ignore simulated hovers, only reveal for _real_ hovers. This helps
|
|
* us avoid state conflicts with the focus-lock from clicks. */
|
|
@media (hover: hover) {
|
|
&:hover {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
`,
|
|
focusIsLocked && "focus-is-locked"
|
|
)}
|
|
onClickCapture={(e) => {
|
|
const opacity = parseFloat(
|
|
getComputedStyle(e.currentTarget).opacity
|
|
);
|
|
if (opacity < 0.5) {
|
|
// If the controls aren't visible right now, then clicks on them are
|
|
// probably accidental. Ignore them! (We prevent default to block
|
|
// built-in behaviors like link nav, and we stop propagation to block
|
|
// our own custom click handlers. I don't know if I can prevent the
|
|
// select clicks though?)
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// We also show the controls, by locking focus. We'll undo this when
|
|
// the user taps elsewhere (because it will trigger a blur event from
|
|
// our child components), in `maybeUnlockFocus`.
|
|
setFocusIsLocked(true);
|
|
}
|
|
}}
|
|
data-test-id="wardrobe-outfit-controls"
|
|
>
|
|
<Box gridArea="back" onClick={maybeUnlockFocus}>
|
|
<BackButton outfitState={outfitState} />
|
|
</Box>
|
|
|
|
<Flex
|
|
gridArea="play-pause"
|
|
// HACK: Better visual centering with other controls
|
|
paddingTop="0.3rem"
|
|
direction="column"
|
|
align="center"
|
|
>
|
|
{showAnimationControls && <PlayPauseButton />}
|
|
<Box height="2" />
|
|
<HStack spacing="2" align="center" justify="center">
|
|
<OutfitHTML5Badge appearance={appearance} />
|
|
<OutfitKnownGlitchesBadge appearance={appearance} />
|
|
<SettingsButton
|
|
onLockFocus={onLockFocus}
|
|
onUnlockFocus={onUnlockFocus}
|
|
/>
|
|
</HStack>
|
|
</Flex>
|
|
<Stack
|
|
gridArea="sharing"
|
|
alignSelf="flex-end"
|
|
spacing={{ base: "2", lg: "4" }}
|
|
align="flex-end"
|
|
onClick={maybeUnlockFocus}
|
|
>
|
|
<Box>
|
|
<DownloadButton outfitState={outfitState} />
|
|
</Box>
|
|
<Box>
|
|
<CopyLinkButton outfitState={outfitState} />
|
|
</Box>
|
|
</Stack>
|
|
<Box gridArea="space" onClick={maybeUnlockFocus} />
|
|
{outfitState.speciesId && outfitState.colorId && (
|
|
<Flex
|
|
gridArea="picker"
|
|
justify="center"
|
|
onClick={maybeUnlockFocus}
|
|
>
|
|
{/**
|
|
* We try to center the species/color picker, but the left spacer will
|
|
* shrink more than the pose picker container if we run out of space!
|
|
*/}
|
|
<Flex
|
|
flex="1 1 0"
|
|
paddingRight="3"
|
|
align="center"
|
|
justify="flex-end"
|
|
/>
|
|
<Box flex="0 0 auto">
|
|
<DarkMode>
|
|
<SpeciesColorPicker
|
|
speciesId={outfitState.speciesId}
|
|
colorId={outfitState.colorId}
|
|
idealPose={outfitState.pose}
|
|
onChange={onSpeciesColorChange}
|
|
stateMustAlwaysBeValid
|
|
speciesTestId="wardrobe-species-picker"
|
|
colorTestId="wardrobe-color-picker"
|
|
/>
|
|
</DarkMode>
|
|
</Box>
|
|
<Flex flex="1 1 0" align="center" pl="2">
|
|
<PosePicker
|
|
speciesId={outfitState.speciesId}
|
|
colorId={outfitState.colorId}
|
|
pose={outfitState.pose}
|
|
appearanceId={outfitState.appearanceId}
|
|
dispatchToOutfit={dispatchToOutfit}
|
|
onLockFocus={onLockFocus}
|
|
onUnlockFocus={onUnlockFocus}
|
|
data-test-id="wardrobe-pose-picker"
|
|
/>
|
|
</Flex>
|
|
</Flex>
|
|
)}
|
|
</Box>
|
|
</OutfitControlsContextMenu>
|
|
)}
|
|
</ClassNames>
|
|
);
|
|
}
|
|
|
|
function OutfitControlsContextMenu({ outfitState, children }) {
|
|
// NOTE: We track these separately, rather than in one atomic state object,
|
|
// because I want to still keep the menu in the right position when it's
|
|
// animating itself closed!
|
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
const [position, setPosition] = React.useState({ x: 0, y: 0 });
|
|
|
|
const [layersInfoModalIsOpen, setLayersInfoModalIsOpen] =
|
|
React.useState(false);
|
|
|
|
const { visibleLayers } = useOutfitAppearance(outfitState);
|
|
const [downloadImageUrl, prepareDownload] =
|
|
useDownloadableImage(visibleLayers);
|
|
|
|
return (
|
|
<Box
|
|
onContextMenuCapture={(e) => {
|
|
setIsOpen(true);
|
|
setPosition({ x: e.pageX, y: e.pageY });
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
{children}
|
|
<Menu isOpen={isOpen} onClose={() => setIsOpen(false)}>
|
|
<Portal>
|
|
<MenuList position="absolute" left={position.x} top={position.y}>
|
|
<MenuItem
|
|
icon={<DownloadIcon />}
|
|
as="a"
|
|
// eslint-disable-next-line no-script-url
|
|
href={downloadImageUrl || "#"}
|
|
onClick={(e) => {
|
|
if (!downloadImageUrl) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
download={(outfitState.name || "Outfit") + ".png"}
|
|
onMouseEnter={prepareDownload}
|
|
onFocus={prepareDownload}
|
|
cursor={!downloadImageUrl && "wait"}
|
|
>
|
|
Download
|
|
</MenuItem>
|
|
<MenuItem
|
|
icon={<LinkIcon />}
|
|
onClick={() => setLayersInfoModalIsOpen(true)}
|
|
>
|
|
Layers (SWF, PNG)
|
|
</MenuItem>
|
|
</MenuList>
|
|
</Portal>
|
|
</Menu>
|
|
<LoadableLayersInfoModal
|
|
isOpen={layersInfoModalIsOpen}
|
|
onClose={() => setLayersInfoModalIsOpen(false)}
|
|
visibleLayers={visibleLayers}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function OutfitHTML5Badge({ appearance }) {
|
|
const petIsUsingHTML5 =
|
|
appearance.petAppearance?.layers.every(layerUsesHTML5);
|
|
|
|
const itemsNotUsingHTML5 = appearance.items.filter((item) =>
|
|
item.appearance.layers.some((l) => !layerUsesHTML5(l))
|
|
);
|
|
itemsNotUsingHTML5.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
const usesHTML5 = petIsUsingHTML5 && itemsNotUsingHTML5.length === 0;
|
|
|
|
let tooltipLabel;
|
|
if (usesHTML5) {
|
|
tooltipLabel = (
|
|
<>This outfit is converted to HTML5, and ready to use on Neopets.com!</>
|
|
);
|
|
} else {
|
|
tooltipLabel = (
|
|
<Box>
|
|
<Box as="p">
|
|
This outfit isn't converted to HTML5 yet, so it might not appear in
|
|
Neopets.com customization yet. Once it's ready, it could look a bit
|
|
different than our temporary preview here. It might even be animated!
|
|
</Box>
|
|
{!petIsUsingHTML5 && (
|
|
<Box as="p" marginTop="1em" fontWeight="bold">
|
|
This pet is not yet converted.
|
|
</Box>
|
|
)}
|
|
{itemsNotUsingHTML5.length > 0 && (
|
|
<>
|
|
<Box as="header" marginTop="1em" fontWeight="bold">
|
|
The following items aren't yet converted:
|
|
</Box>
|
|
<UnorderedList>
|
|
{itemsNotUsingHTML5.map((item) => (
|
|
<ListItem key={item.id}>{item.name}</ListItem>
|
|
))}
|
|
</UnorderedList>
|
|
</>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<HTML5Badge
|
|
usesHTML5={usesHTML5}
|
|
isLoading={appearance.loading}
|
|
tooltipLabel={tooltipLabel}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* BackButton takes you back home, or to Your Outfits if this outfit is yours.
|
|
*/
|
|
function BackButton({ outfitState }) {
|
|
const currentUser = useCurrentUser();
|
|
const outfitBelongsToCurrentUser =
|
|
outfitState.creator && outfitState.creator.id === currentUser.id;
|
|
|
|
return (
|
|
<ControlButton
|
|
as="a"
|
|
href={outfitBelongsToCurrentUser ? "/your-outfits" : "/"}
|
|
icon={<ArrowBackIcon />}
|
|
aria-label="Leave this outfit"
|
|
d="inline-flex" // Not sure why <a> requires this to style right! ^^`
|
|
data-test-id="wardrobe-nav-back-button"
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* DownloadButton downloads the outfit as an image!
|
|
*/
|
|
function DownloadButton({ outfitState }) {
|
|
const { visibleLayers } = useOutfitAppearance(outfitState);
|
|
|
|
const [downloadImageUrl, prepareDownload] =
|
|
useDownloadableImage(visibleLayers);
|
|
|
|
return (
|
|
<Tooltip label="Download" placement="left">
|
|
<Box>
|
|
<ControlButton
|
|
icon={<DownloadIcon />}
|
|
aria-label="Download"
|
|
as="a"
|
|
// eslint-disable-next-line no-script-url
|
|
href={downloadImageUrl || "#"}
|
|
onClick={(e) => {
|
|
if (!downloadImageUrl) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
download={(outfitState.name || "Outfit") + ".png"}
|
|
onMouseEnter={prepareDownload}
|
|
onFocus={prepareDownload}
|
|
cursor={!downloadImageUrl && "wait"}
|
|
/>
|
|
</Box>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* CopyLinkButton copies the outfit URL to the clipboard!
|
|
*/
|
|
function CopyLinkButton({ outfitState }) {
|
|
const { onCopy, hasCopied } = useClipboard(outfitState.url);
|
|
|
|
return (
|
|
<Tooltip label={hasCopied ? "Copied!" : "Copy link"} placement="left">
|
|
<Box>
|
|
<ControlButton
|
|
icon={hasCopied ? <CheckIcon /> : <LinkIcon />}
|
|
aria-label="Copy link"
|
|
onClick={onCopy}
|
|
/>
|
|
</Box>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function PlayPauseButton() {
|
|
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
|
|
|
|
// We show an intro animation if this mounts while paused. Whereas if we're
|
|
// not paused, we initialize as if we had already finished.
|
|
const [blinkInState, setBlinkInState] = React.useState(
|
|
isPaused ? { type: "ready" } : { type: "done" }
|
|
);
|
|
const buttonRef = React.useRef(null);
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (blinkInState.type === "ready" && buttonRef.current) {
|
|
setBlinkInState({
|
|
type: "started",
|
|
position: {
|
|
left: buttonRef.current.offsetLeft,
|
|
top: buttonRef.current.offsetTop,
|
|
},
|
|
});
|
|
}
|
|
}, [blinkInState, setBlinkInState]);
|
|
|
|
return (
|
|
<ClassNames>
|
|
{({ css }) => (
|
|
<>
|
|
<PlayPauseButtonContent
|
|
isPaused={isPaused}
|
|
setIsPaused={setIsPaused}
|
|
ref={buttonRef}
|
|
/>
|
|
{blinkInState.type === "started" && (
|
|
<Portal>
|
|
<PlayPauseButtonContent
|
|
isPaused={isPaused}
|
|
setIsPaused={setIsPaused}
|
|
position="absolute"
|
|
left={blinkInState.position.left}
|
|
top={blinkInState.position.top}
|
|
backgroundColor="gray.600"
|
|
borderColor="gray.50"
|
|
color="gray.50"
|
|
onAnimationEnd={() => setBlinkInState({ type: "done" })}
|
|
// Don't disrupt the hover state of the controls! (And the button
|
|
// doesn't seem to click correctly, not sure why, but instead of
|
|
// debugging I'm adding this :p)
|
|
pointerEvents="none"
|
|
className={css`
|
|
@keyframes fade-in-out {
|
|
0% {
|
|
opacity: 0;
|
|
}
|
|
|
|
10% {
|
|
opacity: 1;
|
|
}
|
|
|
|
90% {
|
|
opacity: 1;
|
|
}
|
|
|
|
100% {
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
opacity: 0;
|
|
animation: fade-in-out 2s;
|
|
`}
|
|
/>
|
|
</Portal>
|
|
)}
|
|
</>
|
|
)}
|
|
</ClassNames>
|
|
);
|
|
}
|
|
|
|
const PlayPauseButtonContent = React.forwardRef(
|
|
({ isPaused, setIsPaused, ...props }, ref) => {
|
|
return (
|
|
<TranslucentButton
|
|
ref={ref}
|
|
leftIcon={isPaused ? <MdPause /> : <MdPlayArrow />}
|
|
onClick={() => setIsPaused(!isPaused)}
|
|
{...props}
|
|
>
|
|
{isPaused ? <>Paused</> : <>Playing</>}
|
|
</TranslucentButton>
|
|
);
|
|
}
|
|
);
|
|
|
|
function SettingsButton({ onLockFocus, onUnlockFocus }) {
|
|
return (
|
|
<Popover onOpen={onLockFocus} onClose={onUnlockFocus}>
|
|
<PopoverTrigger>
|
|
<TranslucentButton size="xs" aria-label="Settings">
|
|
<SettingsIcon />
|
|
<Box width="1" />
|
|
<ChevronDownIcon />
|
|
</TranslucentButton>
|
|
</PopoverTrigger>
|
|
<Portal>
|
|
<PopoverContent width="25ch">
|
|
<PopoverArrow />
|
|
<PopoverBody>
|
|
<HiResModeSetting />
|
|
</PopoverBody>
|
|
</PopoverContent>
|
|
</Portal>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function HiResModeSetting() {
|
|
const [hiResMode, setHiResMode] = useLocalStorage("DTIHiResMode", false);
|
|
const [preferArchive, setPreferArchive] = usePreferArchive();
|
|
|
|
return (
|
|
<Box>
|
|
<FormControl>
|
|
<Flex>
|
|
<Box>
|
|
<FormLabel htmlFor="hi-res-mode-setting" fontSize="sm" margin="0">
|
|
Hi-res mode (SVG)
|
|
</FormLabel>
|
|
<FormHelperText marginTop="0" fontSize="xs">
|
|
Crisper at higher resolutions, but not always accurate
|
|
</FormHelperText>
|
|
</Box>
|
|
<Box width="2" />
|
|
<Switch
|
|
id="hi-res-mode-setting"
|
|
size="sm"
|
|
marginTop="0.1rem"
|
|
isChecked={hiResMode}
|
|
onChange={(e) => setHiResMode(e.target.checked)}
|
|
/>
|
|
</Flex>
|
|
</FormControl>
|
|
<Box height="2" />
|
|
<FormControl>
|
|
<Flex>
|
|
<Box>
|
|
<FormLabel
|
|
htmlFor="prefer-archive-setting"
|
|
fontSize="sm"
|
|
margin="0"
|
|
>
|
|
Use DTI's image archive
|
|
</FormLabel>
|
|
<FormHelperText marginTop="0" fontSize="xs">
|
|
Turn this on when images.neopets.com is slow!
|
|
</FormHelperText>
|
|
</Box>
|
|
<Box width="2" />
|
|
<Switch
|
|
id="prefer-archive-setting"
|
|
size="sm"
|
|
marginTop="0.1rem"
|
|
isChecked={preferArchive ?? false}
|
|
onChange={(e) => setPreferArchive(e.target.checked)}
|
|
/>
|
|
</Flex>
|
|
</FormControl>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
const TranslucentButton = React.forwardRef(({ children, ...props }, ref) => {
|
|
return (
|
|
<Button
|
|
ref={ref}
|
|
size="sm"
|
|
color="gray.100"
|
|
variant="outline"
|
|
borderColor="gray.200"
|
|
borderRadius="full"
|
|
backgroundColor="blackAlpha.600"
|
|
boxShadow="md"
|
|
_hover={{
|
|
backgroundColor: "gray.600",
|
|
borderColor: "gray.50",
|
|
color: "gray.50",
|
|
}}
|
|
_focus={{
|
|
backgroundColor: "gray.600",
|
|
borderColor: "gray.50",
|
|
color: "gray.50",
|
|
}}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</Button>
|
|
);
|
|
});
|
|
|
|
/**
|
|
* ControlButton is a UI helper to render the cute round buttons we use in
|
|
* OutfitControls!
|
|
*/
|
|
function ControlButton({ icon, "aria-label": ariaLabel, ...props }) {
|
|
return (
|
|
<IconButton
|
|
icon={icon}
|
|
aria-label={ariaLabel}
|
|
isRound
|
|
variant="unstyled"
|
|
backgroundColor="gray.600"
|
|
color="gray.50"
|
|
boxShadow="md"
|
|
d="flex"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
transition="backgroundColor 0.2s"
|
|
_focus={{ backgroundColor: "gray.500" }}
|
|
_hover={{ backgroundColor: "gray.500" }}
|
|
outline="initial"
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* useDownloadableImage loads the image data and generates the downloadable
|
|
* image URL.
|
|
*/
|
|
function useDownloadableImage(visibleLayers) {
|
|
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
|
|
const [preferArchive] = usePreferArchive();
|
|
|
|
const [downloadImageUrl, setDownloadImageUrl] = React.useState(null);
|
|
const [preparedForLayerIds, setPreparedForLayerIds] = React.useState([]);
|
|
const toast = useToast();
|
|
|
|
const prepareDownload = React.useCallback(async () => {
|
|
// Skip if the current image URL is already correct for these layers.
|
|
const layerIds = visibleLayers.map((l) => l.id);
|
|
if (layerIds.join(",") === preparedForLayerIds.join(",")) {
|
|
return;
|
|
}
|
|
|
|
// Skip if there are no layers. (This probably means we're still loading!)
|
|
if (layerIds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
setDownloadImageUrl(null);
|
|
|
|
// NOTE: You could argue that we may as well just always use PNGs here,
|
|
// regardless of hi-res mode… but using the same src will help both
|
|
// performance (can use cached SVG), and predictability (image will
|
|
// look like what you see here).
|
|
const imagePromises = visibleLayers.map((layer) =>
|
|
loadImage(getBestImageUrlForLayer(layer, { hiResMode }), {
|
|
crossOrigin: "anonymous",
|
|
preferArchive,
|
|
})
|
|
);
|
|
|
|
let images;
|
|
try {
|
|
images = await Promise.all(imagePromises);
|
|
} catch (e) {
|
|
console.error("Error building downloadable image", e);
|
|
toast({
|
|
status: "error",
|
|
title: "Oops, sorry, we couldn't download the image!",
|
|
description:
|
|
"Check your connection, then reload the page and try again.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const canvas = document.createElement("canvas");
|
|
const context = canvas.getContext("2d");
|
|
canvas.width = 600;
|
|
canvas.height = 600;
|
|
|
|
for (const image of images) {
|
|
context.drawImage(image, 0, 0);
|
|
}
|
|
|
|
console.debug(
|
|
"Generated image for download",
|
|
layerIds,
|
|
canvas.toDataURL("image/png")
|
|
);
|
|
setDownloadImageUrl(canvas.toDataURL("image/png"));
|
|
setPreparedForLayerIds(layerIds);
|
|
}, [preparedForLayerIds, visibleLayers, toast, hiResMode, preferArchive]);
|
|
|
|
return [downloadImageUrl, prepareDownload];
|
|
}
|
|
|
|
export default OutfitControls;
|