227 lines
6.2 KiB
TypeScript
227 lines
6.2 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
|
|
interface HandwrittenTextProps {
|
|
text: string;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
speed?: number; // Characters per second
|
|
startDelay?: number; // Delay before animation starts (ms)
|
|
onComplete?: () => void;
|
|
autoStart?: boolean;
|
|
}
|
|
|
|
export function HandwrittenText({
|
|
text,
|
|
className = "",
|
|
style = {},
|
|
speed = 8,
|
|
startDelay = 0,
|
|
onComplete,
|
|
autoStart = true
|
|
}: HandwrittenTextProps) {
|
|
const [displayedText, setDisplayedText] = useState('');
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [isWriting, setIsWriting] = useState(false);
|
|
const [showCursor, setShowCursor] = useState(false);
|
|
|
|
// Split text into characters, preserving line breaks
|
|
const characters = text.split('');
|
|
|
|
useEffect(() => {
|
|
if (!autoStart) return;
|
|
|
|
const startTimer = setTimeout(() => {
|
|
setIsWriting(true);
|
|
setShowCursor(true);
|
|
}, startDelay);
|
|
|
|
return () => clearTimeout(startTimer);
|
|
}, [autoStart, startDelay]);
|
|
|
|
useEffect(() => {
|
|
if (!isWriting || currentIndex >= characters.length) {
|
|
if (currentIndex >= characters.length) {
|
|
// Hide cursor after a delay
|
|
const cursorTimer = setTimeout(() => {
|
|
setShowCursor(false);
|
|
setIsWriting(false);
|
|
onComplete?.();
|
|
}, 800);
|
|
return () => clearTimeout(cursorTimer);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const char = characters[currentIndex];
|
|
const baseDelay = 1000 / speed;
|
|
|
|
// Variable delays for more natural writing
|
|
let delay = baseDelay;
|
|
|
|
if (char === ' ') {
|
|
delay = baseDelay * 0.5; // Spaces are quicker
|
|
} else if (char === '\n') {
|
|
delay = baseDelay * 2; // Line breaks take longer
|
|
} else if (['.', '!', '?'].includes(char)) {
|
|
delay = baseDelay * 1.5; // Punctuation takes a bit longer
|
|
} else if ([',', ';', ':'].includes(char)) {
|
|
delay = baseDelay * 1.2;
|
|
} else {
|
|
// Add some randomness to letter timing
|
|
delay = baseDelay * (0.8 + Math.random() * 0.4);
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
setDisplayedText(prev => prev + char);
|
|
setCurrentIndex(prev => prev + 1);
|
|
}, delay);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [isWriting, currentIndex, characters, speed, onComplete]);
|
|
|
|
// Reset function for external control
|
|
const reset = () => {
|
|
setDisplayedText('');
|
|
setCurrentIndex(0);
|
|
setIsWriting(false);
|
|
setShowCursor(false);
|
|
};
|
|
|
|
const start = () => {
|
|
reset();
|
|
setTimeout(() => {
|
|
setIsWriting(true);
|
|
setShowCursor(true);
|
|
}, startDelay);
|
|
};
|
|
|
|
// Expose control methods
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
(window as any).handwrittenTextControls = { reset, start };
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<div className={`relative w-fit h-fit inline-block ${className}`} style={style}>
|
|
{/* Main text with character-by-character animation */}
|
|
<motion.div
|
|
className="relative"
|
|
style={{ whiteSpace: 'pre-line' }}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
{displayedText.split('').map((char, index) => (
|
|
<motion.span
|
|
key={`${index}-${char}`}
|
|
initial={{
|
|
opacity: 0,
|
|
scale: 0.8,
|
|
y: 2
|
|
}}
|
|
animate={{
|
|
opacity: 1,
|
|
scale: 1,
|
|
y: 0
|
|
}}
|
|
transition={{
|
|
duration: 0.2,
|
|
ease: "easeOut",
|
|
delay: 0
|
|
}}
|
|
style={{
|
|
display: char === '\n' ? 'block' : 'inline',
|
|
width: char === '\n' ? '100%' : 'auto',
|
|
height: char === '\n' ? '0' : 'auto'
|
|
}}
|
|
>
|
|
{char === '\n' ? '' : char}
|
|
</motion.span>
|
|
))}
|
|
|
|
{/* Writing cursor/pen effect */}
|
|
<AnimatePresence>
|
|
{showCursor && (
|
|
<motion.span
|
|
className="inline-block"
|
|
initial={{ opacity: 0, scale: 0 }}
|
|
animate={{
|
|
opacity: [0.3, 1, 0.3],
|
|
scale: [0.8, 1.1, 0.8],
|
|
rotate: [0, 2, -2, 0]
|
|
}}
|
|
exit={{ opacity: 0, scale: 0 }}
|
|
transition={{
|
|
opacity: {
|
|
duration: 0.8,
|
|
repeat: Infinity,
|
|
ease: "easeInOut"
|
|
},
|
|
scale: {
|
|
duration: 0.8,
|
|
repeat: Infinity,
|
|
ease: "easeInOut"
|
|
},
|
|
rotate: {
|
|
duration: 1.2,
|
|
repeat: Infinity,
|
|
ease: "easeInOut"
|
|
}
|
|
}}
|
|
style={{
|
|
color: 'rgba(85, 70, 50, 0.6)',
|
|
marginLeft: '1px'
|
|
}}
|
|
>
|
|
|
|
|
</motion.span>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
|
|
{/* Ink flow effect */}
|
|
<motion.div
|
|
className="absolute inset-0 pointer-events-none"
|
|
style={{
|
|
background: `
|
|
radial-gradient(ellipse at 20% 30%, rgba(85, 70, 50, 0.05) 0%, transparent 60%),
|
|
radial-gradient(ellipse at 80% 70%, rgba(101, 84, 63, 0.04) 0%, transparent 50%)
|
|
`,
|
|
filter: 'blur(2px)',
|
|
borderRadius: '4px'
|
|
}}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: isWriting ? 0.3 : 0 }}
|
|
transition={{ duration: 0.5 }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Hook for controlling the animation externally
|
|
export function useHandwrittenText(autoStart = true) {
|
|
const [isComplete, setIsComplete] = useState(false);
|
|
|
|
const reset = () => {
|
|
setIsComplete(false);
|
|
if (typeof window !== 'undefined' && (window as any).handwrittenTextControls) {
|
|
(window as any).handwrittenTextControls.reset();
|
|
}
|
|
};
|
|
|
|
const start = () => {
|
|
setIsComplete(false);
|
|
if (typeof window !== 'undefined' && (window as any).handwrittenTextControls) {
|
|
(window as any).handwrittenTextControls.start();
|
|
}
|
|
};
|
|
|
|
return {
|
|
isComplete,
|
|
reset,
|
|
start,
|
|
onComplete: () => setIsComplete(true)
|
|
};
|
|
} |