@erosnicolau/animated-text
Version:
Advanced React animated text component with comprehensive animation effects
293 lines (292 loc) • 16.4 kB
JavaScript
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;