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