UNPKG

@erosnicolau/animated-text

Version:

Advanced React animated text component with comprehensive animation effects

293 lines (292 loc) 16.4 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { cn } from './utils/cn'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import './AnimatedText.css'; import ConfigModal from './components/ConfigModal'; import ContentRenderer from './components/ContentRenderer'; // Import utilities and hooks import { usePerformanceModeState } from './hooks/usePerformanceModeState'; import { useVirtualizationState } from './hooks/useVirtualizationState'; import { useAnimatedTextGroup } from './hooks/useAnimatedTextGroup'; import { useAnimationState } from './hooks/useAnimationState'; import { useVirtualizedIntersectionObserver } from './hooks/useVirtualizedIntersectionObserver'; import { registerAnimation, unregisterAnimation } from './performance/FPSPerformanceMonitor'; import { DEFAULT_TIMING } from './types'; import { generateBlockAnimationStyles } from './utils/animationStyles'; import { hasFontSizeEffects, hasLineHeightEffects, hasFontSizeClasses, hasFontClasses } from './utils/effectDetection'; import { getFontClasses } from './utils/fontScaling'; import { tokenizeContent, tokenizeWordTypewriter } from './utils/textProcessing'; const AnimatedText = (props) => { // Extract props with clear defaults const { content, subtitle, align = 'left', verticalAlign = 'top', className = '', wrapperClassName = '', phraseStyles, allowHtml = false, animation = 'block', startDelay = DEFAULT_TIMING.startDelay, itemSpacing = DEFAULT_TIMING.itemSpacing, blockEffect = ['fadeIn', 'slide-up'], // Composable default with proper naming blockDuration = DEFAULT_TIMING.defaultDuration, blockEasing = DEFAULT_TIMING.defaultEasing, // Word animation defaults wordEffect = ['fadeIn'], wordDuration = 400, wordSpacing = 200, wordEasing = 'ease-out', wordStyles, // Typewriter animation defaults typewriterEffect = ['fadeIn'], typewriterDuration = 200, typewriterDelay = 50, typewriterEasing = 'ease-out', // Word-typewriter animation defaults wordTypewriterEffect = ['fadeIn'], wordTypewriterDuration = 150, wordTypewriterLetterDelay = 50, wordTypewriterWordDelay = 400, wordTypewriterEasing = 'ease-out', // Static styling props transformOrigin, textShadow, // Layout control props width, minWidth, maxWidth, height, minHeight, maxHeight, // Semantic element as = 'div', // Font scaling system fontPreset = 'section', fontScale = 1, // Demo props showReplay = false, showConfig = false, // Performance optimization props (automatic global performance detection enabled) virtualizationMargin = '100px', virtualPlaceholder } = props; // Replay functionality for demo const [replayKey, setReplayKey] = useState(0); const [showConfigModal, setShowConfigModal] = useState(false); // Group context integration const groupContext = useAnimatedTextGroup(); const componentIdRef = useRef(`animated-text-${Math.random().toString(36).substr(2, 9)}`); const componentId = componentIdRef.current; // Reactive states - must be declared before useMemo calls const isVirtualizationEnabled = useVirtualizationState(); const performanceModeActive = usePerformanceModeState(); // Block effect optimization for performance mode const optimizedBlockEffect = useMemo(() => blockEffect, // ContentRenderer handles performance optimization [blockEffect]); const optimizedBlockDuration = useMemo(() => (performanceModeActive ? Math.min(blockDuration, 300) : blockDuration), [blockDuration, performanceModeActive]); const alignClass = align === 'center' ? 'text-center' : align === 'right' ? 'text-right' : 'text-left'; // Determine content processing const usePhrases = content.includes('|'); // Count items for animation based on animation type const itemCount = (() => { if (animation === 'phrases') { return usePhrases ? content.split('|').length : 1; } else if (animation === 'word') { return tokenizeContent(content).filter(token => token.type === 'word').length; } else if (animation === 'typewriter') { return content.replace(/\s+/g, '').length; // Count only letters, not spaces } else if (animation === 'word-typewriter') { // Count only letters (not spaces) since spaces don't get animated return content.replace(/\s+/g, '').length; } return 0; // For 'none' and 'block' })(); // State for virtualization (re-enabled with proper intersection observer integration) const [isVirtualized, setIsVirtualized] = useState(false); const [placeholderHeight, setPlaceholderHeight] = useState(); // Virtualization with reactive global control // Always use virtualized intersection observer for performance optimization (moved here to avoid initialization order issues) const virtualizationResult = useVirtualizedIntersectionObserver({ enableVirtualization: isVirtualizationEnabled, // Use reactive state virtualizationMargin, onVirtualize: isVisible => { setIsVirtualized(!isVisible); // Note: Height capture moved to render to avoid circular dependency } }); // Get group timing if available const groupTiming = groupContext?.getChildTiming(componentId); // Use group timing or fallback to individual timing const effectiveStartDelay = groupTiming ? groupTiming.groupStartDelay + groupTiming.childDelay : startDelay; // Use group replay key if available, otherwise use individual replay key const effectiveReplayKey = groupContext ? groupContext.groupReplayKey : replayKey; // Determine if this is an individual component replay vs group replay const isIndividualReplay = !groupContext || effectiveReplayKey === replayKey; // Group notification callbacks with FPS monitoring integration const handleAnimationStart = useCallback(() => { // Register animation with FPS monitor if (!isVirtualized && virtualizationResult.isInViewport) { registerAnimation(componentId); } // Notify group context if (groupContext) { groupContext.notifyChildStart(componentId); } }, [groupContext, componentId, virtualizationResult.isInViewport, isVirtualized]); const handleAnimationComplete = useCallback(() => { // Unregister animation from FPS monitor if (!isVirtualized && virtualizationResult.isInViewport) { unregisterAnimation(componentId); } // Notify group context if (groupContext) { groupContext.notifyChildComplete(componentId); } }, [groupContext, componentId, virtualizationResult.isInViewport, isVirtualized]); const handleReplay = useCallback(() => { setReplayKey(prev => prev + 1); }, []); const handleShowConfig = () => { setShowConfigModal(true); }; // Custom timing function for word-typewriter animation const customTimingFunction = useMemo(() => { if (animation !== 'word-typewriter') return undefined; const tokens = tokenizeWordTypewriter(content); let cumulativeDelay = 0; const timingMap = new Map(); tokens.forEach(token => { if (token.type === 'word') { // Process each letter in the word token.letters.forEach(letter => { timingMap.set(letter.letterIndex, cumulativeDelay); cumulativeDelay += wordTypewriterLetterDelay; }); } else if (token.type === 'space') { // Spaces get longer delays cumulativeDelay += wordTypewriterWordDelay; } }); return (letterIndex) => timingMap.get(letterIndex) || 0; }, [animation, content, wordTypewriterLetterDelay, wordTypewriterWordDelay]); // Animation state with clean parameters (shared intersection observer for both animation and virtualization) const animationResult = useAnimationState({ animation, startDelay: effectiveStartDelay, itemSpacing: animation === 'word' ? wordSpacing : animation === 'typewriter' ? typewriterDelay : animation === 'word-typewriter' ? wordTypewriterLetterDelay : itemSpacing, itemCount, ...(phraseStyles && { phraseStyles }), forceStart: effectiveReplayKey > 0, // Force start when replay is triggered (replayKey > 0) replayKey: effectiveReplayKey, isIndividualReplay, // Pass the individual replay flag ...(customTimingFunction && { customTimingFunction }), onAnimationStart: handleAnimationStart, onAnimationComplete: handleAnimationComplete, // Pass virtualization intersection observer data to avoid conflict elementRef: virtualizationResult.elementRef, isIntersecting: virtualizationResult.isIntersecting }); // Use the virtualized element ref (shared between animation and virtualization) const elementRef = virtualizationResult.elementRef; const shouldAnimateBlock = animationResult.shouldAnimateBlock; const animatedItems = animationResult.animatedItems; // Capture height when virtualization state changes (separate from onVirtualize to avoid circular dependency) useEffect(() => { if (isVirtualized && elementRef.current) { setPlaceholderHeight(elementRef.current.offsetHeight); } }, [isVirtualized, elementRef]); // Register with group context and get timing configuration useEffect(() => { if (groupContext) { // Calculate estimated duration for this component - memoized to prevent re-calculations const getEstimatedDuration = () => { switch (animation) { case 'block': return blockDuration + startDelay; case 'phrases': { const phraseCount = usePhrases ? content.split('|').length : 1; return phraseCount * itemSpacing + startDelay; } case 'word': return itemCount * wordSpacing + startDelay; case 'typewriter': return itemCount * typewriterDelay + typewriterDuration + startDelay; case 'word-typewriter': return itemCount * wordTypewriterLetterDelay + wordTypewriterDuration + startDelay; default: return 0; } }; const childConfig = { id: componentId, animation, startDelay, estimatedDuration: getEstimatedDuration() }; // Register and get cleanup function const cleanup = groupContext.registerChild(componentId, childConfig); return cleanup; } return undefined; }, [ groupContext, // Include full context as required by linter componentId, animation, startDelay, blockDuration, itemSpacing, itemCount, wordSpacing, typewriterDelay, typewriterDuration, wordTypewriterLetterDelay, wordTypewriterDuration, usePhrases, content // Minimal necessary dependencies to prevent infinite loops ]); const excludeFontSizeClasses = hasFontSizeEffects(animation, blockEffect, phraseStyles, wordEffect, typewriterEffect, wordTypewriterEffect); const excludeLineHeightClasses = hasLineHeightEffects(animation, blockEffect, phraseStyles, wordEffect, typewriterEffect, wordTypewriterEffect); const hasExplicitFontClasses = hasFontClasses(className); const hasExplicitFontSizeClasses = hasFontSizeClasses(className); // Get font classes from the 3-level priority system (only check for font-size classes) const dynamicFontClasses = getFontClasses(hasExplicitFontSizeClasses, fontPreset, fontScale); // Create timing defaults for animations (used in ContentRenderer) const _timingDefaults = { startDelay, itemSpacing, defaultDuration: DEFAULT_TIMING.defaultDuration, defaultEffect: DEFAULT_TIMING.defaultEffect, defaultEasing: DEFAULT_TIMING.defaultEasing }; const renderContent = () => { // Always render virtual placeholder when off-screen for performance optimization if (isVirtualized) { const defaultPlaceholder = (_jsx("div", { style: { height: placeholderHeight || 'auto', width: '100%', backgroundColor: 'transparent' }, "aria-hidden": "true" })); return virtualPlaceholder || defaultPlaceholder; } return (_jsx(ContentRenderer, { content: content, animation: animation, allowHtml: allowHtml, phraseStyles: phraseStyles, usePhrases: usePhrases, itemSpacing: itemSpacing, wordEffect: wordEffect, wordDuration: wordDuration, wordEasing: wordEasing, wordStyles: wordStyles, typewriterEffect: typewriterEffect, typewriterDuration: typewriterDuration, typewriterEasing: typewriterEasing, wordTypewriterEffect: wordTypewriterEffect, wordTypewriterDuration: wordTypewriterDuration, wordTypewriterEasing: wordTypewriterEasing, animatedItems: animatedItems, effectiveReplayKey: effectiveReplayKey, performanceModeActive: performanceModeActive, verticalAlign: verticalAlign })); }; // Create base styles with static properties const baseStyles = {}; if (transformOrigin) { baseStyles.transformOrigin = transformOrigin; } // Apply layout control properties to text element if (width) baseStyles.width = width; if (minWidth) baseStyles.minWidth = minWidth; if (maxWidth) baseStyles.maxWidth = maxWidth; if (height) baseStyles.height = height; if (minHeight) baseStyles.minHeight = minHeight; if (maxHeight) baseStyles.maxHeight = maxHeight; // Create text styles for content elements const textStyles = { ...baseStyles }; if (textShadow) { textStyles.textShadow = textShadow; } // Apply block animation styles to text element, not container if (animation === 'block') { const blockStyles = generateBlockAnimationStyles(optimizedBlockEffect, optimizedBlockDuration, blockEasing, shouldAnimateBlock); Object.assign(textStyles, blockStyles); } // Create the dynamic element const TextElement = as; return (_jsxs("div", { ref: elementRef, className: cn('relative', alignClass, wrapperClassName), style: baseStyles, children: [(showReplay || showConfig) && (_jsxs("div", { className: "animated-text-controls absolute -top-1 -left-1 flex flex-col gap-1 z-10", children: [showReplay && (_jsx("button", { onClick: handleReplay, className: "w-6 h-6 bg-blue-500 hover:bg-blue-800 text-white text-xs rounded-full flex items-center justify-center transition-colors duration-200", title: "Replay animation", "aria-label": "Replay animation", children: "\u21BB" })), showConfig && (_jsx("button", { onClick: handleShowConfig, className: "w-6 h-6 bg-orange-500 hover:bg-orange-800 text-white text-xs rounded-full flex items-center justify-center transition-colors duration-200", title: "Show configuration", "aria-label": "Show configuration", children: "</>" }))] })), _jsx(TextElement, { className: cn('font-bold', // Apply dynamic font classes from the 3-level priority system !excludeFontSizeClasses && dynamicFontClasses, // Only apply default line-height classes if no explicit font classes AND no lineHeight effects are configured !hasExplicitFontClasses && !excludeLineHeightClasses && 'leading-tight', className), style: textStyles, children: renderContent() }), subtitle && _jsx("p", { className: cn('text-lg md:text-xl text-neutral-600 mt-4'), children: subtitle }), showConfigModal && (_jsx(ConfigModal, { isOpen: showConfigModal, onClose: () => setShowConfigModal(false), componentProps: props }))] })); }; export default AnimatedText;