1import React from 'react'; 2 3import { ImageTransition } from '../Image.types'; 4 5type Callbacks = { 6 onReady?: (() => void) | null; 7 onAnimationFinished?: (() => void) | null; 8 onMount?: (() => void) | null; 9 onError?: (() => void) | null; 10}; 11 12export type AnimationManagerNode = [ 13 key: string, 14 renderFunction: ( 15 renderProps: NonNullable<Callbacks> 16 ) => (className: string, style: React.CSSProperties) => React.ReactElement, 17]; 18 19const SUPPORTED_ANIMATIONS: ImageTransition['effect'][] = [ 20 'cross-dissolve', 21 'flip-from-left', 22 'flip-from-right', 23 'flip-from-top', 24 'flip-from-bottom', 25]; 26 27type NodeStatus = 'mounted' | 'in' | 'active' | 'out' | 'errored'; 28 29function useAnimationManagerNode(node: AnimationManagerNode | null, initialStatus?: NodeStatus) { 30 const newNode = React.useMemo(() => { 31 if (!node) { 32 return null; 33 } 34 const [animationKey, renderFunction] = node; 35 // key, ReactElement, ref, callbacks 36 return { 37 animationKey, 38 persistedElement: renderFunction, 39 status: (initialStatus || 'mounted') as NodeStatus, 40 }; 41 }, [node?.[0]]); 42 return newNode; 43} 44 45function validateTimingFunctionForAnimation( 46 animationClass: ImageTransition['effect'], 47 timingFunction: ImageTransition['timing'] 48) { 49 if (animationClass?.includes('flip')) { 50 if (timingFunction?.includes('ease')) { 51 return 'ease-in-out'; 52 } 53 return 'linear'; 54 } 55 return timingFunction || null; 56} 57 58function validateAnimationClass(effect: ImageTransition['effect']) { 59 if (SUPPORTED_ANIMATIONS.includes(effect)) { 60 return effect; 61 } 62 return 'cross-dissolve'; 63} 64 65export function getAnimatorFromTransition(transition: ImageTransition | null | undefined) { 66 if (!transition?.duration) { 67 return null; 68 } 69 const animationClass = validateAnimationClass(transition.effect); 70 if (!animationClass) { 71 return { 72 startingClass: '', 73 animateInClass: '', 74 animateOutClass: '', 75 containerClass: '', 76 timingFunction: 'linear', 77 animationClass: '', 78 duration: 0, 79 }; 80 } 81 82 const timingFunction = validateTimingFunctionForAnimation(animationClass, transition.timing); 83 const timingClass = `image-timing-${timingFunction}`; 84 85 return { 86 startingClass: `${animationClass}-start`, 87 animateInClass: [animationClass, 'transitioning', `${animationClass}-active`, timingClass].join( 88 ' ' 89 ), 90 animateOutClass: [animationClass, `${animationClass}-end`, timingClass].join(' '), 91 containerClass: `${animationClass}-container`, 92 timingFunction, 93 animationClass, 94 duration: transition?.duration || 0, 95 }; 96} 97 98type MountedAnimationNode = { 99 animationKey: string; 100 persistedElement: ( 101 renderProps: Callbacks 102 ) => (className: string, style: React.CSSProperties) => React.ReactElement; 103 status: NodeStatus; 104}; 105 106export default function AnimationManager({ 107 children: renderFunction, 108 initial, 109 transition, 110 recyclingKey, 111}: { 112 children: AnimationManagerNode; 113 initial: AnimationManagerNode | null; 114 transition: ImageTransition | null | undefined; 115 recyclingKey?: string | null | undefined; 116}) { 117 const animation = getAnimatorFromTransition(transition); 118 119 const initialNode = useAnimationManagerNode(initial, 'active'); 120 121 const [nodes, setNodes] = React.useState<MountedAnimationNode[]>( 122 initialNode ? [initialNode] : [] 123 ); 124 125 const [prevRecyclingKey, setPrevRecyclingKey] = React.useState<string>(recyclingKey ?? ''); 126 if (prevRecyclingKey !== (recyclingKey ?? '')) { 127 setPrevRecyclingKey(recyclingKey ?? ''); 128 setNodes(initialNode ? [initialNode] : []); 129 } 130 131 const removeAllNodesOfKeyExceptShowing = (key?: string) => { 132 setNodes((n) => 133 n.filter( 134 (node) => 135 (key ? node.animationKey !== key : false) || 136 node.status === 'in' || 137 node.status === 'active' 138 ) 139 ); 140 }; 141 142 const newNode = useAnimationManagerNode(renderFunction); 143 144 React.useEffect(() => { 145 setNodes((n) => { 146 if (!newNode) { 147 return n; 148 } 149 const existingNodeIndex = n.findIndex((node) => node.animationKey === newNode.animationKey); 150 if (existingNodeIndex >= 0) { 151 if (animation) { 152 return n.map((n2) => 153 n2.animationKey === newNode.animationKey 154 ? { ...newNode, status: 'in' } 155 : { ...n2, status: 'out' } 156 ); 157 } else { 158 return [{ ...newNode, status: 'in' }]; 159 } 160 } 161 return [...n, newNode]; 162 }); 163 }, [newNode]); 164 165 function wrapNodeWithCallbacks(node: MountedAnimationNode) { 166 if (renderFunction[0] === node.animationKey) { 167 return renderFunction[1]({ 168 onReady: () => { 169 if (animation) { 170 setNodes((nodes) => 171 nodes.map((n) => (n === newNode ? { ...n, status: 'in' } : { ...n, status: 'out' })) 172 ); 173 } else { 174 setNodes([{ ...node, status: 'in' }]); 175 } 176 }, 177 onAnimationFinished: () => { 178 setNodes([{ ...node, status: 'in' }]); 179 }, 180 onError: () => { 181 setNodes((nodes) => nodes.map((n) => (n === node ? { ...n, status: 'errored' } : n))); 182 }, 183 }); 184 } 185 if (initial?.[0] === node.animationKey) { 186 return initial[1]({ 187 onAnimationFinished: () => { 188 if (node.status === 'out') { 189 removeAllNodesOfKeyExceptShowing(node.animationKey); 190 } 191 }, 192 onError: () => { 193 setNodes((nodes) => nodes.map((n) => (n === node ? { ...n, status: 'errored' } : n))); 194 }, 195 }); 196 } 197 return node.persistedElement({ 198 onAnimationFinished: () => { 199 removeAllNodesOfKeyExceptShowing(node.animationKey); 200 }, 201 }); 202 } 203 const styles = { 204 transitionDuration: `${animation?.duration || 0}ms`, 205 transitionTimingFunction: animation?.timingFunction || 'linear', 206 }; 207 const classes = { 208 in: animation?.animateInClass, 209 out: animation?.animateOutClass, 210 mounted: animation?.startingClass, 211 }; 212 213 return ( 214 <> 215 {[...nodes] 216 .filter((n) => n.status !== 'errored') 217 .map((n) => ( 218 <div className={animation?.containerClass} key={n.animationKey}> 219 {wrapNodeWithCallbacks(n)(classes[n.status], styles)} 220 </div> 221 ))} 222 </> 223 ); 224} 225