kinetic-components
Version:
Use CSS animations or your favorite JS animation library to animate a single React component or orchestrate animations amongst a collection of React components.
522 lines (512 loc) • 23.9 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
var React = require('react');
var React__default = _interopDefault(React);
var reactIdGenerator = require('react-id-generator');
/* eslint-disable @typescript-eslint/no-explicit-any */
const pendingPromise = Promise.race.bind(Promise, []);
class AnimationControl {
constructor() {
this._cancel = undefined;
this._onFinishPromise = undefined;
this._onFinishAction = undefined;
this.cancel = () => {
try {
this._cancel && this._cancel();
}
catch (e) {
console.log('Error canceling animation', e);
}
};
this.createOnFinishPromise = (animationFinishPromise) => {
let hasCanceled = false;
this._onFinishPromise = new Promise((fulfill, _reject) => {
this._cancel = () => {
fulfill(pendingPromise());
hasCanceled = true;
};
try {
animationFinishPromise
.then(() => {
!hasCanceled && fulfill();
})
.then(() => {
if (this._onFinishAction && !hasCanceled) {
this._onFinishAction();
}
});
}
catch (e) {
_reject(e);
}
});
return this._onFinishPromise;
};
this.setOnFinishAction = (action) => {
this._onFinishAction = action;
};
}
}
/* eslint-disable @typescript-eslint/no-explicit-any */
const setStateForNewAction = (setEState, triggerState, visible) => {
setEState(current => {
// increment action count
const actionCount = current.actionCount + 1;
// either cancel the existing animation ('restarting') or prep for new one ('initializing')
const currentState = current.currentState === 'running' ? 'restarting' : 'initalizing';
// set the flag recording if an animation has taken place to false
const hasRunForCycle = false;
// copy over unmounted child states b/c they wont be in play anymore
// otherwise set child states to undefined for this action
const childStates = Object.keys(current.childStates).reduce((acc, childId) => {
const currentChildState = current.childStates[childId];
if (currentChildState === 'unmounted') {
return Object.assign(Object.assign({}, acc), { [childId]: 'unmounted' });
}
else {
return Object.assign(Object.assign({}, acc), { [childId]: undefined });
}
}, {});
return {
actionCount,
currentState,
prevTriggerState: current.triggerState,
triggerState,
hasRunForCycle,
childStates,
prevVisible: current.visible,
visible,
classNames: [],
prevAnimationKey: current.animationKey,
animationKey: undefined
};
});
};
const setStateForFinishedAction = (setEState) => {
setEState(current => (Object.assign(Object.assign({}, current), { currentState: current.currentState === 'running' ? 'finished' : current.currentState })));
};
const setHasRunForActionCount = (setEState) => {
setEState(current => (Object.assign(Object.assign({}, current), { hasRunForCycle: true })));
};
const setCurrentStateToRunningForActionCount = (setEState, animationKey) => {
setEState(current => (Object.assign(Object.assign({}, current), { currentState: 'running', animationKey })));
};
const setCurrentStateToFinishedForActionCount = (setEState) => {
setEState(current => (Object.assign(Object.assign({}, current), { currentState: 'finished' })));
};
const setCurrentStateToUnmountedForActionCount = (setEState) => {
setEState(current => (Object.assign(Object.assign({}, current), { currentState: 'unmounted' })));
};
const setCurrentStateToInitializingForActionCount = (setEState) => {
setEState(current => (Object.assign(Object.assign({}, current), { currentState: 'initalizing' })));
};
const addClassNamesToCurrentStateForActionCount = (setEState, classNames) => {
setEState(current => (Object.assign(Object.assign({}, current), { classNames })));
};
const setCurrentStateToRestartingToClearExistingAnimationForActionCount = (setEState) => {
setEState(current => (Object.assign(Object.assign({}, current), { currentState: 'restarting', prevAnimationKey: undefined })));
};
const setChildStateForActionCount = (setEState) => (id, state) => {
setEState(current => (Object.assign(Object.assign({}, current), { childStates: Object.assign(Object.assign({}, current.childStates), { [id]: state }) })));
};
const childrenMatch = (childrenOfInterest, statesToMatch, allChildren) => {
const childrenToLookAt = Object.keys(allChildren).filter(childId => childrenOfInterest.includes(childId));
if (childrenToLookAt.length === 0) {
return false;
}
return childrenToLookAt.reduce((acc, childId) => {
return acc && statesToMatch.includes(allChildren[childId]);
}, true);
};
const Animate = ({ name, logger, visible: visibleProp, triggerState, predicateState, when, children, unmountOnHide: _unMountOnHide, id, enterAfterParentStart, enterAfterParentFinish, exitAfterChildStart, exitAfterChildFinish, animationBinding }) => {
const moduleLogger = logger && logger.child('kinnetic-components');
const animateLogger = moduleLogger && moduleLogger.child('Animate Component');
const namedAnimationLogger = animateLogger && animateLogger.child(name || 'unnamed');
const [eState, setEState] = React.useState({
actionCount: 0,
currentState: 'initalizing',
hasRunForCycle: false,
prevTriggerState: undefined,
triggerState: triggerState || undefined,
prevVisible: undefined,
visible: false,
childStates: {},
classNames: [],
prevAnimationKey: undefined,
animationKey: undefined
});
const specificAnimateLogger = namedAnimationLogger && namedAnimationLogger.child(eState.currentState);
specificAnimateLogger && specificAnimateLogger.info(eState.currentState);
const visible = animationBinding && animationBinding.parentVisible === false ? false : visibleProp;
// const visible = visibleProp;
const [uuid] = reactIdGenerator.useId(1, '_kinetic-components');
const [ref, setRef] = React.useState();
const refId = ref ? ref.id : undefined;
const unMountOnHide = _unMountOnHide === undefined ? true : _unMountOnHide;
specificAnimateLogger &&
specificAnimateLogger.debug({
refId: refId,
hasRun: eState.hasRunForCycle,
currentState: eState.currentState,
childState: eState.childStates,
'incoming trigger': triggerState,
'incoming visibility': visible,
'old visibility': eState.visible,
prevAnimationKey: eState.prevAnimationKey,
animationKey: eState.animationKey
}, 'initial state');
const createAnimationControl = () => {
const ac = new AnimationControl();
ac.setOnFinishAction(() => {
setStateForFinishedAction(setEState);
});
return ac;
};
// TODO enable setting of animation control
const [animationControl, setAnimationControl] = React.useState(createAnimationControl());
React.useEffect(() => {
if (animationBinding) {
specificAnimateLogger &&
specificAnimateLogger.info(eState.currentState, 'Notifying parent of state');
animationBinding.notifyParentOfState(id || uuid, eState.currentState);
}
}, [eState.currentState]);
React.useEffect(() => {
return () => {
if (animationBinding) {
specificAnimateLogger &&
specificAnimateLogger.debug('Unmounting from unmount action');
animationBinding.notifyParentOfState(id || uuid, 'unmounted');
}
};
}, ['onExit']);
React.useEffect(() => {
setAnimationControl(c => {
c.cancel();
return createAnimationControl();
});
setStateForNewAction(setEState, triggerState, visible);
}, [JSON.stringify(triggerState), visible]);
const animate = (node, animateSubFnLogger) => {
const asfLogger = animateSubFnLogger && animateSubFnLogger.child('Animate Sub Fn');
asfLogger &&
asfLogger.debug({
prevTriggerState: eState.prevTriggerState,
triggerState,
prevVisible: eState.prevVisible,
visible
}, 'State');
return (when || []).reduce((acc, predicateAnimation) => {
asfLogger &&
asfLogger.debug({ predicateAnimation }, 'Running predicate animation group');
const { hasRun, ctx } = acc;
if (hasRun) {
asfLogger &&
asfLogger.debug({ hasRun }, 'Exiting early from evaluating predicate animation group');
return acc;
}
let shouldRun;
const predicate = predicateAnimation[0];
if (Array.isArray(predicate)) {
const predicateLogger = asfLogger && asfLogger.child('Predicate');
shouldRun = predicate.reduce((accc, childPredicate) => {
const childPredicateResult = childPredicate(predicateState, {
prevTriggerState: eState.prevTriggerState,
triggerState,
prevVisible: eState.prevVisible,
visible
});
predicateLogger &&
predicateLogger.debug({ childPredicateResult }, 'Child Predicate result');
return accc && childPredicateResult;
}, true);
predicateLogger &&
predicateLogger.debug({ shouldRun }, 'Total predicates result');
}
else {
shouldRun = predicate(predicateState, {
prevTriggerState: eState.prevTriggerState,
triggerState,
prevVisible: eState.prevVisible,
visible
});
}
asfLogger && asfLogger.debug({ shouldRun }, 'Should run animation');
if (shouldRun) {
const animation = predicateAnimation[1];
const options = predicateAnimation[2];
const animationKey = options ? options.key : undefined;
asfLogger && asfLogger.debug({ animationKey }, 'Found animation key');
const animationResult = animation(ctx);
if (animationResult) {
return { hasRun: true, ctx, animationResult, animationKey };
}
else {
return Object.assign(Object.assign({}, acc), { hasRun: true, ctx });
}
}
return acc;
}, {
hasRun: false,
ctx: { node },
animationResult: null,
animationKey: undefined
});
};
React.useEffect(() => {
specificAnimateLogger &&
specificAnimateLogger.debug({ actionCount: eState.actionCount }, 'Updated action count');
}, [eState.actionCount]);
React.useEffect(() => {
if (!refId) {
specificAnimateLogger && specificAnimateLogger.debug({ ref: refId }, 'Updated to no ref');
}
else {
specificAnimateLogger && specificAnimateLogger.debug({ ref: refId }, 'Update to new ref');
}
}, [refId]);
const parentState = (animationBinding && animationBinding.parentState) || 'initalizing';
const parentVisible = (animationBinding && animationBinding.parentVisible) || true;
React.useEffect(() => {
specificAnimateLogger && specificAnimateLogger.debug({ parentState }, 'Update parentState');
}, [parentState]);
React.useEffect(() => {
specificAnimateLogger &&
specificAnimateLogger.debug({ parentVisible }, 'Update parentVisible');
}, [parentVisible]);
React.useEffect(() => {
specificAnimateLogger &&
specificAnimateLogger.debug({ childStates: eState.childStates }, 'Update childStates');
}, [JSON.stringify(eState.childStates)]);
/**
* Run animations whenever there is a state change
*/
React.useEffect(() => {
const animateEffectLogger = specificAnimateLogger && specificAnimateLogger.child('animate effect');
if (eState.currentState === 'restarting') {
animateEffectLogger &&
animateEffectLogger.debug({ restarting: true }, 'Exiting should animate effect');
return;
}
if (ref == null) {
const nullRefLogger = animateEffectLogger && animateEffectLogger.child('No ref found');
if (animationControl.cancel) {
nullRefLogger && nullRefLogger.debug('Canceling existing animation');
animationControl.cancel();
}
if (!visible) {
setCurrentStateToUnmountedForActionCount(setEState);
}
nullRefLogger &&
nullRefLogger.debug({ refMissing: true }, 'Exiting should animate effect');
return;
}
if (eState.hasRunForCycle === true) {
animateEffectLogger &&
animateEffectLogger.debug({ hasRunForCycle: true }, 'Exiting should animate effect');
return;
}
if (visible &&
enterAfterParentStart &&
parentState !== 'running' &&
parentState !== 'finished') {
animateEffectLogger &&
animateEffectLogger.debug('Exiting should animate effect: waiting for parent to start');
return;
}
if (!visible &&
exitAfterChildStart &&
exitAfterChildStart.length > 0 &&
childrenMatch(exitAfterChildStart,
// if matches the states that come before the finished state
[undefined, 'restarting', 'initalizing'], eState.childStates)) {
animateEffectLogger &&
animateEffectLogger.debug('Exiting should animate effect: waiting for child to start');
return;
}
if (visible && enterAfterParentFinish && parentState !== 'finished') {
animateEffectLogger &&
animateEffectLogger.debug('Exiting should animate effect: waiting for parent to finish');
return;
}
if (!visible &&
exitAfterChildFinish &&
exitAfterChildFinish.length > 0 &&
childrenMatch(exitAfterChildFinish,
// if matches the states that come before the finished state
[undefined, 'restarting', 'initalizing', 'running'], eState.childStates)) {
animateEffectLogger &&
animateEffectLogger.debug('Exiting should animate effect: waiting for child to finish');
return;
}
animateEffectLogger && animateEffectLogger.debug({ when }, 'Animation running');
const { ctx: animationCtx, hasRun, animationResult, animationKey } = animate(ref, animateEffectLogger);
animateEffectLogger &&
animateEffectLogger.debug({ hasRun, animationCtx, animationResult, animationKey }, 'Animation ran');
if (animationKey !== undefined && animationKey === eState.prevAnimationKey) {
animateEffectLogger &&
animateEffectLogger.debug({ newAnimationKey: animationKey, prevAniamtionKey: eState.prevAnimationKey }, 'Found animation ran twice in a row. Unmounting component to clear animation');
setCurrentStateToRestartingToClearExistingAnimationForActionCount(setEState);
return;
}
else {
setHasRunForActionCount(setEState);
}
if (hasRun) {
animateEffectLogger &&
animateEffectLogger.debug('setting current state to has run = true');
// pass in any animation keys so we can record which animation took place
setCurrentStateToRunningForActionCount(setEState, animationKey);
}
animateEffectLogger &&
animateEffectLogger.debug({ newAnimationKey: animationKey, prevAniamtionKey: eState.animationKey }, 'Keys');
if (animationResult &&
(Array.isArray(animationResult) || typeof animationResult === 'string')) {
animateEffectLogger &&
animateEffectLogger.debug({ hasRun, animationCtx }, 'Found CSS animation');
// eslint-disable-next-line
const createFinishedPromise = () => {
const d = document.getElementById(ref.id);
if (!d) {
return Promise.resolve();
}
return new Promise(resolve => {
const handleEvent = () => {
d.removeEventListener('animationend', handleEvent);
resolve();
};
addEventListener('animationend', handleEvent);
});
};
animationControl.createOnFinishPromise(createFinishedPromise());
addClassNamesToCurrentStateForActionCount(setEState, typeof animationResult === 'string' ? [animationResult] : animationResult);
// TODO change these IAnimationResult castings to type guards
}
else if (animationResult && animationResult.finished) {
animateEffectLogger &&
animateEffectLogger.debug({ hasRun, animationCtx }, 'Found JS animation');
animationControl.createOnFinishPromise(animationResult.finished);
}
else {
animateEffectLogger &&
animateEffectLogger.debug('No finish promises found. Setting state to finished');
setCurrentStateToFinishedForActionCount(setEState);
}
}, [
eState.currentState,
eState.actionCount,
refId,
parentState,
JSON.stringify(eState.childStates)
]);
React.useEffect(() => {
specificAnimateLogger &&
specificAnimateLogger.debug({ currentState: eState.currentState }, 'Updated currentState');
}, [eState.currentState]);
React.useEffect(() => {
if (eState.currentState === 'restarting') {
specificAnimateLogger &&
specificAnimateLogger.debug({ currentState: eState.currentState }, 'Setting currentState to initializing from restarting');
setCurrentStateToInitializingForActionCount(setEState);
}
}, [eState.currentState]);
const endLogger = specificAnimateLogger && specificAnimateLogger.child('end');
if (eState.currentState === 'restarting') {
endLogger &&
endLogger.debug({ currentState: eState.currentState }, 'Returning null for component b/c restarting');
// Return null to unmount children and allow new animation to be in correct dom position
// incase an animation applied a transform or similar
return null;
}
if (ref == null && unMountOnHide && visible === false) {
endLogger && endLogger.debug({ ref, unMountOnHide, visible }, 'Returning null');
return null;
}
const setRefOfAnimatable = (ref) => {
// TODO: for some reason null is returned whenever this component rerenders.
// Possibly due to the cloneElement behavior.
// This prevents knowing about child unmount events, which isn't a big deal
// if using the Animatable component.
// However, if run in uncontrolled mode, this could be a problem.
if (ref == null) {
return;
}
setRef(ref);
};
const realChildren = children
? React__default.cloneElement(children, {
ref: setRefOfAnimatable,
id: uuid,
className: eState.classNames,
animationBinding: {
notifyParentOfState: setChildStateForActionCount(setEState),
parentState: eState.currentState,
parentVisible: eState.visible
}
})
: null;
if (visible &&
enterAfterParentStart &&
parentState !== 'running' &&
parentState !== 'finished') {
endLogger && endLogger.debug('Waiting for parent to start before showing children');
return null;
}
if (visible && enterAfterParentFinish && parentState !== 'finished') {
endLogger && endLogger.debug('Waiting for parent to finish before showing children');
return null;
}
if (eState.currentState === 'unmounted') {
endLogger && endLogger.debug('Unmounted. Not showing showing children');
return null;
}
if (!visible &&
unMountOnHide &&
eState.currentState === 'finished' &&
// TODO change to only do a shallow compare
// Guard against running one new trigger states.
// Often, on !visible states we are also in the 'finished' state
// doing an unmount at the beginning can kill the child animations
JSON.stringify({ triggerState, visible }) ===
JSON.stringify({ triggerState: eState.triggerState, visible: eState.visible })) {
endLogger && endLogger.debug('Unmounted b/c not visible and animation finished');
if (eState.currentState === 'finished') {
setCurrentStateToUnmountedForActionCount(setEState);
}
return null;
}
else {
endLogger && endLogger.debug('Showing children');
return realChildren;
}
};
/* eslint-disable react/prop-types */
const Animatable = React__default.forwardRef(function animatable(props, ref) {
if (!props.id) {
throw new Error('Missing id');
}
if (!props.animationBinding) {
throw new Error('No animation binding prop found. This usually means this component (the animatable component) is not directly mounted under an animation component');
}
return (React__default.createElement("div", { style: props.style, id: props.id, ref: ref, className: props.className }, props.children && typeof props.children === 'function' && props.animationBinding
? props.children(props.animationBinding)
: props.children
? props.children
: null));
});
const justMounted = (_, { prevVisible }) => prevVisible === undefined;
const wasPreviouslyVisible = (_, { prevVisible }) => !!prevVisible;
const wasPreviouslyHidden = (_, { prevVisible }) => !prevVisible;
const isVisible = (_, { visible }) => visible;
const isHidden = (_, { visible }) => !visible;
var predicates = {
justMounted,
wasPreviouslyVisible,
wasPreviouslyHidden,
isVisible,
isHidden
};
exports.Animatable = Animatable;
exports.Animate = Animate;
exports.predicates = predicates;