UNPKG

react-layout-motion

Version:
289 lines (279 loc) 11.6 kB
'use strict'; 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