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