5 changed files with 212 additions and 30 deletions
@ -1,6 +1,9 @@ |
|||
<script setup lang="ts"> |
|||
import TextCursor from './components/TextCursor/TextCursor.vue'; |
|||
</script> |
|||
|
|||
<template> |
|||
<TextCursor class="text-cursor-overlay" text="💀" :delay="0.01" :spacing="100" :exit-duration="0.5" |
|||
:removal-interval="30" :max-points="8" /> |
|||
<router-view /> |
|||
</template> |
|||
|
|||
@ -0,0 +1,188 @@ |
|||
<script setup lang="ts"> |
|||
import { ref, onMounted, onUnmounted } from 'vue'; |
|||
import { Motion } from 'motion-v'; |
|||
|
|||
interface TextCursorProps { |
|||
text?: string; |
|||
delay?: number; |
|||
spacing?: number; |
|||
followMouseDirection?: boolean; |
|||
randomFloat?: boolean; |
|||
exitDuration?: number; |
|||
removalInterval?: number; |
|||
maxPoints?: number; |
|||
} |
|||
|
|||
interface TrailItem { |
|||
id: number; |
|||
x: number; |
|||
y: number; |
|||
angle: number; |
|||
randomX?: number; |
|||
randomY?: number; |
|||
randomRotate?: number; |
|||
} |
|||
|
|||
const props = withDefaults(defineProps<TextCursorProps>(), { |
|||
text: '⚛️', |
|||
delay: 0.01, |
|||
spacing: 100, |
|||
followMouseDirection: true, |
|||
randomFloat: true, |
|||
exitDuration: 0.5, |
|||
removalInterval: 30, |
|||
maxPoints: 5 |
|||
}); |
|||
|
|||
const trail = ref<TrailItem[]>([]); |
|||
const lastMoveTime = ref(Date.now()); |
|||
const idCounter = ref(0); |
|||
|
|||
let removalIntervalId: ReturnType<typeof setInterval> | null = null; |
|||
|
|||
const handleMouseMove = (e: MouseEvent) => { |
|||
// 使用 window 监听:覆盖层为 pointer-events:none 时根节点收不到事件 |
|||
const mouseX = e.clientX; |
|||
const mouseY = e.clientY; |
|||
|
|||
let newTrail = [...trail.value]; |
|||
|
|||
if (newTrail.length === 0) { |
|||
newTrail.push({ |
|||
id: idCounter.value++, |
|||
x: mouseX, |
|||
y: mouseY, |
|||
angle: 0, |
|||
...(props.randomFloat && { |
|||
randomX: Math.random() * 10 - 5, |
|||
randomY: Math.random() * 10 - 5, |
|||
randomRotate: Math.random() * 10 - 5 |
|||
}) |
|||
}); |
|||
} else { |
|||
const last = newTrail[newTrail.length - 1]; |
|||
const dx = mouseX - last.x; |
|||
const dy = mouseY - last.y; |
|||
const distance = Math.sqrt(dx * dx + dy * dy); |
|||
|
|||
if (distance >= props.spacing) { |
|||
let rawAngle = (Math.atan2(dy, dx) * 180) / Math.PI; |
|||
if (rawAngle > 90) rawAngle -= 180; |
|||
else if (rawAngle < -90) rawAngle += 180; |
|||
const computedAngle = props.followMouseDirection ? rawAngle : 0; |
|||
const steps = Math.floor(distance / props.spacing); |
|||
|
|||
for (let i = 1; i <= steps; i++) { |
|||
const t = (props.spacing * i) / distance; |
|||
const newX = last.x + dx * t; |
|||
const newY = last.y + dy * t; |
|||
newTrail.push({ |
|||
id: idCounter.value++, |
|||
x: newX, |
|||
y: newY, |
|||
angle: computedAngle, |
|||
...(props.randomFloat && { |
|||
randomX: Math.random() * 10 - 5, |
|||
randomY: Math.random() * 10 - 5, |
|||
randomRotate: Math.random() * 10 - 5 |
|||
}) |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (newTrail.length > props.maxPoints) { |
|||
newTrail = newTrail.slice(newTrail.length - props.maxPoints); |
|||
} |
|||
|
|||
trail.value = newTrail; |
|||
lastMoveTime.value = Date.now(); |
|||
}; |
|||
|
|||
const startRemovalInterval = () => { |
|||
if (removalIntervalId) { |
|||
clearInterval(removalIntervalId); |
|||
} |
|||
|
|||
removalIntervalId = setInterval(() => { |
|||
if (Date.now() - lastMoveTime.value > 100) { |
|||
if (trail.value.length > 0) { |
|||
trail.value = trail.value.slice(1); |
|||
} |
|||
} |
|||
}, props.removalInterval); |
|||
}; |
|||
|
|||
onMounted(() => { |
|||
window.addEventListener('mousemove', handleMouseMove, { passive: true }); |
|||
startRemovalInterval(); |
|||
}); |
|||
|
|||
onUnmounted(() => { |
|||
window.removeEventListener('mousemove', handleMouseMove); |
|||
if (removalIntervalId) { |
|||
clearInterval(removalIntervalId); |
|||
} |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="text-cursor-container"> |
|||
<div class="text-cursor-content"> |
|||
<Motion |
|||
v-for="item in trail" |
|||
:key="item.id" |
|||
:initial="{ opacity: 0, scale: 0.5, rotate: item.angle }" |
|||
:animate="{ |
|||
opacity: 1, |
|||
scale: 1, |
|||
x: props.randomFloat ? [0, item.randomX || 0, 0] : 0, |
|||
y: props.randomFloat ? [0, item.randomY || 0, 0] : 0, |
|||
rotate: props.randomFloat ? [item.angle, item.angle + (item.randomRotate || 0), item.angle] : item.angle |
|||
}" |
|||
:transition="{ |
|||
duration: props.randomFloat ? 2 : props.exitDuration, |
|||
repeat: props.randomFloat ? Infinity : 0, |
|||
repeatType: props.randomFloat ? 'mirror' : 'loop' |
|||
}" |
|||
class="text-cursor-item" |
|||
:style="{ left: `${item.x}px`, top: `${item.y}px` }" |
|||
> |
|||
{{ props.text }} |
|||
</Motion> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.text-cursor-container { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
width: 100vw; |
|||
height: 100vh; |
|||
pointer-events: none; |
|||
z-index: 9999; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.text-cursor-content { |
|||
position: absolute; |
|||
top: 0; |
|||
left: 0; |
|||
width: 100%; |
|||
height: 100%; |
|||
} |
|||
|
|||
.text-cursor-item { |
|||
position: absolute; |
|||
font-size: 24px; |
|||
white-space: nowrap; |
|||
user-select: none; |
|||
transform: translate(-50%, -50%); |
|||
} |
|||
|
|||
.text-cursor-item:hover { |
|||
cursor: none; |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue