mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
180 lines
4.4 KiB
Vue
180 lines
4.4 KiB
Vue
<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 containerRef = ref<HTMLDivElement>();
|
|
const trail = ref<TrailItem[]>([]);
|
|
const lastMoveTime = ref(Date.now());
|
|
const idCounter = ref(0);
|
|
|
|
let removalIntervalId: number | null = null;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!containerRef.value) return;
|
|
|
|
const rect = containerRef.value.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
|
|
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(() => {
|
|
if (containerRef.value) {
|
|
containerRef.value.addEventListener('mousemove', handleMouseMove);
|
|
startRemovalInterval();
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (containerRef.value) {
|
|
containerRef.value.removeEventListener('mousemove', handleMouseMove);
|
|
}
|
|
if (removalIntervalId) {
|
|
clearInterval(removalIntervalId);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="containerRef" class="w-full h-full relative">
|
|
<div class="absolute inset-0 pointer-events-none">
|
|
<Motion
|
|
v-for="item in trail"
|
|
:key="item.id"
|
|
:initial="{ opacity: 0, scale: 1, 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="absolute select-none whitespace-nowrap text-3xl"
|
|
:style="{ left: `${item.x}px`, top: `${item.y}px` }"
|
|
>
|
|
{{ props.text }}
|
|
</Motion>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.trail-enter-active,
|
|
.trail-leave-active {
|
|
transition: all 0.5s ease;
|
|
}
|
|
|
|
.trail-enter-from {
|
|
opacity: 0;
|
|
transform: scale(0);
|
|
}
|
|
|
|
.trail-leave-to {
|
|
opacity: 0;
|
|
transform: scale(0);
|
|
}
|
|
</style>
|