diff --git a/src/app/components/EaselCanvas.js b/src/app/components/EaselCanvas.js new file mode 100644 index 0000000..eb75493 --- /dev/null +++ b/src/app/components/EaselCanvas.js @@ -0,0 +1,249 @@ +import React from "react"; + +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); + + const { loading } = useEaselDependenciesLoader(); + + React.useLayoutEffect(() => { + if (loading) { + return; + } + + 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); + }, [loading]); + + 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; + + if (loading) { + return null; + } + + return ( + + + {stage && children} + + ); +} + +export 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; +} + +/** + * useEaselDependenciesLoader loads the CreateJS scripts we use in EaselCanvas. + * We load it as part of EaselCanvas, but callers can also use this to preload + * the scripts and track loading progress. + */ +export function useEaselDependenciesLoader() { + // 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" + ); + + return { loading: easelLoading || tweenLoading }; +} + +function useScriptTag(src) { + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + const existingScript = document.querySelector( + `script[src=${CSS.escape(src)}]` + ); + if (existingScript) { + setLoading(false); + return; + } + + 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; +} + +export 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; +} + +export default EaselCanvas; diff --git a/src/app/components/OutfitPreview.js b/src/app/components/OutfitPreview.js index 09e4cbe..d571564 100644 --- a/src/app/components/OutfitPreview.js +++ b/src/app/components/OutfitPreview.js @@ -4,6 +4,11 @@ import { WarningIcon } from "@chakra-ui/icons"; import { css, cx } from "emotion"; import { CSSTransition, TransitionGroup } from "react-transition-group"; +import EaselCanvas, { + EaselBitmap, + loadImage, + useEaselDependenciesLoader, +} from "./EaselCanvas"; import HangerSpinner from "./HangerSpinner"; import useOutfitAppearance from "./useOutfitAppearance"; @@ -83,29 +88,20 @@ export function OutfitLayers({ doTransitions = false, engine = "images", }) { - // 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 ); + const { loading: loadingEasel } = useEaselDependenciesLoader(); + + const loadingAnything = loading || loadingEasel; + // When we start in a loading state, or re-enter a loading state, start the // loading delay timer. React.useEffect(() => { - if (loading) { + if (loadingAnything) { setLoadingDelayHasPassed(false); const t = setTimeout( () => setLoadingDelayHasPassed(true), @@ -113,7 +109,7 @@ export function OutfitLayers({ ); return () => clearTimeout(t); } - }, [loadingDelayMs, loading]); + }, [loadingDelayMs, loadingAnything]); React.useLayoutEffect(() => { function computeAndSizeCanvasSize() { @@ -129,8 +125,6 @@ export function OutfitLayers({ return () => window.removeEventListener("resize", computeAndSizeCanvasSize); }, [setCanvasSize]); - console.log(loading, scriptsLoading); - return ( {visibleLayers.map((layer) => ( @@ -235,7 +229,7 @@ export function OutfitLayers({ // also use a timeout to delay the fade-in by 0.5s, but don't delay the // fade-out at all. (The timeout was an awkward choice, it was hard to // find a good CSS way to specify this delay well!) - opacity={(loading || scriptsLoading) && loadingDelayHasPassed ? 1 : 0} + opacity={loadingAnything && loadingDelayHasPassed ? 1 : 0} transition="opacity 0.2s" > {spinnerVariant === "overlay" && ( @@ -280,209 +274,6 @@ export function FullScreenCenter({ children, ...otherProps }) { ); } -function useScriptTag(src) { - const [loading, setLoading] = React.useState(true); - - React.useEffect(() => { - const existingScript = document.querySelector( - `script[src=${CSS.escape(src)}]` - ); - if (existingScript) { - setLoading(false); - return; - } - - 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)}`; @@ -491,19 +282,6 @@ function getBestImageUrlForLayer(layer) { } } -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 diff --git a/src/stories/EaselCanvas.stories.js b/src/stories/EaselCanvas.stories.js new file mode 100644 index 0000000..feeaf4a --- /dev/null +++ b/src/stories/EaselCanvas.stories.js @@ -0,0 +1,37 @@ +import React from "react"; + +import EaselCanvas, { EaselBitmap } from "../app/components/EaselCanvas"; + +export default { + title: "Dress to Impress/EaselCanvas", + component: EaselCanvas, +}; + +export const BlueAcara = () => ( + + + + + + + + +); diff --git a/src/stories/Button.js b/src/stories/example/Button.js similarity index 100% rename from src/stories/Button.js rename to src/stories/example/Button.js diff --git a/src/stories/Button.stories.js b/src/stories/example/Button.stories.js similarity index 100% rename from src/stories/Button.stories.js rename to src/stories/example/Button.stories.js diff --git a/src/stories/Header.js b/src/stories/example/Header.js similarity index 100% rename from src/stories/Header.js rename to src/stories/example/Header.js diff --git a/src/stories/Header.stories.js b/src/stories/example/Header.stories.js similarity index 100% rename from src/stories/Header.stories.js rename to src/stories/example/Header.stories.js diff --git a/src/stories/Introduction.stories.mdx b/src/stories/example/Introduction.stories.mdx similarity index 100% rename from src/stories/Introduction.stories.mdx rename to src/stories/example/Introduction.stories.mdx diff --git a/src/stories/Page.js b/src/stories/example/Page.js similarity index 100% rename from src/stories/Page.js rename to src/stories/example/Page.js diff --git a/src/stories/Page.stories.js b/src/stories/example/Page.stories.js similarity index 100% rename from src/stories/Page.stories.js rename to src/stories/example/Page.stories.js diff --git a/src/stories/assets/code-brackets.svg b/src/stories/example/assets/code-brackets.svg similarity index 100% rename from src/stories/assets/code-brackets.svg rename to src/stories/example/assets/code-brackets.svg diff --git a/src/stories/assets/colors.svg b/src/stories/example/assets/colors.svg similarity index 100% rename from src/stories/assets/colors.svg rename to src/stories/example/assets/colors.svg diff --git a/src/stories/assets/comments.svg b/src/stories/example/assets/comments.svg similarity index 100% rename from src/stories/assets/comments.svg rename to src/stories/example/assets/comments.svg diff --git a/src/stories/assets/direction.svg b/src/stories/example/assets/direction.svg similarity index 100% rename from src/stories/assets/direction.svg rename to src/stories/example/assets/direction.svg diff --git a/src/stories/assets/flow.svg b/src/stories/example/assets/flow.svg similarity index 100% rename from src/stories/assets/flow.svg rename to src/stories/example/assets/flow.svg diff --git a/src/stories/assets/plugin.svg b/src/stories/example/assets/plugin.svg similarity index 100% rename from src/stories/assets/plugin.svg rename to src/stories/example/assets/plugin.svg diff --git a/src/stories/assets/repo.svg b/src/stories/example/assets/repo.svg similarity index 100% rename from src/stories/assets/repo.svg rename to src/stories/example/assets/repo.svg diff --git a/src/stories/assets/stackalt.svg b/src/stories/example/assets/stackalt.svg similarity index 100% rename from src/stories/assets/stackalt.svg rename to src/stories/example/assets/stackalt.svg diff --git a/src/stories/button.css b/src/stories/example/button.css similarity index 100% rename from src/stories/button.css rename to src/stories/example/button.css diff --git a/src/stories/header.css b/src/stories/example/header.css similarity index 100% rename from src/stories/header.css rename to src/stories/example/header.css diff --git a/src/stories/page.css b/src/stories/example/page.css similarity index 100% rename from src/stories/page.css rename to src/stories/example/page.css