import React from "react"; import { Box, DarkMode, Flex, Text } from "@chakra-ui/core"; import { WarningIcon } from "@chakra-ui/icons"; import HangerSpinner from "./HangerSpinner"; import useOutfitAppearance from "./useOutfitAppearance"; /** * OutfitPreview is for rendering a full outfit! It accepts outfit data, * fetches the appearance data for it, and preloads and renders the layers * together. * * If the species/color/pose fields are null and a `placeholder` node is * provided instead, we'll render the placeholder. And then, once those props * become non-null, we'll keep showing the placeholder below the loading * overlay until loading completes. (We use this on the homepage to show the * beach splash until outfit data arrives!) * * TODO: There's some duplicate work happening in useOutfitAppearance and * useOutfitState both getting appearance data on first load... */ function OutfitPreview({ speciesId, colorId, pose, wornItemIds, appearanceId = null, isLoading = false, placeholder, loadingDelayMs, spinnerVariant, }) { const { loading, error, visibleLayers } = useOutfitAppearance({ speciesId, colorId, pose, appearanceId, wornItemIds, }); const { loading: loading2, error: error2, loadedLayers } = usePreloadLayers( visibleLayers ); if (error || error2) { return ( Could not load preview. Try again? ); } return ( ); } /** * OutfitLayers is the raw UI component for rendering outfit layers. It's * used both in the main outfit preview, and in other minor UIs! */ export function OutfitLayers({ loading, visibleLayers, placeholder, loadingDelayMs = 500, 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 ); React.useEffect(() => { const t = setTimeout(() => setLoadingDelayHasPassed(true), loadingDelayMs); 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 && ( {placeholder} )} {!scriptsLoading && ( {visibleLayers.map((layer) => ( ))} )} {spinnerVariant === "overlay" && ( <> {/* Against the dark overlay, use the Dark Mode spinner. */} )} {spinnerVariant === "corner" && ( )} ); } export function FullScreenCenter({ children, ...otherProps }) { return ( {children} ); } 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); function onTick(event) { stage.update(event); } window.createjs.Ticker.timingMode = window.createjs.Ticker.RAF; window.createjs.Ticker.on("tick", onTick); return () => window.createjs.Ticker.off("tick", onTick); }, []); const addChild = React.useCallback( (child, zIndex, { afterFirstDraw = null } = {}) => { // 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(); if (afterFirstDraw) { stage.on("drawend", afterFirstDraw, null, true); } }, [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 image; let bitmap; let tween; 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); // We're gonna fade in! Wait for the first frame to draw, to make the // timing smooth, but yeah here we go! bitmap.alpha = 0; tween = window.createjs.Tween.get(bitmap, { paused: true }).to( { alpha: 1 }, 200 ); const startFadeIn = () => { // NOTE: You must cache bitmaps to apply filters to them, and caching // doesn't work until the first draw. bitmap.cache(0, 0, image.width, image.height); tween.paused = false; }; setBitmapSize(); addChild(bitmap, zIndex, { afterFirstDraw: startFadeIn }); addResizeListener(setBitmapSize); } function removeBitmap() { removeResizeListener(setBitmapSize); removeChild(bitmap); } addBitmap(); return () => { if (bitmap) { // Reverse the fade-in into a fade-out, then remove the bitmap. tween.reversed = true; tween.setPosition(0); tween.paused = false; tween.on("complete", removeBitmap, null, true); } }; }, [ src, zIndex, width, height, addChild, removeChild, addResizeListener, removeResizeListener, ]); return null; } function getBestImageUrlForLayer(layer) { if (layer.svgUrl) { return `/api/assetProxy?url=${encodeURIComponent(layer.svgUrl)}`; } else { return layer.imageUrl; } } function loadImage(url) { const image = new Image(); const promise = new Promise((resolve, reject) => { image.onload = () => resolve(image); image.onerror = (e) => reject(e); image.src = url; }); promise.cancel = () => { image.src = ""; }; return promise; } /** * usePreloadLayers preloads the images for the given layers, and yields them * when done. This enables us to keep the old outfit preview on screen until * all the new layers are ready, then show them all at once! */ export function usePreloadLayers(layers) { const [error, setError] = React.useState(null); const [loadedLayers, setLoadedLayers] = React.useState([]); // NOTE: This condition would need to change if we started loading one at a // time, or if the error case would need to show a partial state! const loading = loadedLayers !== layers; React.useEffect(() => { // HACK: Don't clear the preview when we have zero layers, because it // usually means the parent is still loading data. I feel like this isn't // the right abstraction, though... if (loadedLayers.length > 0 && layers.length === 0) { return; } // If the layers already match, we can ignore extra effect triggers. if (!loading) { return; } let canceled = false; setError(null); const loadImages = async () => { const imagePromises = layers.map(getBestImageUrlForLayer).map(loadImage); try { // TODO: Load in one at a time, under a loading spinner & delay? await Promise.all(imagePromises); } catch (e) { if (canceled) return; console.error("Error preloading outfit layers", e); imagePromises.forEach((p) => p.cancel()); setError(e); return; } if (canceled) return; setLoadedLayers(layers); }; loadImages(); return () => { canceled = true; }; }, [layers, loadedLayers.length, loading]); return { loading, error, loadedLayers }; } export default OutfitPreview;