@gambito-corp/mbs-library
Version:
Librería de componentes React reutilizables - Sistema de diseño modular y escalable
293 lines (252 loc) • 10.2 kB
JSX
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { getTextBEMClasses } from './Text.utils.js';
import { TEXT_VARIANTS, TEXT_SIZES, TEXT_COLORS, TEXT_GRADIENTS, TEXT_NEON_COLORS } from './Text.constants.js';
import './Text.css';
const Text = ({
children,
variant = 'default',
size = 'medium',
as = 'span',
color,
gradientFrom,
gradientTo,
gradientType = 'blue-purple',
neonColor = 'cyan',
typewriterSpeed = 80,
typewriterLoop = false,
typewriterPause = 300,
htmlContent = false,
className = '',
...props
}) => {
const Component = as;
const bemClasses = getTextBEMClasses({ variant, size, color, className });
const [displayedText, setDisplayedText] = useState('');
const [currentIndex, setCurrentIndex] = useState(0);
const [isCompleted, setIsCompleted] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const intervalRef = useRef(null);
const loopTimeoutRef = useRef(null);
const mountedRef = useRef(true);
// Contenido a mostrar
const content = children || '';
const textToType = String(content);
useEffect(() => {
mountedRef.current = true;
if (variant !== 'typewriter') {
clearAllTimers();
setDisplayedText(textToType);
return;
}
startTypewriter();
return () => {
mountedRef.current = false;
clearAllTimers();
};
}, [variant, textToType, typewriterSpeed, typewriterLoop, typewriterPause, htmlContent]);
useEffect(() => {
return () => {
mountedRef.current = false;
clearAllTimers();
};
}, []);
const getCustomGradientStyle = () => {
if (variant === 'gradient' || variant === 'gradient-animated') {
let fromColor, toColor;
if (gradientFrom && gradientTo) {
fromColor = gradientFrom;
toColor = gradientTo;
} else if (TEXT_GRADIENTS[gradientType]) {
fromColor = TEXT_GRADIENTS[gradientType].from;
toColor = TEXT_GRADIENTS[gradientType].to;
} else {
fromColor = '#3b82f6';
toColor = '#8b5cf6';
}
return {
background: `linear-gradient(135deg, ${fromColor}, ${toColor})`,
backgroundSize: variant === 'gradient-animated' ? '300% 300%' : '100% 100%',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
fontWeight: 600,
display: 'inline-block'
};
}
return {};
};
const getCustomNeonStyle = () => {
if (variant === 'neon') {
const neonColorValue = TEXT_NEON_COLORS[neonColor]?.color || '#00ffff';
return {
color: neonColorValue,
textShadow: `
0 0 5px ${neonColorValue},
0 0 10px ${neonColorValue},
0 0 15px ${neonColorValue},
0 0 20px ${neonColorValue},
0 0 35px ${neonColorValue}
`,
fontWeight: 600,
display: 'inline-block',
animation: 'neonPulse 2s ease-in-out infinite alternate'
};
}
return {};
};
const clearAllTimers = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (loopTimeoutRef.current) {
clearTimeout(loopTimeoutRef.current);
loopTimeoutRef.current = null;
}
};
const startTypewriter = () => {
clearAllTimers();
setDisplayedText('');
setIsTyping(true);
let localIndex = 0;
const typeNextCharacter = () => {
if (!mountedRef.current) return;
if (localIndex < textToType.length) {
if (htmlContent) {
const partialHTML = buildPartialHTML(textToType, localIndex + 1);
setDisplayedText(partialHTML);
} else {
setDisplayedText(textToType.substring(0, localIndex + 1));
}
localIndex++;
intervalRef.current = setTimeout(typeNextCharacter, typewriterSpeed);
} else {
setIsTyping(false);
if (typewriterLoop && mountedRef.current) {
loopTimeoutRef.current = setTimeout(() => {
if (mountedRef.current) {
startTypewriter();
}
}, typewriterPause);
}
}
};
typeNextCharacter();
};
const buildPartialHTML = (originalHTML, targetLength) => {
if (!htmlContent) {
return originalHTML.substring(0, targetLength);
}
// Si no hay contenido, devolver vacío
if (!originalHTML || targetLength <= 0) {
return '';
}
// Si el target es mayor o igual al contenido, devolver todo
if (targetLength >= originalHTML.length) {
return originalHTML;
}
try {
// Crear un elemento temporal para procesar el HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = originalHTML;
let currentLength = 0;
const result = document.createElement('div');
const processNode = (node, container) => {
if (currentLength >= targetLength) return false;
if (node.nodeType === Node.TEXT_NODE) {
const textContent = node.textContent || '';
const remainingLength = targetLength - currentLength;
if (textContent.length <= remainingLength) {
container.appendChild(document.createTextNode(textContent));
currentLength += textContent.length;
} else {
const partialText = textContent.substring(0, remainingLength);
container.appendChild(document.createTextNode(partialText));
currentLength += partialText.length;
return false;
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const clonedElement = document.createElement(node.tagName.toLowerCase());
// Copiar atributos
for (let i = 0; i < node.attributes.length; i++) {
const attr = node.attributes[i];
clonedElement.setAttribute(attr.name, attr.value);
}
container.appendChild(clonedElement);
// Procesar hijos
for (let child of node.childNodes) {
if (!processNode(child, clonedElement)) break;
}
}
return currentLength < targetLength;
};
// Procesar todos los nodos hijos
for (let child of tempDiv.childNodes) {
if (!processNode(child, result)) break;
}
return result.innerHTML;
} catch (error) {
console.error('Error procesando HTML:', error);
// Fallback: devolver texto plano
return originalHTML.substring(0, targetLength);
}
};
const customStyle = {
...getCustomGradientStyle(),
...getCustomNeonStyle()
};
const renderContent = () => {
if (variant === 'typewriter') {
return (
<>
{htmlContent ? (
<span dangerouslySetInnerHTML={{ __html: displayedText }} />
) : (
displayedText
)}
{isTyping && (
<span className="typewriter-cursor">|</span>
)}
</>
);
}
// Para variantes normales
if (htmlContent) {
return <span dangerouslySetInnerHTML={{ __html: textToType }} />;
}
return textToType;
};
return (
<Component
className={bemClasses}
style={Object.keys(customStyle).length > 0 ? customStyle : undefined}
data-testid="Text"
{...props}
>
{renderContent()}
</Component>
);
};
Text.propTypes = {
children: PropTypes.node.isRequired,
variant: PropTypes.oneOf([
'default', 'bold', 'bolder', 'tiny', 'light', 'cursiva',
'subrayado', 'muted', 'gradient', 'gradient-animated', 'neon', 'typewriter'
]),
size: PropTypes.oneOf(['xs', 'small', 'medium', 'large', 'xlarge', '2xl']),
color: PropTypes.oneOf([
'default', 'primary', 'secondary', 'success', 'warning', 'error', 'info', 'white', 'black'
]),
gradientFrom: PropTypes.string,
gradientTo: PropTypes.string,
gradientType: PropTypes.string,
neonColor: PropTypes.oneOf(['cyan', 'pink', 'green', 'orange', 'purple', 'yellow', 'red', 'blue']),
typewriterSpeed: PropTypes.number,
typewriterLoop: PropTypes.bool,
typewriterPause: PropTypes.number,
htmlContent: PropTypes.bool,
as: PropTypes.oneOf(['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'label']),
className: PropTypes.string
};
export default Text;