Files
WeChatDataAnalysis/frontend/components/wrapped/shared/BitsSplitText.vue
2977094657 676ad84db9 improvement(wrapped-ui): 引入 Bits 动效组件并新增 GSAP 依赖
- 新增卡片交换、网格流动、文本拆分三类动效组件,统一沉淀到 wrapped 共享层
- 前端新增 gsap 依赖并同步 lock 文件,确保动画能力可复现
- 为年度总结封面动效与后续复用打基础
2026-02-07 14:20:30 +08:00

161 lines
3.8 KiB
Vue

<template>
<p
ref="textRef"
class="bits-split-text inline-block overflow-hidden whitespace-normal"
:class="className"
:style="{ textAlign, wordWrap: 'break-word' }"
>
{{ text }}
</p>
</template>
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { SplitText as GSAPSplitText } from 'gsap/SplitText'
const props = defineProps({
text: { type: String, required: true },
className: { type: String, default: '' },
delay: { type: Number, default: 100 },
duration: { type: Number, default: 0.6 },
ease: { type: [String, Function], default: 'power3.out' },
splitType: { type: String, default: 'chars' },
from: { type: Object, default: () => ({ opacity: 0, y: 32 }) },
to: { type: Object, default: () => ({ opacity: 1, y: 0 }) },
threshold: { type: Number, default: 0.1 },
rootMargin: { type: String, default: '-100px' },
textAlign: { type: String, default: 'center' },
onLetterAnimationComplete: { type: Function, default: null }
})
const emit = defineEmits(['animationComplete'])
gsap.registerPlugin(ScrollTrigger, GSAPSplitText)
const textRef = ref(null)
let timeline = null
let scrollTrigger = null
let splitter = null
const cleanup = () => {
if (timeline) {
timeline.kill()
timeline = null
}
if (scrollTrigger) {
scrollTrigger.kill()
scrollTrigger = null
}
if (splitter) {
splitter.revert()
splitter = null
}
}
const createAnimation = async () => {
if (!import.meta.client || !textRef.value || !props.text) return
await nextTick()
const element = textRef.value
const absoluteLines = props.splitType === 'lines'
if (absoluteLines) element.style.position = 'relative'
try {
splitter = new GSAPSplitText(element, {
type: props.splitType,
absolute: absoluteLines,
linesClass: 'split-line'
})
} catch {
return
}
let targets = splitter.chars
if (props.splitType === 'words') targets = splitter.words
if (props.splitType === 'lines') targets = splitter.lines
if (!targets?.length) {
cleanup()
return
}
targets.forEach((target) => {
target.style.willChange = 'transform, opacity'
})
const startPercent = (1 - props.threshold) * 100
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin)
const marginValue = marginMatch ? Number.parseFloat(marginMatch[1]) : 0
const marginUnit = marginMatch ? marginMatch[2] || 'px' : 'px'
const sign = marginValue < 0
? `-=${Math.abs(marginValue)}${marginUnit}`
: `+=${marginValue}${marginUnit}`
timeline = gsap.timeline({
scrollTrigger: {
trigger: element,
start: `top ${startPercent}%${sign}`,
toggleActions: 'play none none none',
once: true,
onToggle: (self) => {
scrollTrigger = self
}
},
onComplete: () => {
gsap.set(targets, {
...props.to,
clearProps: 'willChange',
immediateRender: true
})
if (typeof props.onLetterAnimationComplete === 'function') {
props.onLetterAnimationComplete()
}
emit('animationComplete')
}
})
timeline.set(targets, {
...props.from,
immediateRender: false,
force3D: true
})
timeline.to(targets, {
...props.to,
duration: props.duration,
ease: props.ease,
stagger: props.delay / 1000,
force3D: true
})
}
watch(
() => [
props.text,
props.delay,
props.duration,
props.ease,
props.splitType,
props.from,
props.to,
props.threshold,
props.rootMargin,
props.textAlign,
props.onLetterAnimationComplete
],
async () => {
cleanup()
await createAnimation()
}
)
onMounted(async () => {
await createAnimation()
})
onBeforeUnmount(() => {
cleanup()
})
</script>