| import { useEffect, useRef } from 'react'; |
|
|
| const ThoughtBubble = ({ |
| isFirstLoad, |
| isThinking, |
| thought, |
| isMouthOpen, |
| handDetected, |
| isLeftHand, |
| thumbPosition, |
| canvasWidth, |
| isMobile, |
| animateThinking, |
| createSparkleParticles, |
| createPopParticles |
| }) => { |
| const thoughtBubbleRef = useRef(null); |
|
|
| |
| const calculateFontSize = (text) => { |
| if (!text) return isMobile ? '18px' : '22px'; |
| |
| const length = text.length; |
| let fontSize; |
| |
| if (length <= 20) { |
| fontSize = isMobile ? 18 : 22; |
| } else if (length <= 50) { |
| fontSize = isMobile ? 16 : 20; |
| } else if (length <= 100) { |
| fontSize = isMobile ? 14 : 18; |
| } else { |
| fontSize = isMobile ? 12 : 16; |
| } |
| |
| return `${fontSize}px`; |
| }; |
|
|
| |
| useEffect(() => { |
| if (thought && isMouthOpen && thoughtBubbleRef.current) { |
| const bubbleRect = thoughtBubbleRef.current.getBoundingClientRect(); |
| const x = bubbleRect.left + bubbleRect.width / 2; |
| const y = bubbleRect.top + bubbleRect.height / 2; |
| createSparkleParticles(x, y); |
| } |
| }, [thought, isMouthOpen, createSparkleParticles]); |
|
|
| |
| useEffect(() => { |
| if (!isMouthOpen && thought && thoughtBubbleRef.current) { |
| const bubbleRect = thoughtBubbleRef.current.getBoundingClientRect(); |
| const x = bubbleRect.left + bubbleRect.width / 2; |
| const y = bubbleRect.top + bubbleRect.height / 2; |
| createPopParticles(x, y); |
| } |
| }, [isMouthOpen, thought, createPopParticles]); |
|
|
| |
| const getBubbleContent = () => { |
| if (isFirstLoad) { |
| return <p className={`${isMobile ? 'text-sm' : 'text-lg'} font-medium text-gray-500 italic`}>Open and close your hand to generate thoughts...</p>; |
| } |
| |
| if (isThinking) { |
| |
| return ( |
| <span |
| style={{ |
| fontSize: isMobile ? '28px' : '36px', |
| animation: 'thinking-spin 1.5s linear infinite' |
| }} |
| > |
| 💭 |
| </span> |
| ); |
| } |
| |
| if (thought && isMouthOpen) { |
| return ( |
| <p |
| style={{ |
| fontSize: calculateFontSize(thought), |
| hyphens: 'none', |
| wordBreak: 'normal', |
| overflowWrap: 'break-word', |
| lineHeight: '1.4', |
| margin: 0 |
| }} |
| className="font-medium text-gray-800" |
| > |
| {thought} |
| </p> |
| ); |
| } |
| |
| return <p className={`${isMobile ? 'text-sm' : 'text-lg'} font-medium text-gray-400 italic`}>Waiting for hand gesture...</p>; |
| }; |
|
|
| |
| const shouldShowBubble = () => { |
| |
| return isFirstLoad || isThinking || isMouthOpen; |
| }; |
|
|
| |
| const getOpacity = () => { |
| return isMouthOpen || isThinking ? 1 : 0.7; |
| }; |
|
|
| |
| const getBubblePadding = () => { |
| if (isThinking) { |
| |
| return isMobile ? '10px' : '12px'; |
| } |
| |
| return isMobile ? '12px 16px' : '16px 20px'; |
| }; |
|
|
| |
| const getBubbleBorderRadius = () => { |
| if (isThinking) { |
| |
| return isMobile ? '35px' : '40px'; |
| } |
| |
| return isMobile ? '16px' : '20px'; |
| }; |
| |
| |
| const getBubbleWidth = () => { |
| if (!handDetected) { |
| return isMobile ? '90%' : `${canvasWidth * 0.8}px`; |
| } |
| |
| if (isThinking) { |
| |
| return isMobile ? '70px' : '80px'; |
| } |
| |
| |
| const bubbleWidth = isMobile |
| ? Math.min(220, canvasWidth * 0.7) |
| : Math.min(300, canvasWidth * 0.6); |
| |
| return `${bubbleWidth}px`; |
| }; |
|
|
| |
| const getBubbleStyle = () => { |
| if (!handDetected) { |
| |
| return { |
| position: 'absolute', |
| bottom: '20px', |
| left: '50%', |
| transform: animateThinking ? undefined : 'translateX(-50%)', |
| width: getBubbleWidth(), |
| }; |
| } |
| |
| const offset = isMobile ? 12 : 20; |
| |
| if (isLeftHand) { |
| |
| return { |
| position: 'absolute', |
| top: `${thumbPosition.y - (isMobile ? 20 : 30)}px`, |
| left: `${thumbPosition.x + offset}px`, |
| width: getBubbleWidth(), |
| maxWidth: isThinking ? 'none' : `${canvasWidth - thumbPosition.x - (offset * 2)}px` |
| }; |
| } else { |
| |
| return { |
| position: 'absolute', |
| top: `${thumbPosition.y - (isMobile ? 20 : 30)}px`, |
| right: `${canvasWidth - thumbPosition.x + offset}px`, |
| width: getBubbleWidth(), |
| maxWidth: isThinking ? 'none' : `${thumbPosition.x - (offset * 2)}px` |
| }; |
| } |
| }; |
|
|
| if (!shouldShowBubble()) { |
| return null; |
| } |
|
|
| return ( |
| <div |
| ref={thoughtBubbleRef} |
| className="thought-bubble" |
| style={{ |
| ...getBubbleStyle(), |
| backgroundColor: 'rgba(255, 255, 255, 0.8)', |
| backdropFilter: 'blur(8px)', |
| border: 'none', |
| boxShadow: '0 4px 30px rgba(0, 0, 0, 0.1)', |
| padding: getBubblePadding(), |
| borderRadius: getBubbleBorderRadius(), |
| textAlign: isThinking ? 'center' : 'left', |
| zIndex: 50, |
| fontFamily: 'Google Sans, sans-serif', |
| opacity: getOpacity(), |
| display: 'flex', |
| justifyContent: isThinking ? 'center' : 'flex-start', |
| alignItems: 'center', |
| animation: !isMouthOpen && thought ? 'pop-out 0.3s ease-out forwards' : |
| animateThinking ? 'spring-wiggle 1.2s cubic-bezier(0.2, 0.9, 0.3, 1.5)' : |
| 'none', |
| transformOrigin: isLeftHand ? 'left center' : 'right center', |
| willChange: 'transform, opacity' |
| }} |
| > |
| {getBubbleContent()} |
| </div> |
| ); |
| }; |
|
|
| export default ThoughtBubble; |