Spaces:
Sleeping
Sleeping
| import { useEffect, useRef } from 'react'; | |
| const ThoughtBubble = ({ | |
| isFirstLoad, | |
| isThinking, | |
| thought, | |
| isMouthOpen, | |
| handDetected, | |
| isLeftHand, | |
| thumbPosition, | |
| canvasWidth, | |
| isMobile, | |
| animateThinking, | |
| createSparkleParticles, | |
| createPopParticles | |
| }) => { | |
| const thoughtBubbleRef = useRef(null); | |
| // Calculate dynamic font size based on text length | |
| 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`; | |
| }; | |
| // Add particle effects when thought appears | |
| 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]); | |
| // Add pop effect when thought disappears | |
| 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]); | |
| // Get bubble content based on state | |
| 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) { | |
| // Much larger emoji size with blinking animation | |
| 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>; | |
| }; | |
| // Determine if the bubble should be visible | |
| const shouldShowBubble = () => { | |
| // Only show during first load, when thinking, or when mouth is open | |
| return isFirstLoad || isThinking || isMouthOpen; | |
| }; | |
| // Calculate opacity based on state | |
| const getOpacity = () => { | |
| return isMouthOpen || isThinking ? 1 : 0.7; | |
| }; | |
| // Get appropriate padding based on content | |
| const getBubblePadding = () => { | |
| if (isThinking) { | |
| // More padding for larger emoji | |
| return isMobile ? '10px' : '12px'; | |
| } | |
| // More padding for larger text | |
| return isMobile ? '12px 16px' : '16px 20px'; | |
| }; | |
| // Get appropriate border radius based on content | |
| const getBubbleBorderRadius = () => { | |
| if (isThinking) { | |
| // More circular for emoji | |
| return isMobile ? '35px' : '40px'; | |
| } | |
| // Normal border radius for text content | |
| return isMobile ? '16px' : '20px'; | |
| }; | |
| // Get appropriate width based on content | |
| const getBubbleWidth = () => { | |
| if (!handDetected) { | |
| return isMobile ? '90%' : `${canvasWidth * 0.8}px`; | |
| } | |
| if (isThinking) { | |
| // Much wider to accommodate larger emoji | |
| return isMobile ? '70px' : '80px'; | |
| } | |
| // Wider for larger text content | |
| const bubbleWidth = isMobile | |
| ? Math.min(220, canvasWidth * 0.7) | |
| : Math.min(300, canvasWidth * 0.6); | |
| return `${bubbleWidth}px`; | |
| }; | |
| // Calculate thought bubble position | |
| const getBubbleStyle = () => { | |
| if (!handDetected) { | |
| // Default position when no hand is detected | |
| return { | |
| position: 'absolute', | |
| bottom: '20px', | |
| left: '50%', | |
| transform: animateThinking ? undefined : 'translateX(-50%)', | |
| width: getBubbleWidth(), | |
| }; | |
| } | |
| const offset = isMobile ? 12 : 20; // Space between thumb and bubble | |
| if (isLeftHand) { | |
| // For left hand, position to the right of thumb | |
| 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` // Prevent overflow | |
| }; | |
| } else { | |
| // For right hand, position to the left of thumb | |
| 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` // Prevent overflow | |
| }; | |
| } | |
| }; | |
| 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; |