1
0
Fork 0
forked from OpenNeo/impress
impress/app/javascript/wardrobe-2020/WardrobePage/OutfitControls.js
Matchu ddfdd5fc11 Add outfit layers info modal to download PNGs etc
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!
2023-10-23 19:05:10 -07:00

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;