import { useEffect, useState, useRef } from 'react';
import { motion } from 'motion/react';
export default function DecryptedText({
text,
speed = 50,
maxIterations = 10,
sequential = false,
revealDirection = 'start',
useOriginalCharsOnly = false,
characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+',
className = '',
parentClassName = '',
encryptedClassName = '',
animateOn = 'hover',
...props
}) {
const [displayText, setDisplayText] = useState(text);
const [isHovering, setIsHovering] = useState(false);
const [isScrambling, setIsScrambling] = useState(false);
const [revealedIndices, setRevealedIndices] = useState(new Set());
const [hasAnimated, setHasAnimated] = useState(false);
const containerRef = useRef(null);
useEffect(() => {
let interval;
let currentIteration = 0;
const getNextIndex = revealedSet => {
const textLength = text.length;
switch (revealDirection) {
case 'start':
return revealedSet.size;
case 'end':
return textLength - 1 - revealedSet.size;
case 'center': {
const middle = Math.floor(textLength / 2);
const offset = Math.floor(revealedSet.size / 2);
const nextIndex = revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
return nextIndex;
}
for (let i = 0; i < textLength; i++) {
if (!revealedSet.has(i)) return i;
}
return 0;
}
default:
return revealedSet.size;
}
};
const availableChars = useOriginalCharsOnly
? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
: characters.split('');
const shuffleText = (originalText, currentRevealed) => {
if (useOriginalCharsOnly) {
const positions = originalText.split('').map((char, i) => ({
char,
isSpace: char === ' ',
index: i,
isRevealed: currentRevealed.has(i)
}));
const nonSpaceChars = positions.filter(p => !p.isSpace && !p.isRevealed).map(p => p.char);
for (let i = nonSpaceChars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]];
}
let charIndex = 0;
return positions
.map(p => {
if (p.isSpace) return ' ';
if (p.isRevealed) return originalText[p.index];
return nonSpaceChars[charIndex++];
})
.join('');
} else {
return originalText
.split('')
.map((char, i) => {
if (char === ' ') return ' ';
if (currentRevealed.has(i)) return originalText[i];
return availableChars[Math.floor(Math.random() * availableChars.length)];
})
.join('');
}
};
if (isHovering) {
setIsScrambling(true);
interval = setInterval(() => {
setRevealedIndices(prevRevealed => {
if (sequential) {
if (prevRevealed.size < text.length) {
const nextIndex = getNextIndex(prevRevealed);
const newRevealed = new Set(prevRevealed);
newRevealed.add(nextIndex);
setDisplayText(shuffleText(text, newRevealed));
return newRevealed;
} else {
clearInterval(interval);
setIsScrambling(false);
return prevRevealed;
}
} else {
setDisplayText(shuffleText(text, prevRevealed));
currentIteration++;
if (currentIteration >= maxIterations) {
clearInterval(interval);
setIsScrambling(false);
setDisplayText(text);
}
return prevRevealed;
}
});
}, speed);
} else {
setDisplayText(text);
setRevealedIndices(new Set());
setIsScrambling(false);
}
return () => {
if (interval) clearInterval(interval);
};
}, [isHovering, text, speed, maxIterations, sequential, revealDirection, characters, useOriginalCharsOnly]);
useEffect(() => {
if (animateOn !== 'view' && animateOn !== 'both') return;
const observerCallback = entries => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated) {
setIsHovering(true);
setHasAnimated(true);
}
});
};
const observerOptions = {
root: null,
rootMargin: '0px',
threshold: 0.1
};
const observer = new IntersectionObserver(observerCallback, observerOptions);
const currentRef = containerRef.current;
if (currentRef) {
observer.observe(currentRef);
}
return () => {
if (currentRef) observer.unobserve(currentRef);
};
}, [animateOn, hasAnimated]);
const hoverProps =
animateOn === 'hover' || animateOn === 'both'
? {
onMouseEnter: () => setIsHovering(true),
onMouseLeave: () => setIsHovering(false)
}
: {};
return (
<motion.span
ref={containerRef}
className={`inline-block whitespace-pre-wrap ${parentClassName}`}
{...hoverProps}
{...props}
>
<span className="sr-only">{displayText}</span>
<span aria-hidden="true">
{displayText.split('').map((char, index) => {
const isRevealedOrDone = revealedIndices.has(index) || !isScrambling || !isHovering;
return (
<span key={index} className={isRevealedOrDone ? className : encryptedClassName}>
{char}
</span>
);
})}
</span>
</motion.span>
);
}