diff --git a/src/app/components/OutfitPreview.js b/src/app/components/OutfitPreview.js index 7957baa..a8bd38c 100644 --- a/src/app/components/OutfitPreview.js +++ b/src/app/components/OutfitPreview.js @@ -1,6 +1,4 @@ import React from "react"; -import { css, cx } from "emotion"; -import { CSSTransition, TransitionGroup } from "react-transition-group"; import { Box, DarkMode, Flex, Text } from "@chakra-ui/core"; import { WarningIcon } from "@chakra-ui/icons"; @@ -80,6 +78,21 @@ export function OutfitLayers({ spinnerVariant = "overlay", doAnimations = false, }) { + // NOTE: I couldn't find an official NPM source for this that worked with + // Webpack, and I didn't want to rely on random people's ports, and I + // couldn't get a bundled version to work quite right. So we load + // createjs async! + const easelLoading = useScriptTag( + "https://code.createjs.com/1.0.0/easeljs.min.js" + ); + const tweenLoading = useScriptTag( + "https://code.createjs.com/1.0.0/tweenjs.min.js" + ); + const scriptsLoading = easelLoading || tweenLoading; + + const containerRef = React.useRef(null); + const [canvasSize, setCanvasSize] = React.useState(0); + const [loadingDelayHasPassed, setLoadingDelayHasPassed] = React.useState( false ); @@ -89,6 +102,20 @@ export function OutfitLayers({ return () => clearTimeout(t); }, [loadingDelayMs]); + React.useLayoutEffect(() => { + function computeAndSizeCanvasSize() { + setCanvasSize( + Math.min( + containerRef.current.offsetWidth, + containerRef.current.offsetHeight + ) + ); + } + + window.addEventListener("resize", computeAndSizeCanvasSize); + return () => window.removeEventListener("resize", computeAndSizeCanvasSize); + }, [setCanvasSize]); + return ( {placeholder && ( @@ -109,63 +137,19 @@ export function OutfitLayers({ )} - - {visibleLayers.map((layer) => ( - - - + + {visibleLayers.map((layer) => ( + finishes preloading and - // applies the src to the underlying . - className={cx( - css` - object-fit: contain; - max-width: 100%; - max-height: 100%; - - &.do-animations { - animation: fade-in 0.2s; - } - - @keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - `, - doAnimations && "do-animations" - )} - // This sets up the cache to not need to reload images during - // download! - // TODO: Re-enable this once we get our change into Chakra - // main. For now, this will make Downloads a bit slower, which - // is fine! - // crossOrigin="Anonymous" + zIndex={layer.zone.depth} /> - - - ))} - + ))} + + + )} {spinnerVariant === "overlay" && ( @@ -218,6 +202,165 @@ export function FullScreenCenter({ children, ...otherProps }) { ); } +function useScriptTag(src) { + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + let canceled = false; + const script = document.createElement("script"); + script.onload = () => { + if (!canceled) { + setLoading(false); + } + }; + script.src = src; + document.body.appendChild(script); + + return () => { + canceled = true; + setLoading(true); + }; + }, [src, setLoading]); + + return loading; +} + +const EaselContext = React.createContext({ + stage: null, + addResizeListener: () => {}, + removeResizeListener: () => {}, +}); + +function EaselCanvas({ children, width, height }) { + const [stage, setStage] = React.useState(null); + const resizeListenersRef = React.useRef([]); + const canvasRef = React.useRef(null); + + React.useLayoutEffect(() => { + const stage = new window.createjs.Stage(canvasRef.current); + setStage(stage); + }, []); + + const addChild = React.useCallback( + (child, zIndex) => { + // Save this child's z-index for future sorting. + child.DTI_zIndex = zIndex; + // Add the child, then slot it into the right place in the order. + stage.addChild(child); + stage.sortChildren((a, b) => a.DTI_zIndex - b.DTI_zIndex); + // Then update in bulk! + stage.update(); + }, + [stage] + ); + + const removeChild = React.useCallback( + (child) => { + stage.removeChild(child); + stage.update(); + }, + [stage] + ); + + const addResizeListener = React.useCallback((handler) => { + resizeListenersRef.current.push(handler); + }, []); + const removeResizeListener = React.useCallback((handler) => { + resizeListenersRef.current = resizeListenersRef.current.filter( + (h) => h !== handler + ); + }, []); + + // When the canvas resizes, resize all the layers, then a single bulk update. + React.useEffect(() => { + for (const handler of resizeListenersRef.current) { + handler(); + } + if (stage) { + stage.update(); + } + }, [stage, width, height]); + + // Set the canvas's internal dimensions to be higher, if the device has high + // DPI like retina. But we'll keep the layout width/height as expected! + const internalWidth = width * window.devicePixelRatio; + const internalHeight = height * window.devicePixelRatio; + + return ( + + + {stage && children} + + ); +} + +function EaselBitmap({ src, zIndex }) { + const { + width, + height, + addChild, + removeChild, + addResizeListener, + removeResizeListener, + } = React.useContext(EaselContext); + + React.useEffect(() => { + let bitmap; + let image; + + function setBitmapSize() { + bitmap.scaleX = width / image.width; + bitmap.scaleY = height / image.height; + } + + async function addBitmap() { + image = await loadImage(src); + bitmap = new window.createjs.Bitmap(image); + setBitmapSize(); + addChild(bitmap, zIndex); + addResizeListener(setBitmapSize); + } + + addBitmap(); + + return () => { + if (bitmap) { + removeResizeListener(setBitmapSize); + removeChild(bitmap); + } + }; + }, [ + src, + zIndex, + width, + height, + addChild, + removeChild, + addResizeListener, + removeResizeListener, + ]); + + return null; +} + function getBestImageUrlForLayer(layer) { if (layer.svgUrl) { return `/api/assetProxy?url=${encodeURIComponent(layer.svgUrl)}`; @@ -229,7 +372,7 @@ function getBestImageUrlForLayer(layer) { function loadImage(url) { const image = new Image(); const promise = new Promise((resolve, reject) => { - image.onload = () => resolve(); + image.onload = () => resolve(image); image.onerror = (e) => reject(e); image.src = url; });