react-layout-motion
Version:
React layout animation library (coming soon)
289 lines (279 loc) • 11.6 kB
JavaScript
;
var jsxRuntime = require('react/jsx-runtime');
var react = require('react');
const ViewportContext = react.createContext(null);
function Viewport({ children, className, style, as: Component = "div", viewportId: propViewportId, }) {
const internalViewportId = react.useId();
const viewportId = propViewportId || internalViewportId;
const elementRef = react.useRef(null);
return (jsxRuntime.jsx(ViewportContext.Provider, { value: viewportId, children: jsxRuntime.jsx(Component, { "data-viewport-id": viewportId, ref: elementRef, className: className, style: style, children: children }) }));
}
function useViewportId() {
const viewportId = react.useContext(ViewportContext);
return viewportId ?? "root";
}
class LayoutManager {
constructor() {
this.layoutMap = new Map();
}
static getInstance() {
if (!LayoutManager.instance) {
LayoutManager.instance = new LayoutManager();
}
return LayoutManager.instance;
}
registerLayout(layoutId, element) {
const viewport = element.closest("[data-viewport-id]");
const rect = viewport ? this.getRelativeRect(element, viewport) : element.getBoundingClientRect();
const viewportId = viewport ? viewport.getAttribute("data-viewport-id") : "root";
const viewportLayoutId = `${viewportId}:${layoutId}`;
this.layoutMap.set(viewportLayoutId, { element, rect });
return viewportLayoutId;
}
unregisterLayout(id) {
this.layoutMap.delete(id);
}
getLayout(id) {
return this.layoutMap.get(id) || null;
}
getRelativeRect(element, viewport) {
const elementRect = element.getBoundingClientRect();
const viewportRect = viewport.getBoundingClientRect();
return new DOMRect(elementRect.left - viewportRect.left, elementRect.top - viewportRect.top, elementRect.width, elementRect.height);
}
}
function noop() { }
function useEvent(fn) {
const fnRef = react.useRef(fn);
fnRef.current = react.useMemo(() => fn, [fn]);
const memoizedFn = react.useRef(undefined);
if (!memoizedFn.current) {
memoizedFn.current = function (...args) {
return fnRef.current.apply(this, args);
};
}
return memoizedFn.current;
}
const LayoutManagerInstance = LayoutManager.getInstance();
const DefaultLayoutConfig = {
duration: 300,
easing: "cubic-bezier(0.25, 0.1, 0.25, 1)",
};
function useLayoutAnimation(layoutId, config, onLayoutAnimationStart = noop, onLayoutAnimationComplete = noop) {
const elementRef = react.useRef(null);
const animationRef = react.useRef(null);
const configRef = react.useRef({ ...DefaultLayoutConfig, ...config });
const onAnimationStart = useEvent(onLayoutAnimationStart);
const onAnimationFinish = useEvent(onLayoutAnimationComplete);
const viewportId = useViewportId();
const viewportLayoutId = layoutId ? `${viewportId}:${layoutId}` : null;
const layout = viewportLayoutId ? LayoutManagerInstance.getLayout(viewportLayoutId) : null;
react.useLayoutEffect(() => {
const element = elementRef.current;
if (!element || !layoutId) {
return;
}
const viewportLayoutId = LayoutManagerInstance.registerLayout(layoutId, element);
return () => {
LayoutManagerInstance.unregisterLayout(viewportLayoutId);
animationRef.current?.cancel();
};
}, []);
react.useLayoutEffect(() => {
const element = elementRef.current;
if (!element || !viewportLayoutId) {
return;
}
const currentLayout = LayoutManagerInstance.getLayout(viewportLayoutId);
const previousLayout = layout;
if (previousLayout && previousLayout.rect && currentLayout) {
// cancel any existing animation
if (animationRef.current) {
animationRef.current.cancel();
}
// FLIP
const deltaX = previousLayout.rect.left - currentLayout.rect.left;
const deltaY = previousLayout.rect.top - currentLayout.rect.top;
const deltaScaleX = previousLayout.rect.width / currentLayout.rect.width;
const deltaScaleY = previousLayout.rect.height / currentLayout.rect.height;
const previousTransformOrigin = element.style.transformOrigin;
const nextTransformOrigin = configRef.current.origin;
if (previousTransformOrigin !== nextTransformOrigin && nextTransformOrigin) {
element.style.transformOrigin = nextTransformOrigin;
}
animationRef.current = element.animate([
{
transform: `translate(${deltaX}px, ${deltaY}px) scale(${deltaScaleX}, ${deltaScaleY})`,
offset: 0,
},
{
transform: "translate(0px, 0px) scale(1, 1)",
offset: 1,
},
], {
duration: configRef.current.duration,
easing: configRef.current.easing,
fill: "both",
});
if (animationRef.current.playState === "idle") {
animationRef.current.addEventListener("start", onAnimationStart);
}
else {
onAnimationStart();
}
if (animationRef.current.playState === "finished") {
onAnimationFinish();
}
else {
animationRef.current.addEventListener("finish", onAnimationFinish);
}
}
}, []);
return elementRef;
}
var MotionState;
(function (MotionState) {
MotionState["INITIAL"] = "initial";
MotionState["ANIMATE"] = "animate";
MotionState["EXIT"] = "exit";
})(MotionState || (MotionState = {}));
const DefaultMotionConfig = {
transitions: {
enter: { duration: 300, easing: "cubic-bezier(0.25, 0.1, 0.25, 1)" },
exit: { duration: 200, easing: "ease-out" },
},
};
function createTransformString(phase) {
const transforms = [];
if (phase.scale !== undefined)
transforms.push(`scale(${phase.scale})`);
if (phase.x !== undefined)
transforms.push(`translateX(${phase.x}px)`);
if (phase.y !== undefined)
transforms.push(`translateY(${phase.y}px)`);
if (phase.rotate !== undefined)
transforms.push(`rotate(${phase.rotate}deg)`);
return transforms.join(" ");
}
function phaseToKeyframe(phase) {
const keyframe = {};
const transform = createTransformString(phase);
if (transform)
keyframe.transform = transform;
Object.entries(phase).forEach(([key, value]) => {
if (!["scale", "x", "y", "rotate"].includes(key)) {
keyframe[key] = value;
}
});
return keyframe;
}
function useMotionAnimation(elementRef, config) {
const animationRef = react.useRef(null);
const [currentState, setCurrentState] = react.useState(MotionState.INITIAL);
const configRef = react.useRef({ ...DefaultMotionConfig, ...config });
const finishHandlerRef = react.useRef(null);
const cleanup = react.useCallback(() => {
if (animationRef.current) {
if (finishHandlerRef.current) {
animationRef.current.removeEventListener("finish", finishHandlerRef.current);
finishHandlerRef.current = null;
}
animationRef.current.cancel();
animationRef.current = null;
}
}, []);
react.useLayoutEffect(() => {
if (!elementRef.current)
return;
const element = elementRef.current;
const motionConfig = configRef.current;
if (motionConfig.phases?.initial) {
const initialKeyframe = phaseToKeyframe(motionConfig.phases.initial);
Object.assign(element.style, initialKeyframe);
}
if (motionConfig.phases?.animate) {
const enterConfig = motionConfig.transitions?.enter;
const keyframes = [
motionConfig.phases.initial ? phaseToKeyframe(motionConfig.phases.initial) : {},
phaseToKeyframe(motionConfig.phases.animate),
];
cleanup();
animationRef.current = element.animate(keyframes, {
duration: enterConfig?.duration || 300,
easing: enterConfig?.easing || DefaultMotionConfig.transitions?.enter?.easing,
delay: enterConfig?.delay || 0,
fill: "forwards",
});
finishHandlerRef.current = () => {
setCurrentState(MotionState.ANIMATE);
finishHandlerRef.current = null;
};
animationRef.current.addEventListener("finish", finishHandlerRef.current);
}
else {
setCurrentState(MotionState.ANIMATE);
}
return cleanup;
}, [cleanup]);
react.useLayoutEffect(() => {
configRef.current = { ...DefaultMotionConfig, ...config };
}, [config]);
const performExitAnimation = react.useCallback(() => {
if (!elementRef.current)
return Promise.resolve();
return new Promise((resolve) => {
const element = elementRef.current;
const motionConfig = configRef.current;
if (motionConfig.phases?.exit) {
const exitConfig = motionConfig.transitions?.exit;
const currentPhase = motionConfig.phases.animate || {};
const keyframes = [
phaseToKeyframe(currentPhase),
phaseToKeyframe(motionConfig.phases.exit),
];
cleanup();
const animation = element.animate(keyframes, {
duration: exitConfig?.duration || DefaultMotionConfig.transitions?.exit?.duration,
easing: exitConfig?.easing || DefaultMotionConfig.transitions?.exit?.easing,
delay: exitConfig?.delay,
fill: "forwards",
});
const exitFinishHandler = () => {
setCurrentState(MotionState.EXIT);
animation.removeEventListener("finish", exitFinishHandler);
resolve();
};
animation.addEventListener("finish", exitFinishHandler);
animationRef.current = animation;
}
else {
resolve();
}
});
}, [cleanup]);
return {
currentState,
performExitAnimation,
};
}
function Motion({ layout, layoutId: propLayoutId, children, style, className, as: Component = "div", initial, animate, exit, transition, layoutTransition, onLayoutAnimationStart, onLayoutAnimationComplete, ...props }) {
const motionConfig = {
phases: {
initial,
animate,
exit,
},
transitions: {
enter: transition,
exit: transition,
},
};
const internalLayoutId = react.useId();
const layoutId = propLayoutId || (layout ? internalLayoutId : undefined);
const elementRef = useLayoutAnimation(layoutId, layoutTransition, onLayoutAnimationStart, onLayoutAnimationComplete);
useMotionAnimation(elementRef, motionConfig);
return (jsxRuntime.jsx(Component, { ref: elementRef, style: style, className: className, ...props, children: children }));
}
exports.Motion = Motion;
exports.Viewport = Viewport;
//# sourceMappingURL=index.cjs.map