feat: enhance Typewriter component animation and performance
This commit is contained in:
parent
65f44b146b
commit
f1e3d4b698
@ -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,78 +35,136 @@ 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
|
||||
// 监听props变化
|
||||
watch(() => props.texts, (newVal, oldVal) => {
|
||||
if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) {
|
||||
resetAnimation()
|
||||
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 })
|
||||
|
||||
// 开发环境错误提示
|
||||
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
|
||||
v-show="isRunning"
|
||||
class="typewriter-cursor"
|
||||
:class="{ 'cursor-visible': cursorVisible }"
|
||||
aria-hidden="true"
|
||||
@ -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 {
|
||||
@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>
|
Loading…
x
Reference in New Issue
Block a user