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) => (