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