044042fe7e
- 新增 AppLoader 组件作为全局加载器 - 引入 Pinia 状态管理应用加载状态 - 创建 usePageScrollLock 组合式函数管理滚动锁定 - 重构首页动画流程,使用 Promise 链式调用 - 移除旧的 HomeLoader 组件 - 优化头部导航栏的入场动画触发逻辑
224 lines
5.0 KiB
Vue
224 lines
5.0 KiB
Vue
<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>
|