mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 14:20:51 +08:00
improvement(wrapped-ui): 引入 Bits 动效组件并新增 GSAP 依赖
- 新增卡片交换、网格流动、文本拆分三类动效组件,统一沉淀到 wrapped 共享层 - 前端新增 gsap 依赖并同步 lock 文件,确保动画能力可复现 - 为年度总结封面动效与后续复用打基础
This commit is contained in:
291
frontend/components/wrapped/shared/BitsCardSwap.vue
Normal file
291
frontend/components/wrapped/shared/BitsCardSwap.vue
Normal file
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="bits-card-swap absolute bottom-0 right-0 translate-x-[24%] translate-y-[-2%] origin-bottom-right overflow-visible [perspective:900px]"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(_, index) in visibleCardCount"
|
||||
:key="index"
|
||||
ref="cardRefs"
|
||||
class="bits-card-swap-item absolute top-1/2 left-1/2 rounded-xl [transform-style:preserve-3d] [will-change:transform] [backface-visibility:hidden]"
|
||||
:style="cardStyle"
|
||||
@click="onCardClick(index)"
|
||||
>
|
||||
<slot :name="`card-${index}`" :index="index" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import gsap from 'gsap'
|
||||
|
||||
const props = defineProps({
|
||||
cardCount: { type: Number, default: 3 },
|
||||
width: { type: [Number, String], default: 250 },
|
||||
height: { type: [Number, String], default: 300 },
|
||||
cardDistance: { type: Number, default: 24 },
|
||||
verticalDistance: { type: Number, default: 34 },
|
||||
delay: { type: Number, default: 4200 },
|
||||
pauseOnHover: { type: Boolean, default: false },
|
||||
onCardClick: { type: Function, default: null },
|
||||
skewAmount: { type: Number, default: 4 },
|
||||
easing: { type: String, default: 'elastic' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['cardClick'])
|
||||
|
||||
const containerRef = ref(null)
|
||||
const cardRefs = ref([])
|
||||
const order = ref([0, 1, 2])
|
||||
const timelineRef = ref(null)
|
||||
let intervalRef = null
|
||||
|
||||
const toPx = (value) => (typeof value === 'number' ? `${value}px` : value)
|
||||
|
||||
const containerStyle = computed(() => ({
|
||||
width: toPx(props.width),
|
||||
height: toPx(props.height)
|
||||
}))
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
width: toPx(props.width),
|
||||
height: toPx(props.height)
|
||||
}))
|
||||
|
||||
const visibleCardCount = computed(() => {
|
||||
const count = Number(props.cardCount)
|
||||
if (!Number.isFinite(count)) return 1
|
||||
const normalized = Math.floor(count)
|
||||
return Math.max(1, normalized)
|
||||
})
|
||||
|
||||
const config = computed(() => {
|
||||
if (props.easing === 'elastic') {
|
||||
return {
|
||||
ease: 'elastic.out(0.6,0.9)',
|
||||
dropDuration: 1.8,
|
||||
moveDuration: 1.8,
|
||||
returnDuration: 1.8,
|
||||
overlap: 0.85,
|
||||
returnDelay: 0.08
|
||||
}
|
||||
}
|
||||
return {
|
||||
ease: 'power1.inOut',
|
||||
dropDuration: 0.8,
|
||||
moveDuration: 0.8,
|
||||
returnDuration: 0.8,
|
||||
overlap: 0.45,
|
||||
returnDelay: 0.2
|
||||
}
|
||||
})
|
||||
|
||||
const makeSlot = (index, total) => ({
|
||||
x: index * props.cardDistance,
|
||||
y: -index * props.verticalDistance,
|
||||
z: -index * props.cardDistance * 1.5,
|
||||
zIndex: total - index
|
||||
})
|
||||
|
||||
const placeNow = (element, slot) => {
|
||||
gsap.set(element, {
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
xPercent: -50,
|
||||
yPercent: -50,
|
||||
skewY: props.skewAmount,
|
||||
transformOrigin: 'center center',
|
||||
zIndex: slot.zIndex,
|
||||
force3D: true
|
||||
})
|
||||
}
|
||||
|
||||
const initializeCards = () => {
|
||||
const list = cardRefs.value || []
|
||||
if (!list.length) return
|
||||
const total = visibleCardCount.value
|
||||
list.forEach((element, index) => {
|
||||
if (!element) return
|
||||
placeNow(element, makeSlot(index, total))
|
||||
})
|
||||
}
|
||||
|
||||
const updateCardPositions = () => {
|
||||
const list = cardRefs.value || []
|
||||
if (!list.length) return
|
||||
const total = visibleCardCount.value
|
||||
list.forEach((element, index) => {
|
||||
if (!element) return
|
||||
const slot = makeSlot(index, total)
|
||||
gsap.set(element, {
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
skewY: props.skewAmount
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const runSwap = () => {
|
||||
const total = visibleCardCount.value
|
||||
if (order.value.length !== total) {
|
||||
order.value = Array.from({ length: total }, (_, idx) => idx)
|
||||
}
|
||||
const activeOrder = order.value.slice(0, total)
|
||||
if (activeOrder.length < 2) return
|
||||
const [front, ...rest] = activeOrder
|
||||
const frontElement = cardRefs.value[front]
|
||||
if (!frontElement) return
|
||||
|
||||
const tl = gsap.timeline()
|
||||
timelineRef.value = tl
|
||||
|
||||
tl.to(frontElement, {
|
||||
y: '+=480',
|
||||
duration: config.value.dropDuration,
|
||||
ease: config.value.ease
|
||||
})
|
||||
|
||||
tl.addLabel('promote', `-=${config.value.dropDuration * config.value.overlap}`)
|
||||
|
||||
rest.forEach((index, slotIndex) => {
|
||||
const element = cardRefs.value[index]
|
||||
if (!element) return
|
||||
const slot = makeSlot(slotIndex, activeOrder.length)
|
||||
tl.set(element, { zIndex: slot.zIndex }, 'promote')
|
||||
tl.to(
|
||||
element,
|
||||
{
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
duration: config.value.moveDuration,
|
||||
ease: config.value.ease
|
||||
},
|
||||
`promote+=${slotIndex * 0.15}`
|
||||
)
|
||||
})
|
||||
|
||||
const backSlot = makeSlot(activeOrder.length - 1, activeOrder.length)
|
||||
|
||||
tl.addLabel('return', `promote+=${config.value.moveDuration * config.value.returnDelay}`)
|
||||
tl.call(() => {
|
||||
gsap.set(frontElement, { zIndex: backSlot.zIndex })
|
||||
}, undefined, 'return')
|
||||
tl.set(frontElement, { x: backSlot.x, z: backSlot.z }, 'return')
|
||||
tl.to(
|
||||
frontElement,
|
||||
{
|
||||
y: backSlot.y,
|
||||
duration: config.value.returnDuration,
|
||||
ease: config.value.ease
|
||||
},
|
||||
'return'
|
||||
)
|
||||
|
||||
tl.call(() => {
|
||||
order.value = [...rest, front]
|
||||
})
|
||||
}
|
||||
|
||||
const stopAnimation = () => {
|
||||
if (timelineRef.value) {
|
||||
timelineRef.value.kill()
|
||||
timelineRef.value = null
|
||||
}
|
||||
if (intervalRef) {
|
||||
clearInterval(intervalRef)
|
||||
intervalRef = null
|
||||
}
|
||||
}
|
||||
|
||||
const startAnimation = () => {
|
||||
stopAnimation()
|
||||
if (visibleCardCount.value < 2) {
|
||||
initializeCards()
|
||||
return
|
||||
}
|
||||
runSwap()
|
||||
intervalRef = window.setInterval(runSwap, props.delay)
|
||||
}
|
||||
|
||||
const resumeAnimation = () => {
|
||||
timelineRef.value?.play()
|
||||
if (!intervalRef) intervalRef = window.setInterval(runSwap, props.delay)
|
||||
}
|
||||
|
||||
const onMouseEnter = () => {
|
||||
stopAnimation()
|
||||
}
|
||||
|
||||
const onMouseLeave = () => {
|
||||
resumeAnimation()
|
||||
}
|
||||
|
||||
const setupHoverListeners = () => {
|
||||
if (!props.pauseOnHover || !containerRef.value) return
|
||||
containerRef.value.addEventListener('mouseenter', onMouseEnter)
|
||||
containerRef.value.addEventListener('mouseleave', onMouseLeave)
|
||||
}
|
||||
|
||||
const removeHoverListeners = () => {
|
||||
if (!containerRef.value) return
|
||||
containerRef.value.removeEventListener('mouseenter', onMouseEnter)
|
||||
containerRef.value.removeEventListener('mouseleave', onMouseLeave)
|
||||
}
|
||||
|
||||
const onCardClick = (index) => {
|
||||
emit('cardClick', index)
|
||||
if (typeof props.onCardClick === 'function') props.onCardClick(index)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.cardDistance, props.verticalDistance, props.skewAmount],
|
||||
() => {
|
||||
updateCardPositions()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.delay,
|
||||
() => {
|
||||
if (intervalRef) {
|
||||
clearInterval(intervalRef)
|
||||
intervalRef = window.setInterval(runSwap, props.delay)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.pauseOnHover,
|
||||
() => {
|
||||
removeHoverListeners()
|
||||
setupHoverListeners()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
initializeCards()
|
||||
startAnimation()
|
||||
setupHoverListeners()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => visibleCardCount.value,
|
||||
async () => {
|
||||
order.value = Array.from({ length: visibleCardCount.value }, (_, idx) => idx)
|
||||
await nextTick()
|
||||
initializeCards()
|
||||
startAnimation()
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAnimation()
|
||||
removeHoverListeners()
|
||||
})
|
||||
</script>
|
||||
184
frontend/components/wrapped/shared/BitsGridMotion.vue
Normal file
184
frontend/components/wrapped/shared/BitsGridMotion.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="bits-grid-motion w-full h-full overflow-hidden">
|
||||
<section class="relative flex h-full w-full items-center justify-center overflow-hidden" :style="sectionStyle">
|
||||
<div class="bits-grid-motion-grid">
|
||||
<div
|
||||
v-for="rowIndex in safeRowCount"
|
||||
:key="`row-${rowIndex}`"
|
||||
class="bits-grid-motion-row"
|
||||
:style="rowInlineStyle"
|
||||
>
|
||||
<div
|
||||
v-for="columnIndex in renderColumnCount"
|
||||
:key="`cell-${rowIndex}-${columnIndex}`"
|
||||
class="bits-grid-motion-cell"
|
||||
v-show="loopedItems[resolveIndex(rowIndex, columnIndex)]"
|
||||
>
|
||||
<slot
|
||||
name="item"
|
||||
:item="loopedItems[resolveIndex(rowIndex, columnIndex)]"
|
||||
:index="resolveIndex(rowIndex, columnIndex)"
|
||||
>
|
||||
<div class="bits-grid-motion-fallback">
|
||||
{{ String(loopedItems[resolveIndex(rowIndex, columnIndex)] ?? '') }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bits-grid-motion-mask" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import gsap from 'gsap'
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
gradientColor: { type: String, default: 'rgba(7, 193, 96, 0.2)' },
|
||||
rowCount: { type: Number, default: 8 },
|
||||
columnCount: { type: Number, default: 10 },
|
||||
scrollSpeed: { type: Number, default: 38 },
|
||||
baseOffsetX: { type: Number, default: 0 },
|
||||
itemWidth: { type: Number, default: 300 },
|
||||
rowGap: { type: Number, default: 12 }
|
||||
})
|
||||
|
||||
let removeTicker = null
|
||||
let lastTickAt = 0
|
||||
let loopDistance = 0
|
||||
const marqueeX = ref(0)
|
||||
|
||||
const safeRowCount = computed(() => Math.max(1, Number(props.rowCount) || 1))
|
||||
const safeColumnCount = computed(() => Math.max(1, Number(props.columnCount) || 1))
|
||||
const safeItemWidth = computed(() => Math.max(1, Number(props.itemWidth) || 1))
|
||||
const safeRowGap = computed(() => Math.max(0, Number(props.rowGap) || 0))
|
||||
const safeScrollSpeed = computed(() => Math.max(0, Number(props.scrollSpeed) || 0))
|
||||
|
||||
const renderColumnCount = computed(() => safeColumnCount.value * 2)
|
||||
const totalSlots = computed(() => safeRowCount.value * renderColumnCount.value)
|
||||
|
||||
const repeatedItems = computed(() => {
|
||||
const source = Array.isArray(props.items) ? props.items.filter(Boolean) : []
|
||||
if (!source.length) return []
|
||||
const output = []
|
||||
for (let idx = 0; idx < safeRowCount.value * safeColumnCount.value; idx += 1) {
|
||||
output.push(source[idx % source.length])
|
||||
}
|
||||
return output
|
||||
})
|
||||
|
||||
const loopedItems = computed(() => {
|
||||
const base = repeatedItems.value
|
||||
if (!base.length) return []
|
||||
const output = []
|
||||
for (let idx = 0; idx < totalSlots.value; idx += 1) {
|
||||
output.push(base[idx % base.length])
|
||||
}
|
||||
return output
|
||||
})
|
||||
|
||||
const rowInlineStyle = computed(() => ({
|
||||
willChange: 'transform',
|
||||
transform: `translate3d(${props.baseOffsetX + marqueeX.value}px, 0, 0)`
|
||||
}))
|
||||
|
||||
const sectionStyle = computed(() => ({
|
||||
background: `radial-gradient(circle at center, ${props.gradientColor} 0%, transparent 72%)`
|
||||
}))
|
||||
|
||||
const resolveIndex = (rowIndex, columnIndex) => (
|
||||
(Number(rowIndex) - 1) * renderColumnCount.value + (Number(columnIndex) - 1)
|
||||
)
|
||||
|
||||
const updateMotion = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||
const dt = lastTickAt > 0 ? Math.min((now - lastTickAt) / 1000, 0.08) : 0
|
||||
lastTickAt = now
|
||||
|
||||
if (loopDistance <= 0 || safeScrollSpeed.value <= 0 || dt <= 0) return
|
||||
|
||||
marqueeX.value -= safeScrollSpeed.value * dt
|
||||
if (marqueeX.value <= -loopDistance) {
|
||||
marqueeX.value += loopDistance
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
loopDistance = safeColumnCount.value * (safeItemWidth.value + safeRowGap.value)
|
||||
marqueeX.value = 0
|
||||
lastTickAt = 0
|
||||
|
||||
// Kick one frame immediately to avoid initial static delay.
|
||||
marqueeX.value = -Math.min(loopDistance * 0.02, 8)
|
||||
|
||||
gsap.ticker.lagSmoothing(1000, 33)
|
||||
removeTicker = gsap.ticker.add(updateMotion)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
loopDistance = 0
|
||||
lastTickAt = 0
|
||||
if (typeof removeTicker === 'function') removeTicker()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bits-grid-motion-grid {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
width: 180%;
|
||||
height: 165%;
|
||||
transform: rotate(-15deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.bits-grid-motion-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bits-grid-motion-cell {
|
||||
position: relative;
|
||||
height: 210px;
|
||||
min-width: 300px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bits-grid-motion-fallback {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(7, 193, 96, 0.2);
|
||||
background: #ffffff;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.bits-grid-motion-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(243, 255, 248, 0.78) 0%, rgba(243, 255, 248, 0.12) 20%, rgba(243, 255, 248, 0) 38%),
|
||||
linear-gradient(90deg, rgba(243, 255, 248, 0.86) 0%, rgba(243, 255, 248, 0.12) 24%, rgba(243, 255, 248, 0) 44%),
|
||||
linear-gradient(270deg, rgba(243, 255, 248, 0.9) 0%, rgba(243, 255, 248, 0.14) 30%, rgba(243, 255, 248, 0) 48%),
|
||||
linear-gradient(0deg, rgba(243, 255, 248, 0.88) 0%, rgba(243, 255, 248, 0.16) 36%, rgba(243, 255, 248, 0) 58%);
|
||||
z-index: 3;
|
||||
}
|
||||
</style>
|
||||
160
frontend/components/wrapped/shared/BitsSplitText.vue
Normal file
160
frontend/components/wrapped/shared/BitsSplitText.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<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>
|
||||
296
frontend/package-lock.json
generated
296
frontend/package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"axios": "^1.11.0",
|
||||
"gsap": "^3.14.2",
|
||||
"nuxt": "^4.0.1",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1"
|
||||
@@ -7363,6 +7364,12 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmmirror.com/gsap/-/gsap-3.14.2.tgz",
|
||||
"integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/gzip-size": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/gzip-size/-/gzip-size-7.0.0.tgz",
|
||||
@@ -8394,6 +8401,279 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.30.2",
|
||||
"lightningcss-darwin-arm64": "1.30.2",
|
||||
"lightningcss-darwin-x64": "1.30.2",
|
||||
"lightningcss-freebsd-x64": "1.30.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||
"lightningcss-linux-x64-musl": "1.30.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss/node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -8449,14 +8729,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.1.tgz",
|
||||
"integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mlly": "^1.7.4",
|
||||
"pkg-types": "^2.0.1",
|
||||
"quansync": "^0.2.8"
|
||||
"pkg-types": "^2.3.0",
|
||||
"quansync": "^0.2.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -10740,9 +11020,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.10.tgz",
|
||||
"integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==",
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"axios": "^1.11.0",
|
||||
"gsap": "^3.14.2",
|
||||
"nuxt": "^4.0.1",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1"
|
||||
|
||||
Reference in New Issue
Block a user