extract EaselCanvas to its own file, basic stories
249
src/app/components/EaselCanvas.js
Normal file
|
@ -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 (
|
||||
<EaselContext.Provider
|
||||
value={{
|
||||
width: internalWidth,
|
||||
height: internalHeight,
|
||||
addChild,
|
||||
removeChild,
|
||||
addResizeListener,
|
||||
removeResizeListener,
|
||||
stage, // Not used, but available for debugging.
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={internalWidth}
|
||||
height={internalHeight}
|
||||
style={{
|
||||
width: width + "px",
|
||||
height: height + "px",
|
||||
}}
|
||||
/>
|
||||
{stage && children}
|
||||
</EaselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
|
@ -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 (
|
||||
<Box
|
||||
pos="relative"
|
||||
|
@ -155,7 +149,7 @@ export function OutfitLayers({
|
|||
{
|
||||
// TODO: A bit of a mess in here! Extract these out?
|
||||
engine === "canvas" ? (
|
||||
!scriptsLoading && (
|
||||
!loadingEasel && (
|
||||
<FullScreenCenter>
|
||||
<EaselCanvas width={canvasSize} height={canvasSize}>
|
||||
{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 (
|
||||
<EaselContext.Provider
|
||||
value={{
|
||||
width: internalWidth,
|
||||
height: internalHeight,
|
||||
addChild,
|
||||
removeChild,
|
||||
addResizeListener,
|
||||
removeResizeListener,
|
||||
stage, // Not used, but available for debugging.
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={internalWidth}
|
||||
height={internalHeight}
|
||||
style={{
|
||||
width: width + "px",
|
||||
height: height + "px",
|
||||
}}
|
||||
/>
|
||||
{stage && children}
|
||||
</EaselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
|
37
src/stories/EaselCanvas.stories.js
Normal file
|
@ -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 = () => (
|
||||
<EaselCanvas width={300} height={300}>
|
||||
<EaselBitmap
|
||||
src="http://images.neopets.com/cp/bio/data/000/000/002/2426_898928db88/2426.svg"
|
||||
zIndex={10}
|
||||
/>
|
||||
<EaselBitmap
|
||||
src="http://images.neopets.com/cp/bio/data/000/000/002/2425_501f596cef/2425.svg"
|
||||
zIndex={20}
|
||||
/>
|
||||
<EaselBitmap
|
||||
src="http://images.neopets.com/cp/bio/data/000/000/002/2427_f12853f18a/2427.svg"
|
||||
zIndex={30}
|
||||
/>
|
||||
<EaselBitmap
|
||||
src="http://images.neopets.com/cp/bio/data/000/000/032/32185_dc8f076ae3/32185.svg"
|
||||
zIndex={40}
|
||||
/>
|
||||
<EaselBitmap
|
||||
src="http://images.neopets.com/cp/bio/data/000/000/002/2428_991dcdedc7/2428.svg"
|
||||
zIndex={50}
|
||||
/>
|
||||
<EaselBitmap
|
||||
src="http://images.neopets.com/cp/bio/data/000/000/002/2430_87edccba4c/2430.svg"
|
||||
zIndex={60}
|
||||
/>
|
||||
</EaselCanvas>
|
||||
);
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |