feat: enhance Typewriter component animation and performance
This commit is contained in:
parent
65f44b146b
commit
f1e3d4b698
@ -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,78 +35,136 @@ 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()
|
||||||
|
|
||||||
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()
|
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
|
||||||
|
v-show="isRunning"
|
||||||
class="typewriter-cursor"
|
class="typewriter-cursor"
|
||||||
:class="{ 'cursor-visible': cursorVisible }"
|
:class="{ 'cursor-visible': cursorVisible }"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -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) {
|
||||||
|
.typewriter-text {
|
||||||
color: var(--text-primary-dark);
|
color: var(--text-primary-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .typewriter-cursor {
|
.typewriter-cursor {
|
||||||
color: var(--primary-color-dark);
|
background-color: var(--primary-color-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 减少重排优化 */
|
||||||
|
.typewriter-text {
|
||||||
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
Loading…
x
Reference in New Issue
Block a user