diff --git a/src/app/OutfitControls.js b/src/app/OutfitControls.js new file mode 100644 index 0000000..5fbd046 --- /dev/null +++ b/src/app/OutfitControls.js @@ -0,0 +1,227 @@ +import React from "react"; +import { css } from "emotion"; +import { + Box, + Flex, + IconButton, + PseudoBox, + Stack, + Tooltip, + useClipboard, +} from "@chakra-ui/core"; + +import OutfitResetModal from "./OutfitResetModal"; +import SpeciesColorPicker from "./SpeciesColorPicker"; +import useOutfitAppearance from "./useOutfitAppearance"; + +/** + * OutfitControls is the set of controls layered over the outfit preview, to + * control things like species/color and sharing links! + */ +function OutfitControls({ outfitState, dispatchToOutfit }) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +/** + * DownloadButton downloads the outfit as an image! + */ +function DownloadButton({ outfitState }) { + const { visibleLayers } = useOutfitAppearance(outfitState); + + const [downloadImageUrl, prepareDownload] = useDownloadableImage( + visibleLayers + ); + + return ( + + + + + + ); +} + +/** + * CopyLinkButton copies the outfit URL to the clipboard! + */ +function CopyLinkButton({ outfitState }) { + const { onCopy, hasCopied } = useClipboard(outfitState.url); + + return ( + + + + + + ); +} + +/** + * BackButton opens a reset modal to let you clear the outfit or enter a new + * pet's name to start from! + */ +function BackButton({ dispatchToOutfit }) { + const [showResetModal, setShowResetModal] = React.useState(false); + + return ( + <> + setShowResetModal(true)} + /> + setShowResetModal(false)} + dispatchToOutfit={dispatchToOutfit} + /> + + ); +} + +/** + * ControlButton is a UI helper to render the cute round buttons we use in + * OutfitControls! + */ +function ControlButton({ icon, "aria-label": ariaLabel, ...props }) { + return ( + + ); +} + +/** + * 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([]); + + 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); + + const imagePromises = visibleLayers.map( + (layer) => + new Promise((resolve, reject) => { + const image = new window.Image(); + image.crossOrigin = "Anonymous"; // Requires S3 CORS config! + image.addEventListener("load", () => resolve(image), false); + image.addEventListener("error", (e) => reject(e), false); + image.src = layer.imageUrl + "&xoxo"; + }) + ); + + const images = await Promise.all(imagePromises); + + 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); + }, [preparedForLayerIds, visibleLayers]); + + return [downloadImageUrl, prepareDownload]; +} + +export default OutfitControls; diff --git a/src/app/OutfitPreview.js b/src/app/OutfitPreview.js index e908a55..6628e26 100644 --- a/src/app/OutfitPreview.js +++ b/src/app/OutfitPreview.js @@ -1,75 +1,17 @@ import React from "react"; import { css } from "emotion"; import { CSSTransition, TransitionGroup } from "react-transition-group"; -import gql from "graphql-tag"; -import { useQuery } from "@apollo/react-hooks"; -import { - Box, - Flex, - Icon, - IconButton, - Image, - PseudoBox, - Spinner, - Stack, - Text, - Tooltip, - useClipboard, -} from "@chakra-ui/core"; + +import { Box, Flex, Icon, Image, Spinner, Text } from "@chakra-ui/core"; import { Delay } from "./util"; -import OutfitResetModal from "./OutfitResetModal"; -import SpeciesColorPicker from "./SpeciesColorPicker"; +import useOutfitAppearance from "./useOutfitAppearance"; -export const itemAppearanceFragment = gql` - fragment AppearanceForOutfitPreview on Appearance { - layers { - id - imageUrl(size: SIZE_600) - zone { - id - depth - } - } - - restrictedZones { - id - } - } -`; - -function OutfitPreview({ outfitState, dispatchToOutfit }) { - const { wornItemIds, speciesId, colorId } = outfitState; - const [hasFocus, setHasFocus] = React.useState(false); - const [showResetModal, setShowResetModal] = React.useState(false); - - const { loading, error, data } = useQuery( - gql` - query($wornItemIds: [ID!]!, $speciesId: ID!, $colorId: ID!) { - petAppearance(speciesId: $speciesId, colorId: $colorId) { - ...AppearanceForOutfitPreview - } - - items(ids: $wornItemIds) { - id - appearanceOn(speciesId: $speciesId, colorId: $colorId) { - ...AppearanceForOutfitPreview - } - } - } - ${itemAppearanceFragment} - `, - { - variables: { wornItemIds, speciesId, colorId }, - } - ); - - const visibleLayers = getVisibleLayers(data); - const [downloadImageUrl, prepareDownload] = useDownloadableImage( - visibleLayers - ); - - const { onCopy, hasCopied } = useClipboard(outfitState.url); +/** + * OutfitPreview renders the actual image layers for the outfit we're viewing! + */ +function OutfitPreview({ outfitState }) { + const { loading, error, visibleLayers } = useOutfitAppearance(outfitState); if (error) { return ( @@ -84,9 +26,11 @@ function OutfitPreview({ outfitState, dispatchToOutfit }) { } return ( - + {visibleLayers.map((layer) => ( + // We manage the fade-in and fade-out separately! The fade-out + // happens here, when the layer exits the DOM. finishes preloading and + // applies the src to the underlying . className={css` opacity: 0.01; @@ -127,7 +74,7 @@ function OutfitPreview({ outfitState, dispatchToOutfit }) { ))} {loading && ( - + )} - - - setHasFocus(true)} - onBlur={() => setHasFocus(false)} - onClick={() => setShowResetModal(true)} - /> - - - - - { - prepareDownload(); - setHasFocus(true); - }} - onBlur={() => setHasFocus(false)} - cursor={!downloadImageUrl && "wait"} - variant="unstyled" - backgroundColor="gray.600" - color="gray.50" - boxShadow="md" - d="flex" - alignItems="center" - justifyContent="center" - opacity={hasFocus ? 1 : 0} - transition="all 0.2s" - _groupHover={{ - opacity: 1, - }} - _focus={{ - opacity: 1, - backgroundColor: "gray.500", - }} - _hover={{ - backgroundColor: "gray.500", - }} - outline="initial" - /> - - - - - setHasFocus(true)} - onBlur={() => setHasFocus(false)} - variant="unstyled" - backgroundColor="gray.600" - color="gray.50" - boxShadow="md" - d="flex" - alignItems="center" - justifyContent="center" - opacity={hasFocus ? 1 : 0} - transition="all 0.2s" - _groupHover={{ - opacity: 1, - }} - _focus={{ - opacity: 1, - backgroundColor: "gray.500", - }} - _hover={{ - backgroundColor: "gray.500", - }} - outline="initial" - /> - - - - - - setHasFocus(true)} - onBlur={() => setHasFocus(false)} - /> - - - setShowResetModal(false)} - dispatchToOutfit={dispatchToOutfit} - /> - + ); } -function getVisibleLayers(data) { - if (!data) { - return []; - } - - const allAppearances = [ - data.petAppearance, - ...(data.items || []).map((i) => i.appearanceOn), - ].filter((a) => a); - let allLayers = allAppearances.map((a) => a.layers).flat(); - - // Clean up our data a bit, by ensuring only one layer per zone. This - // shouldn't happen in theory, but sometimes our database doesn't clean up - // after itself correctly :( - allLayers = allLayers.filter((l, i) => { - return allLayers.findIndex((l2) => l2.zone.id === l.zone.id) === i; - }); - - const allRestrictedZoneIds = allAppearances - .map((l) => l.restrictedZones) - .flat() - .map((z) => z.id); - - const visibleLayers = allLayers.filter( - (l) => !allRestrictedZoneIds.includes(l.zone.id) - ); - visibleLayers.sort((a, b) => a.zone.depth - b.zone.depth); - - return visibleLayers; -} - function FullScreenCenter({ children }) { return ( { - // 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; - } - - setDownloadImageUrl(null); - - const imagePromises = visibleLayers.map( - (layer) => - new Promise((resolve, reject) => { - const image = new window.Image(); - image.crossOrigin = "Anonymous"; // Requires S3 CORS config! - image.addEventListener("load", () => resolve(image), false); - image.addEventListener("error", (e) => reject(e), false); - image.src = layer.imageUrl + "&xoxo"; - }) - ); - - const images = await Promise.all(imagePromises); - - 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); - }, [preparedForLayerIds, visibleLayers]); - - return [downloadImageUrl, prepareDownload]; -} - export default OutfitPreview; diff --git a/src/app/SearchPanel.js b/src/app/SearchPanel.js index fe34d3d..246fd40 100644 --- a/src/app/SearchPanel.js +++ b/src/app/SearchPanel.js @@ -5,7 +5,7 @@ import { useQuery } from "@apollo/react-hooks"; import { Delay, Heading1, useDebounce } from "./util"; import { Item, ItemListContainer, ItemListSkeleton } from "./Item"; -import { itemAppearanceFragment } from "./OutfitPreview"; +import { itemAppearanceFragment } from "./useOutfitAppearance"; /** * SearchPanel shows item search results to the user, so they can preview them diff --git a/src/app/SpeciesColorPicker.js b/src/app/SpeciesColorPicker.js index e6ed589..79f41de 100644 --- a/src/app/SpeciesColorPicker.js +++ b/src/app/SpeciesColorPicker.js @@ -11,12 +11,7 @@ import { Delay } from "./util"; * It preloads all species, colors, and valid species/color pairs; and then * ensures that the outfit is always in a valid state. */ -function SpeciesColorPicker({ - outfitState, - dispatchToOutfit, - onFocus, - onBlur, -}) { +function SpeciesColorPicker({ outfitState, dispatchToOutfit }) { const toast = useToast(); const { loading, error, data } = useQuery(gql` query { @@ -121,8 +116,6 @@ function SpeciesColorPicker({ border="none" boxShadow="md" width="auto" - onFocus={onFocus} - onBlur={onBlur} > {allColors.map((color) => (