Files
arcticnewone/app/components/home/HomeHero.vue
T
virtheart 044042fe7e feat: 重构应用加载流程和动画管理
- 新增 AppLoader 组件作为全局加载器
- 引入 Pinia 状态管理应用加载状态
- 创建 usePageScrollLock 组合式函数管理滚动锁定
- 重构首页动画流程,使用 Promise 链式调用
- 移除旧的 HomeLoader 组件
- 优化头部导航栏的入场动画触发逻辑
2026-04-27 02:19:29 +08:00

224 lines
5.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { useNuxtApp } from '#app'
import gsap from 'gsap'
const heroPinDistance = 1200
const heroGalleryColumns = [
{
side: 'left',
images: [
'https://picsum.photos/id/1018/900/1200',
'https://picsum.photos/id/1015/900/1200',
'https://picsum.photos/id/1024/900/1200',
'https://picsum.photos/id/1039/900/1200',
],
},
{
side: 'right',
images: [
'https://picsum.photos/id/1043/900/1200',
'https://picsum.photos/id/1050/900/1200',
'https://picsum.photos/id/1067/900/1200',
'https://picsum.photos/id/1074/900/1200',
],
},
] as const
const heroStageRef = ref<HTMLElement | null>(null)
const heroRef = ref<HTMLElement | null>(null)
const heroPanelRef = ref<HTMLElement | null>(null)
let cleanupHeroScroll: (() => void) | null = null
const play = () => new Promise<void>((resolve) => {
if (!heroRef.value) return resolve()
let eventDispatched = false
gsap.to(heroRef.value, {
yPercent: 0,
duration: 1.2,
ease: 'power3.out',
onUpdate() {
if (!eventDispatched && this.progress() > 0.6) {
eventDispatched = true
window.dispatchEvent(new CustomEvent('hero-intro-done'))
}
},
onComplete: resolve,
})
})
onMounted(() => {
if (heroRef.value) {
gsap.set(heroRef.value, { yPercent: 100 })
}
if (!heroStageRef.value || !heroPanelRef.value) return
const { $lenis } = useNuxtApp() as { $lenis?: { scroll: number } }
const pinEndScale = 0.65
const finalScale = 0.18
const stageTop = heroStageRef.value.offsetTop
gsap.set(heroPanelRef.value, {
scale: 1,
force3D: true,
transformOrigin: 'center center',
})
let renderedScale = -1
const updateHeroPanelScale = () => {
const exitDistance = window.innerHeight
const totalDistance = heroPinDistance + exitDistance
const currentScroll = $lenis ? $lenis.scroll : window.scrollY
const currentDistance = gsap.utils.clamp(0, totalDistance, currentScroll - stageTop)
const newScale = currentDistance <= heroPinDistance
? gsap.utils.interpolate(1, pinEndScale, currentDistance / heroPinDistance)
: gsap.utils.interpolate(pinEndScale, finalScale, (currentDistance - heroPinDistance) / exitDistance)
if (Math.abs(renderedScale - newScale) > 0.001) {
renderedScale = newScale
gsap.set(heroPanelRef.value, { scale: newScale })
}
}
updateHeroPanelScale()
gsap.ticker.add(updateHeroPanelScale)
cleanupHeroScroll = () => gsap.ticker.remove(updateHeroPanelScale)
})
onBeforeUnmount(() => {
cleanupHeroScroll?.()
cleanupHeroScroll = null
})
defineExpose({
heroStageRef,
heroRef,
play,
})
</script>
<template>
<section
ref="heroStageRef"
class="hero-stage"
:style="{ '--hero-pin-distance': `${heroPinDistance}px` }"
>
<section ref="heroRef" class="hero-section" v-once>
<div class="hero-backdrop" aria-hidden="true">
<div
v-for="column in heroGalleryColumns"
:key="column.side"
class="hero-gallery"
:class="`hero-gallery-${column.side}`"
>
<!-- 复制两份图片组配合 translateY(-50%) 做无缝循环 -->
<div
class="hero-gallery-track"
:class="{ 'hero-gallery-track-slow': column.side === 'right' }"
>
<div v-for="copy in 2" :key="`${column.side}-copy-${copy}`" class="hero-gallery-group">
<img
v-for="image in column.images"
:key="`${column.side}-${copy}-${image}`"
class="hero-gallery-image"
:src="image"
alt=""
decoding="async"
loading="eager"
/>
</div>
</div>
</div>
</div>
<div ref="heroPanelRef" class="hero-panel">
<p>Hero Section</p>
</div>
</section>
</section>
</template>
<style scoped>
.hero-stage {
position: relative;
height: calc(100svh + var(--hero-pin-distance));
background: #000;
}
.hero-section {
position: sticky;
top: 0;
height: 100svh;
overflow: hidden;
background: #111;
}
.hero-backdrop {
position: absolute;
inset: 0;
overflow: hidden;
background: #000;
}
.hero-gallery {
position: absolute;
top: -40px;
bottom: -40px;
width: 49vw;
overflow: hidden;
}
.hero-gallery-left {
left: 0;
}
.hero-gallery-right {
right: 0;
}
.hero-gallery-track {
will-change: transform;
animation: hero-gallery-scroll 28s linear infinite;
}
.hero-gallery-track-slow {
animation-duration: 42s;
}
.hero-gallery-group {
display: grid;
gap: 36px;
padding-bottom: 36px;
}
.hero-gallery-image {
display: block;
width: 100%;
height: clamp(260px, 42vh, 430px);
border-radius: 0;
object-fit: cover;
}
@keyframes hero-gallery-scroll {
to {
transform: translateY(-50%);
}
}
.hero-panel {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #fff;
font-size: 48px;
will-change: transform;
}
</style>