extract EaselCanvas to its own file, basic stories

This commit is contained in:
Emi Matchu 2020-09-22 01:44:24 -07:00
parent 31a0108d44
commit 9a8047c613
21 changed files with 299 additions and 235 deletions

View 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;

View file

@ -4,6 +4,11 @@ import { WarningIcon } from "@chakra-ui/icons";
import { css, cx } from "emotion"; import { css, cx } from "emotion";
import { CSSTransition, TransitionGroup } from "react-transition-group"; import { CSSTransition, TransitionGroup } from "react-transition-group";
import EaselCanvas, {
EaselBitmap,
loadImage,
useEaselDependenciesLoader,
} from "./EaselCanvas";
import HangerSpinner from "./HangerSpinner"; import HangerSpinner from "./HangerSpinner";
import useOutfitAppearance from "./useOutfitAppearance"; import useOutfitAppearance from "./useOutfitAppearance";
@ -83,29 +88,20 @@ export function OutfitLayers({
doTransitions = false, doTransitions = false,
engine = "images", 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 containerRef = React.useRef(null);
const [canvasSize, setCanvasSize] = React.useState(0); const [canvasSize, setCanvasSize] = React.useState(0);
const [loadingDelayHasPassed, setLoadingDelayHasPassed] = React.useState( const [loadingDelayHasPassed, setLoadingDelayHasPassed] = React.useState(
false false
); );
const { loading: loadingEasel } = useEaselDependenciesLoader();
const loadingAnything = loading || loadingEasel;
// When we start in a loading state, or re-enter a loading state, start the // When we start in a loading state, or re-enter a loading state, start the
// loading delay timer. // loading delay timer.
React.useEffect(() => { React.useEffect(() => {
if (loading) { if (loadingAnything) {
setLoadingDelayHasPassed(false); setLoadingDelayHasPassed(false);
const t = setTimeout( const t = setTimeout(
() => setLoadingDelayHasPassed(true), () => setLoadingDelayHasPassed(true),
@ -113,7 +109,7 @@ export function OutfitLayers({
); );
return () => clearTimeout(t); return () => clearTimeout(t);
} }
}, [loadingDelayMs, loading]); }, [loadingDelayMs, loadingAnything]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
function computeAndSizeCanvasSize() { function computeAndSizeCanvasSize() {
@ -129,8 +125,6 @@ export function OutfitLayers({
return () => window.removeEventListener("resize", computeAndSizeCanvasSize); return () => window.removeEventListener("resize", computeAndSizeCanvasSize);
}, [setCanvasSize]); }, [setCanvasSize]);
console.log(loading, scriptsLoading);
return ( return (
<Box <Box
pos="relative" pos="relative"
@ -155,7 +149,7 @@ export function OutfitLayers({
{ {
// TODO: A bit of a mess in here! Extract these out? // TODO: A bit of a mess in here! Extract these out?
engine === "canvas" ? ( engine === "canvas" ? (
!scriptsLoading && ( !loadingEasel && (
<FullScreenCenter> <FullScreenCenter>
<EaselCanvas width={canvasSize} height={canvasSize}> <EaselCanvas width={canvasSize} height={canvasSize}>
{visibleLayers.map((layer) => ( {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 // 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 // fade-out at all. (The timeout was an awkward choice, it was hard to
// find a good CSS way to specify this delay well!) // 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" transition="opacity 0.2s"
> >
{spinnerVariant === "overlay" && ( {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) { function getBestImageUrlForLayer(layer) {
if (layer.svgUrl) { if (layer.svgUrl) {
return `/api/assetProxy?url=${encodeURIComponent(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 * 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 * when done. This enables us to keep the old outfit preview on screen until

View 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>
);

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB