Spaces:
Running
Running
| import { useState, useRef, useEffect, RefObject } from 'react'; | |
| interface AnimationTarget { | |
| x: number; | |
| y: number; | |
| } | |
| interface UseCoachModalAnimationReturn { | |
| // State | |
| isClosingModal: boolean; | |
| animationTarget: AnimationTarget; | |
| tailTarget: AnimationTarget | null; | |
| // Refs to attach to elements | |
| coachBubbleRef: RefObject<HTMLDivElement | null>; | |
| coachBubbleContentRef: RefObject<HTMLDivElement | null>; | |
| // Handlers | |
| handleCloseModal: () => void; | |
| // Animation class names | |
| getOverlayClassName: (baseClass: string) => string; | |
| getBubbleClassName: (baseClass: string) => string; | |
| getBubbleStyle: () => React.CSSProperties; | |
| } | |
| const ANIMATION_DURATION = 400; | |
| const ESTIMATED_BUBBLE_HEIGHT = 280; | |
| export function useCoachModalAnimation( | |
| showModal: boolean, | |
| setShowModal: (show: boolean) => void, | |
| triggerRef: RefObject<HTMLButtonElement | null> | |
| ): UseCoachModalAnimationReturn { | |
| const [isClosingModal, setIsClosingModal] = useState(false); | |
| const [animationTarget, setAnimationTarget] = useState<AnimationTarget>({ x: 0, y: 0 }); | |
| const [tailTarget, setTailTarget] = useState<AnimationTarget | null>(null); | |
| const coachBubbleRef = useRef<HTMLDivElement>(null); | |
| const coachBubbleContentRef = useRef<HTMLDivElement>(null); | |
| // Calculate animation targets when modal opens | |
| useEffect(() => { | |
| if (showModal && !isClosingModal) { | |
| // Calculate animation target | |
| const buttonRect = triggerRef.current?.getBoundingClientRect(); | |
| const viewportCenterX = window.innerWidth / 2; | |
| const viewportCenterY = window.innerHeight / 2; | |
| if (buttonRect) { | |
| const buttonCenterX = buttonRect.left + buttonRect.width / 2; | |
| const buttonCenterY = buttonRect.top + buttonRect.height / 2; | |
| setAnimationTarget({ | |
| x: buttonCenterX - viewportCenterX, | |
| y: buttonCenterY - viewportCenterY, | |
| }); | |
| // Calculate tail target | |
| const estimatedBubbleWidth = Math.min(384, window.innerWidth - 32); | |
| const bubbleLeft = viewportCenterX - estimatedBubbleWidth / 2; | |
| const bubbleBottom = viewportCenterY + ESTIMATED_BUBBLE_HEIGHT / 2; | |
| const buttonTargetX = buttonRect.left + buttonRect.width * 0.5; | |
| const buttonTopY = buttonRect.top; | |
| setTailTarget({ | |
| x: buttonTargetX - bubbleLeft, | |
| y: buttonTopY - bubbleBottom, | |
| }); | |
| } | |
| } else if (!showModal) { | |
| setTailTarget(null); | |
| } | |
| }, [showModal, isClosingModal, triggerRef]); | |
| const handleCloseModal = () => { | |
| // Recalculate animation target for closing (bubble position may have changed) | |
| const buttonRect = triggerRef.current?.getBoundingClientRect(); | |
| const bubbleRect = coachBubbleRef.current?.getBoundingClientRect(); | |
| if (buttonRect && bubbleRect) { | |
| const buttonCenterX = buttonRect.left + buttonRect.width / 2; | |
| const buttonCenterY = buttonRect.top + buttonRect.height / 2; | |
| const bubbleCenterX = bubbleRect.left + bubbleRect.width / 2; | |
| const bubbleCenterY = bubbleRect.top + bubbleRect.height / 2; | |
| const targetX = buttonCenterX - bubbleCenterX; | |
| const targetY = buttonCenterY - bubbleCenterY; | |
| // Set CSS variables directly on element for immediate effect (before React re-renders) | |
| if (coachBubbleRef.current) { | |
| coachBubbleRef.current.style.setProperty('--translate-x', `${targetX}px`); | |
| coachBubbleRef.current.style.setProperty('--translate-y', `${targetY}px`); | |
| } | |
| // Also update state to prevent React from overwriting on re-render | |
| setAnimationTarget({ x: targetX, y: targetY }); | |
| } | |
| setIsClosingModal(true); | |
| // Wait for animation to complete before actually closing | |
| setTimeout(() => { | |
| setShowModal(false); | |
| setIsClosingModal(false); | |
| }, ANIMATION_DURATION); | |
| }; | |
| const getOverlayClassName = (baseClass: string): string => { | |
| return `${baseClass} ${isClosingModal ? 'animate-fade-out' : 'animate-fade-in'}`; | |
| }; | |
| const getBubbleClassName = (baseClass: string): string => { | |
| return `${baseClass} ${isClosingModal ? 'animate-shrink-to-coach-button' : 'animate-expand-from-coach-button'}`; | |
| }; | |
| const getBubbleStyle = (): React.CSSProperties => { | |
| return { | |
| '--translate-x': `${animationTarget.x}px`, | |
| '--translate-y': `${animationTarget.y}px`, | |
| } as React.CSSProperties; | |
| }; | |
| return { | |
| isClosingModal, | |
| animationTarget, | |
| tailTarget, | |
| coachBubbleRef, | |
| coachBubbleContentRef, | |
| handleCloseModal, | |
| getOverlayClassName, | |
| getBubbleClassName, | |
| getBubbleStyle, | |
| }; | |
| } | |