'use client'; import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; import { motion } from 'motion/react'; import type { HTMLMotionProps } from 'motion/react'; interface DecryptedTextProps extends HTMLMotionProps<'span'> { text: string; speed?: number; maxIterations?: number; sequential?: boolean; revealDirection?: 'start' | 'end' | 'center'; useOriginalCharsOnly?: boolean; characters?: string; className?: string; encryptedClassName?: string; parentClassName?: string; animateOn?: 'view' | 'hover' | 'inViewHover' | 'click'; clickMode?: 'once' | 'toggle'; } type Direction = 'forward' | 'reverse'; export default function DecryptedText({ text, speed = 50, maxIterations = 10, sequential = false, revealDirection = 'start', useOriginalCharsOnly = false, characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+', className = '', parentClassName = '', encryptedClassName = '', animateOn = 'hover', clickMode = 'once', ...props }: DecryptedTextProps) { const [displayText, setDisplayText] = useState(text); const [isAnimating, setIsAnimating] = useState(false); const [revealedIndices, setRevealedIndices] = useState>(new Set()); const [hasAnimated, setHasAnimated] = useState(false); const [isDecrypted, setIsDecrypted] = useState(animateOn !== 'click'); const [direction, setDirection] = useState('forward'); const containerRef = useRef(null); const orderRef = useRef([]); const pointerRef = useRef(0); const intervalRef = useRef | null>(null); const availableChars = useMemo(() => { return useOriginalCharsOnly ? Array.from(new Set(text.split(''))).filter(char => char !== '') : characters.split(''); }, [useOriginalCharsOnly, text, characters]); const shuffleText = useCallback( (originalText: string, currentRevealed: Set) => { 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(''); }, [availableChars] ); const computeOrder = useCallback( (len: number): number[] => { const order: number[] = []; if (len <= 0) return order; if (revealDirection === 'start') { for (let i = 0; i < len; i++) order.push(i); return order; } if (revealDirection === 'end') { for (let i = len - 1; i >= 0; i--) order.push(i); return order; } // center const middle = Math.floor(len / 2); let offset = 0; while (order.length < len) { if (offset % 2 === 0) { const idx = middle + offset / 2; if (idx >= 0 && idx < len) order.push(idx); } else { const idx = middle - Math.ceil(offset / 2); if (idx >= 0 && idx < len) order.push(idx); } offset++; } return order.slice(0, len); }, [revealDirection] ); const fillAllIndices = useCallback((): Set => { const s = new Set(); for (let i = 0; i < text.length; i++) s.add(i); return s; }, [text]); const removeRandomIndices = useCallback((set: Set, count: number): Set => { const arr = Array.from(set); for (let i = 0; i < count && arr.length > 0; i++) { const idx = Math.floor(Math.random() * arr.length); arr.splice(idx, 1); } return new Set(arr); }, []); const encryptInstantly = useCallback(() => { const emptySet = new Set(); setRevealedIndices(emptySet); setDisplayText(shuffleText(text, emptySet)); setIsDecrypted(false); }, [text, shuffleText]); const triggerDecrypt = useCallback(() => { if (sequential) { orderRef.current = computeOrder(text.length); pointerRef.current = 0; setRevealedIndices(new Set()); } else { setRevealedIndices(new Set()); } setDirection('forward'); setIsAnimating(true); }, [sequential, computeOrder, text.length]); const triggerReverse = useCallback(() => { if (sequential) { // compute forward order then reverse it: we'll remove indices in that order orderRef.current = computeOrder(text.length).slice().reverse(); pointerRef.current = 0; setRevealedIndices(fillAllIndices()); // start fully revealed setDisplayText(shuffleText(text, fillAllIndices())); } else { // non-seq: start from fully revealed as well setRevealedIndices(fillAllIndices()); setDisplayText(shuffleText(text, fillAllIndices())); } setDirection('reverse'); setIsAnimating(true); }, [sequential, computeOrder, fillAllIndices, shuffleText, text]); useEffect(() => { if (!isAnimating) return; let currentIteration = 0; const getNextIndex = (revealedSet: Set): number => { 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; } }; intervalRef.current = setInterval(() => { setRevealedIndices(prevRevealed => { if (sequential) { // Forward if (direction === 'forward') { 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(intervalRef.current ?? undefined); setIsAnimating(false); setIsDecrypted(true); return prevRevealed; } } // Reverse if (direction === 'reverse') { if (pointerRef.current < orderRef.current.length) { const idxToRemove = orderRef.current[pointerRef.current++]; const newRevealed = new Set(prevRevealed); newRevealed.delete(idxToRemove); setDisplayText(shuffleText(text, newRevealed)); if (newRevealed.size === 0) { clearInterval(intervalRef.current ?? undefined); setIsAnimating(false); setIsDecrypted(false); } return newRevealed; } else { clearInterval(intervalRef.current ?? undefined); setIsAnimating(false); setIsDecrypted(false); return prevRevealed; } } } else { // Non-Sequential if (direction === 'forward') { setDisplayText(shuffleText(text, prevRevealed)); currentIteration++; if (currentIteration >= maxIterations) { clearInterval(intervalRef.current ?? undefined); setIsAnimating(false); setDisplayText(text); setIsDecrypted(true); } return prevRevealed; } // Non-Sequential Reverse if (direction === 'reverse') { let currentSet = prevRevealed; if (currentSet.size === 0) { currentSet = fillAllIndices(); } const removeCount = Math.max(1, Math.ceil(text.length / Math.max(1, maxIterations))); const nextSet = removeRandomIndices(currentSet, removeCount); setDisplayText(shuffleText(text, nextSet)); currentIteration++; if (nextSet.size === 0 || currentIteration >= maxIterations) { clearInterval(intervalRef.current ?? undefined); setIsAnimating(false); setIsDecrypted(false); // ensure final scrambled state setDisplayText(shuffleText(text, new Set())); return new Set(); } return nextSet; } } return prevRevealed; }); }, speed); return () => clearInterval(intervalRef.current ?? undefined); }, [ isAnimating, text, speed, maxIterations, sequential, revealDirection, shuffleText, direction, fillAllIndices, removeRandomIndices, characters, useOriginalCharsOnly ]); /* Click Behaviour */ const handleClick = () => { if (animateOn !== 'click') return; if (clickMode === 'once') { if (isDecrypted) return; setDirection('forward'); triggerDecrypt(); } if (clickMode === 'toggle') { if (isDecrypted) { triggerReverse(); } else { setDirection('forward'); triggerDecrypt(); } } }; /* Hover Behaviour */ const triggerHoverDecrypt = useCallback(() => { if (isAnimating) return; setRevealedIndices(new Set()); setIsDecrypted(false); setDisplayText(text); setDirection('forward'); setIsAnimating(true); }, [isAnimating, text]); const resetToPlainText = useCallback(() => { clearInterval(intervalRef.current ?? undefined); setIsAnimating(false); setRevealedIndices(new Set()); setDisplayText(text); setIsDecrypted(true); setDirection('forward'); }, [text]); /* View Observer */ useEffect(() => { if (animateOn !== 'view' && animateOn !== 'inViewHover') return; const observerCallback = (entries: IntersectionObserverEntry[]) => { entries.forEach(entry => { if (entry.isIntersecting && !hasAnimated) { triggerDecrypt(); 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, triggerDecrypt]); useEffect(() => { if (animateOn === 'click') { encryptInstantly(); } else { setDisplayText(text); setIsDecrypted(true); } setRevealedIndices(new Set()); setDirection('forward'); }, [animateOn, text, encryptInstantly]); const animateProps = animateOn === 'hover' || animateOn === 'inViewHover' ? { onMouseEnter: triggerHoverDecrypt, onMouseLeave: resetToPlainText } : animateOn === 'click' ? { onClick: handleClick } : {}; return ( {displayText} ); }