1. 海报

This commit is contained in:
李琦
2026-08-03 09:31:32 +08:00
parent 38558b0b43
commit 631c1afcc0
16 changed files with 1921 additions and 9 deletions

View 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">&nbsp;</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 时等 IntersectionObservernull 等同 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>

View 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>