Files
vue-bits/src/content/TextAnimations/TextCursor/TextCursor.vue
2025-07-12 11:59:33 +03:00

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>