feat: enhance Typewriter component animation and performance

This commit is contained in:
Cat Tom 2025-03-26 01:43:48 +08:00
parent 65f44b146b
commit f1e3d4b698

View File

@ -1,27 +1,33 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
const props = defineProps({
texts: {
type: Array,
required: true,
validator: value => value.length > 0
validator: value => Array.isArray(value) &&
value.length > 0 &&
value.every(item => typeof item === 'string' && item.length > 0)
},
typingSpeed: {
type: Number,
default: 100 // /
default: 100,
validator: value => value > 0
},
deletingSpeed: {
type: Number,
default: 50 // /
default: 50,
validator: value => value > 0
},
delayBetweenTexts: {
type: Number,
default: 1500 //
default: 1500,
validator: value => value >= 0
},
cursorBlinkSpeed: {
type: Number,
default: 500 //
default: 500,
validator: value => value > 0
},
loop: {
type: Boolean,
@ -29,79 +35,137 @@ const props = defineProps({
}
})
const emit = defineEmits(['cycle-complete'])
//
const currentText = ref('')
const isTyping = ref(true)
const isDeleting = ref(false)
const phase = ref('typing') // 'typing' | 'pausing' | 'deleting'
const currentIndex = ref(0)
const cursorVisible = ref(true)
const isRunning = ref(false)
//
//
const currentFullText = computed(() => {
return props.texts[currentIndex.value] || ''
})
//
let timers = []
const clearAllTimers = () => {
timers.forEach(timer => clearTimeout(timer))
timers = []
}
//
const setSafeTimeout = (fn, delay) => {
const timer = setTimeout(() => {
try {
fn()
} catch (error) {
console.error('Typewriter error:', error)
resetAnimation()
}
}, delay)
timers.push(timer)
return timer
}
//
let cursorInterval
onMounted(() => {
const startCursorAnimation = () => {
cursorInterval = setInterval(() => {
cursorVisible.value = !cursorVisible.value
}, props.cursorBlinkSpeed)
}
//
const resetAnimation = () => {
clearAllTimers()
currentText.value = ''
currentIndex.value = 0
phase.value = 'typing'
isRunning.value = false
}
//
const type = () => {
if (!isRunning.value) return
switch (phase.value) {
case 'typing':
if (currentText.value.length < currentFullText.value.length) {
currentText.value = currentFullText.value.substring(0, currentText.value.length + 1)
setSafeTimeout(type, props.typingSpeed)
} else {
phase.value = 'pausing'
setSafeTimeout(() => {
phase.value = 'deleting'
type()
}, props.delayBetweenTexts)
}
break
case 'deleting':
if (currentText.value.length > 0) {
currentText.value = currentFullText.value.substring(0, currentText.value.length - 1)
setSafeTimeout(type, props.deletingSpeed)
} else {
if (props.loop || currentIndex.value < props.texts.length - 1) {
currentIndex.value = (currentIndex.value + 1) % props.texts.length
emit('cycle-complete', currentIndex.value)
phase.value = 'typing'
setSafeTimeout(type, props.delayBetweenTexts / 2)
} else {
isRunning.value = false
}
}
break
}
}
//
const startTyping = () => {
if (isRunning.value) return
if (props.texts.length === 0) return
isRunning.value = true
currentIndex.value = 0
phase.value = 'typing'
currentText.value = ''
type()
}
//
onMounted(() => {
startCursorAnimation()
startTyping()
})
onUnmounted(() => {
clearInterval(cursorInterval)
clearAllTimers()
})
//
let typingTimeout
const startTyping = () => {
const fullText = props.texts[currentIndex.value]
if (isTyping.value) {
//
currentText.value = fullText.substring(0, currentText.value.length + 1)
if (currentText.value === fullText) {
isTyping.value = false
typingTimeout = setTimeout(() => {
isDeleting.value = true
startTyping()
}, props.delayBetweenTexts)
} else {
typingTimeout = setTimeout(startTyping, props.typingSpeed)
}
} else if (isDeleting.value) {
//
currentText.value = fullText.substring(0, currentText.value.length - 1)
if (currentText.value === '') {
isDeleting.value = false
if (props.loop || currentIndex.value < props.texts.length - 1) {
currentIndex.value = (currentIndex.value + 1) % props.texts.length
isTyping.value = true
}
typingTimeout = setTimeout(startTyping, props.delayBetweenTexts / 2)
} else {
typingTimeout = setTimeout(startTyping, props.deletingSpeed)
}
// props
watch(() => props.texts, (newVal, oldVal) => {
if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) {
resetAnimation()
startTyping()
}
}
//
watch(() => props.texts, () => {
clearTimeout(typingTimeout)
currentText.value = ''
currentIndex.value = 0
isTyping.value = true
isDeleting.value = false
startTyping()
}, { deep: true })
//
if (import.meta.env.DEV && props.texts.some(text => !text || typeof text !== 'string')) {
console.warn('Typewriter: 检测到无效文本内容,请确保所有文本都是非空字符串')
}
</script>
<template>
<div class="typewriter-container">
<div class="typewriter-container" aria-live="polite">
<span class="typewriter-text">{{ currentText }}</span>
<span
class="typewriter-cursor"
v-show="isRunning"
class="typewriter-cursor"
:class="{ 'cursor-visible': cursorVisible }"
aria-hidden="true"
>|</span>
@ -115,34 +179,36 @@ watch(() => props.texts, () => {
font-family: 'Fira Code', 'Courier New', monospace;
font-weight: 500;
line-height: 1.5;
min-height: 1.5em; /* 防止内容变化时布局抖动 */
}
.typewriter-text {
color: var(--text-primary);
white-space: pre-wrap;
display: inline-block;
vertical-align: top;
}
.typewriter-cursor {
position: relative;
display: inline-block;
width: 0.1em;
color: var(--primary-color);
height: 1em;
margin-left: 2px;
background-color: var(--primary-color);
opacity: 0;
animation: cursorPulse 1s infinite;
transition: opacity 0.2s ease;
vertical-align: top;
}
.cursor-visible {
opacity: 1;
}
/* 光标动画 */
@keyframes cursorPulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
/* 性能优化 */
.typewriter-container {
will-change: contents;
contain: content;
}
/* 响应式调整 */
@ -153,11 +219,18 @@ watch(() => props.texts, () => {
}
/* 暗黑模式适配 */
[data-theme="dark"] .typewriter-text {
color: var(--text-primary-dark);
@media (prefers-color-scheme: dark) {
.typewriter-text {
color: var(--text-primary-dark);
}
.typewriter-cursor {
background-color: var(--primary-color-dark);
}
}
[data-theme="dark"] .typewriter-cursor {
color: var(--primary-color-dark);
/* 减少重排优化 */
.typewriter-text {
backface-visibility: hidden;
}
</style>
</style>