3173bd3cd6
- 在 lenis.client.ts 中提供 lenis 实例供全局使用 - 添加首页入场动画,包含字母动画和过渡效果 - 优化滚动行为,入场期间锁定滚动 - 修复自定义光标初始位置问题 - 调整页面布局样式,隐藏滚动条 - 更新头部组件类名以匹配新设计
345 lines
7.7 KiB
Vue
345 lines
7.7 KiB
Vue
<script setup lang="ts">
|
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import gsap from 'gsap'
|
|
|
|
/**
|
|
* 全站自定义鼠标。
|
|
*
|
|
* 使用方式:
|
|
* - 默认状态是一个圆点,加上横向和纵向的十字线。
|
|
* - 给任意元素添加 `data-cursor-label`,鼠标移入后圆点会变成胶囊提示。
|
|
*
|
|
* 示例:
|
|
* <section data-cursor-label="Read more">
|
|
* ...
|
|
* </section>
|
|
*
|
|
* 说明:
|
|
* - 这个组件只需要在默认 layout 中挂载一次。
|
|
* - 只在精确指针设备上运行,所以触摸屏会继续使用系统默认鼠标。
|
|
* - 鼠标元素会 Teleport 到 body,避免被页面局部层级或布局包住。
|
|
*/
|
|
|
|
const dotRef = ref<HTMLElement | null>(null)
|
|
const horizontalLineRef = ref<HTMLElement | null>(null)
|
|
const verticalLineRef = ref<HTMLElement | null>(null)
|
|
const pillRef = ref<HTMLElement | null>(null)
|
|
const pillTextRef = ref<HTMLElement | null>(null)
|
|
|
|
let cleanupCursor: (() => void) | null = null
|
|
let cursorX = 0
|
|
let cursorY = 0
|
|
let pillWidth = 100
|
|
let isPillVisible = false
|
|
let hasCursorPosition = false
|
|
|
|
// 查找最近的 `data-cursor-label` 元素,用来判断是否需要显示胶囊态。
|
|
const getCursorTarget = (target: EventTarget | null) => {
|
|
if (!(target instanceof Element)) {
|
|
return null
|
|
}
|
|
|
|
return target.closest<HTMLElement>('[data-cursor-label]')
|
|
}
|
|
|
|
onMounted(() => {
|
|
const finePointer = window.matchMedia('(pointer: fine)')
|
|
|
|
// 触摸设备保留系统默认鼠标,不启用自定义鼠标。
|
|
if (!finePointer.matches) {
|
|
return
|
|
}
|
|
|
|
const cursorTargets = [
|
|
dotRef.value,
|
|
horizontalLineRef.value,
|
|
verticalLineRef.value,
|
|
]
|
|
const allTargets = [
|
|
...cursorTargets,
|
|
pillRef.value,
|
|
pillTextRef.value,
|
|
]
|
|
|
|
document.documentElement.classList.add('has-custom-cursor')
|
|
|
|
// 初始隐藏,避免页面刚打开、鼠标还没移动时出现闪烁。
|
|
gsap.set(cursorTargets, {
|
|
autoAlpha: 0,
|
|
})
|
|
gsap.set(pillRef.value, {
|
|
autoAlpha: 0,
|
|
width: 0,
|
|
height: 0,
|
|
})
|
|
gsap.set(pillTextRef.value, {
|
|
autoAlpha: 0,
|
|
})
|
|
|
|
// quickTo 会复用 tween,让十字线跟随更轻量,不需要每一帧都创建新动画。
|
|
const moveLineX = gsap.quickTo(verticalLineRef.value, 'x', {
|
|
duration: 0.08,
|
|
ease: 'power3.out',
|
|
})
|
|
const moveLineY = gsap.quickTo(horizontalLineRef.value, 'y', {
|
|
duration: 0.08,
|
|
ease: 'power3.out',
|
|
})
|
|
|
|
const movePill = () => {
|
|
// 胶囊提示始终以真实鼠标位置为中心。
|
|
gsap.set(pillRef.value, {
|
|
x: cursorX - pillWidth / 2,
|
|
y: cursorY - 15,
|
|
})
|
|
}
|
|
|
|
const showCursor = () => {
|
|
if (!hasCursorPosition) {
|
|
return
|
|
}
|
|
|
|
gsap.to([horizontalLineRef.value, verticalLineRef.value], {
|
|
autoAlpha: 1,
|
|
duration: 0.15,
|
|
overwrite: 'auto',
|
|
})
|
|
|
|
if (!isPillVisible) {
|
|
gsap.to(dotRef.value, {
|
|
autoAlpha: 1,
|
|
scale: 1,
|
|
duration: 0.15,
|
|
overwrite: 'auto',
|
|
})
|
|
}
|
|
}
|
|
|
|
const hideCursor = () => {
|
|
isPillVisible = false
|
|
hasCursorPosition = false
|
|
|
|
gsap.to(allTargets, {
|
|
autoAlpha: 0,
|
|
duration: 0.15,
|
|
overwrite: 'auto',
|
|
})
|
|
}
|
|
|
|
const showPill = (label: string) => {
|
|
if (!pillRef.value || !pillTextRef.value) {
|
|
return
|
|
}
|
|
|
|
pillTextRef.value.textContent = label
|
|
// 短文案保持一个基础宽度,长文案则按内容自然变宽。
|
|
pillWidth = Math.max(100, label.length * 9 + 32)
|
|
isPillVisible = true
|
|
movePill()
|
|
|
|
gsap.to(dotRef.value, {
|
|
autoAlpha: 0,
|
|
scale: 0,
|
|
duration: 0.18,
|
|
ease: 'power2.out',
|
|
overwrite: 'auto',
|
|
})
|
|
gsap.to(pillRef.value, {
|
|
autoAlpha: 1,
|
|
width: pillWidth,
|
|
height: 30,
|
|
duration: 0.2,
|
|
ease: 'power3.out',
|
|
overwrite: 'auto',
|
|
})
|
|
gsap.to(pillTextRef.value, {
|
|
autoAlpha: 1,
|
|
delay: 0.08,
|
|
duration: 0.18,
|
|
overwrite: 'auto',
|
|
})
|
|
}
|
|
|
|
const hidePill = () => {
|
|
if (!isPillVisible) {
|
|
return
|
|
}
|
|
|
|
isPillVisible = false
|
|
|
|
gsap.to(pillTextRef.value, {
|
|
autoAlpha: 0,
|
|
duration: 0.12,
|
|
overwrite: 'auto',
|
|
})
|
|
gsap.to(pillRef.value, {
|
|
autoAlpha: 0,
|
|
width: 0,
|
|
height: 0,
|
|
duration: 0.2,
|
|
ease: 'power3.out',
|
|
overwrite: 'auto',
|
|
})
|
|
gsap.to(dotRef.value, {
|
|
autoAlpha: 1,
|
|
scale: 1,
|
|
delay: 0.08,
|
|
duration: 0.18,
|
|
ease: 'power2.out',
|
|
overwrite: 'auto',
|
|
})
|
|
}
|
|
|
|
const moveCursor = (event: MouseEvent) => {
|
|
cursorX = event.clientX
|
|
cursorY = event.clientY
|
|
hasCursorPosition = true
|
|
|
|
showCursor()
|
|
gsap.set(dotRef.value, {
|
|
x: cursorX - 12,
|
|
y: cursorY - 12,
|
|
})
|
|
movePill()
|
|
moveLineX(cursorX)
|
|
moveLineY(cursorY)
|
|
}
|
|
|
|
const handleMouseOver = (event: MouseEvent) => {
|
|
const target = getCursorTarget(event.target)
|
|
|
|
if (!target) {
|
|
return
|
|
}
|
|
|
|
if (!hasCursorPosition) {
|
|
moveCursor(event)
|
|
}
|
|
|
|
showPill(target.dataset.cursorLabel || 'Read more')
|
|
}
|
|
|
|
const handleMouseOut = (event: MouseEvent) => {
|
|
const target = getCursorTarget(event.target)
|
|
const nextTarget = getCursorTarget(event.relatedTarget)
|
|
|
|
// 在同一个标记区域的子元素之间移动时,不关闭胶囊提示。
|
|
if (!target || nextTarget === target) {
|
|
return
|
|
}
|
|
|
|
hidePill()
|
|
}
|
|
|
|
window.addEventListener('mousemove', moveCursor)
|
|
window.addEventListener('mouseenter', moveCursor)
|
|
window.addEventListener('mouseleave', hideCursor)
|
|
document.addEventListener('mouseover', handleMouseOver)
|
|
document.addEventListener('mouseout', handleMouseOut)
|
|
|
|
cleanupCursor = () => {
|
|
window.removeEventListener('mousemove', moveCursor)
|
|
window.removeEventListener('mouseenter', moveCursor)
|
|
window.removeEventListener('mouseleave', hideCursor)
|
|
document.removeEventListener('mouseover', handleMouseOver)
|
|
document.removeEventListener('mouseout', handleMouseOut)
|
|
document.documentElement.classList.remove('has-custom-cursor')
|
|
gsap.killTweensOf(allTargets)
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
cleanupCursor?.()
|
|
cleanupCursor = null
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<div aria-hidden="true">
|
|
<div ref="dotRef" class="custom-cursor-dot" />
|
|
<div ref="horizontalLineRef" class="custom-cursor-line custom-cursor-line-x" />
|
|
<div ref="verticalLineRef" class="custom-cursor-line custom-cursor-line-y" />
|
|
<div ref="pillRef" class="custom-cursor-pill">
|
|
<div ref="pillTextRef" class="custom-cursor-pill-text">Read more</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.custom-cursor-dot,
|
|
.custom-cursor-line,
|
|
.custom-cursor-pill,
|
|
.custom-cursor-pill-text {
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
}
|
|
|
|
.custom-cursor-dot,
|
|
.custom-cursor-line {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
pointer-events: none;
|
|
mix-blend-mode: difference;
|
|
}
|
|
|
|
.custom-cursor-dot {
|
|
z-index: 2147483647;
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 999px;
|
|
background: #fff;
|
|
}
|
|
|
|
.custom-cursor-line {
|
|
z-index: 2147483646;
|
|
background: #888;
|
|
}
|
|
|
|
.custom-cursor-line-x {
|
|
right: 0;
|
|
height: 0.5px;
|
|
}
|
|
|
|
.custom-cursor-line-y {
|
|
width: 0.5px;
|
|
height: 100vh;
|
|
}
|
|
|
|
.custom-cursor-pill {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
z-index: 2147483647;
|
|
pointer-events: none;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
border-radius: 30px;
|
|
color: #fff;
|
|
background: rgb(0 0 0 / 35%);
|
|
-webkit-backdrop-filter: blur(10px);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.custom-cursor-pill-text {
|
|
position: absolute;
|
|
width: 100%;
|
|
overflow-wrap: normal;
|
|
white-space: nowrap;
|
|
text-align: center;
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
}
|
|
|
|
@media (pointer: fine) {
|
|
:global(.has-custom-cursor),
|
|
:global(.has-custom-cursor *) {
|
|
cursor: none !important;
|
|
}
|
|
}
|
|
</style>
|