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 const copy = [...n]; 152 copy.splice(existingNodeIndex, 1, newNode); 153 return copy; 154 } 155 return [...n, newNode]; 156 }); 157 }, [newNode]); 158 159 function wrapNodeWithCallbacks(node: MountedAnimationNode) { 160 if (renderFunction[0] === node.animationKey) { 161 return renderFunction[1]({ 162 onReady: () => { 163 if (animation) { 164 setNodes((nodes) => 165 nodes.map((n) => (n === newNode ? { ...n, status: 'in' } : { ...n, status: 'out' })) 166 ); 167 } else { 168 setNodes([{ ...node, status: 'in' }]); 169 } 170 }, 171 onAnimationFinished: () => { 172 setNodes([{ ...node, status: 'in' }]); 173 }, 174 onError: () => { 175 setNodes((nodes) => nodes.map((n) => (n === node ? { ...n, status: 'errored' } : n))); 176 }, 177 }); 178 } 179 if (initial?.[0] === node.animationKey) { 180 return initial[1]({ 181 onAnimationFinished: () => { 182 if (node.status === 'out') { 183 removeAllNodesOfKeyExceptShowing(node.animationKey); 184 } 185 }, 186 onError: () => { 187 setNodes((nodes) => nodes.map((n) => (n === node ? { ...n, status: 'errored' } : n))); 188 }, 189 }); 190 } 191 return node.persistedElement({ 192 onAnimationFinished: () => { 193 removeAllNodesOfKeyExceptShowing(node.animationKey); 194 }, 195 }); 196 } 197 const styles = { 198 transitionDuration: `${animation?.duration || 0}ms`, 199 transitionTimingFunction: animation?.timingFunction || 'linear', 200 }; 201 const classes = { 202 in: animation?.animateInClass, 203 out: animation?.animateOutClass, 204 mounted: animation?.startingClass, 205 }; 206 207 return ( 208 <> 209 {[...nodes] 210 .filter((n) => n.status !== 'errored') 211 .map((n) => ( 212 <div className={animation?.containerClass} key={n.animationKey}> 213 {wrapNodeWithCallbacks(n)(classes[n.status], styles)} 214 </div> 215 ))} 216 </> 217 ); 218} 219