import React from "react"; import { css, cx } from "emotion"; import { Box, Button, DarkMode, Flex, IconButton, Stack, Tooltip, useClipboard, useToast, } from "@chakra-ui/core"; import { ArrowBackIcon, CheckIcon, DownloadIcon, LinkIcon, } from "@chakra-ui/icons"; import { MdPause, MdPlayArrow } from "react-icons/md"; import { Link } from "react-router-dom"; import PosePicker from "./PosePicker"; import SpeciesColorPicker from "../components/SpeciesColorPicker"; import { useLocalStorage } from "../util"; import useOutfitAppearance from "../components/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, showAnimationControls, }) { 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 ( { 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); } }} > } aria-label="Leave this outfit" d="inline-flex" // Not sure why requires this to style right! ^^` /> {showAnimationControls && ( )} {/** * 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! */} ); } /** * DownloadButton downloads the outfit as an image! */ function DownloadButton({ outfitState }) { const { visibleLayers } = useOutfitAppearance(outfitState); const [downloadImageUrl, prepareDownload] = useDownloadableImage( visibleLayers ); return ( } 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"} /> ); } /** * CopyLinkButton copies the outfit URL to the clipboard! */ function CopyLinkButton({ outfitState }) { const { onCopy, hasCopied } = useClipboard(outfitState.url); return ( : } aria-label="Copy link" onClick={onCopy} /> ); } function PlayPauseButton() { const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true); return ( ); } /** * 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;