diff --git a/src/components/TypewriterEffect.tsx b/src/components/TypewriterEffect.tsx new file mode 100644 index 0000000..d65309b --- /dev/null +++ b/src/components/TypewriterEffect.tsx @@ -0,0 +1,155 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface TypewriterEffectProps { + /** + * Text or array of texts to display in typewriter effect + */ + text: string | string[]; + /** + * Typing speed in milliseconds per character + */ + typingSpeed?: number; + /** + * Deleting speed in milliseconds per character + */ + deletingSpeed?: number; + /** + * Delay before starting to delete text in milliseconds + */ + delayBeforeDelete?: number; + /** + * Delay before typing the next text in milliseconds + */ + delayBeforeTyping?: number; + /** + * Whether to loop through the texts indefinitely + */ + loop?: boolean; + /** + * CSS class name for the cursor + */ + cursorClassName?: string; + /** + * CSS class name for the text container + */ + className?: string; + /** + * Whether to show the cursor + */ + showCursor?: boolean; +} + +/** + * TypewriterEffect component that creates a typewriter animation effect + * for a single text or cycles through an array of texts + */ +const TypewriterEffect: React.FC = ({ + text, + typingSpeed = 100, + deletingSpeed = 50, + delayBeforeDelete = 2000, + delayBeforeTyping = 700, + loop = true, + cursorClassName = 'text-purple-500', + className = '', + showCursor = true, +}) => { + // Convert single string to array for consistent handling + const textArray = Array.isArray(text) ? text : [text]; + const [currentTextIndex, setCurrentTextIndex] = useState(0); + const [displayedText, setDisplayedText] = useState(''); + const [isTyping, setIsTyping] = useState(true); + const [isDeleting, setIsDeleting] = useState(false); + const [isPaused, setIsPaused] = useState(false); + + // Use a ref to track if component is mounted + const isMounted = useRef(true); + + useEffect(() => { + // Cleanup function to prevent state updates after unmount + return () => { + isMounted.current = false; + }; + }, []); + + useEffect(() => { + if (!isMounted.current) return; + + const currentFullText = textArray[currentTextIndex]; + + // Handle typing state + if (isTyping && !isPaused) { + if (displayedText.length < currentFullText.length) { + // Continue typing + const timeoutId = setTimeout(() => { + if (isMounted.current) { + setDisplayedText(currentFullText.substring(0, displayedText.length + 1)); + } + }, typingSpeed); + return () => clearTimeout(timeoutId); + } else { + // Finished typing, pause before deleting + setIsTyping(false); + setIsPaused(true); + const timeoutId = setTimeout(() => { + if (isMounted.current) { + setIsPaused(false); + setIsDeleting(true); + } + }, delayBeforeDelete); + return () => clearTimeout(timeoutId); + } + } + + // Handle deleting state + if (isDeleting && !isPaused) { + if (displayedText.length > 0) { + // Continue deleting + const timeoutId = setTimeout(() => { + if (isMounted.current) { + setDisplayedText(displayedText.substring(0, displayedText.length - 1)); + } + }, deletingSpeed); + return () => clearTimeout(timeoutId); + } else { + // Finished deleting, move to next text + setIsDeleting(false); + setIsPaused(true); + + const timeoutId = setTimeout(() => { + if (isMounted.current) { + // 无条件进入下一个文本,确保循环总是继续 + const nextIndex = (currentTextIndex + 1) % textArray.length; + setCurrentTextIndex(nextIndex); + setIsPaused(false); + setIsTyping(true); + } + }, delayBeforeTyping); + return () => clearTimeout(timeoutId); + } + } + }, [displayedText, isTyping, isDeleting, isPaused, currentTextIndex, textArray, typingSpeed, deletingSpeed, delayBeforeDelete, delayBeforeTyping]); + + return ( + + {/* 直接显示文本,不使用 AnimatePresence 包装整个文本 */} + + {displayedText} + + {showCursor && ( + + | + + )} + + ); +}; + +export default TypewriterEffect; \ No newline at end of file diff --git a/src/pages/index.astro b/src/pages/index.astro index d4cca03..d08142a 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -2,6 +2,7 @@ import Layout from "@/layouts/Layout.astro"; import GlassHeader from "@/components/GlassHeader"; import SkillsMarquee from "@/components/SkillsMarquee"; + import TypewriterEffect from "@/components/TypewriterEffect"; import Footer from "@/components/Footer"; import Container from "@/components/ui/Container"; import { useTranslations, type Lang } from "@/i18n/utils"; @@ -39,9 +40,28 @@ const pageTitle = t('site.title'); {personalInfo.name} - -

- {personalInfo.position.en} 👨‍💻 + +

+ + + 👨‍💻 +

diff --git a/src/pages/zh/index.astro b/src/pages/zh/index.astro index d697fd4..fda523e 100644 --- a/src/pages/zh/index.astro +++ b/src/pages/zh/index.astro @@ -2,6 +2,7 @@ import Layout from "@/layouts/Layout.astro"; import GlassHeader from "@/components/GlassHeader"; import SkillsMarquee from "@/components/SkillsMarquee"; +import TypewriterEffect from "@/components/TypewriterEffect"; import Footer from "@/components/Footer"; import Container from "@/components/ui/Container"; import { useTranslations, type Lang } from "@/i18n/utils"; @@ -39,9 +40,28 @@ const pageTitle = t('site.title'); {personalInfo.name} - -

- {personalInfo.position.zh} 👨‍💻 + +

+ + + 👨‍💻 +