1. 海报
This commit is contained in:
159
vite-tailwindcss/src/components/vue-bits/BlurText.vue
Normal file
159
vite-tailwindcss/src/components/vue-bits/BlurText.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<p ref="rootRef" :class="className" style="display: flex; flex-wrap: wrap; margin: 0">
|
||||
<Motion
|
||||
v-for="(segment, index) in elements"
|
||||
:key="`${index}-${segment}`"
|
||||
tag="span"
|
||||
:initial="fromSnapshot"
|
||||
:animate="started ? buildKeyframes(fromSnapshot, toSnapshots) : fromSnapshot"
|
||||
:transition="getTransition(index)"
|
||||
:on-animation-complete="() => handleAnimationComplete(index)"
|
||||
style="display: inline-block; will-change: transform, filter, opacity"
|
||||
>
|
||||
{{ segment === ' ' ? '\u00A0' : segment }}
|
||||
<span v-if="animateBy === 'words' && index < elements.length - 1"> </span>
|
||||
</Motion>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Motion } from 'motion-v'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
text: { type: String, default: '' },
|
||||
delay: { type: Number, default: 80 },
|
||||
className: { type: String, default: '' },
|
||||
animateBy: { type: String, default: 'letters' }, // words | letters
|
||||
direction: { type: String, default: 'top' }, // top | bottom
|
||||
threshold: { type: Number, default: 0.1 },
|
||||
rootMargin: { type: String, default: '0px' },
|
||||
animationFrom: { type: Object, default: null },
|
||||
animationTo: { type: Array, default: null },
|
||||
stepDuration: { type: Number, default: 0.35 },
|
||||
/** 为 true 时立即开启动画;false 时等 IntersectionObserver;null 等同 false */
|
||||
active: { type: Boolean, default: undefined },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['animationComplete'])
|
||||
|
||||
const rootRef = ref(null)
|
||||
const inView = ref(false)
|
||||
let observer = null
|
||||
let completed = false
|
||||
let fallbackTimer = null
|
||||
|
||||
const started = computed(() => {
|
||||
if (props.active === true) return true
|
||||
if (props.active === false) return false
|
||||
return inView.value
|
||||
})
|
||||
|
||||
function clearFallback() {
|
||||
if (fallbackTimer) {
|
||||
clearTimeout(fallbackTimer)
|
||||
fallbackTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function markComplete() {
|
||||
if (completed) return
|
||||
completed = true
|
||||
clearFallback()
|
||||
emit('animationComplete')
|
||||
}
|
||||
|
||||
function armFallback() {
|
||||
clearFallback()
|
||||
completed = false
|
||||
const n = Math.max(elements.value.length, 1)
|
||||
const ms = props.delay * n + props.stepDuration * 1000 * 2 + 240
|
||||
fallbackTimer = setTimeout(markComplete, ms)
|
||||
}
|
||||
|
||||
function buildKeyframes(from, steps) {
|
||||
const keys = new Set([...Object.keys(from), ...steps.flatMap((s) => Object.keys(s))])
|
||||
const keyframes = {}
|
||||
keys.forEach((k) => {
|
||||
keyframes[k] = [from[k], ...steps.map((s) => s[k])]
|
||||
})
|
||||
return keyframes
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.active === true || !rootRef.value) return
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
inView.value = true
|
||||
observer?.unobserve(rootRef.value)
|
||||
}
|
||||
},
|
||||
{ threshold: props.threshold, rootMargin: props.rootMargin },
|
||||
)
|
||||
observer.observe(rootRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
clearFallback()
|
||||
})
|
||||
|
||||
const elements = computed(() =>
|
||||
props.animateBy === 'words' ? props.text.split(' ') : props.text.split(''),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.text, props.animateBy, props.direction, props.delay, props.active],
|
||||
() => {
|
||||
if (props.active === true) {
|
||||
armFallback()
|
||||
return
|
||||
}
|
||||
inView.value = false
|
||||
if (rootRef.value && observer) observer.observe(rootRef.value)
|
||||
},
|
||||
)
|
||||
|
||||
watch(started, (v) => {
|
||||
if (v) armFallback()
|
||||
else clearFallback()
|
||||
}, { immediate: true })
|
||||
|
||||
const defaultFrom = computed(() =>
|
||||
props.direction === 'top'
|
||||
? { filter: 'blur(10px)', opacity: 0, y: -40 }
|
||||
: { filter: 'blur(10px)', opacity: 0, y: 40 },
|
||||
)
|
||||
|
||||
const defaultTo = computed(() => [
|
||||
{
|
||||
filter: 'blur(5px)',
|
||||
opacity: 0.5,
|
||||
y: props.direction === 'top' ? 4 : -4,
|
||||
},
|
||||
{ filter: 'blur(0px)', opacity: 1, y: 0 },
|
||||
])
|
||||
|
||||
const fromSnapshot = computed(() => props.animationFrom || defaultFrom.value)
|
||||
const toSnapshots = computed(() => props.animationTo || defaultTo.value)
|
||||
const stepCount = computed(() => toSnapshots.value.length + 1)
|
||||
const totalDuration = computed(() => props.stepDuration * (stepCount.value - 1))
|
||||
const times = computed(() =>
|
||||
Array.from({ length: stepCount.value }, (_, i) =>
|
||||
stepCount.value === 1 ? 0 : i / (stepCount.value - 1),
|
||||
),
|
||||
)
|
||||
|
||||
function getTransition(index) {
|
||||
return {
|
||||
duration: totalDuration.value,
|
||||
times: times.value,
|
||||
delay: (index * props.delay) / 1000,
|
||||
}
|
||||
}
|
||||
|
||||
function handleAnimationComplete(index) {
|
||||
if (index === elements.value.length - 1) markComplete()
|
||||
}
|
||||
</script>
|
||||
186
vite-tailwindcss/src/components/vue-bits/TextType.vue
Normal file
186
vite-tailwindcss/src/components/vue-bits/TextType.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<component
|
||||
:is="as"
|
||||
ref="containerRef"
|
||||
:class="['inline-block whitespace-pre-wrap tracking-tight', className]"
|
||||
>
|
||||
<span :style="{ color: currentColor }">{{ displayedText }}</span>
|
||||
<span
|
||||
v-if="showCursor"
|
||||
ref="cursorRef"
|
||||
:class="[
|
||||
'ml-0.5 inline-block',
|
||||
cursorClassName,
|
||||
hideCursorWhileTyping && isTyping ? 'opacity-0' : '',
|
||||
]"
|
||||
>{{ cursorCharacter }}</span>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
|
||||
const props = defineProps({
|
||||
text: { type: [String, Array], required: true },
|
||||
as: { type: String, default: 'div' },
|
||||
typingSpeed: { type: Number, default: 55 },
|
||||
initialDelay: { type: Number, default: 0 },
|
||||
pauseDuration: { type: Number, default: 2000 },
|
||||
deletingSpeed: { type: Number, default: 30 },
|
||||
loop: { type: Boolean, default: false },
|
||||
className: { type: String, default: '' },
|
||||
showCursor: { type: Boolean, default: true },
|
||||
hideCursorWhileTyping: { type: Boolean, default: false },
|
||||
cursorCharacter: { type: String, default: '|' },
|
||||
cursorBlinkDuration: { type: Number, default: 0.5 },
|
||||
cursorClassName: { type: String, default: '' },
|
||||
textColors: { type: Array, default: () => [] },
|
||||
startOnVisible: { type: Boolean, default: false },
|
||||
reverseMode: { type: Boolean, default: false },
|
||||
/** 外部控制:为 true 时开始打字 */
|
||||
active: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['sentenceComplete', 'animationComplete'])
|
||||
|
||||
const displayedText = ref('')
|
||||
const currentCharIndex = ref(0)
|
||||
const isDeleting = ref(false)
|
||||
const currentTextIndex = ref(0)
|
||||
const isVisible = ref(!props.startOnVisible)
|
||||
const cursorRef = ref(null)
|
||||
const containerRef = ref(null)
|
||||
let timeout = null
|
||||
let blinkTween = null
|
||||
let observer = null
|
||||
let finished = false
|
||||
|
||||
const textArray = computed(() => (Array.isArray(props.text) ? props.text : [props.text]))
|
||||
const isTyping = computed(
|
||||
() => currentCharIndex.value < (textArray.value[currentTextIndex.value]?.length || 0) || isDeleting.value,
|
||||
)
|
||||
const currentColor = computed(() => {
|
||||
if (!props.textColors.length) return undefined
|
||||
return props.textColors[currentTextIndex.value % props.textColors.length]
|
||||
})
|
||||
|
||||
function clearTimeoutIfNeeded() {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
clearTimeoutIfNeeded()
|
||||
displayedText.value = ''
|
||||
currentCharIndex.value = 0
|
||||
isDeleting.value = false
|
||||
currentTextIndex.value = 0
|
||||
finished = false
|
||||
}
|
||||
|
||||
function executeTypingAnimation() {
|
||||
if (finished || !props.active || !isVisible.value) return
|
||||
const currentText = textArray.value[currentTextIndex.value] || ''
|
||||
const processedText = props.reverseMode ? currentText.split('').reverse().join('') : currentText
|
||||
|
||||
if (isDeleting.value) {
|
||||
if (displayedText.value === '') {
|
||||
isDeleting.value = false
|
||||
if (currentTextIndex.value === textArray.value.length - 1 && !props.loop) {
|
||||
finished = true
|
||||
emit('animationComplete')
|
||||
return
|
||||
}
|
||||
emit('sentenceComplete', textArray.value[currentTextIndex.value], currentTextIndex.value)
|
||||
currentTextIndex.value = (currentTextIndex.value + 1) % textArray.value.length
|
||||
currentCharIndex.value = 0
|
||||
timeout = setTimeout(() => executeTypingAnimation(), props.pauseDuration)
|
||||
} else {
|
||||
timeout = setTimeout(() => {
|
||||
displayedText.value = displayedText.value.slice(0, -1)
|
||||
executeTypingAnimation()
|
||||
}, props.deletingSpeed)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (currentCharIndex.value < processedText.length) {
|
||||
timeout = setTimeout(() => {
|
||||
displayedText.value += processedText[currentCharIndex.value]
|
||||
currentCharIndex.value += 1
|
||||
executeTypingAnimation()
|
||||
}, props.typingSpeed)
|
||||
return
|
||||
}
|
||||
|
||||
// 当前句打完
|
||||
emit('sentenceComplete', textArray.value[currentTextIndex.value], currentTextIndex.value)
|
||||
if (textArray.value.length > 1 && props.loop) {
|
||||
timeout = setTimeout(() => {
|
||||
isDeleting.value = true
|
||||
executeTypingAnimation()
|
||||
}, props.pauseDuration)
|
||||
return
|
||||
}
|
||||
if (currentTextIndex.value < textArray.value.length - 1) {
|
||||
timeout = setTimeout(() => {
|
||||
currentTextIndex.value += 1
|
||||
currentCharIndex.value = 0
|
||||
displayedText.value = ''
|
||||
executeTypingAnimation()
|
||||
}, props.pauseDuration)
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
emit('animationComplete')
|
||||
}
|
||||
|
||||
function startTyping() {
|
||||
if (!props.active || !isVisible.value || finished) return
|
||||
clearTimeoutIfNeeded()
|
||||
timeout = setTimeout(() => executeTypingAnimation(), props.initialDelay)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.active, isVisible.value, props.text],
|
||||
() => {
|
||||
resetState()
|
||||
if (props.active && isVisible.value) startTyping()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.showCursor && cursorRef.value) {
|
||||
gsap.set(cursorRef.value, { opacity: 1 })
|
||||
blinkTween = gsap.to(cursorRef.value, {
|
||||
opacity: 0,
|
||||
duration: props.cursorBlinkDuration,
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
ease: 'power2.inOut',
|
||||
})
|
||||
}
|
||||
if (props.startOnVisible && containerRef.value) {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) isVisible.value = true
|
||||
})
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
const el = containerRef.value?.$el || containerRef.value
|
||||
if (el instanceof Element) observer.observe(el)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeoutIfNeeded()
|
||||
blinkTween?.kill()
|
||||
observer?.disconnect()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user