2020-05-02 13:40:37 -07:00
|
|
|
import React from "react";
|
2021-01-03 19:11:46 -08:00
|
|
|
import { ClassNames } from "@emotion/react";
|
2020-05-02 13:40:37 -07:00
|
|
|
import {
|
|
|
|
Box,
|
2020-09-24 06:04:59 -07:00
|
|
|
Button,
|
2020-08-12 00:37:31 -07:00
|
|
|
DarkMode,
|
2020-05-02 13:40:37 -07:00
|
|
|
Flex,
|
|
|
|
IconButton,
|
2021-02-10 13:47:02 -08:00
|
|
|
ListItem,
|
2020-09-24 07:48:37 -07:00
|
|
|
Portal,
|
2020-05-02 13:40:37 -07:00
|
|
|
Stack,
|
|
|
|
Tooltip,
|
2021-02-10 13:47:02 -08:00
|
|
|
UnorderedList,
|
2020-05-02 13:40:37 -07:00
|
|
|
useClipboard,
|
2020-05-10 00:21:04 -07:00
|
|
|
useToast,
|
2021-03-13 01:48:12 -08:00
|
|
|
VStack,
|
2020-12-25 09:08:33 -08:00
|
|
|
} from "@chakra-ui/react";
|
2020-07-20 21:32:42 -07:00
|
|
|
import {
|
|
|
|
ArrowBackIcon,
|
|
|
|
CheckIcon,
|
|
|
|
DownloadIcon,
|
|
|
|
LinkIcon,
|
2021-03-13 01:48:12 -08:00
|
|
|
WarningTwoIcon,
|
2020-07-20 21:32:42 -07:00
|
|
|
} from "@chakra-ui/icons";
|
2021-03-13 01:48:12 -08:00
|
|
|
import { FaBug } from "react-icons/fa";
|
2020-09-22 05:39:48 -07:00
|
|
|
import { MdPause, MdPlayArrow } from "react-icons/md";
|
|
|
|
import { Link } from "react-router-dom";
|
2020-05-02 13:40:37 -07:00
|
|
|
|
fix Download button to use better caching
So I broke the Download button when we switched to impress-2020.openneo.net, and I forgot to update the Amazon S3 config.
But in addition to that, I'm making some code changes here, to make downloads faster: we now use exactly the same URL and crossOrigin configuration between the <img> tag on the page, and the image that the Download button requests, which ensures that it can use the cached copy instead of loading new stuff. (There were two main cases: 1. it always loaded the PNGs instead of the SVG, which doesn't matter for quality if we're rendering a 600x600 bitmap anyway, but is good caching, and 2. send `crossOrigin` on the <img> tag, which isn't necessary there, but is necessary for Download, and having them match means we can use the cached copy.)
2020-10-10 01:19:59 -07:00
|
|
|
import { getBestImageUrlForLayer } from "../components/OutfitPreview";
|
2021-03-13 01:48:12 -08:00
|
|
|
import HTML5Badge, {
|
|
|
|
GlitchBadgeLayout,
|
|
|
|
layerUsesHTML5,
|
|
|
|
} from "../components/HTML5Badge";
|
2020-05-02 15:41:02 -07:00
|
|
|
import PosePicker from "./PosePicker";
|
2020-07-20 21:41:26 -07:00
|
|
|
import SpeciesColorPicker from "../components/SpeciesColorPicker";
|
fix Download button to use better caching
So I broke the Download button when we switched to impress-2020.openneo.net, and I forgot to update the Amazon S3 config.
But in addition to that, I'm making some code changes here, to make downloads faster: we now use exactly the same URL and crossOrigin configuration between the <img> tag on the page, and the image that the Download button requests, which ensures that it can use the cached copy instead of loading new stuff. (There were two main cases: 1. it always loaded the PNGs instead of the SVG, which doesn't matter for quality if we're rendering a 600x600 bitmap anyway, but is good caching, and 2. send `crossOrigin` on the <img> tag, which isn't necessary there, but is necessary for Download, and having them match means we can use the cached copy.)
2020-10-10 01:19:59 -07:00
|
|
|
import { loadImage, useLocalStorage } from "../util";
|
2021-01-17 08:04:21 -08:00
|
|
|
import useCurrentUser from "../components/useCurrentUser";
|
2020-07-22 21:29:57 -07:00
|
|
|
import useOutfitAppearance from "../components/useOutfitAppearance";
|
2020-05-02 13:40:37 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* OutfitControls is the set of controls layered over the outfit preview, to
|
|
|
|
* control things like species/color and sharing links!
|
|
|
|
*/
|
2020-09-24 06:13:27 -07:00
|
|
|
function OutfitControls({
|
|
|
|
outfitState,
|
|
|
|
dispatchToOutfit,
|
|
|
|
showAnimationControls,
|
2021-02-10 13:35:34 -08:00
|
|
|
appearance,
|
2020-09-24 06:13:27 -07:00
|
|
|
}) {
|
2020-05-02 15:41:02 -07:00
|
|
|
const [focusIsLocked, setFocusIsLocked] = React.useState(false);
|
2020-08-04 23:58:52 -07:00
|
|
|
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 {
|
2020-08-19 19:05:44 -07:00
|
|
|
// 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!
|
2020-08-04 23:58:52 -07:00
|
|
|
toast({
|
|
|
|
title: `We haven't seen a ${color.name} ${species.name} before! 😓`,
|
|
|
|
status: "warning",
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
[dispatchToOutfit, toast]
|
|
|
|
);
|
2020-05-02 15:41:02 -07:00
|
|
|
|
2020-08-05 01:06:05 -07:00
|
|
|
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();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-05-02 13:40:37 -07:00
|
|
|
return (
|
2021-01-03 19:11:46 -08:00
|
|
|
<ClassNames>
|
|
|
|
{({ css, cx }) => (
|
|
|
|
<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"
|
2020-09-24 06:04:59 -07:00
|
|
|
"space space space"
|
|
|
|
"picker picker picker"`}
|
2021-01-03 19:11:46 -08:00
|
|
|
gridTemplateRows="auto minmax(1rem, 1fr) auto"
|
|
|
|
className={cx(
|
|
|
|
css`
|
|
|
|
opacity: 0;
|
|
|
|
transition: opacity 0.2s;
|
2020-05-02 13:40:37 -07:00
|
|
|
|
2021-01-03 19:11:46 -08:00
|
|
|
&:focus-within,
|
|
|
|
&.focus-is-locked {
|
|
|
|
opacity: 1;
|
|
|
|
}
|
2020-08-05 01:06:05 -07:00
|
|
|
|
2021-01-03 19:11:46 -08:00
|
|
|
/* Ignore simulated hovers, only reveal for _real_ hovers. This helps
|
2020-08-05 01:06:05 -07:00
|
|
|
* us avoid state conflicts with the focus-lock from clicks. */
|
2021-01-03 19:11:46 -08:00
|
|
|
@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();
|
2020-08-05 01:06:05 -07:00
|
|
|
|
2021-01-03 19:11:46 -08:00
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Box gridArea="back" onClick={maybeUnlockFocus}>
|
2021-01-17 08:04:21 -08:00
|
|
|
<BackButton outfitState={outfitState} />
|
2021-01-03 19:11:46 -08:00
|
|
|
</Box>
|
|
|
|
{showAnimationControls && (
|
|
|
|
<Box gridArea="play-pause" display="flex" justifyContent="center">
|
|
|
|
<DarkMode>
|
|
|
|
<PlayPauseButton />
|
|
|
|
</DarkMode>
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
<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} />
|
2021-01-17 07:24:54 -08:00
|
|
|
{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!
|
|
|
|
*/}
|
2021-02-10 13:35:34 -08:00
|
|
|
<Flex
|
|
|
|
flex="1 1 0"
|
|
|
|
paddingRight="2"
|
|
|
|
align="center"
|
|
|
|
justify="center"
|
|
|
|
>
|
2021-03-13 01:15:29 -08:00
|
|
|
<OutfitHTML5Badge appearance={appearance} />
|
2021-03-13 01:58:06 -08:00
|
|
|
<Box width="2" />
|
2021-03-13 01:48:12 -08:00
|
|
|
<OutfitKnownGlitchesBadge appearance={appearance} />
|
2021-02-10 13:35:34 -08:00
|
|
|
</Flex>
|
2021-01-17 07:24:54 -08:00
|
|
|
<Box flex="0 0 auto">
|
|
|
|
<DarkMode>
|
|
|
|
{
|
|
|
|
<SpeciesColorPicker
|
|
|
|
speciesId={outfitState.speciesId}
|
|
|
|
colorId={outfitState.colorId}
|
|
|
|
idealPose={outfitState.pose}
|
|
|
|
onChange={onSpeciesColorChange}
|
|
|
|
stateMustAlwaysBeValid
|
|
|
|
/>
|
|
|
|
}
|
|
|
|
</DarkMode>
|
|
|
|
</Box>
|
2021-01-08 03:37:56 -08:00
|
|
|
<Flex flex="1 1 0" align="center" pl="4">
|
|
|
|
<PosePicker
|
|
|
|
speciesId={outfitState.speciesId}
|
|
|
|
colorId={outfitState.colorId}
|
|
|
|
pose={outfitState.pose}
|
|
|
|
appearanceId={outfitState.appearanceId}
|
|
|
|
dispatchToOutfit={dispatchToOutfit}
|
|
|
|
onLockFocus={onLockFocus}
|
|
|
|
onUnlockFocus={onUnlockFocus}
|
|
|
|
/>
|
|
|
|
</Flex>
|
2021-01-17 07:24:54 -08:00
|
|
|
</Flex>
|
|
|
|
)}
|
2020-05-02 15:41:02 -07:00
|
|
|
</Box>
|
2021-01-03 19:11:46 -08:00
|
|
|
)}
|
|
|
|
</ClassNames>
|
2020-05-02 13:40:37 -07:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-03-13 01:15:29 -08:00
|
|
|
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}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-03-13 01:48:12 -08:00
|
|
|
function OutfitKnownGlitchesBadge({ appearance }) {
|
|
|
|
const glitchMessages = [];
|
|
|
|
|
2021-03-13 01:58:06 -08:00
|
|
|
// Look for conflicts between Static pet zones (UCs), and Static items.
|
2021-03-13 01:48:12 -08:00
|
|
|
const petHasStaticZone = appearance.petAppearance?.layers.some(
|
|
|
|
(l) => l.zone.id === "46"
|
|
|
|
);
|
2021-03-13 01:58:06 -08:00
|
|
|
if (petHasStaticZone) {
|
|
|
|
for (const item of appearance.items) {
|
|
|
|
const itemHasStaticZone = item.appearance.layers.some(
|
|
|
|
(l) => l.zone.id === "46"
|
|
|
|
);
|
|
|
|
if (itemHasStaticZone) {
|
|
|
|
glitchMessages.push(
|
|
|
|
<Box key={`static-zone-conflict-for-item-${item.id}`}>
|
|
|
|
When you apply a Static-zone item like <i>{item.name}</i> to an
|
|
|
|
Unconverted pet, it hides the pet. This is a known bug on
|
|
|
|
Neopets.com, so we reproduce it here, too.
|
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Look for items with the OFFICIAL_SVG_IS_INCORRECT glitch.
|
|
|
|
for (const item of appearance.items) {
|
|
|
|
const itemHasOfficialSvgIsIncorrect = item.appearance.layers.some((l) =>
|
|
|
|
l.knownGlitches.includes("OFFICIAL_SVG_IS_INCORRECT")
|
2021-03-13 01:48:12 -08:00
|
|
|
);
|
2021-03-13 01:58:06 -08:00
|
|
|
if (itemHasOfficialSvgIsIncorrect) {
|
|
|
|
glitchMessages.push(
|
|
|
|
<Box key={`official-svg-is-incorrect-for-item-${item.id}`}>
|
|
|
|
There's a glitch in the art for <i>{item.name}</i> that prevents us
|
|
|
|
from showing the full-scale SVG version of the image. Instead, we're
|
|
|
|
showing a PNG, which might look a bit blurry at larger screen sizes.
|
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
}
|
2021-03-13 01:48:12 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
if (glitchMessages.length === 0) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<GlitchBadgeLayout
|
|
|
|
aria-label="Has known glitches"
|
|
|
|
tooltipLabel={
|
|
|
|
<Box>
|
|
|
|
<Box as="header" fontWeight="bold" fontSize="sm" marginBottom="1">
|
|
|
|
Known glitches
|
|
|
|
</Box>
|
|
|
|
<VStack spacing="1em">{glitchMessages}</VStack>
|
|
|
|
</Box>
|
|
|
|
}
|
|
|
|
>
|
|
|
|
<WarningTwoIcon fontSize="xs" marginRight="1" />
|
|
|
|
<FaBug />
|
|
|
|
</GlitchBadgeLayout>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-01-17 08:04:21 -08:00
|
|
|
/**
|
|
|
|
* 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={Link}
|
|
|
|
to={outfitBelongsToCurrentUser ? "/your-outfits" : "/"}
|
|
|
|
icon={<ArrowBackIcon />}
|
|
|
|
aria-label="Leave this outfit"
|
|
|
|
d="inline-flex" // Not sure why <a> requires this to style right! ^^`
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-05-02 13:40:37 -07:00
|
|
|
/**
|
|
|
|
* 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
|
2020-07-20 21:32:42 -07:00
|
|
|
icon={<DownloadIcon />}
|
2020-05-02 13:40:37 -07:00
|
|
|
aria-label="Download"
|
|
|
|
as="a"
|
|
|
|
// eslint-disable-next-line no-script-url
|
2020-05-19 18:30:54 -07:00
|
|
|
href={downloadImageUrl || "#"}
|
|
|
|
onClick={(e) => {
|
|
|
|
if (!downloadImageUrl) {
|
|
|
|
e.preventDefault();
|
|
|
|
}
|
|
|
|
}}
|
2020-05-02 13:40:37 -07:00
|
|
|
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
|
2020-07-20 21:32:42 -07:00
|
|
|
icon={hasCopied ? <CheckIcon /> : <LinkIcon />}
|
2020-05-02 13:40:37 -07:00
|
|
|
aria-label="Copy link"
|
|
|
|
onClick={onCopy}
|
|
|
|
/>
|
|
|
|
</Box>
|
|
|
|
</Tooltip>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-09-22 05:39:48 -07:00
|
|
|
function PlayPauseButton() {
|
|
|
|
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
|
|
|
|
|
2020-09-24 07:48:37 -07:00
|
|
|
// 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]);
|
|
|
|
|
2020-09-22 05:39:48 -07:00
|
|
|
return (
|
2021-01-03 19:11:46 -08:00
|
|
|
<ClassNames>
|
|
|
|
{({ css }) => (
|
|
|
|
<>
|
2020-09-24 07:48:37 -07:00
|
|
|
<PlayPauseButtonContent
|
|
|
|
isPaused={isPaused}
|
|
|
|
setIsPaused={setIsPaused}
|
2021-01-03 19:11:46 -08:00
|
|
|
marginTop="0.3rem" // to center-align with buttons (not sure on amt?)
|
|
|
|
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;
|
|
|
|
}
|
2020-09-24 07:48:37 -07:00
|
|
|
|
2021-01-03 19:11:46 -08:00
|
|
|
10% {
|
|
|
|
opacity: 1;
|
|
|
|
}
|
2020-09-24 07:48:37 -07:00
|
|
|
|
2021-01-03 19:11:46 -08:00
|
|
|
90% {
|
|
|
|
opacity: 1;
|
|
|
|
}
|
2020-09-24 07:48:37 -07:00
|
|
|
|
2021-01-03 19:11:46 -08:00
|
|
|
100% {
|
|
|
|
opacity: 0;
|
|
|
|
}
|
|
|
|
}
|
2020-09-24 07:48:37 -07:00
|
|
|
|
2021-01-03 19:11:46 -08:00
|
|
|
opacity: 0;
|
|
|
|
animation: fade-in-out 2s;
|
|
|
|
`}
|
|
|
|
/>
|
|
|
|
</Portal>
|
|
|
|
)}
|
|
|
|
</>
|
2020-09-24 07:48:37 -07:00
|
|
|
)}
|
2021-01-03 19:11:46 -08:00
|
|
|
</ClassNames>
|
2020-09-22 05:39:48 -07:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-09-24 07:48:37 -07:00
|
|
|
const PlayPauseButtonContent = React.forwardRef(
|
|
|
|
({ isPaused, setIsPaused, ...props }, ref) => {
|
|
|
|
return (
|
|
|
|
<Button
|
|
|
|
ref={ref}
|
|
|
|
leftIcon={isPaused ? <MdPause /> : <MdPlayArrow />}
|
|
|
|
size="sm"
|
|
|
|
color="gray.100"
|
|
|
|
variant="outline"
|
|
|
|
borderColor="gray.200"
|
|
|
|
borderRadius="full"
|
|
|
|
backgroundColor="blackAlpha.600"
|
|
|
|
boxShadow="md"
|
|
|
|
position="absolute"
|
|
|
|
_hover={{
|
|
|
|
backgroundColor: "gray.600",
|
|
|
|
borderColor: "gray.50",
|
|
|
|
color: "gray.50",
|
|
|
|
}}
|
|
|
|
_focus={{
|
|
|
|
backgroundColor: "gray.600",
|
|
|
|
borderColor: "gray.50",
|
|
|
|
color: "gray.50",
|
|
|
|
}}
|
|
|
|
onClick={() => setIsPaused(!isPaused)}
|
|
|
|
{...props}
|
|
|
|
>
|
|
|
|
{isPaused ? <>Paused</> : <>Playing</>}
|
|
|
|
</Button>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2020-05-02 13:40:37 -07:00
|
|
|
/**
|
|
|
|
* 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 [downloadImageUrl, setDownloadImageUrl] = React.useState(null);
|
|
|
|
const [preparedForLayerIds, setPreparedForLayerIds] = React.useState([]);
|
Clearer errors when image download fails
Two fixes in here, for when image downloads fail!
1) Actually catch the error, and show UI feedback
2) Throw it as an actual exception, so the console message will have a stack trace
Additionally, debugging this was a bit trickier than normal, because I didn't fully understand that the image `onerror` argument is an error _event_, not an Error object. So, Sentry captured the uncaught promise rejection, but it didn't have trace information, because it wasn't an Error. Whereas now, if I forget to catch `loadImage` calls in the future, we'll get a real trace! both in the console for debugging, and in Sentry if it makes it to prod :)
2021-01-17 04:42:35 -08:00
|
|
|
const toast = useToast();
|
2020-05-02 13:40:37 -07:00
|
|
|
|
|
|
|
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);
|
|
|
|
|
fix Download button to use better caching
So I broke the Download button when we switched to impress-2020.openneo.net, and I forgot to update the Amazon S3 config.
But in addition to that, I'm making some code changes here, to make downloads faster: we now use exactly the same URL and crossOrigin configuration between the <img> tag on the page, and the image that the Download button requests, which ensures that it can use the cached copy instead of loading new stuff. (There were two main cases: 1. it always loaded the PNGs instead of the SVG, which doesn't matter for quality if we're rendering a 600x600 bitmap anyway, but is good caching, and 2. send `crossOrigin` on the <img> tag, which isn't necessary there, but is necessary for Download, and having them match means we can use the cached copy.)
2020-10-10 01:19:59 -07:00
|
|
|
const imagePromises = visibleLayers.map((layer) =>
|
|
|
|
loadImage(getBestImageUrlForLayer(layer))
|
2020-05-02 13:40:37 -07:00
|
|
|
);
|
|
|
|
|
Clearer errors when image download fails
Two fixes in here, for when image downloads fail!
1) Actually catch the error, and show UI feedback
2) Throw it as an actual exception, so the console message will have a stack trace
Additionally, debugging this was a bit trickier than normal, because I didn't fully understand that the image `onerror` argument is an error _event_, not an Error object. So, Sentry captured the uncaught promise rejection, but it didn't have trace information, because it wasn't an Error. Whereas now, if I forget to catch `loadImage` calls in the future, we'll get a real trace! both in the console for debugging, and in Sentry if it makes it to prod :)
2021-01-17 04:42:35 -08:00
|
|
|
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;
|
|
|
|
}
|
2020-05-02 13:40:37 -07:00
|
|
|
|
|
|
|
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.log(
|
|
|
|
"Generated image for download",
|
|
|
|
layerIds,
|
|
|
|
canvas.toDataURL("image/png")
|
|
|
|
);
|
|
|
|
setDownloadImageUrl(canvas.toDataURL("image/png"));
|
|
|
|
setPreparedForLayerIds(layerIds);
|
Clearer errors when image download fails
Two fixes in here, for when image downloads fail!
1) Actually catch the error, and show UI feedback
2) Throw it as an actual exception, so the console message will have a stack trace
Additionally, debugging this was a bit trickier than normal, because I didn't fully understand that the image `onerror` argument is an error _event_, not an Error object. So, Sentry captured the uncaught promise rejection, but it didn't have trace information, because it wasn't an Error. Whereas now, if I forget to catch `loadImage` calls in the future, we'll get a real trace! both in the console for debugging, and in Sentry if it makes it to prod :)
2021-01-17 04:42:35 -08:00
|
|
|
}, [preparedForLayerIds, visibleLayers, toast]);
|
2020-05-02 13:40:37 -07:00
|
|
|
|
|
|
|
return [downloadImageUrl, prepareDownload];
|
|
|
|
}
|
|
|
|
|
|
|
|
export default OutfitControls;
|