mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
Merge branch 'main' into add-ascii-texts
This commit is contained in:
@@ -1,20 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Motion } from 'motion-v'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { Motion } from 'motion-v';
|
||||
|
||||
interface BlurTextProps {
|
||||
text?: string
|
||||
delay?: number
|
||||
className?: string
|
||||
animateBy?: 'words' | 'letters'
|
||||
direction?: 'top' | 'bottom'
|
||||
threshold?: number
|
||||
rootMargin?: string
|
||||
animationFrom?: Record<string, string | number>
|
||||
animationTo?: Array<Record<string, string | number>>
|
||||
easing?: (t: number) => number
|
||||
onAnimationComplete?: () => void
|
||||
stepDuration?: number
|
||||
text?: string;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
animateBy?: 'words' | 'letters';
|
||||
direction?: 'top' | 'bottom';
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
animationFrom?: Record<string, string | number>;
|
||||
animationTo?: Array<Record<string, string | number>>;
|
||||
easing?: (t: number) => number;
|
||||
onAnimationComplete?: () => void;
|
||||
stepDuration?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<BlurTextProps>(), {
|
||||
@@ -27,37 +27,30 @@ const props = withDefaults(defineProps<BlurTextProps>(), {
|
||||
rootMargin: '0px',
|
||||
easing: (t: number) => t,
|
||||
stepDuration: 0.35
|
||||
})
|
||||
});
|
||||
|
||||
const buildKeyframes = (
|
||||
from: Record<string, string | number>,
|
||||
steps: Array<Record<string, string | number>>
|
||||
): Record<string, Array<string | number>> => {
|
||||
const keys = new Set<string>([
|
||||
...Object.keys(from),
|
||||
...steps.flatMap((s) => Object.keys(s))
|
||||
])
|
||||
const keys = new Set<string>([...Object.keys(from), ...steps.flatMap(s => Object.keys(s))]);
|
||||
|
||||
const keyframes: Record<string, Array<string | number>> = {}
|
||||
keys.forEach((k) => {
|
||||
keyframes[k] = [from[k], ...steps.map((s) => s[k])]
|
||||
})
|
||||
return keyframes
|
||||
}
|
||||
const keyframes: Record<string, Array<string | number>> = {};
|
||||
keys.forEach(k => {
|
||||
keyframes[k] = [from[k], ...steps.map(s => s[k])];
|
||||
});
|
||||
return keyframes;
|
||||
};
|
||||
|
||||
const elements = computed(() =>
|
||||
props.animateBy === 'words' ? props.text.split(' ') : props.text.split('')
|
||||
)
|
||||
const elements = computed(() => (props.animateBy === 'words' ? props.text.split(' ') : props.text.split('')));
|
||||
|
||||
const inView = ref(false)
|
||||
const rootRef = ref<HTMLParagraphElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
const inView = ref(false);
|
||||
const rootRef = ref<HTMLParagraphElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const defaultFrom = computed(() =>
|
||||
props.direction === 'top'
|
||||
? { filter: 'blur(10px)', opacity: 0, y: -50 }
|
||||
: { filter: 'blur(10px)', opacity: 0, y: 50 }
|
||||
)
|
||||
props.direction === 'top' ? { filter: 'blur(10px)', opacity: 0, y: -50 } : { filter: 'blur(10px)', opacity: 0, y: 50 }
|
||||
);
|
||||
|
||||
const defaultTo = computed(() => [
|
||||
{
|
||||
@@ -66,51 +59,49 @@ const defaultTo = computed(() => [
|
||||
y: props.direction === 'top' ? 5 : -5
|
||||
},
|
||||
{ filter: 'blur(0px)', opacity: 1, y: 0 }
|
||||
])
|
||||
]);
|
||||
|
||||
const fromSnapshot = computed(() => props.animationFrom ?? defaultFrom.value)
|
||||
const toSnapshots = computed(() => props.animationTo ?? defaultTo.value)
|
||||
const fromSnapshot = computed(() => props.animationFrom ?? defaultFrom.value);
|
||||
const toSnapshots = computed(() => props.animationTo ?? defaultTo.value);
|
||||
|
||||
const stepCount = computed(() => toSnapshots.value.length + 1)
|
||||
const totalDuration = computed(() => props.stepDuration * (stepCount.value - 1))
|
||||
const stepCount = computed(() => toSnapshots.value.length + 1);
|
||||
const totalDuration = computed(() => props.stepDuration * (stepCount.value - 1));
|
||||
const times = computed(() =>
|
||||
Array.from({ length: stepCount.value }, (_, i) =>
|
||||
stepCount.value === 1 ? 0 : i / (stepCount.value - 1)
|
||||
)
|
||||
)
|
||||
Array.from({ length: stepCount.value }, (_, i) => (stepCount.value === 1 ? 0 : i / (stepCount.value - 1)))
|
||||
);
|
||||
|
||||
const setupObserver = () => {
|
||||
if (!rootRef.value) return
|
||||
if (!rootRef.value) return;
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
inView.value = true
|
||||
observer?.unobserve(rootRef.value as Element)
|
||||
inView.value = true;
|
||||
observer?.unobserve(rootRef.value as Element);
|
||||
}
|
||||
},
|
||||
{ threshold: props.threshold, rootMargin: props.rootMargin }
|
||||
)
|
||||
);
|
||||
|
||||
observer.observe(rootRef.value)
|
||||
}
|
||||
observer.observe(rootRef.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setupObserver()
|
||||
})
|
||||
setupObserver();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect()
|
||||
})
|
||||
observer?.disconnect();
|
||||
});
|
||||
|
||||
watch([() => props.threshold, () => props.rootMargin], () => {
|
||||
observer?.disconnect()
|
||||
setupObserver()
|
||||
})
|
||||
observer?.disconnect();
|
||||
setupObserver();
|
||||
});
|
||||
|
||||
const getAnimateKeyframes = () => {
|
||||
return buildKeyframes(fromSnapshot.value, toSnapshots.value)
|
||||
}
|
||||
return buildKeyframes(fromSnapshot.value, toSnapshots.value);
|
||||
};
|
||||
|
||||
const getTransition = (index: number) => {
|
||||
return {
|
||||
@@ -118,24 +109,33 @@ const getTransition = (index: number) => {
|
||||
times: times.value,
|
||||
delay: (index * props.delay) / 1000,
|
||||
ease: props.easing
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleAnimationComplete = (index: number) => {
|
||||
if (index === elements.value.length - 1 && props.onAnimationComplete) {
|
||||
props.onAnimationComplete()
|
||||
props.onAnimationComplete();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p ref="rootRef" :class="`blur-text ${className} flex flex-wrap`">
|
||||
<Motion v-for="(segment, index) in elements" :key="index" tag="span" :initial="fromSnapshot"
|
||||
:animate="inView ? getAnimateKeyframes() : fromSnapshot" :transition="getTransition(index)" :style="{
|
||||
<Motion
|
||||
v-for="(segment, index) in elements"
|
||||
:key="index"
|
||||
tag="span"
|
||||
:initial="fromSnapshot"
|
||||
:animate="inView ? getAnimateKeyframes() : fromSnapshot"
|
||||
:transition="getTransition(index)"
|
||||
:style="{
|
||||
display: 'inline-block',
|
||||
willChange: 'transform, filter, opacity'
|
||||
}" @animation-complete="handleAnimationComplete(index)">
|
||||
{{ segment === ' ' ? '\u00A0' : segment }}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : ''
|
||||
}} </Motion>
|
||||
}"
|
||||
@animation-complete="handleAnimationComplete(index)"
|
||||
>
|
||||
{{ segment === ' ' ? '\u00A0' : segment
|
||||
}}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : '' }}
|
||||
</Motion>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect, onUnmounted } from 'vue'
|
||||
import { Motion } from 'motion-v'
|
||||
import { computed, ref, watchEffect, onUnmounted } from 'vue';
|
||||
import { Motion } from 'motion-v';
|
||||
|
||||
interface CircularTextProps {
|
||||
text: string
|
||||
spinDuration?: number
|
||||
onHover?: 'slowDown' | 'speedUp' | 'pause' | 'goBonkers'
|
||||
className?: string
|
||||
text: string;
|
||||
spinDuration?: number;
|
||||
onHover?: 'slowDown' | 'speedUp' | 'pause' | 'goBonkers';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CircularTextProps>(), {
|
||||
@@ -14,99 +14,101 @@ const props = withDefaults(defineProps<CircularTextProps>(), {
|
||||
spinDuration: 20,
|
||||
onHover: 'speedUp',
|
||||
className: ''
|
||||
})
|
||||
});
|
||||
|
||||
const letters = computed(() => Array.from(props.text))
|
||||
const isHovered = ref(false)
|
||||
const letters = computed(() => Array.from(props.text));
|
||||
const isHovered = ref(false);
|
||||
|
||||
const currentRotation = ref(0)
|
||||
const animationId = ref<number | null>(null)
|
||||
const lastTime = ref<number>(Date.now())
|
||||
const rotationSpeed = ref<number>(0)
|
||||
const currentRotation = ref(0);
|
||||
const animationId = ref<number | null>(null);
|
||||
const lastTime = ref<number>(Date.now());
|
||||
const rotationSpeed = ref<number>(0);
|
||||
|
||||
const getCurrentSpeed = () => {
|
||||
if (isHovered.value && props.onHover === 'pause') return 0
|
||||
if (isHovered.value && props.onHover === 'pause') return 0;
|
||||
|
||||
const baseDuration = props.spinDuration
|
||||
const baseSpeed = 360 / baseDuration
|
||||
const baseDuration = props.spinDuration;
|
||||
const baseSpeed = 360 / baseDuration;
|
||||
|
||||
if (!isHovered.value) return baseSpeed
|
||||
if (!isHovered.value) return baseSpeed;
|
||||
|
||||
switch (props.onHover) {
|
||||
case 'slowDown':
|
||||
return baseSpeed / 2
|
||||
return baseSpeed / 2;
|
||||
case 'speedUp':
|
||||
return baseSpeed * 4
|
||||
return baseSpeed * 4;
|
||||
case 'goBonkers':
|
||||
return baseSpeed * 20
|
||||
return baseSpeed * 20;
|
||||
default:
|
||||
return baseSpeed
|
||||
return baseSpeed;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentScale = () => {
|
||||
return (isHovered.value && props.onHover === 'goBonkers') ? 0.8 : 1
|
||||
}
|
||||
return isHovered.value && props.onHover === 'goBonkers' ? 0.8 : 1;
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
const now = Date.now()
|
||||
const deltaTime = (now - lastTime.value) / 1000
|
||||
lastTime.value = now
|
||||
const now = Date.now();
|
||||
const deltaTime = (now - lastTime.value) / 1000;
|
||||
lastTime.value = now;
|
||||
|
||||
const targetSpeed = getCurrentSpeed()
|
||||
const targetSpeed = getCurrentSpeed();
|
||||
|
||||
const speedDiff = targetSpeed - rotationSpeed.value
|
||||
const smoothingFactor = Math.min(1, deltaTime * 5)
|
||||
rotationSpeed.value += speedDiff * smoothingFactor
|
||||
const speedDiff = targetSpeed - rotationSpeed.value;
|
||||
const smoothingFactor = Math.min(1, deltaTime * 5);
|
||||
rotationSpeed.value += speedDiff * smoothingFactor;
|
||||
|
||||
currentRotation.value = (currentRotation.value + rotationSpeed.value * deltaTime) % 360
|
||||
currentRotation.value = (currentRotation.value + rotationSpeed.value * deltaTime) % 360;
|
||||
|
||||
animationId.value = requestAnimationFrame(animate)
|
||||
}
|
||||
animationId.value = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const startAnimation = () => {
|
||||
if (animationId.value) {
|
||||
cancelAnimationFrame(animationId.value)
|
||||
cancelAnimationFrame(animationId.value);
|
||||
}
|
||||
lastTime.value = Date.now()
|
||||
rotationSpeed.value = getCurrentSpeed()
|
||||
animate()
|
||||
}
|
||||
lastTime.value = Date.now();
|
||||
rotationSpeed.value = getCurrentSpeed();
|
||||
animate();
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
startAnimation()
|
||||
})
|
||||
startAnimation();
|
||||
});
|
||||
|
||||
startAnimation()
|
||||
startAnimation();
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationId.value) {
|
||||
cancelAnimationFrame(animationId.value)
|
||||
cancelAnimationFrame(animationId.value);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handleHoverStart = () => {
|
||||
isHovered.value = true
|
||||
}
|
||||
isHovered.value = true;
|
||||
};
|
||||
|
||||
const handleHoverEnd = () => {
|
||||
isHovered.value = false
|
||||
}
|
||||
isHovered.value = false;
|
||||
};
|
||||
|
||||
const getLetterTransform = (index: number) => {
|
||||
const rotationDeg = (360 / letters.value.length) * index
|
||||
const factor = Math.PI / letters.value.length
|
||||
const x = factor * index
|
||||
const y = factor * index
|
||||
return `rotateZ(${rotationDeg}deg) translate3d(${x}px, ${y}px, 0)`
|
||||
}
|
||||
const rotationDeg = (360 / letters.value.length) * index;
|
||||
const factor = Math.PI / letters.value.length;
|
||||
const x = factor * index;
|
||||
const y = factor * index;
|
||||
return `rotateZ(${rotationDeg}deg) translate3d(${x}px, ${y}px, 0)`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Motion :animate="{
|
||||
rotate: currentRotation,
|
||||
scale: getCurrentScale()
|
||||
}" :transition="{
|
||||
<Motion
|
||||
:animate="{
|
||||
rotate: currentRotation,
|
||||
scale: getCurrentScale()
|
||||
}"
|
||||
:transition="{
|
||||
rotate: {
|
||||
duration: 0
|
||||
},
|
||||
@@ -117,13 +119,19 @@ const getLetterTransform = (index: number) => {
|
||||
}
|
||||
}"
|
||||
:class="`m-0 mx-auto rounded-full w-[200px] h-[200px] relative font-black text-white text-center cursor-pointer origin-center ${props.className}`"
|
||||
@mouseenter="handleHoverStart" @mouseleave="handleHoverEnd">
|
||||
<span v-for="(letter, i) in letters" :key="i"
|
||||
class="absolute inline-block inset-0 text-2xl transition-all duration-500 ease-[cubic-bezier(0,0,0,1)]" :style="{
|
||||
@mouseenter="handleHoverStart"
|
||||
@mouseleave="handleHoverEnd"
|
||||
>
|
||||
<span
|
||||
v-for="(letter, i) in letters"
|
||||
:key="i"
|
||||
class="absolute inline-block inset-0 text-2xl transition-all duration-500 ease-[cubic-bezier(0,0,0,1)]"
|
||||
:style="{
|
||||
transform: getLetterTransform(i),
|
||||
WebkitTransform: getLetterTransform(i)
|
||||
}">
|
||||
}"
|
||||
>
|
||||
{{ letter }}
|
||||
</span>
|
||||
</Motion>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue';
|
||||
|
||||
interface CurvedLoopProps {
|
||||
marqueeText?: string
|
||||
speed?: number
|
||||
className?: string
|
||||
curveAmount?: number
|
||||
direction?: 'left' | 'right'
|
||||
interactive?: boolean
|
||||
marqueeText?: string;
|
||||
speed?: number;
|
||||
className?: string;
|
||||
curveAmount?: number;
|
||||
direction?: 'left' | 'right';
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CurvedLoopProps>(), {
|
||||
@@ -17,161 +17,166 @@ const props = withDefaults(defineProps<CurvedLoopProps>(), {
|
||||
curveAmount: 400,
|
||||
direction: 'left',
|
||||
interactive: true
|
||||
})
|
||||
});
|
||||
|
||||
const text = computed(() => {
|
||||
const hasTrailing = /\s|\u00A0$/.test(props.marqueeText)
|
||||
return (
|
||||
(hasTrailing ? props.marqueeText.replace(/\s+$/, '') : props.marqueeText) + '\u00A0'
|
||||
)
|
||||
})
|
||||
const hasTrailing = /\s|\u00A0$/.test(props.marqueeText);
|
||||
return (hasTrailing ? props.marqueeText.replace(/\s+$/, '') : props.marqueeText) + '\u00A0';
|
||||
});
|
||||
|
||||
const measureRef = ref<SVGTextElement | null>(null)
|
||||
const tspansRef = ref<SVGTSpanElement[]>([])
|
||||
const pathRef = ref<SVGPathElement | null>(null)
|
||||
const pathLength = ref(0)
|
||||
const spacing = ref(0)
|
||||
const uid = Math.random().toString(36).substr(2, 9)
|
||||
const pathId = `curve-${uid}`
|
||||
const measureRef = ref<SVGTextElement | null>(null);
|
||||
const tspansRef = ref<SVGTSpanElement[]>([]);
|
||||
const pathRef = ref<SVGPathElement | null>(null);
|
||||
const pathLength = ref(0);
|
||||
const spacing = ref(0);
|
||||
const uid = Math.random().toString(36).substr(2, 9);
|
||||
const pathId = `curve-${uid}`;
|
||||
|
||||
const pathD = computed(() => `M-100,40 Q500,${40 + props.curveAmount} 1540,40`)
|
||||
const pathD = computed(() => `M-100,40 Q500,${40 + props.curveAmount} 1540,40`);
|
||||
|
||||
const dragRef = ref(false)
|
||||
const lastXRef = ref(0)
|
||||
const dirRef = ref<'left' | 'right'>(props.direction)
|
||||
const velRef = ref(0)
|
||||
const dragRef = ref(false);
|
||||
const lastXRef = ref(0);
|
||||
const dirRef = ref<'left' | 'right'>(props.direction);
|
||||
const velRef = ref(0);
|
||||
|
||||
let animationFrame: number | null = null
|
||||
let animationFrame: number | null = null;
|
||||
|
||||
const updateSpacing = () => {
|
||||
if (measureRef.value) {
|
||||
spacing.value = measureRef.value.getComputedTextLength()
|
||||
spacing.value = measureRef.value.getComputedTextLength();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updatePathLength = () => {
|
||||
if (pathRef.value) {
|
||||
pathLength.value = pathRef.value.getTotalLength()
|
||||
pathLength.value = pathRef.value.getTotalLength();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
if (!spacing.value) return
|
||||
if (!spacing.value) return;
|
||||
|
||||
const step = () => {
|
||||
tspansRef.value.forEach((t) => {
|
||||
if (!t) return
|
||||
let x = parseFloat(t.getAttribute('x') || '0')
|
||||
tspansRef.value.forEach(t => {
|
||||
if (!t) return;
|
||||
let x = parseFloat(t.getAttribute('x') || '0');
|
||||
if (!dragRef.value) {
|
||||
const delta = dirRef.value === 'right' ? Math.abs(props.speed) : -Math.abs(props.speed)
|
||||
x += delta
|
||||
const delta = dirRef.value === 'right' ? Math.abs(props.speed) : -Math.abs(props.speed);
|
||||
x += delta;
|
||||
}
|
||||
const maxX = (tspansRef.value.length - 1) * spacing.value
|
||||
if (x < -spacing.value) x = maxX
|
||||
if (x > maxX) x = -spacing.value
|
||||
t.setAttribute('x', x.toString())
|
||||
})
|
||||
animationFrame = requestAnimationFrame(step)
|
||||
}
|
||||
step()
|
||||
}
|
||||
const maxX = (tspansRef.value.length - 1) * spacing.value;
|
||||
if (x < -spacing.value) x = maxX;
|
||||
if (x > maxX) x = -spacing.value;
|
||||
t.setAttribute('x', x.toString());
|
||||
});
|
||||
animationFrame = requestAnimationFrame(step);
|
||||
};
|
||||
step();
|
||||
};
|
||||
|
||||
const stopAnimation = () => {
|
||||
if (animationFrame) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
animationFrame = null
|
||||
cancelAnimationFrame(animationFrame);
|
||||
animationFrame = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const repeats = computed(() => {
|
||||
return pathLength.value && spacing.value ? Math.ceil(pathLength.value / spacing.value) + 2 : 0
|
||||
})
|
||||
return pathLength.value && spacing.value ? Math.ceil(pathLength.value / spacing.value) + 2 : 0;
|
||||
});
|
||||
|
||||
const ready = computed(() => pathLength.value > 0 && spacing.value > 0)
|
||||
const ready = computed(() => pathLength.value > 0 && spacing.value > 0);
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (!props.interactive) return
|
||||
dragRef.value = true
|
||||
lastXRef.value = e.clientX
|
||||
velRef.value = 0
|
||||
; (e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}
|
||||
if (!props.interactive) return;
|
||||
dragRef.value = true;
|
||||
lastXRef.value = e.clientX;
|
||||
velRef.value = 0;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!props.interactive || !dragRef.value) return
|
||||
const dx = e.clientX - lastXRef.value
|
||||
lastXRef.value = e.clientX
|
||||
velRef.value = dx
|
||||
tspansRef.value.forEach((t) => {
|
||||
if (!t) return
|
||||
let x = parseFloat(t.getAttribute('x') || '0')
|
||||
x += dx
|
||||
const maxX = (tspansRef.value.length - 1) * spacing.value
|
||||
if (x < -spacing.value) x = maxX
|
||||
if (x > maxX) x = -spacing.value
|
||||
t.setAttribute('x', x.toString())
|
||||
})
|
||||
}
|
||||
if (!props.interactive || !dragRef.value) return;
|
||||
const dx = e.clientX - lastXRef.value;
|
||||
lastXRef.value = e.clientX;
|
||||
velRef.value = dx;
|
||||
tspansRef.value.forEach(t => {
|
||||
if (!t) return;
|
||||
let x = parseFloat(t.getAttribute('x') || '0');
|
||||
x += dx;
|
||||
const maxX = (tspansRef.value.length - 1) * spacing.value;
|
||||
if (x < -spacing.value) x = maxX;
|
||||
if (x > maxX) x = -spacing.value;
|
||||
t.setAttribute('x', x.toString());
|
||||
});
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
if (!props.interactive) return
|
||||
dragRef.value = false
|
||||
dirRef.value = velRef.value > 0 ? 'right' : 'left'
|
||||
}
|
||||
if (!props.interactive) return;
|
||||
dragRef.value = false;
|
||||
dirRef.value = velRef.value > 0 ? 'right' : 'left';
|
||||
};
|
||||
|
||||
const cursorStyle = computed(() => {
|
||||
return props.interactive
|
||||
? dragRef.value
|
||||
? 'grabbing'
|
||||
: 'grab'
|
||||
: 'auto'
|
||||
})
|
||||
return props.interactive ? (dragRef.value ? 'grabbing' : 'grab') : 'auto';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
updateSpacing()
|
||||
updatePathLength()
|
||||
animate()
|
||||
})
|
||||
})
|
||||
updateSpacing();
|
||||
updatePathLength();
|
||||
animate();
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAnimation()
|
||||
})
|
||||
stopAnimation();
|
||||
});
|
||||
|
||||
watch([text, () => props.className], () => {
|
||||
nextTick(() => {
|
||||
updateSpacing()
|
||||
})
|
||||
})
|
||||
updateSpacing();
|
||||
});
|
||||
});
|
||||
|
||||
watch(() => props.curveAmount, () => {
|
||||
nextTick(() => {
|
||||
updatePathLength()
|
||||
})
|
||||
})
|
||||
watch(
|
||||
() => props.curveAmount,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
updatePathLength();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch([spacing, () => props.speed], () => {
|
||||
stopAnimation()
|
||||
stopAnimation();
|
||||
if (spacing.value) {
|
||||
animate()
|
||||
animate();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
watch(repeats, () => {
|
||||
tspansRef.value = []
|
||||
})
|
||||
tspansRef.value = [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center w-full" :style="{
|
||||
visibility: ready ? 'visible' : 'hidden',
|
||||
cursor: cursorStyle
|
||||
}" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="endDrag" @pointerleave="endDrag">
|
||||
<div
|
||||
class="min-h-screen flex items-center justify-center w-full"
|
||||
:style="{
|
||||
visibility: ready ? 'visible' : 'hidden',
|
||||
cursor: cursorStyle
|
||||
}"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="endDrag"
|
||||
@pointerleave="endDrag"
|
||||
>
|
||||
<svg
|
||||
class="select-none w-full overflow-visible block aspect-[100/12] text-[6rem] font-bold tracking-[5px] uppercase leading-none"
|
||||
viewBox="0 0 1440 120">
|
||||
<text ref="measureRef" xml:space="preserve" style="visibility: hidden; opacity: 0; pointer-events: none;">
|
||||
viewBox="0 0 1440 120"
|
||||
>
|
||||
<text ref="measureRef" xml:space="preserve" style="visibility: hidden; opacity: 0; pointer-events: none">
|
||||
{{ text }}
|
||||
</text>
|
||||
|
||||
@@ -181,9 +186,16 @@ watch(repeats, () => {
|
||||
|
||||
<text v-if="ready" xml:space="preserve" :class="`fill-white ${className}`">
|
||||
<textPath :href="`#${pathId}`" xml:space="preserve">
|
||||
<tspan v-for="i in repeats" :key="i" :x="(i - 1) * spacing" :ref="(el) => {
|
||||
if (el) tspansRef[i - 1] = el as SVGTSpanElement
|
||||
}">
|
||||
<tspan
|
||||
v-for="i in repeats"
|
||||
:key="i"
|
||||
:x="(i - 1) * spacing"
|
||||
:ref="
|
||||
el => {
|
||||
if (el) tspansRef[i - 1] = el as SVGTSpanElement;
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ text }}
|
||||
</tspan>
|
||||
</textPath>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
|
||||
interface DecryptedTextProps {
|
||||
text: string
|
||||
speed?: number
|
||||
maxIterations?: number
|
||||
sequential?: boolean
|
||||
revealDirection?: 'start' | 'end' | 'center'
|
||||
useOriginalCharsOnly?: boolean
|
||||
characters?: string
|
||||
className?: string
|
||||
encryptedClassName?: string
|
||||
parentClassName?: string
|
||||
animateOn?: 'view' | 'hover'
|
||||
text: string;
|
||||
speed?: number;
|
||||
maxIterations?: number;
|
||||
sequential?: boolean;
|
||||
revealDirection?: 'start' | 'end' | 'center';
|
||||
useOriginalCharsOnly?: boolean;
|
||||
characters?: string;
|
||||
className?: string;
|
||||
encryptedClassName?: string;
|
||||
parentClassName?: string;
|
||||
animateOn?: 'view' | 'hover';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DecryptedTextProps>(), {
|
||||
@@ -27,201 +27,205 @@ const props = withDefaults(defineProps<DecryptedTextProps>(), {
|
||||
parentClassName: '',
|
||||
encryptedClassName: '',
|
||||
animateOn: 'hover'
|
||||
})
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
animationComplete: []
|
||||
}>()
|
||||
animationComplete: [];
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLSpanElement>()
|
||||
const displayText = ref(props.text)
|
||||
const isHovering = ref(false)
|
||||
const isScrambling = ref(false)
|
||||
const revealedIndices = ref(new Set<number>())
|
||||
const hasAnimated = ref(false)
|
||||
const containerRef = ref<HTMLSpanElement>();
|
||||
const displayText = ref(props.text);
|
||||
const isHovering = ref(false);
|
||||
const isScrambling = ref(false);
|
||||
const revealedIndices = ref(new Set<number>());
|
||||
const hasAnimated = ref(false);
|
||||
|
||||
let interval: number | null = null
|
||||
let intersectionObserver: IntersectionObserver | null = null
|
||||
let interval: number | null = null;
|
||||
let intersectionObserver: IntersectionObserver | null = null;
|
||||
|
||||
watch([
|
||||
() => isHovering.value,
|
||||
() => props.text,
|
||||
() => props.speed,
|
||||
() => props.maxIterations,
|
||||
() => props.sequential,
|
||||
() => props.revealDirection,
|
||||
() => props.characters,
|
||||
() => props.useOriginalCharsOnly
|
||||
], () => {
|
||||
let currentIteration = 0
|
||||
watch(
|
||||
[
|
||||
() => isHovering.value,
|
||||
() => props.text,
|
||||
() => props.speed,
|
||||
() => props.maxIterations,
|
||||
() => props.sequential,
|
||||
() => props.revealDirection,
|
||||
() => props.characters,
|
||||
() => props.useOriginalCharsOnly
|
||||
],
|
||||
() => {
|
||||
let currentIteration = 0;
|
||||
|
||||
const getNextIndex = (revealedSet: Set<number>): number => {
|
||||
const textLength = props.text.length
|
||||
switch (props.revealDirection) {
|
||||
case 'start':
|
||||
return revealedSet.size
|
||||
case 'end':
|
||||
return textLength - 1 - revealedSet.size
|
||||
case 'center': {
|
||||
const middle = Math.floor(textLength / 2)
|
||||
const offset = Math.floor(revealedSet.size / 2)
|
||||
const nextIndex =
|
||||
revealedSet.size % 2 === 0
|
||||
? middle + offset
|
||||
: middle - offset - 1
|
||||
const getNextIndex = (revealedSet: Set<number>): number => {
|
||||
const textLength = props.text.length;
|
||||
switch (props.revealDirection) {
|
||||
case 'start':
|
||||
return revealedSet.size;
|
||||
case 'end':
|
||||
return textLength - 1 - revealedSet.size;
|
||||
case 'center': {
|
||||
const middle = Math.floor(textLength / 2);
|
||||
const offset = Math.floor(revealedSet.size / 2);
|
||||
const nextIndex = revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;
|
||||
|
||||
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
|
||||
return nextIndex
|
||||
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
|
||||
return nextIndex;
|
||||
}
|
||||
for (let i = 0; i < textLength; i++) {
|
||||
if (!revealedSet.has(i)) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
for (let i = 0; i < textLength; i++) {
|
||||
if (!revealedSet.has(i)) return i
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return revealedSet.size;
|
||||
}
|
||||
default:
|
||||
return revealedSet.size
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const availableChars = props.useOriginalCharsOnly
|
||||
? Array.from(new Set(props.text.split(''))).filter((char) => char !== ' ')
|
||||
: props.characters.split('')
|
||||
const availableChars = props.useOriginalCharsOnly
|
||||
? Array.from(new Set(props.text.split(''))).filter(char => char !== ' ')
|
||||
: props.characters.split('');
|
||||
|
||||
const shuffleText = (originalText: string, currentRevealed: Set<number>): string => {
|
||||
if (props.useOriginalCharsOnly) {
|
||||
const positions = originalText.split('').map((char, i) => ({
|
||||
char,
|
||||
isSpace: char === ' ',
|
||||
index: i,
|
||||
isRevealed: currentRevealed.has(i)
|
||||
}))
|
||||
const shuffleText = (originalText: string, currentRevealed: Set<number>): string => {
|
||||
if (props.useOriginalCharsOnly) {
|
||||
const positions = originalText.split('').map((char, i) => ({
|
||||
char,
|
||||
isSpace: char === ' ',
|
||||
index: i,
|
||||
isRevealed: currentRevealed.has(i)
|
||||
}));
|
||||
|
||||
const nonSpaceChars = positions
|
||||
.filter((p) => !p.isSpace && !p.isRevealed)
|
||||
.map((p) => p.char)
|
||||
const nonSpaceChars = positions.filter(p => !p.isSpace && !p.isRevealed).map(p => p.char);
|
||||
|
||||
for (let i = nonSpaceChars.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]]
|
||||
}
|
||||
|
||||
let charIndex = 0
|
||||
return positions
|
||||
.map((p) => {
|
||||
if (p.isSpace) return ' '
|
||||
if (p.isRevealed) return originalText[p.index]
|
||||
return nonSpaceChars[charIndex++]
|
||||
})
|
||||
.join('')
|
||||
} else {
|
||||
return originalText
|
||||
.split('')
|
||||
.map((char, i) => {
|
||||
if (char === ' ') return ' '
|
||||
if (currentRevealed.has(i)) return originalText[i]
|
||||
return availableChars[Math.floor(Math.random() * availableChars.length)]
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
}
|
||||
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
|
||||
if (isHovering.value) {
|
||||
isScrambling.value = true
|
||||
interval = setInterval(() => {
|
||||
if (props.sequential) {
|
||||
if (revealedIndices.value.size < props.text.length) {
|
||||
const nextIndex = getNextIndex(revealedIndices.value)
|
||||
const newRevealed = new Set(revealedIndices.value)
|
||||
newRevealed.add(nextIndex)
|
||||
revealedIndices.value = newRevealed
|
||||
displayText.value = shuffleText(props.text, newRevealed)
|
||||
} else {
|
||||
clearInterval(interval!)
|
||||
interval = null
|
||||
isScrambling.value = false
|
||||
emit('animationComplete')
|
||||
for (let i = nonSpaceChars.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]];
|
||||
}
|
||||
|
||||
let charIndex = 0;
|
||||
return positions
|
||||
.map(p => {
|
||||
if (p.isSpace) return ' ';
|
||||
if (p.isRevealed) return originalText[p.index];
|
||||
return nonSpaceChars[charIndex++];
|
||||
})
|
||||
.join('');
|
||||
} else {
|
||||
displayText.value = shuffleText(props.text, revealedIndices.value)
|
||||
currentIteration++
|
||||
if (currentIteration >= props.maxIterations) {
|
||||
clearInterval(interval!)
|
||||
interval = null
|
||||
isScrambling.value = false
|
||||
displayText.value = props.text
|
||||
emit('animationComplete')
|
||||
}
|
||||
return originalText
|
||||
.split('')
|
||||
.map((char, i) => {
|
||||
if (char === ' ') return ' ';
|
||||
if (currentRevealed.has(i)) return originalText[i];
|
||||
return availableChars[Math.floor(Math.random() * availableChars.length)];
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
}, props.speed)
|
||||
} else {
|
||||
displayText.value = props.text
|
||||
revealedIndices.value = new Set()
|
||||
isScrambling.value = false
|
||||
};
|
||||
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
|
||||
if (isHovering.value) {
|
||||
isScrambling.value = true;
|
||||
interval = setInterval(() => {
|
||||
if (props.sequential) {
|
||||
if (revealedIndices.value.size < props.text.length) {
|
||||
const nextIndex = getNextIndex(revealedIndices.value);
|
||||
const newRevealed = new Set(revealedIndices.value);
|
||||
newRevealed.add(nextIndex);
|
||||
revealedIndices.value = newRevealed;
|
||||
displayText.value = shuffleText(props.text, newRevealed);
|
||||
} else {
|
||||
clearInterval(interval!);
|
||||
interval = null;
|
||||
isScrambling.value = false;
|
||||
emit('animationComplete');
|
||||
}
|
||||
} else {
|
||||
displayText.value = shuffleText(props.text, revealedIndices.value);
|
||||
currentIteration++;
|
||||
if (currentIteration >= props.maxIterations) {
|
||||
clearInterval(interval!);
|
||||
interval = null;
|
||||
isScrambling.value = false;
|
||||
displayText.value = props.text;
|
||||
emit('animationComplete');
|
||||
}
|
||||
}
|
||||
}, props.speed);
|
||||
} else {
|
||||
displayText.value = props.text;
|
||||
revealedIndices.value = new Set();
|
||||
isScrambling.value = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (props.animateOn === 'hover') {
|
||||
isHovering.value = true
|
||||
isHovering.value = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (props.animateOn === 'hover') {
|
||||
isHovering.value = false
|
||||
isHovering.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.animateOn === 'view') {
|
||||
await nextTick()
|
||||
await nextTick();
|
||||
|
||||
const observerCallback = (entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !hasAnimated.value) {
|
||||
isHovering.value = true
|
||||
hasAnimated.value = true
|
||||
isHovering.value = true;
|
||||
hasAnimated.value = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.1
|
||||
}
|
||||
};
|
||||
|
||||
intersectionObserver = new IntersectionObserver(observerCallback, observerOptions)
|
||||
intersectionObserver = new IntersectionObserver(observerCallback, observerOptions);
|
||||
if (containerRef.value) {
|
||||
intersectionObserver.observe(containerRef.value)
|
||||
intersectionObserver.observe(containerRef.value);
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
clearInterval(interval);
|
||||
}
|
||||
if (intersectionObserver && containerRef.value) {
|
||||
intersectionObserver.unobserve(containerRef.value)
|
||||
intersectionObserver.unobserve(containerRef.value);
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="containerRef" :class="`inline-block whitespace-pre-wrap ${props.parentClassName}`"
|
||||
@mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
|
||||
<span
|
||||
ref="containerRef"
|
||||
:class="`inline-block whitespace-pre-wrap ${props.parentClassName}`"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<span class="sr-only">{{ displayText }}</span>
|
||||
|
||||
<span aria-hidden="true">
|
||||
<span v-for="(char, index) in displayText.split('')" :key="index" :class="(revealedIndices.has(index) || !isScrambling || !isHovering)
|
||||
? props.className
|
||||
: props.encryptedClassName">
|
||||
<span
|
||||
v-for="(char, index) in displayText.split('')"
|
||||
:key="index"
|
||||
:class="revealedIndices.has(index) || !isScrambling || !isHovering ? props.className : props.encryptedClassName"
|
||||
>
|
||||
{{ char }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import Matter from 'matter-js'
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
import Matter from 'matter-js';
|
||||
|
||||
interface FallingTextProps {
|
||||
text?: string
|
||||
highlightWords?: string[]
|
||||
trigger?: 'auto' | 'scroll' | 'click' | 'hover'
|
||||
backgroundColor?: string
|
||||
wireframes?: boolean
|
||||
gravity?: number
|
||||
mouseConstraintStiffness?: number
|
||||
fontSize?: string
|
||||
text?: string;
|
||||
highlightWords?: string[];
|
||||
trigger?: 'auto' | 'scroll' | 'click' | 'hover';
|
||||
backgroundColor?: string;
|
||||
wireframes?: boolean;
|
||||
gravity?: number;
|
||||
mouseConstraintStiffness?: number;
|
||||
fontSize?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<FallingTextProps>(), {
|
||||
@@ -22,80 +22,79 @@ const props = withDefaults(defineProps<FallingTextProps>(), {
|
||||
gravity: 1,
|
||||
mouseConstraintStiffness: 0.2,
|
||||
fontSize: '1rem'
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const textRef = ref<HTMLDivElement>()
|
||||
const canvasContainerRef = ref<HTMLDivElement>()
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const textRef = ref<HTMLDivElement>();
|
||||
const canvasContainerRef = ref<HTMLDivElement>();
|
||||
|
||||
const effectStarted = ref(false)
|
||||
const effectStarted = ref(false);
|
||||
|
||||
let engine: Matter.Engine | null = null
|
||||
let render: Matter.Render | null = null
|
||||
let runner: Matter.Runner | null = null
|
||||
let mouseConstraint: Matter.MouseConstraint | null = null
|
||||
let wordBodies: Array<{ elem: HTMLElement; body: Matter.Body }> = []
|
||||
let intersectionObserver: IntersectionObserver | null = null
|
||||
let animationFrameId: number | null = null
|
||||
let engine: Matter.Engine | null = null;
|
||||
let render: Matter.Render | null = null;
|
||||
let runner: Matter.Runner | null = null;
|
||||
let mouseConstraint: Matter.MouseConstraint | null = null;
|
||||
let wordBodies: Array<{ elem: HTMLElement; body: Matter.Body }> = [];
|
||||
let intersectionObserver: IntersectionObserver | null = null;
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
const createTextHTML = () => {
|
||||
if (!textRef.value) return
|
||||
if (!textRef.value) return;
|
||||
|
||||
const words = props.text.split(' ')
|
||||
const words = props.text.split(' ');
|
||||
const newHTML = words
|
||||
.map((word) => {
|
||||
const isHighlighted = props.highlightWords.some((hw) => word.startsWith(hw))
|
||||
return `<span class="inline-block mx-[2px] select-none ${isHighlighted ? 'text-green-500 font-bold' : ''
|
||||
}">${word}</span>`
|
||||
.map(word => {
|
||||
const isHighlighted = props.highlightWords.some(hw => word.startsWith(hw));
|
||||
return `<span class="inline-block mx-[2px] select-none ${isHighlighted ? 'text-green-500 font-bold' : ''}">${word}</span>`;
|
||||
})
|
||||
.join(' ')
|
||||
.join(' ');
|
||||
|
||||
textRef.value.innerHTML = newHTML
|
||||
}
|
||||
textRef.value.innerHTML = newHTML;
|
||||
};
|
||||
|
||||
const setupTrigger = () => {
|
||||
if (props.trigger === 'auto') {
|
||||
setTimeout(() => {
|
||||
effectStarted.value = true
|
||||
}, 100)
|
||||
return
|
||||
effectStarted.value = true;
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.trigger === 'scroll' && containerRef.value) {
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
effectStarted.value = true
|
||||
intersectionObserver?.disconnect()
|
||||
effectStarted.value = true;
|
||||
intersectionObserver?.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
intersectionObserver.observe(containerRef.value)
|
||||
);
|
||||
intersectionObserver.observe(containerRef.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrigger = () => {
|
||||
if (!effectStarted.value && (props.trigger === 'click' || props.trigger === 'hover')) {
|
||||
effectStarted.value = true
|
||||
effectStarted.value = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startPhysics = async () => {
|
||||
if (!containerRef.value || !canvasContainerRef.value || !textRef.value) return
|
||||
if (!containerRef.value || !canvasContainerRef.value || !textRef.value) return;
|
||||
|
||||
await nextTick()
|
||||
await nextTick();
|
||||
|
||||
const { Engine, Render, World, Bodies, Runner, Mouse, MouseConstraint } = Matter
|
||||
const { Engine, Render, World, Bodies, Runner, Mouse, MouseConstraint } = Matter;
|
||||
|
||||
const containerRect = containerRef.value.getBoundingClientRect()
|
||||
const width = containerRect.width
|
||||
const height = containerRect.height
|
||||
const containerRect = containerRef.value.getBoundingClientRect();
|
||||
const width = containerRect.width;
|
||||
const height = containerRect.height;
|
||||
|
||||
if (width <= 0 || height <= 0) return
|
||||
if (width <= 0 || height <= 0) return;
|
||||
|
||||
engine = Engine.create()
|
||||
engine.world.gravity.y = props.gravity
|
||||
engine = Engine.create();
|
||||
engine.world.gravity.y = props.gravity;
|
||||
|
||||
render = Render.create({
|
||||
element: canvasContainerRef.value,
|
||||
@@ -106,180 +105,175 @@ const startPhysics = async () => {
|
||||
background: props.backgroundColor,
|
||||
wireframes: props.wireframes
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const boundaryOptions = {
|
||||
isStatic: true,
|
||||
render: { fillStyle: 'transparent' }
|
||||
}
|
||||
};
|
||||
|
||||
const floor = Bodies.rectangle(width / 2, height + 25, width, 50, boundaryOptions)
|
||||
const leftWall = Bodies.rectangle(-25, height / 2, 50, height, boundaryOptions)
|
||||
const rightWall = Bodies.rectangle(width + 25, height / 2, 50, height, boundaryOptions)
|
||||
const ceiling = Bodies.rectangle(width / 2, -25, width, 50, boundaryOptions)
|
||||
const floor = Bodies.rectangle(width / 2, height + 25, width, 50, boundaryOptions);
|
||||
const leftWall = Bodies.rectangle(-25, height / 2, 50, height, boundaryOptions);
|
||||
const rightWall = Bodies.rectangle(width + 25, height / 2, 50, height, boundaryOptions);
|
||||
const ceiling = Bodies.rectangle(width / 2, -25, width, 50, boundaryOptions);
|
||||
|
||||
const wordSpans = textRef.value.querySelectorAll('span') as NodeListOf<HTMLElement>
|
||||
wordBodies = Array.from(wordSpans).map((elem) => {
|
||||
const rect = elem.getBoundingClientRect()
|
||||
const containerRect = containerRef.value!.getBoundingClientRect()
|
||||
const wordSpans = textRef.value.querySelectorAll('span') as NodeListOf<HTMLElement>;
|
||||
wordBodies = Array.from(wordSpans).map(elem => {
|
||||
const rect = elem.getBoundingClientRect();
|
||||
const containerRect = containerRef.value!.getBoundingClientRect();
|
||||
|
||||
const x = rect.left - containerRect.left + rect.width / 2
|
||||
const y = rect.top - containerRect.top + rect.height / 2
|
||||
const x = rect.left - containerRect.left + rect.width / 2;
|
||||
const y = rect.top - containerRect.top + rect.height / 2;
|
||||
|
||||
const body = Bodies.rectangle(x, y, rect.width, rect.height, {
|
||||
render: { fillStyle: 'transparent' },
|
||||
restitution: 0.8,
|
||||
frictionAir: 0.01,
|
||||
friction: 0.2
|
||||
})
|
||||
});
|
||||
|
||||
Matter.Body.setVelocity(body, {
|
||||
x: (Math.random() - 0.5) * 5,
|
||||
y: 0
|
||||
})
|
||||
Matter.Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.05)
|
||||
});
|
||||
Matter.Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.05);
|
||||
|
||||
return { elem, body }
|
||||
})
|
||||
return { elem, body };
|
||||
});
|
||||
|
||||
wordBodies.forEach(({ elem, body }) => {
|
||||
elem.style.position = 'absolute'
|
||||
elem.style.left = `${body.position.x - (body.bounds.max.x - body.bounds.min.x) / 2}px`
|
||||
elem.style.top = `${body.position.y - (body.bounds.max.y - body.bounds.min.y) / 2}px`
|
||||
elem.style.transform = 'none'
|
||||
})
|
||||
elem.style.position = 'absolute';
|
||||
elem.style.left = `${body.position.x - (body.bounds.max.x - body.bounds.min.x) / 2}px`;
|
||||
elem.style.top = `${body.position.y - (body.bounds.max.y - body.bounds.min.y) / 2}px`;
|
||||
elem.style.transform = 'none';
|
||||
});
|
||||
|
||||
const mouse = Mouse.create(containerRef.value)
|
||||
const mouse = Mouse.create(containerRef.value);
|
||||
mouseConstraint = MouseConstraint.create(engine, {
|
||||
mouse,
|
||||
constraint: {
|
||||
stiffness: props.mouseConstraintStiffness,
|
||||
render: { visible: false }
|
||||
}
|
||||
})
|
||||
render.mouse = mouse
|
||||
});
|
||||
render.mouse = mouse;
|
||||
|
||||
World.add(engine.world, [
|
||||
floor,
|
||||
leftWall,
|
||||
rightWall,
|
||||
ceiling,
|
||||
mouseConstraint,
|
||||
...wordBodies.map((wb) => wb.body)
|
||||
])
|
||||
World.add(engine.world, [floor, leftWall, rightWall, ceiling, mouseConstraint, ...wordBodies.map(wb => wb.body)]);
|
||||
|
||||
runner = Runner.create()
|
||||
Runner.run(runner, engine)
|
||||
Render.run(render)
|
||||
runner = Runner.create();
|
||||
Runner.run(runner, engine);
|
||||
Render.run(render);
|
||||
|
||||
const updateLoop = () => {
|
||||
wordBodies.forEach(({ body, elem }) => {
|
||||
const { x, y } = body.position
|
||||
elem.style.left = `${x}px`
|
||||
elem.style.top = `${y}px`
|
||||
elem.style.transform = `translate(-50%, -50%) rotate(${body.angle}rad)`
|
||||
})
|
||||
Matter.Engine.update(engine!)
|
||||
animationFrameId = requestAnimationFrame(updateLoop)
|
||||
}
|
||||
updateLoop()
|
||||
}
|
||||
const { x, y } = body.position;
|
||||
elem.style.left = `${x}px`;
|
||||
elem.style.top = `${y}px`;
|
||||
elem.style.transform = `translate(-50%, -50%) rotate(${body.angle}rad)`;
|
||||
});
|
||||
Matter.Engine.update(engine!);
|
||||
animationFrameId = requestAnimationFrame(updateLoop);
|
||||
};
|
||||
updateLoop();
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
|
||||
if (render) {
|
||||
Matter.Render.stop(render)
|
||||
Matter.Render.stop(render);
|
||||
if (render.canvas && canvasContainerRef.value) {
|
||||
canvasContainerRef.value.removeChild(render.canvas)
|
||||
canvasContainerRef.value.removeChild(render.canvas);
|
||||
}
|
||||
render = null
|
||||
render = null;
|
||||
}
|
||||
|
||||
if (runner && engine) {
|
||||
Matter.Runner.stop(runner)
|
||||
runner = null
|
||||
Matter.Runner.stop(runner);
|
||||
runner = null;
|
||||
}
|
||||
|
||||
if (engine) {
|
||||
Matter.World.clear(engine.world, false)
|
||||
Matter.Engine.clear(engine)
|
||||
engine = null
|
||||
Matter.World.clear(engine.world, false);
|
||||
Matter.Engine.clear(engine);
|
||||
engine = null;
|
||||
}
|
||||
|
||||
if (intersectionObserver) {
|
||||
intersectionObserver.disconnect()
|
||||
intersectionObserver = null
|
||||
intersectionObserver.disconnect();
|
||||
intersectionObserver = null;
|
||||
}
|
||||
|
||||
mouseConstraint = null
|
||||
wordBodies = []
|
||||
}
|
||||
mouseConstraint = null;
|
||||
wordBodies = [];
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.text, props.highlightWords],
|
||||
() => {
|
||||
createTextHTML()
|
||||
createTextHTML();
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.trigger,
|
||||
() => {
|
||||
effectStarted.value = false
|
||||
cleanup()
|
||||
setupTrigger()
|
||||
effectStarted.value = false;
|
||||
cleanup();
|
||||
setupTrigger();
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
);
|
||||
|
||||
watch(
|
||||
() => effectStarted.value,
|
||||
(started) => {
|
||||
started => {
|
||||
if (started) {
|
||||
startPhysics()
|
||||
startPhysics();
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.gravity,
|
||||
props.wireframes,
|
||||
props.backgroundColor,
|
||||
props.mouseConstraintStiffness
|
||||
],
|
||||
() => [props.gravity, props.wireframes, props.backgroundColor, props.mouseConstraintStiffness],
|
||||
() => {
|
||||
if (effectStarted.value) {
|
||||
cleanup()
|
||||
startPhysics()
|
||||
cleanup();
|
||||
startPhysics();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
createTextHTML()
|
||||
setupTrigger()
|
||||
})
|
||||
createTextHTML();
|
||||
setupTrigger();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative z-[1] w-full h-full cursor-pointer text-center pt-8 overflow-hidden"
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative z-[1] w-full h-full cursor-pointer text-center pt-8 overflow-hidden"
|
||||
@click="props.trigger === 'click' ? handleTrigger() : undefined"
|
||||
@mouseenter="props.trigger === 'hover' ? handleTrigger() : undefined">
|
||||
<div ref="textRef" class="inline-block" :style="{
|
||||
fontSize: props.fontSize,
|
||||
lineHeight: 1.4
|
||||
}" />
|
||||
@mouseenter="props.trigger === 'hover' ? handleTrigger() : undefined"
|
||||
>
|
||||
<div
|
||||
ref="textRef"
|
||||
class="inline-block"
|
||||
:style="{
|
||||
fontSize: props.fontSize,
|
||||
lineHeight: 1.4
|
||||
}"
|
||||
/>
|
||||
|
||||
<div class="absolute top-0 left-0 z-0" ref="canvasContainerRef" />
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
|
||||
interface FuzzyTextProps {
|
||||
text: string
|
||||
fontSize?: number | string
|
||||
fontWeight?: string | number
|
||||
fontFamily?: string
|
||||
color?: string
|
||||
enableHover?: boolean
|
||||
baseIntensity?: number
|
||||
hoverIntensity?: number
|
||||
text: string;
|
||||
fontSize?: number | string;
|
||||
fontWeight?: string | number;
|
||||
fontFamily?: string;
|
||||
color?: string;
|
||||
enableHover?: boolean;
|
||||
baseIntensity?: number;
|
||||
hoverIntensity?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<FuzzyTextProps>(), {
|
||||
@@ -21,246 +21,227 @@ const props = withDefaults(defineProps<FuzzyTextProps>(), {
|
||||
enableHover: true,
|
||||
baseIntensity: 0.18,
|
||||
hoverIntensity: 0.5
|
||||
})
|
||||
});
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
let animationFrameId: number
|
||||
let isCancelled = false
|
||||
let cleanup: (() => void) | null = null
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
let animationFrameId: number;
|
||||
let isCancelled = false;
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
const waitForFont = async (fontFamily: string, fontWeight: string | number, fontSize: string): Promise<boolean> => {
|
||||
if (document.fonts?.check) {
|
||||
const fontString = `${fontWeight} ${fontSize} ${fontFamily}`
|
||||
const fontString = `${fontWeight} ${fontSize} ${fontFamily}`;
|
||||
|
||||
if (document.fonts.check(fontString)) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await document.fonts.load(fontString)
|
||||
return document.fonts.check(fontString)
|
||||
await document.fonts.load(fontString);
|
||||
return document.fonts.check(fontString);
|
||||
} catch (error) {
|
||||
console.warn('Font loading failed:', error)
|
||||
return false
|
||||
console.warn('Font loading failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
return new Promise(resolve => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
resolve(false)
|
||||
return
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`
|
||||
const testWidth = ctx.measureText('M').width
|
||||
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
|
||||
const testWidth = ctx.measureText('M').width;
|
||||
|
||||
let attempts = 0
|
||||
let attempts = 0;
|
||||
const checkFont = () => {
|
||||
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`
|
||||
const newWidth = ctx.measureText('M').width
|
||||
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
|
||||
const newWidth = ctx.measureText('M').width;
|
||||
|
||||
if (newWidth !== testWidth && newWidth > 0) {
|
||||
resolve(true)
|
||||
resolve(true);
|
||||
} else if (attempts < 20) {
|
||||
attempts++
|
||||
setTimeout(checkFont, 50)
|
||||
attempts++;
|
||||
setTimeout(checkFont, 50);
|
||||
} else {
|
||||
resolve(false)
|
||||
resolve(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(checkFont, 10)
|
||||
})
|
||||
}
|
||||
setTimeout(checkFont, 10);
|
||||
});
|
||||
};
|
||||
|
||||
const initCanvas = async () => {
|
||||
if (document.fonts?.ready) {
|
||||
await document.fonts.ready
|
||||
await document.fonts.ready;
|
||||
}
|
||||
|
||||
if (isCancelled) return
|
||||
if (isCancelled) return;
|
||||
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const computedFontFamily = props.fontFamily === 'inherit'
|
||||
? window.getComputedStyle(canvas).fontFamily || 'sans-serif'
|
||||
: props.fontFamily
|
||||
const computedFontFamily =
|
||||
props.fontFamily === 'inherit' ? window.getComputedStyle(canvas).fontFamily || 'sans-serif' : props.fontFamily;
|
||||
|
||||
const fontSizeStr = typeof props.fontSize === 'number' ? `${props.fontSize}px` : props.fontSize
|
||||
let numericFontSize: number
|
||||
const fontSizeStr = typeof props.fontSize === 'number' ? `${props.fontSize}px` : props.fontSize;
|
||||
let numericFontSize: number;
|
||||
|
||||
if (typeof props.fontSize === 'number') {
|
||||
numericFontSize = props.fontSize
|
||||
numericFontSize = props.fontSize;
|
||||
} else {
|
||||
const temp = document.createElement('span')
|
||||
temp.style.fontSize = props.fontSize
|
||||
temp.style.fontFamily = computedFontFamily
|
||||
document.body.appendChild(temp)
|
||||
const computedSize = window.getComputedStyle(temp).fontSize
|
||||
numericFontSize = parseFloat(computedSize)
|
||||
document.body.removeChild(temp)
|
||||
const temp = document.createElement('span');
|
||||
temp.style.fontSize = props.fontSize;
|
||||
temp.style.fontFamily = computedFontFamily;
|
||||
document.body.appendChild(temp);
|
||||
const computedSize = window.getComputedStyle(temp).fontSize;
|
||||
numericFontSize = parseFloat(computedSize);
|
||||
document.body.removeChild(temp);
|
||||
}
|
||||
|
||||
const fontLoaded = await waitForFont(computedFontFamily, props.fontWeight, fontSizeStr)
|
||||
const fontLoaded = await waitForFont(computedFontFamily, props.fontWeight, fontSizeStr);
|
||||
if (!fontLoaded) {
|
||||
console.warn(`Font not loaded: ${computedFontFamily}`)
|
||||
console.warn(`Font not loaded: ${computedFontFamily}`);
|
||||
}
|
||||
|
||||
const text = props.text
|
||||
const text = props.text;
|
||||
|
||||
const offscreen = document.createElement('canvas')
|
||||
const offCtx = offscreen.getContext('2d')
|
||||
if (!offCtx) return
|
||||
const offscreen = document.createElement('canvas');
|
||||
const offCtx = offscreen.getContext('2d');
|
||||
if (!offCtx) return;
|
||||
|
||||
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`
|
||||
offCtx.font = fontString
|
||||
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
|
||||
offCtx.font = fontString;
|
||||
|
||||
const testMetrics = offCtx.measureText('M')
|
||||
const testMetrics = offCtx.measureText('M');
|
||||
if (testMetrics.width === 0) {
|
||||
setTimeout(() => {
|
||||
if (!isCancelled) {
|
||||
initCanvas()
|
||||
initCanvas();
|
||||
}
|
||||
}, 100)
|
||||
return
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
offCtx.textBaseline = 'alphabetic'
|
||||
const metrics = offCtx.measureText(text)
|
||||
offCtx.textBaseline = 'alphabetic';
|
||||
const metrics = offCtx.measureText(text);
|
||||
|
||||
const actualLeft = metrics.actualBoundingBoxLeft ?? 0
|
||||
const actualRight = metrics.actualBoundingBoxRight ?? metrics.width
|
||||
const actualAscent = metrics.actualBoundingBoxAscent ?? numericFontSize
|
||||
const actualDescent = metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2
|
||||
const actualLeft = metrics.actualBoundingBoxLeft ?? 0;
|
||||
const actualRight = metrics.actualBoundingBoxRight ?? metrics.width;
|
||||
const actualAscent = metrics.actualBoundingBoxAscent ?? numericFontSize;
|
||||
const actualDescent = metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2;
|
||||
|
||||
const textBoundingWidth = Math.ceil(actualLeft + actualRight)
|
||||
const tightHeight = Math.ceil(actualAscent + actualDescent)
|
||||
const textBoundingWidth = Math.ceil(actualLeft + actualRight);
|
||||
const tightHeight = Math.ceil(actualAscent + actualDescent);
|
||||
|
||||
const extraWidthBuffer = 10
|
||||
const offscreenWidth = textBoundingWidth + extraWidthBuffer
|
||||
const extraWidthBuffer = 10;
|
||||
const offscreenWidth = textBoundingWidth + extraWidthBuffer;
|
||||
|
||||
offscreen.width = offscreenWidth
|
||||
offscreen.height = tightHeight
|
||||
offscreen.width = offscreenWidth;
|
||||
offscreen.height = tightHeight;
|
||||
|
||||
const xOffset = extraWidthBuffer / 2
|
||||
offCtx.font = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`
|
||||
offCtx.textBaseline = 'alphabetic'
|
||||
offCtx.fillStyle = props.color
|
||||
offCtx.fillText(text, xOffset - actualLeft, actualAscent)
|
||||
const xOffset = extraWidthBuffer / 2;
|
||||
offCtx.font = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
|
||||
offCtx.textBaseline = 'alphabetic';
|
||||
offCtx.fillStyle = props.color;
|
||||
offCtx.fillText(text, xOffset - actualLeft, actualAscent);
|
||||
|
||||
const horizontalMargin = 50
|
||||
const verticalMargin = 0
|
||||
canvas.width = offscreenWidth + horizontalMargin * 2
|
||||
canvas.height = tightHeight + verticalMargin * 2
|
||||
ctx.translate(horizontalMargin, verticalMargin)
|
||||
const horizontalMargin = 50;
|
||||
const verticalMargin = 0;
|
||||
canvas.width = offscreenWidth + horizontalMargin * 2;
|
||||
canvas.height = tightHeight + verticalMargin * 2;
|
||||
ctx.translate(horizontalMargin, verticalMargin);
|
||||
|
||||
const interactiveLeft = horizontalMargin + xOffset
|
||||
const interactiveTop = verticalMargin
|
||||
const interactiveRight = interactiveLeft + textBoundingWidth
|
||||
const interactiveBottom = interactiveTop + tightHeight
|
||||
const interactiveLeft = horizontalMargin + xOffset;
|
||||
const interactiveTop = verticalMargin;
|
||||
const interactiveRight = interactiveLeft + textBoundingWidth;
|
||||
const interactiveBottom = interactiveTop + tightHeight;
|
||||
|
||||
let isHovering = false
|
||||
const fuzzRange = 30
|
||||
let isHovering = false;
|
||||
const fuzzRange = 30;
|
||||
|
||||
const run = () => {
|
||||
if (isCancelled) return
|
||||
ctx.clearRect(
|
||||
-fuzzRange,
|
||||
-fuzzRange,
|
||||
offscreenWidth + 2 * fuzzRange,
|
||||
tightHeight + 2 * fuzzRange
|
||||
)
|
||||
const intensity = isHovering ? props.hoverIntensity : props.baseIntensity
|
||||
if (isCancelled) return;
|
||||
ctx.clearRect(-fuzzRange, -fuzzRange, offscreenWidth + 2 * fuzzRange, tightHeight + 2 * fuzzRange);
|
||||
const intensity = isHovering ? props.hoverIntensity : props.baseIntensity;
|
||||
for (let j = 0; j < tightHeight; j++) {
|
||||
const dx = Math.floor(intensity * (Math.random() - 0.5) * fuzzRange)
|
||||
ctx.drawImage(
|
||||
offscreen,
|
||||
0,
|
||||
j,
|
||||
offscreenWidth,
|
||||
1,
|
||||
dx,
|
||||
j,
|
||||
offscreenWidth,
|
||||
1
|
||||
)
|
||||
const dx = Math.floor(intensity * (Math.random() - 0.5) * fuzzRange);
|
||||
ctx.drawImage(offscreen, 0, j, offscreenWidth, 1, dx, j, offscreenWidth, 1);
|
||||
}
|
||||
animationFrameId = window.requestAnimationFrame(run)
|
||||
}
|
||||
animationFrameId = window.requestAnimationFrame(run);
|
||||
};
|
||||
|
||||
run()
|
||||
run();
|
||||
|
||||
const isInsideTextArea = (x: number, y: number) =>
|
||||
x >= interactiveLeft &&
|
||||
x <= interactiveRight &&
|
||||
y >= interactiveTop &&
|
||||
y <= interactiveBottom
|
||||
x >= interactiveLeft && x <= interactiveRight && y >= interactiveTop && y <= interactiveBottom;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!props.enableHover) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
isHovering = isInsideTextArea(x, y)
|
||||
}
|
||||
if (!props.enableHover) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
isHovering = isInsideTextArea(x, y);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovering = false
|
||||
}
|
||||
isHovering = false;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!props.enableHover) return
|
||||
e.preventDefault()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const touch = e.touches[0]
|
||||
const x = touch.clientX - rect.left
|
||||
const y = touch.clientY - rect.top
|
||||
isHovering = isInsideTextArea(x, y)
|
||||
}
|
||||
if (!props.enableHover) return;
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const touch = e.touches[0];
|
||||
const x = touch.clientX - rect.left;
|
||||
const y = touch.clientY - rect.top;
|
||||
isHovering = isInsideTextArea(x, y);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
isHovering = false
|
||||
}
|
||||
isHovering = false;
|
||||
};
|
||||
|
||||
if (props.enableHover) {
|
||||
canvas.addEventListener('mousemove', handleMouseMove)
|
||||
canvas.addEventListener('mouseleave', handleMouseLeave)
|
||||
canvas.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
canvas.addEventListener('touchend', handleTouchEnd)
|
||||
canvas.addEventListener('mousemove', handleMouseMove);
|
||||
canvas.addEventListener('mouseleave', handleMouseLeave);
|
||||
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
canvas.addEventListener('touchend', handleTouchEnd);
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
window.cancelAnimationFrame(animationFrameId)
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
if (props.enableHover) {
|
||||
canvas.removeEventListener('mousemove', handleMouseMove)
|
||||
canvas.removeEventListener('mouseleave', handleMouseLeave)
|
||||
canvas.removeEventListener('touchmove', handleTouchMove)
|
||||
canvas.removeEventListener('touchend', handleTouchEnd)
|
||||
canvas.removeEventListener('mousemove', handleMouseMove);
|
||||
canvas.removeEventListener('mouseleave', handleMouseLeave);
|
||||
canvas.removeEventListener('touchmove', handleTouchMove);
|
||||
canvas.removeEventListener('touchend', handleTouchEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initCanvas()
|
||||
})
|
||||
})
|
||||
initCanvas();
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
isCancelled = true
|
||||
isCancelled = true;
|
||||
if (animationFrameId) {
|
||||
window.cancelAnimationFrame(animationFrameId)
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
if (cleanup) {
|
||||
cleanup()
|
||||
cleanup();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
@@ -274,19 +255,19 @@ watch(
|
||||
() => props.hoverIntensity
|
||||
],
|
||||
() => {
|
||||
isCancelled = true
|
||||
isCancelled = true;
|
||||
if (animationFrameId) {
|
||||
window.cancelAnimationFrame(animationFrameId)
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
if (cleanup) {
|
||||
cleanup()
|
||||
cleanup();
|
||||
}
|
||||
isCancelled = false
|
||||
isCancelled = false;
|
||||
nextTick(() => {
|
||||
initCanvas()
|
||||
})
|
||||
initCanvas();
|
||||
});
|
||||
}
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
183
src/content/TextAnimations/GlitchText/GlitchText.vue
Normal file
183
src/content/TextAnimations/GlitchText/GlitchText.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div :class="computedClasses" :style="inlineStyles" :data-text="children">
|
||||
{{ children }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
interface GlitchTextProps {
|
||||
children: string;
|
||||
speed?: number;
|
||||
enableShadows?: boolean;
|
||||
enableOnHover?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CustomCSSProperties extends CSSProperties {
|
||||
'--after-duration': string;
|
||||
'--before-duration': string;
|
||||
'--after-shadow': string;
|
||||
'--before-shadow': string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<GlitchTextProps>(), {
|
||||
speed: 0.5,
|
||||
enableShadows: true,
|
||||
enableOnHover: false,
|
||||
className: ''
|
||||
});
|
||||
|
||||
const inlineStyles = computed(
|
||||
(): CustomCSSProperties => ({
|
||||
'--after-duration': `${props.speed * 3}s`,
|
||||
'--before-duration': `${props.speed * 2}s`,
|
||||
'--after-shadow': props.enableShadows ? '-5px 0 red' : 'none',
|
||||
'--before-shadow': props.enableShadows ? '5px 0 cyan' : 'none'
|
||||
})
|
||||
);
|
||||
|
||||
const baseClasses = [
|
||||
'text-white',
|
||||
'font-black',
|
||||
'whitespace-nowrap',
|
||||
'relative',
|
||||
'mx-auto',
|
||||
'select-none',
|
||||
'cursor-pointer',
|
||||
'text-[clamp(2rem,10vw,8rem)]',
|
||||
|
||||
'before:content-[attr(data-text)]',
|
||||
'before:absolute',
|
||||
'before:top-0',
|
||||
'before:text-white',
|
||||
'before:bg-[#060010]',
|
||||
'before:overflow-hidden',
|
||||
'before:[clip-path:inset(0_0_0_0)]',
|
||||
|
||||
'after:content-[attr(data-text)]',
|
||||
'after:absolute',
|
||||
'after:top-0',
|
||||
'after:text-white',
|
||||
'after:bg-[#060010]',
|
||||
'after:overflow-hidden',
|
||||
'after:[clip-path:inset(0_0_0_0)]'
|
||||
];
|
||||
|
||||
const normalGlitchClasses = [
|
||||
'after:left-[10px]',
|
||||
'after:[text-shadow:var(--after-shadow,-10px_0_red)]',
|
||||
'after:[animation:animate-glitch_var(--after-duration,3s)_infinite_linear_alternate-reverse]',
|
||||
|
||||
'before:left-[-10px]',
|
||||
'before:[text-shadow:var(--before-shadow,10px_0_cyan)]',
|
||||
'before:[animation:animate-glitch_var(--before-duration,2s)_infinite_linear_alternate-reverse]'
|
||||
];
|
||||
|
||||
const hoverOnlyClasses = [
|
||||
'before:content-[""]',
|
||||
'before:opacity-0',
|
||||
'before:[animation:none]',
|
||||
'after:content-[""]',
|
||||
'after:opacity-0',
|
||||
'after:[animation:none]',
|
||||
|
||||
'hover:before:content-[attr(data-text)]',
|
||||
'hover:before:opacity-100',
|
||||
'hover:before:left-[-10px]',
|
||||
'hover:before:[text-shadow:var(--before-shadow,10px_0_cyan)]',
|
||||
'hover:before:[animation:animate-glitch_var(--before-duration,2s)_infinite_linear_alternate-reverse]',
|
||||
|
||||
'hover:after:content-[attr(data-text)]',
|
||||
'hover:after:opacity-100',
|
||||
'hover:after:left-[10px]',
|
||||
'hover:after:[text-shadow:var(--after-shadow,-10px_0_red)]',
|
||||
'hover:after:[animation:animate-glitch_var(--after-duration,3s)_infinite_linear_alternate-reverse]'
|
||||
];
|
||||
|
||||
const computedClasses = computed(() => {
|
||||
const classes = [...baseClasses];
|
||||
|
||||
if (props.enableOnHover) {
|
||||
classes.push(...hoverOnlyClasses);
|
||||
} else {
|
||||
classes.push(...normalGlitchClasses);
|
||||
}
|
||||
|
||||
if (props.className) {
|
||||
classes.push(props.className);
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes animate-glitch {
|
||||
0% {
|
||||
clip-path: inset(20% 0 50% 0);
|
||||
}
|
||||
5% {
|
||||
clip-path: inset(10% 0 60% 0);
|
||||
}
|
||||
10% {
|
||||
clip-path: inset(15% 0 55% 0);
|
||||
}
|
||||
15% {
|
||||
clip-path: inset(25% 0 35% 0);
|
||||
}
|
||||
20% {
|
||||
clip-path: inset(30% 0 40% 0);
|
||||
}
|
||||
25% {
|
||||
clip-path: inset(40% 0 20% 0);
|
||||
}
|
||||
30% {
|
||||
clip-path: inset(10% 0 60% 0);
|
||||
}
|
||||
35% {
|
||||
clip-path: inset(15% 0 55% 0);
|
||||
}
|
||||
40% {
|
||||
clip-path: inset(25% 0 35% 0);
|
||||
}
|
||||
45% {
|
||||
clip-path: inset(30% 0 40% 0);
|
||||
}
|
||||
50% {
|
||||
clip-path: inset(20% 0 50% 0);
|
||||
}
|
||||
55% {
|
||||
clip-path: inset(10% 0 60% 0);
|
||||
}
|
||||
60% {
|
||||
clip-path: inset(15% 0 55% 0);
|
||||
}
|
||||
65% {
|
||||
clip-path: inset(25% 0 35% 0);
|
||||
}
|
||||
70% {
|
||||
clip-path: inset(30% 0 40% 0);
|
||||
}
|
||||
75% {
|
||||
clip-path: inset(40% 0 20% 0);
|
||||
}
|
||||
80% {
|
||||
clip-path: inset(20% 0 50% 0);
|
||||
}
|
||||
85% {
|
||||
clip-path: inset(10% 0 60% 0);
|
||||
}
|
||||
90% {
|
||||
clip-path: inset(15% 0 55% 0);
|
||||
}
|
||||
95% {
|
||||
clip-path: inset(25% 0 35% 0);
|
||||
}
|
||||
100% {
|
||||
clip-path: inset(30% 0 40% 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface GradientTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
colors?: string[]
|
||||
animationSpeed?: number
|
||||
showBorder?: boolean
|
||||
text: string;
|
||||
className?: string;
|
||||
colors?: string[];
|
||||
animationSpeed?: number;
|
||||
showBorder?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<GradientTextProps>(), {
|
||||
@@ -15,24 +15,24 @@ const props = withDefaults(defineProps<GradientTextProps>(), {
|
||||
colors: () => ['#ffaa40', '#9c40ff', '#ffaa40'],
|
||||
animationSpeed: 8,
|
||||
showBorder: false
|
||||
})
|
||||
});
|
||||
|
||||
const gradientStyle = computed(() => ({
|
||||
backgroundImage: `linear-gradient(to right, ${props.colors.join(', ')})`,
|
||||
animationDuration: `${props.animationSpeed}s`,
|
||||
backgroundSize: '300% 100%',
|
||||
'--animation-duration': `${props.animationSpeed}s`
|
||||
}))
|
||||
}));
|
||||
|
||||
const borderStyle = computed(() => ({
|
||||
...gradientStyle.value
|
||||
}))
|
||||
}));
|
||||
|
||||
const textStyle = computed(() => ({
|
||||
...gradientStyle.value,
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text'
|
||||
}))
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,20 +46,11 @@ const textStyle = computed(() => ({
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black rounded-[1.25rem] z-[-1]"
|
||||
style="
|
||||
width: calc(100% - 2px);
|
||||
height: calc(100% - 2px);
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
"
|
||||
style="width: calc(100% - 2px); height: calc(100% - 2px); left: 50%; top: 50%; transform: translate(-50%, -50%)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="inline-block relative z-2 text-transparent bg-cover animate-gradient"
|
||||
:style="textStyle"
|
||||
>
|
||||
|
||||
<div class="inline-block relative z-2 text-transparent bg-cover animate-gradient" :style="textStyle">
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
248
src/content/TextAnimations/RotatingText/RotatingText.vue
Normal file
248
src/content/TextAnimations/RotatingText/RotatingText.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import { AnimatePresence, Motion, type Target, type Transition, type VariantLabels } from 'motion-v';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
type StaggerFrom = 'first' | 'last' | 'center' | 'random' | number;
|
||||
type SplitBy = 'characters' | 'words' | 'lines';
|
||||
|
||||
interface WordElement {
|
||||
characters: string[];
|
||||
needsSpace: boolean;
|
||||
}
|
||||
|
||||
interface RotatingTextProps {
|
||||
texts: string[];
|
||||
transition?: Transition;
|
||||
initial?: boolean | Target | VariantLabels;
|
||||
animate?: Target | VariantLabels;
|
||||
exit?: Target | VariantLabels;
|
||||
animatePresenceMode?: 'sync' | 'wait';
|
||||
animatePresenceInitial?: boolean;
|
||||
rotationInterval?: number;
|
||||
staggerDuration?: number;
|
||||
staggerFrom?: StaggerFrom;
|
||||
loop?: boolean;
|
||||
auto?: boolean;
|
||||
splitBy?: SplitBy;
|
||||
onNext?: (index: number) => void;
|
||||
mainClassName?: string;
|
||||
splitLevelClassName?: string;
|
||||
elementLevelClassName?: string;
|
||||
}
|
||||
|
||||
const cn = (...classes: (string | undefined | null | boolean)[]): string => {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<RotatingTextProps>(), {
|
||||
transition: () =>
|
||||
({
|
||||
type: 'spring',
|
||||
damping: 25,
|
||||
stiffness: 300
|
||||
}) as Transition,
|
||||
initial: () => ({ y: '100%', opacity: 0 }) as Target,
|
||||
animate: () => ({ y: 0, opacity: 1 }) as Target,
|
||||
exit: () => ({ y: '-120%', opacity: 0 }) as Target,
|
||||
animatePresenceMode: 'wait',
|
||||
animatePresenceInitial: false,
|
||||
rotationInterval: 2000,
|
||||
staggerDuration: 0,
|
||||
staggerFrom: 'first',
|
||||
loop: true,
|
||||
auto: true,
|
||||
splitBy: 'characters'
|
||||
});
|
||||
|
||||
const currentTextIndex = ref(0);
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const splitIntoCharacters = (text: string): string[] => {
|
||||
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const segmenter = new (Intl as any).Segmenter('en', { granularity: 'grapheme' });
|
||||
return [...segmenter.segment(text)].map(({ segment }) => segment);
|
||||
}
|
||||
|
||||
return [...text];
|
||||
};
|
||||
|
||||
const elements = computed((): WordElement[] => {
|
||||
const currentText = props.texts[currentTextIndex.value];
|
||||
|
||||
switch (props.splitBy) {
|
||||
case 'characters': {
|
||||
const words = currentText.split(' ');
|
||||
return words.map((word, i) => ({
|
||||
characters: splitIntoCharacters(word),
|
||||
needsSpace: i !== words.length - 1
|
||||
}));
|
||||
}
|
||||
case 'words': {
|
||||
const words = currentText.split(' ');
|
||||
return words.map((word, i) => ({
|
||||
characters: [word],
|
||||
needsSpace: i !== words.length - 1
|
||||
}));
|
||||
}
|
||||
case 'lines': {
|
||||
const lines = currentText.split('\n');
|
||||
return lines.map((line, i) => ({
|
||||
characters: [line],
|
||||
needsSpace: i !== lines.length - 1
|
||||
}));
|
||||
}
|
||||
default: {
|
||||
const parts = currentText.split(props.splitBy!);
|
||||
return parts.map((part, i) => ({
|
||||
characters: [part],
|
||||
needsSpace: i !== parts.length - 1
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const getStaggerDelay = (index: number, totalChars: number): number => {
|
||||
const { staggerDuration, staggerFrom } = props;
|
||||
|
||||
switch (staggerFrom) {
|
||||
case 'first':
|
||||
return index * staggerDuration;
|
||||
case 'last':
|
||||
return (totalChars - 1 - index) * staggerDuration;
|
||||
case 'center': {
|
||||
const center = Math.floor(totalChars / 2);
|
||||
return Math.abs(center - index) * staggerDuration;
|
||||
}
|
||||
case 'random': {
|
||||
const randomIndex = Math.floor(Math.random() * totalChars);
|
||||
return Math.abs(randomIndex - index) * staggerDuration;
|
||||
}
|
||||
default:
|
||||
return Math.abs((staggerFrom as number) - index) * staggerDuration;
|
||||
}
|
||||
};
|
||||
|
||||
const handleIndexChange = (newIndex: number): void => {
|
||||
currentTextIndex.value = newIndex;
|
||||
props.onNext?.(newIndex);
|
||||
};
|
||||
|
||||
const next = (): void => {
|
||||
const isAtEnd = currentTextIndex.value === props.texts.length - 1;
|
||||
const nextIndex = isAtEnd ? (props.loop ? 0 : currentTextIndex.value) : currentTextIndex.value + 1;
|
||||
|
||||
if (nextIndex !== currentTextIndex.value) {
|
||||
handleIndexChange(nextIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const previous = (): void => {
|
||||
const isAtStart = currentTextIndex.value === 0;
|
||||
const prevIndex = isAtStart
|
||||
? props.loop
|
||||
? props.texts.length - 1
|
||||
: currentTextIndex.value
|
||||
: currentTextIndex.value - 1;
|
||||
|
||||
if (prevIndex !== currentTextIndex.value) {
|
||||
handleIndexChange(prevIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const jumpTo = (index: number): void => {
|
||||
const validIndex = Math.max(0, Math.min(index, props.texts.length - 1));
|
||||
if (validIndex !== currentTextIndex.value) {
|
||||
handleIndexChange(validIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = (): void => {
|
||||
if (currentTextIndex.value !== 0) {
|
||||
handleIndexChange(0);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupInterval = (): void => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startInterval = (): void => {
|
||||
if (props.auto) {
|
||||
intervalId = setInterval(next, props.rotationInterval);
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
next,
|
||||
previous,
|
||||
jumpTo,
|
||||
reset
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.auto, props.rotationInterval] as const,
|
||||
() => {
|
||||
cleanupInterval();
|
||||
startInterval();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
startInterval();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupInterval();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Motion
|
||||
tag="span"
|
||||
:class="cn('flex flex-wrap whitespace-pre-wrap relative', mainClassName)"
|
||||
v-bind="$attrs"
|
||||
:transition="transition"
|
||||
layout
|
||||
>
|
||||
<span class="sr-only">
|
||||
{{ texts[currentTextIndex] }}
|
||||
</span>
|
||||
|
||||
<AnimatePresence :mode="animatePresenceMode" :initial="animatePresenceInitial">
|
||||
<Motion
|
||||
:key="currentTextIndex"
|
||||
tag="span"
|
||||
:class="cn(splitBy === 'lines' ? 'flex flex-col w-full' : 'flex flex-wrap whitespace-pre-wrap relative')"
|
||||
aria-hidden="true"
|
||||
layout
|
||||
>
|
||||
<span v-for="(wordObj, wordIndex) in elements" :key="wordIndex" :class="cn('inline-flex', splitLevelClassName)">
|
||||
<Motion
|
||||
v-for="(char, charIndex) in wordObj.characters"
|
||||
:key="charIndex"
|
||||
tag="span"
|
||||
:initial="initial"
|
||||
:animate="animate"
|
||||
:exit="exit"
|
||||
:transition="{
|
||||
...transition,
|
||||
delay: getStaggerDelay(
|
||||
elements.slice(0, wordIndex).reduce((sum, word) => sum + word.characters.length, 0) + charIndex,
|
||||
elements.reduce((sum, word) => sum + word.characters.length, 0)
|
||||
)
|
||||
}"
|
||||
:class="cn('inline-block', elementLevelClassName)"
|
||||
>
|
||||
{{ char }}
|
||||
</Motion>
|
||||
<span v-if="wordObj.needsSpace" class="whitespace-pre"></span>
|
||||
</span>
|
||||
</Motion>
|
||||
</AnimatePresence>
|
||||
</Motion>
|
||||
</template>
|
||||
120
src/content/TextAnimations/ScrollFloat/ScrollFloat.vue
Normal file
120
src/content/TextAnimations/ScrollFloat/ScrollFloat.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<h2 ref="containerRef" :class="`overflow-hidden ${containerClassName}`">
|
||||
<span
|
||||
:class="`inline-block text-center leading-relaxed font-black ${textClassName}`"
|
||||
style="font-size: clamp(1.6rem, 8vw, 10rem)"
|
||||
>
|
||||
<span v-for="(char, index) in splitText" :key="index" class="inline-block char">
|
||||
{{ char === ' ' ? '\u00A0' : char }}
|
||||
</span>
|
||||
</span>
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
interface Props {
|
||||
children: string;
|
||||
scrollContainerRef?: { current: HTMLElement | null };
|
||||
containerClassName?: string;
|
||||
textClassName?: string;
|
||||
animationDuration?: number;
|
||||
ease?: string;
|
||||
scrollStart?: string;
|
||||
scrollEnd?: string;
|
||||
stagger?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
containerClassName: '',
|
||||
textClassName: '',
|
||||
animationDuration: 1,
|
||||
ease: 'back.inOut(2)',
|
||||
scrollStart: 'center bottom+=50%',
|
||||
scrollEnd: 'bottom bottom-=40%',
|
||||
stagger: 0.03
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
let scrollTriggerInstance: ScrollTrigger | null = null;
|
||||
|
||||
const splitText = computed(() => {
|
||||
const text = typeof props.children === 'string' ? props.children : '';
|
||||
return text.split('');
|
||||
});
|
||||
|
||||
const initializeAnimation = () => {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
|
||||
const scroller =
|
||||
props.scrollContainerRef && props.scrollContainerRef.current ? props.scrollContainerRef.current : window;
|
||||
|
||||
const charElements = el.querySelectorAll('.char');
|
||||
|
||||
if (scrollTriggerInstance) {
|
||||
scrollTriggerInstance.kill();
|
||||
}
|
||||
|
||||
const tl = gsap.fromTo(
|
||||
charElements,
|
||||
{
|
||||
willChange: 'opacity, transform',
|
||||
opacity: 0,
|
||||
yPercent: 120,
|
||||
scaleY: 2.3,
|
||||
scaleX: 0.7,
|
||||
transformOrigin: '50% 0%'
|
||||
},
|
||||
{
|
||||
duration: props.animationDuration,
|
||||
ease: props.ease,
|
||||
opacity: 1,
|
||||
yPercent: 0,
|
||||
scaleY: 1,
|
||||
scaleX: 1,
|
||||
stagger: props.stagger,
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
scroller,
|
||||
start: props.scrollStart,
|
||||
end: props.scrollEnd,
|
||||
scrub: true
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
scrollTriggerInstance = tl.scrollTrigger || null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeAnimation();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (scrollTriggerInstance) {
|
||||
scrollTriggerInstance.kill();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.children,
|
||||
() => props.scrollContainerRef,
|
||||
() => props.animationDuration,
|
||||
() => props.ease,
|
||||
() => props.scrollStart,
|
||||
() => props.scrollEnd,
|
||||
() => props.stagger
|
||||
],
|
||||
() => {
|
||||
initializeAnimation();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
155
src/content/TextAnimations/ScrollReveal/ScrollReveal.vue
Normal file
155
src/content/TextAnimations/ScrollReveal/ScrollReveal.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<h2 ref="containerRef" :class="`my-5 ${containerClassName}`">
|
||||
<p :class="`leading-relaxed font-semibold ${textClassName}`" style="font-size: clamp(1.6rem, 4vw, 3rem)">
|
||||
<span v-for="(word, index) in splitText" :key="index" :class="word.isWhitespace ? '' : 'inline-block word'">
|
||||
{{ word.text }}
|
||||
</span>
|
||||
</p>
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
interface Props {
|
||||
children: string;
|
||||
scrollContainerRef?: { current: HTMLElement | null };
|
||||
enableBlur?: boolean;
|
||||
baseOpacity?: number;
|
||||
baseRotation?: number;
|
||||
blurStrength?: number;
|
||||
containerClassName?: string;
|
||||
textClassName?: string;
|
||||
rotationEnd?: string;
|
||||
wordAnimationEnd?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
enableBlur: true,
|
||||
baseOpacity: 0.1,
|
||||
baseRotation: 3,
|
||||
blurStrength: 4,
|
||||
containerClassName: '',
|
||||
textClassName: '',
|
||||
rotationEnd: 'bottom bottom',
|
||||
wordAnimationEnd: 'bottom bottom'
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
let scrollTriggerInstances: ScrollTrigger[] = [];
|
||||
|
||||
const splitText = computed(() => {
|
||||
const text = typeof props.children === 'string' ? props.children : '';
|
||||
return text.split(/(\s+)/).map((word, index) => ({
|
||||
text: word,
|
||||
isWhitespace: word.match(/^\s+$/) !== null,
|
||||
key: index
|
||||
}));
|
||||
});
|
||||
|
||||
const initializeAnimation = () => {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
|
||||
scrollTriggerInstances.forEach(trigger => trigger.kill());
|
||||
scrollTriggerInstances = [];
|
||||
|
||||
const scroller =
|
||||
props.scrollContainerRef && props.scrollContainerRef.current ? props.scrollContainerRef.current : window;
|
||||
|
||||
const rotationTl = gsap.fromTo(
|
||||
el,
|
||||
{ transformOrigin: '0% 50%', rotate: props.baseRotation },
|
||||
{
|
||||
ease: 'none',
|
||||
rotate: 0,
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
scroller,
|
||||
start: 'top bottom',
|
||||
end: props.rotationEnd,
|
||||
scrub: true
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (rotationTl.scrollTrigger) {
|
||||
scrollTriggerInstances.push(rotationTl.scrollTrigger);
|
||||
}
|
||||
|
||||
const wordElements = el.querySelectorAll('.word');
|
||||
|
||||
const opacityTl = gsap.fromTo(
|
||||
wordElements,
|
||||
{ opacity: props.baseOpacity, willChange: 'opacity' },
|
||||
{
|
||||
ease: 'none',
|
||||
opacity: 1,
|
||||
stagger: 0.05,
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
scroller,
|
||||
start: 'top bottom-=20%',
|
||||
end: props.wordAnimationEnd,
|
||||
scrub: true
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (opacityTl.scrollTrigger) {
|
||||
scrollTriggerInstances.push(opacityTl.scrollTrigger);
|
||||
}
|
||||
|
||||
if (props.enableBlur) {
|
||||
const blurTl = gsap.fromTo(
|
||||
wordElements,
|
||||
{ filter: `blur(${props.blurStrength}px)` },
|
||||
{
|
||||
ease: 'none',
|
||||
filter: 'blur(0px)',
|
||||
stagger: 0.05,
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
scroller,
|
||||
start: 'top bottom-=20%',
|
||||
end: props.wordAnimationEnd,
|
||||
scrub: true
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (blurTl.scrollTrigger) {
|
||||
scrollTriggerInstances.push(blurTl.scrollTrigger);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeAnimation();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
scrollTriggerInstances.forEach(trigger => trigger.kill());
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.children,
|
||||
() => props.scrollContainerRef,
|
||||
() => props.enableBlur,
|
||||
() => props.baseRotation,
|
||||
() => props.baseOpacity,
|
||||
() => props.rotationEnd,
|
||||
() => props.wordAnimationEnd,
|
||||
() => props.blurStrength
|
||||
],
|
||||
() => {
|
||||
initializeAnimation();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface ShinyTextProps {
|
||||
text: string
|
||||
disabled?: boolean
|
||||
speed?: number
|
||||
className?: string
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
speed?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ShinyTextProps>(), {
|
||||
@@ -13,16 +13,17 @@ const props = withDefaults(defineProps<ShinyTextProps>(), {
|
||||
disabled: false,
|
||||
speed: 5,
|
||||
className: ''
|
||||
})
|
||||
});
|
||||
|
||||
const animationDuration = computed(() => `${props.speed}s`)
|
||||
const animationDuration = computed(() => `${props.speed}s`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="`text-[#b5b5b5a4] bg-clip-text inline-block ${!props.disabled ? 'animate-shine' : ''} ${props.className}`"
|
||||
:style="{
|
||||
backgroundImage: 'linear-gradient(120deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, 0.8) 50%, rgba(255, 255, 255, 0) 60%)',
|
||||
backgroundImage:
|
||||
'linear-gradient(120deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, 0.8) 50%, rgba(255, 255, 255, 0) 60%)',
|
||||
backgroundSize: '200% 100%',
|
||||
WebkitBackgroundClip: 'text',
|
||||
animationDuration: animationDuration
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
:class="`split-parent overflow-hidden inline-block whitespace-normal ${className}`"
|
||||
:style="{
|
||||
textAlign,
|
||||
wordWrap: 'break-word',
|
||||
wordWrap: 'break-word'
|
||||
}"
|
||||
>
|
||||
{{ text }}
|
||||
@@ -12,26 +12,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
import { SplitText as GSAPSplitText } from 'gsap/SplitText'
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger, GSAPSplitText)
|
||||
gsap.registerPlugin(ScrollTrigger, GSAPSplitText);
|
||||
|
||||
export interface SplitTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
delay?: number
|
||||
duration?: number
|
||||
ease?: string | ((t: number) => number)
|
||||
splitType?: 'chars' | 'words' | 'lines' | 'words, chars'
|
||||
from?: gsap.TweenVars
|
||||
to?: gsap.TweenVars
|
||||
threshold?: number
|
||||
rootMargin?: string
|
||||
textAlign?: 'left' | 'center' | 'right' | 'justify'
|
||||
onLetterAnimationComplete?: () => void
|
||||
text: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
ease?: string | ((t: number) => number);
|
||||
splitType?: 'chars' | 'words' | 'lines' | 'words, chars';
|
||||
from?: gsap.TweenVars;
|
||||
to?: gsap.TweenVars;
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
textAlign?: 'left' | 'center' | 'right' | 'justify';
|
||||
onLetterAnimationComplete?: () => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SplitTextProps>(), {
|
||||
@@ -45,74 +45,74 @@ const props = withDefaults(defineProps<SplitTextProps>(), {
|
||||
threshold: 0.1,
|
||||
rootMargin: '-100px',
|
||||
textAlign: 'center'
|
||||
})
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'animation-complete': []
|
||||
}>()
|
||||
'animation-complete': [];
|
||||
}>();
|
||||
|
||||
const textRef = ref<HTMLParagraphElement | null>(null)
|
||||
const animationCompletedRef = ref(false)
|
||||
const scrollTriggerRef = ref<ScrollTrigger | null>(null)
|
||||
const timelineRef = ref<gsap.core.Timeline | null>(null)
|
||||
const splitterRef = ref<GSAPSplitText | null>(null)
|
||||
const textRef = ref<HTMLParagraphElement | null>(null);
|
||||
const animationCompletedRef = ref(false);
|
||||
const scrollTriggerRef = ref<ScrollTrigger | null>(null);
|
||||
const timelineRef = ref<gsap.core.Timeline | null>(null);
|
||||
const splitterRef = ref<GSAPSplitText | null>(null);
|
||||
|
||||
const initializeAnimation = async () => {
|
||||
if (typeof window === 'undefined' || !textRef.value || !props.text) return
|
||||
if (typeof window === 'undefined' || !textRef.value || !props.text) return;
|
||||
|
||||
await nextTick()
|
||||
|
||||
const el = textRef.value
|
||||
|
||||
animationCompletedRef.value = false
|
||||
await nextTick();
|
||||
|
||||
const absoluteLines = props.splitType === 'lines'
|
||||
if (absoluteLines) el.style.position = 'relative'
|
||||
const el = textRef.value;
|
||||
|
||||
let splitter: GSAPSplitText
|
||||
animationCompletedRef.value = false;
|
||||
|
||||
const absoluteLines = props.splitType === 'lines';
|
||||
if (absoluteLines) el.style.position = 'relative';
|
||||
|
||||
let splitter: GSAPSplitText;
|
||||
try {
|
||||
splitter = new GSAPSplitText(el, {
|
||||
type: props.splitType,
|
||||
absolute: absoluteLines,
|
||||
linesClass: 'split-line',
|
||||
})
|
||||
splitterRef.value = splitter
|
||||
linesClass: 'split-line'
|
||||
});
|
||||
splitterRef.value = splitter;
|
||||
} catch (error) {
|
||||
console.error('Failed to create SplitText:', error)
|
||||
return
|
||||
console.error('Failed to create SplitText:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
let targets: Element[]
|
||||
let targets: Element[];
|
||||
switch (props.splitType) {
|
||||
case 'lines':
|
||||
targets = splitter.lines
|
||||
break
|
||||
targets = splitter.lines;
|
||||
break;
|
||||
case 'words':
|
||||
targets = splitter.words
|
||||
break
|
||||
targets = splitter.words;
|
||||
break;
|
||||
case 'chars':
|
||||
targets = splitter.chars
|
||||
break
|
||||
targets = splitter.chars;
|
||||
break;
|
||||
default:
|
||||
targets = splitter.chars
|
||||
targets = splitter.chars;
|
||||
}
|
||||
|
||||
if (!targets || targets.length === 0) {
|
||||
console.warn('No targets found for SplitText animation')
|
||||
splitter.revert()
|
||||
return
|
||||
console.warn('No targets found for SplitText animation');
|
||||
splitter.revert();
|
||||
return;
|
||||
}
|
||||
|
||||
targets.forEach((t) => {
|
||||
;(t as HTMLElement).style.willChange = 'transform, opacity'
|
||||
})
|
||||
targets.forEach(t => {
|
||||
(t as HTMLElement).style.willChange = 'transform, opacity';
|
||||
});
|
||||
|
||||
const startPct = (1 - props.threshold) * 100
|
||||
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin)
|
||||
const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0
|
||||
const marginUnit = marginMatch ? (marginMatch[2] || 'px') : 'px'
|
||||
const sign = marginValue < 0 ? `-=${Math.abs(marginValue)}${marginUnit}` : `+=${marginValue}${marginUnit}`
|
||||
const start = `top ${startPct}%${sign}`
|
||||
const startPct = (1 - props.threshold) * 100;
|
||||
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin);
|
||||
const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0;
|
||||
const marginUnit = marginMatch ? marginMatch[2] || 'px' : 'px';
|
||||
const sign = marginValue < 0 ? `-=${Math.abs(marginValue)}${marginUnit}` : `+=${marginValue}${marginUnit}`;
|
||||
const start = `top ${startPct}%${sign}`;
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
@@ -120,58 +120,58 @@ const initializeAnimation = async () => {
|
||||
start,
|
||||
toggleActions: 'play none none none',
|
||||
once: true,
|
||||
onToggle: (self) => {
|
||||
scrollTriggerRef.value = self
|
||||
},
|
||||
onToggle: self => {
|
||||
scrollTriggerRef.value = self;
|
||||
}
|
||||
},
|
||||
smoothChildTiming: true,
|
||||
onComplete: () => {
|
||||
animationCompletedRef.value = true
|
||||
animationCompletedRef.value = true;
|
||||
gsap.set(targets, {
|
||||
...props.to,
|
||||
clearProps: 'willChange',
|
||||
immediateRender: true,
|
||||
})
|
||||
props.onLetterAnimationComplete?.()
|
||||
emit('animation-complete')
|
||||
},
|
||||
})
|
||||
immediateRender: true
|
||||
});
|
||||
props.onLetterAnimationComplete?.();
|
||||
emit('animation-complete');
|
||||
}
|
||||
});
|
||||
|
||||
timelineRef.value = tl
|
||||
timelineRef.value = tl;
|
||||
|
||||
tl.set(targets, { ...props.from, immediateRender: false, force3D: true })
|
||||
tl.set(targets, { ...props.from, immediateRender: false, force3D: true });
|
||||
tl.to(targets, {
|
||||
...props.to,
|
||||
duration: props.duration,
|
||||
ease: props.ease,
|
||||
stagger: props.delay / 1000,
|
||||
force3D: true,
|
||||
})
|
||||
}
|
||||
force3D: true
|
||||
});
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (timelineRef.value) {
|
||||
timelineRef.value.kill()
|
||||
timelineRef.value = null
|
||||
timelineRef.value.kill();
|
||||
timelineRef.value = null;
|
||||
}
|
||||
if (scrollTriggerRef.value) {
|
||||
scrollTriggerRef.value.kill()
|
||||
scrollTriggerRef.value = null
|
||||
scrollTriggerRef.value.kill();
|
||||
scrollTriggerRef.value = null;
|
||||
}
|
||||
if (splitterRef.value) {
|
||||
gsap.killTweensOf(textRef.value)
|
||||
splitterRef.value.revert()
|
||||
splitterRef.value = null
|
||||
gsap.killTweensOf(textRef.value);
|
||||
splitterRef.value.revert();
|
||||
splitterRef.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeAnimation()
|
||||
})
|
||||
initializeAnimation();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
cleanup();
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
@@ -184,12 +184,11 @@ watch(
|
||||
() => props.to,
|
||||
() => props.threshold,
|
||||
() => props.rootMargin,
|
||||
() => props.onLetterAnimationComplete,
|
||||
() => props.onLetterAnimationComplete
|
||||
],
|
||||
() => {
|
||||
cleanup()
|
||||
initializeAnimation()
|
||||
cleanup();
|
||||
initializeAnimation();
|
||||
}
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Motion } from 'motion-v'
|
||||
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
|
||||
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
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
angle: number;
|
||||
randomX?: number;
|
||||
randomY?: number;
|
||||
randomRotate?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TextCursorProps>(), {
|
||||
@@ -32,24 +32,24 @@ const props = withDefaults(defineProps<TextCursorProps>(), {
|
||||
exitDuration: 0.5,
|
||||
removalInterval: 30,
|
||||
maxPoints: 5
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const trail = ref<TrailItem[]>([])
|
||||
const lastMoveTime = ref(Date.now())
|
||||
const idCounter = ref(0)
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const trail = ref<TrailItem[]>([]);
|
||||
const lastMoveTime = ref(Date.now());
|
||||
const idCounter = ref(0);
|
||||
|
||||
let removalIntervalId: number | null = null
|
||||
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
|
||||
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];
|
||||
|
||||
let newTrail = [...trail.value]
|
||||
|
||||
if (newTrail.length === 0) {
|
||||
newTrail.push({
|
||||
id: idCounter.value++,
|
||||
@@ -61,24 +61,24 @@ const handleMouseMove = (e: MouseEvent) => {
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
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,
|
||||
@@ -89,48 +89,48 @@ const handleMouseMove = (e: MouseEvent) => {
|
||||
randomY: Math.random() * 10 - 5,
|
||||
randomRotate: Math.random() * 10 - 5
|
||||
})
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (newTrail.length > props.maxPoints) {
|
||||
newTrail = newTrail.slice(newTrail.length - props.maxPoints)
|
||||
newTrail = newTrail.slice(newTrail.length - props.maxPoints);
|
||||
}
|
||||
|
||||
trail.value = newTrail
|
||||
lastMoveTime.value = Date.now()
|
||||
}
|
||||
|
||||
trail.value = newTrail;
|
||||
lastMoveTime.value = Date.now();
|
||||
};
|
||||
|
||||
const startRemovalInterval = () => {
|
||||
if (removalIntervalId) {
|
||||
clearInterval(removalIntervalId)
|
||||
clearInterval(removalIntervalId);
|
||||
}
|
||||
|
||||
|
||||
removalIntervalId = setInterval(() => {
|
||||
if (Date.now() - lastMoveTime.value > 100) {
|
||||
if (trail.value.length > 0) {
|
||||
trail.value = trail.value.slice(1)
|
||||
trail.value = trail.value.slice(1);
|
||||
}
|
||||
}
|
||||
}, props.removalInterval)
|
||||
}
|
||||
}, props.removalInterval);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.addEventListener('mousemove', handleMouseMove)
|
||||
startRemovalInterval()
|
||||
containerRef.value.addEventListener('mousemove', handleMouseMove);
|
||||
startRemovalInterval();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('mousemove', handleMouseMove)
|
||||
containerRef.value.removeEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
if (removalIntervalId) {
|
||||
clearInterval(removalIntervalId)
|
||||
clearInterval(removalIntervalId);
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -140,16 +140,14 @@ onUnmounted(() => {
|
||||
v-for="item in trail"
|
||||
:key="item.id"
|
||||
:initial="{ opacity: 0, scale: 1, rotate: item.angle }"
|
||||
:animate="{
|
||||
opacity: 1,
|
||||
: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
|
||||
rotate: props.randomFloat ? [item.angle, item.angle + (item.randomRotate || 0), item.angle] : item.angle
|
||||
}"
|
||||
:transition="{
|
||||
:transition="{
|
||||
duration: props.randomFloat ? 2 : props.exitDuration,
|
||||
repeat: props.randomFloat ? Infinity : 0,
|
||||
repeatType: props.randomFloat ? 'mirror' : 'loop'
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue';
|
||||
|
||||
interface TextPressureProps {
|
||||
text?: string
|
||||
fontFamily?: string
|
||||
fontUrl?: string
|
||||
width?: boolean
|
||||
weight?: boolean
|
||||
italic?: boolean
|
||||
alpha?: boolean
|
||||
flex?: boolean
|
||||
stroke?: boolean
|
||||
scale?: boolean
|
||||
textColor?: string
|
||||
strokeColor?: string
|
||||
strokeWidth?: number
|
||||
className?: string
|
||||
minFontSize?: number
|
||||
text?: string;
|
||||
fontFamily?: string;
|
||||
fontUrl?: string;
|
||||
width?: boolean;
|
||||
weight?: boolean;
|
||||
italic?: boolean;
|
||||
alpha?: boolean;
|
||||
flex?: boolean;
|
||||
stroke?: boolean;
|
||||
scale?: boolean;
|
||||
textColor?: string;
|
||||
strokeColor?: string;
|
||||
strokeWidth?: number;
|
||||
className?: string;
|
||||
minFontSize?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TextPressureProps>(), {
|
||||
@@ -35,141 +35,141 @@ const props = withDefaults(defineProps<TextPressureProps>(), {
|
||||
strokeWidth: 2,
|
||||
className: '',
|
||||
minFontSize: 24
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const titleRef = ref<HTMLHeadingElement | null>(null)
|
||||
const spansRef = ref<(HTMLSpanElement | null)[]>([])
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const titleRef = ref<HTMLHeadingElement | null>(null);
|
||||
const spansRef = ref<(HTMLSpanElement | null)[]>([]);
|
||||
|
||||
const mouseRef = ref({ x: 0, y: 0 })
|
||||
const cursorRef = ref({ x: 0, y: 0 })
|
||||
const mouseRef = ref({ x: 0, y: 0 });
|
||||
const cursorRef = ref({ x: 0, y: 0 });
|
||||
|
||||
const fontSize = ref(props.minFontSize)
|
||||
const scaleY = ref(1)
|
||||
const lineHeight = ref(1)
|
||||
const fontSize = ref(props.minFontSize);
|
||||
const scaleY = ref(1);
|
||||
const lineHeight = ref(1);
|
||||
|
||||
const chars = computed(() => props.text.split(''))
|
||||
const chars = computed(() => props.text.split(''));
|
||||
|
||||
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => {
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
cursorRef.value.x = e.clientX
|
||||
cursorRef.value.y = e.clientY
|
||||
}
|
||||
cursorRef.value.x = e.clientX;
|
||||
cursorRef.value.y = e.clientY;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
const t = e.touches[0]
|
||||
cursorRef.value.x = t.clientX
|
||||
cursorRef.value.y = t.clientY
|
||||
}
|
||||
const t = e.touches[0];
|
||||
cursorRef.value.x = t.clientX;
|
||||
cursorRef.value.y = t.clientY;
|
||||
};
|
||||
|
||||
const setSize = () => {
|
||||
if (!containerRef.value || !titleRef.value) return
|
||||
if (!containerRef.value || !titleRef.value) return;
|
||||
|
||||
const { width: containerW, height: containerH } = containerRef.value.getBoundingClientRect()
|
||||
const { width: containerW, height: containerH } = containerRef.value.getBoundingClientRect();
|
||||
|
||||
let newFontSize = containerW / (chars.value.length / 2)
|
||||
newFontSize = Math.max(newFontSize, props.minFontSize)
|
||||
let newFontSize = containerW / (chars.value.length / 2);
|
||||
newFontSize = Math.max(newFontSize, props.minFontSize);
|
||||
|
||||
fontSize.value = newFontSize
|
||||
scaleY.value = 1
|
||||
lineHeight.value = 1
|
||||
fontSize.value = newFontSize;
|
||||
scaleY.value = 1;
|
||||
lineHeight.value = 1;
|
||||
|
||||
nextTick(() => {
|
||||
if (!titleRef.value) return
|
||||
const textRect = titleRef.value.getBoundingClientRect()
|
||||
if (!titleRef.value) return;
|
||||
const textRect = titleRef.value.getBoundingClientRect();
|
||||
|
||||
if (props.scale && textRect.height > 0) {
|
||||
const yRatio = containerH / textRect.height
|
||||
scaleY.value = yRatio
|
||||
lineHeight.value = yRatio
|
||||
const yRatio = containerH / textRect.height;
|
||||
scaleY.value = yRatio;
|
||||
lineHeight.value = yRatio;
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let rafId: number
|
||||
let rafId: number;
|
||||
|
||||
const animate = () => {
|
||||
mouseRef.value.x += (cursorRef.value.x - mouseRef.value.x) / 15
|
||||
mouseRef.value.y += (cursorRef.value.y - mouseRef.value.y) / 15
|
||||
mouseRef.value.x += (cursorRef.value.x - mouseRef.value.x) / 15;
|
||||
mouseRef.value.y += (cursorRef.value.y - mouseRef.value.y) / 15;
|
||||
|
||||
if (titleRef.value) {
|
||||
const titleRect = titleRef.value.getBoundingClientRect()
|
||||
const maxDist = titleRect.width / 2
|
||||
const titleRect = titleRef.value.getBoundingClientRect();
|
||||
const maxDist = titleRect.width / 2;
|
||||
|
||||
spansRef.value.forEach((span) => {
|
||||
if (!span) return
|
||||
spansRef.value.forEach(span => {
|
||||
if (!span) return;
|
||||
|
||||
const rect = span.getBoundingClientRect()
|
||||
const rect = span.getBoundingClientRect();
|
||||
const charCenter = {
|
||||
x: rect.x + rect.width / 2,
|
||||
y: rect.y + rect.height / 2,
|
||||
}
|
||||
y: rect.y + rect.height / 2
|
||||
};
|
||||
|
||||
const d = dist(mouseRef.value, charCenter)
|
||||
const d = dist(mouseRef.value, charCenter);
|
||||
|
||||
const getAttr = (distance: number, minVal: number, maxVal: number) => {
|
||||
const val = maxVal - Math.abs((maxVal * distance) / maxDist)
|
||||
return Math.max(minVal, val + minVal)
|
||||
}
|
||||
const val = maxVal - Math.abs((maxVal * distance) / maxDist);
|
||||
return Math.max(minVal, val + minVal);
|
||||
};
|
||||
|
||||
const wdth = props.width ? Math.floor(getAttr(d, 5, 200)) : 100
|
||||
const wght = props.weight ? Math.floor(getAttr(d, 100, 900)) : 400
|
||||
const italVal = props.italic ? getAttr(d, 0, 1).toFixed(2) : '0'
|
||||
const alphaVal = props.alpha ? getAttr(d, 0, 1).toFixed(2) : '1'
|
||||
const wdth = props.width ? Math.floor(getAttr(d, 5, 200)) : 100;
|
||||
const wght = props.weight ? Math.floor(getAttr(d, 100, 900)) : 400;
|
||||
const italVal = props.italic ? getAttr(d, 0, 1).toFixed(2) : '0';
|
||||
const alphaVal = props.alpha ? getAttr(d, 0, 1).toFixed(2) : '1';
|
||||
|
||||
span.style.opacity = alphaVal
|
||||
span.style.fontVariationSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${italVal}`
|
||||
})
|
||||
span.style.opacity = alphaVal;
|
||||
span.style.fontVariationSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${italVal}`;
|
||||
});
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(animate)
|
||||
}
|
||||
rafId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const styleElement = document.createElement('style')
|
||||
styleElement.textContent = dynamicStyles.value
|
||||
document.head.appendChild(styleElement)
|
||||
styleElement.setAttribute('data-text-pressure', 'true')
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = dynamicStyles.value;
|
||||
document.head.appendChild(styleElement);
|
||||
styleElement.setAttribute('data-text-pressure', 'true');
|
||||
|
||||
setSize()
|
||||
setSize();
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
window.addEventListener('resize', setSize)
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
window.addEventListener('resize', setSize);
|
||||
|
||||
if (containerRef.value) {
|
||||
const { left, top, width, height } = containerRef.value.getBoundingClientRect()
|
||||
mouseRef.value.x = left + width / 2
|
||||
mouseRef.value.y = top + height / 2
|
||||
cursorRef.value.x = mouseRef.value.x
|
||||
cursorRef.value.y = mouseRef.value.y
|
||||
const { left, top, width, height } = containerRef.value.getBoundingClientRect();
|
||||
mouseRef.value.x = left + width / 2;
|
||||
mouseRef.value.y = top + height / 2;
|
||||
cursorRef.value.x = mouseRef.value.x;
|
||||
cursorRef.value.y = mouseRef.value.y;
|
||||
}
|
||||
|
||||
animate()
|
||||
})
|
||||
animate();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
const styleElements = document.querySelectorAll('style[data-text-pressure="true"]')
|
||||
styleElements.forEach(el => el.remove())
|
||||
const styleElements = document.querySelectorAll('style[data-text-pressure="true"]');
|
||||
styleElements.forEach(el => el.remove());
|
||||
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('touchmove', handleTouchMove)
|
||||
window.removeEventListener('resize', setSize)
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
window.removeEventListener('resize', setSize);
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
watch([() => props.scale, () => props.text], () => {
|
||||
setSize()
|
||||
})
|
||||
setSize();
|
||||
});
|
||||
|
||||
watch([() => props.width, () => props.weight, () => props.italic, () => props.alpha], () => {})
|
||||
watch([() => props.width, () => props.weight, () => props.italic, () => props.alpha], () => {});
|
||||
|
||||
const titleStyle = computed(() => ({
|
||||
fontFamily: props.fontFamily,
|
||||
@@ -179,10 +179,11 @@ const titleStyle = computed(() => ({
|
||||
transformOrigin: 'center top',
|
||||
margin: 0,
|
||||
fontWeight: 100,
|
||||
color: props.stroke ? undefined : props.textColor,
|
||||
}))
|
||||
color: props.stroke ? undefined : props.textColor
|
||||
}));
|
||||
|
||||
const dynamicStyles = computed(() => `
|
||||
const dynamicStyles = computed(
|
||||
() => `
|
||||
@font-face {
|
||||
font-family: '${props.fontFamily}';
|
||||
src: url('${props.fontUrl}');
|
||||
@@ -202,29 +203,37 @@ const dynamicStyles = computed(() => `
|
||||
-webkit-text-stroke-width: ${props.strokeWidth}px;
|
||||
-webkit-text-stroke-color: ${props.strokeColor};
|
||||
}
|
||||
`)
|
||||
`
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
const styleElement = document.createElement('style')
|
||||
styleElement.textContent = dynamicStyles.value
|
||||
document.head.appendChild(styleElement)
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = dynamicStyles.value;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
styleElement.setAttribute('data-text-pressure', 'true')
|
||||
})
|
||||
styleElement.setAttribute('data-text-pressure', 'true');
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
const styleElements = document.querySelectorAll('style[data-text-pressure="true"]')
|
||||
styleElements.forEach(el => el.remove())
|
||||
})
|
||||
const styleElements = document.querySelectorAll('style[data-text-pressure="true"]');
|
||||
styleElements.forEach(el => el.remove());
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative w-full h-full overflow-hidden bg-transparent">
|
||||
<h1 ref="titleRef"
|
||||
<h1
|
||||
ref="titleRef"
|
||||
:class="`text-pressure-title ${className} ${flex ? 'flex justify-between' : ''} ${stroke ? 'stroke' : ''} uppercase text-center`"
|
||||
:style="titleStyle">
|
||||
<span v-for="(char, i) in chars" :key="i" :ref="(el) => spansRef[i] = el as HTMLSpanElement" :data-char="char"
|
||||
class="inline-block">
|
||||
:style="titleStyle"
|
||||
>
|
||||
<span
|
||||
v-for="(char, i) in chars"
|
||||
:key="i"
|
||||
:ref="el => (spansRef[i] = el as HTMLSpanElement)"
|
||||
:data-char="char"
|
||||
class="inline-block"
|
||||
>
|
||||
{{ char }}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import {
|
||||
CanvasTexture,
|
||||
Clock,
|
||||
@@ -15,22 +15,22 @@ import {
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
WebGLRenderTarget
|
||||
} from 'three'
|
||||
} from 'three';
|
||||
|
||||
interface TextTrailProps {
|
||||
text?: string
|
||||
fontFamily?: string
|
||||
fontWeight?: string | number
|
||||
noiseFactor?: number
|
||||
noiseScale?: number
|
||||
rgbPersistFactor?: number
|
||||
alphaPersistFactor?: number
|
||||
animateColor?: boolean
|
||||
startColor?: string
|
||||
textColor?: string
|
||||
backgroundColor?: number | string
|
||||
colorCycleInterval?: number
|
||||
supersample?: number
|
||||
text?: string;
|
||||
fontFamily?: string;
|
||||
fontWeight?: string | number;
|
||||
noiseFactor?: number;
|
||||
noiseScale?: number;
|
||||
rgbPersistFactor?: number;
|
||||
alphaPersistFactor?: number;
|
||||
animateColor?: boolean;
|
||||
startColor?: string;
|
||||
textColor?: string;
|
||||
backgroundColor?: number | string;
|
||||
colorCycleInterval?: number;
|
||||
supersample?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TextTrailProps>(), {
|
||||
@@ -47,31 +47,31 @@ const props = withDefaults(defineProps<TextTrailProps>(), {
|
||||
backgroundColor: 0x151515,
|
||||
colorCycleInterval: 3000,
|
||||
supersample: 2
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
|
||||
const hexToRgb = (hex: string): [number, number, number] => {
|
||||
let h = hex.replace('#', '')
|
||||
let h = hex.replace('#', '');
|
||||
if (h.length === 3)
|
||||
h = h
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
const n = parseInt(h, 16)
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||
}
|
||||
.map(c => c + c)
|
||||
.join('');
|
||||
const n = parseInt(h, 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
};
|
||||
|
||||
const loadFont = async (fam: string) => {
|
||||
if ('fonts' in document) {
|
||||
const fonts = (document as Document & { fonts: { load: (font: string) => Promise<void> } }).fonts
|
||||
await fonts.load(`64px "${fam}"`)
|
||||
const fonts = (document as Document & { fonts: { load: (font: string) => Promise<void> } }).fonts;
|
||||
await fonts.load(`64px "${fam}"`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const BASE_VERT = `
|
||||
varying vec2 v_uv;
|
||||
void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);v_uv=uv;}`
|
||||
void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);v_uv=uv;}`;
|
||||
|
||||
const SIMPLEX = `
|
||||
vec3 mod289(vec3 x){return x-floor(x*(1./289.))*289.;}
|
||||
@@ -114,7 +114,7 @@ float snoise3(vec3 v){
|
||||
vec4 m=max(.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.);
|
||||
m*=m;
|
||||
return 42.*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
|
||||
}`
|
||||
}`;
|
||||
|
||||
const PERSIST_FRAG = `
|
||||
uniform sampler2D sampler;
|
||||
@@ -128,7 +128,7 @@ void main(){
|
||||
float b=snoise3(vec3(v_uv*noiseFactor,time*.1+100.))*noiseScale;
|
||||
vec4 t=texture2D(sampler,v_uv+vec2(a,b)+mousePos*.005);
|
||||
gl_FragColor=vec4(t.xyz*rgbPersistFactor,alphaPersistFactor);
|
||||
}`
|
||||
}`;
|
||||
|
||||
const TEXT_FRAG = `
|
||||
uniform sampler2D sampler;uniform vec3 color;varying vec2 v_uv;
|
||||
@@ -137,77 +137,77 @@ void main(){
|
||||
float alpha=smoothstep(0.1,0.9,t.a);
|
||||
if(alpha<0.01)discard;
|
||||
gl_FragColor=vec4(color,alpha);
|
||||
}`
|
||||
}`;
|
||||
|
||||
let renderer: WebGLRenderer | null = null
|
||||
let scene: Scene | null = null
|
||||
let fluidScene: Scene | null = null
|
||||
let clock: Clock | null = null
|
||||
let cam: OrthographicCamera | null = null
|
||||
let rt0: WebGLRenderTarget | null = null
|
||||
let rt1: WebGLRenderTarget | null = null
|
||||
let quadMat: ShaderMaterial | null = null
|
||||
let quad: Mesh | null = null
|
||||
let labelMat: ShaderMaterial | null = null
|
||||
let label: Mesh | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let colorTimer: number | null = null
|
||||
let renderer: WebGLRenderer | null = null;
|
||||
let scene: Scene | null = null;
|
||||
let fluidScene: Scene | null = null;
|
||||
let clock: Clock | null = null;
|
||||
let cam: OrthographicCamera | null = null;
|
||||
let rt0: WebGLRenderTarget | null = null;
|
||||
let rt1: WebGLRenderTarget | null = null;
|
||||
let quadMat: ShaderMaterial | null = null;
|
||||
let quad: Mesh | null = null;
|
||||
let labelMat: ShaderMaterial | null = null;
|
||||
let label: Mesh | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let colorTimer: number | null = null;
|
||||
|
||||
const persistColor = ref<[number, number, number]>(
|
||||
hexToRgb(props.textColor || props.startColor).map((c) => c / 255) as [number, number, number]
|
||||
)
|
||||
const targetColor = ref<[number, number, number]>([...persistColor.value])
|
||||
hexToRgb(props.textColor || props.startColor).map(c => c / 255) as [number, number, number]
|
||||
);
|
||||
const targetColor = ref<[number, number, number]>([...persistColor.value]);
|
||||
|
||||
const mouse = [0, 0]
|
||||
const target = [0, 0]
|
||||
const mouse = [0, 0];
|
||||
const target = [0, 0];
|
||||
|
||||
const getSize = () => ({
|
||||
w: containerRef.value!.clientWidth,
|
||||
h: containerRef.value!.clientHeight
|
||||
})
|
||||
});
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!containerRef.value) return
|
||||
const r = containerRef.value.getBoundingClientRect()
|
||||
target[0] = ((e.clientX - r.left) / r.width) * 2 - 1
|
||||
target[1] = ((r.top + r.height - e.clientY) / r.height) * 2 - 1
|
||||
}
|
||||
if (!containerRef.value) return;
|
||||
const r = containerRef.value.getBoundingClientRect();
|
||||
target[0] = ((e.clientX - r.left) / r.width) * 2 - 1;
|
||||
target[1] = ((r.top + r.height - e.clientY) / r.height) * 2 - 1;
|
||||
};
|
||||
|
||||
const drawText = () => {
|
||||
if (!renderer || !labelMat) return
|
||||
|
||||
const texCanvas = document.createElement('canvas')
|
||||
if (!renderer || !labelMat) return;
|
||||
|
||||
const texCanvas = document.createElement('canvas');
|
||||
const ctx = texCanvas.getContext('2d', {
|
||||
alpha: true,
|
||||
colorSpace: 'srgb'
|
||||
})!
|
||||
|
||||
const max = Math.min(renderer.capabilities.maxTextureSize, 4096)
|
||||
const pixelRatio = (window.devicePixelRatio || 1) * props.supersample
|
||||
const canvasSize = max * pixelRatio
|
||||
texCanvas.width = canvasSize
|
||||
texCanvas.height = canvasSize
|
||||
texCanvas.style.width = `${max}px`
|
||||
texCanvas.style.height = `${max}px`
|
||||
})!;
|
||||
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.scale(pixelRatio, pixelRatio)
|
||||
ctx.clearRect(0, 0, max, max)
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
ctx.shadowColor = 'rgba(255,255,255,0.3)'
|
||||
ctx.shadowBlur = 2
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
const max = Math.min(renderer.capabilities.maxTextureSize, 4096);
|
||||
const pixelRatio = (window.devicePixelRatio || 1) * props.supersample;
|
||||
const canvasSize = max * pixelRatio;
|
||||
texCanvas.width = canvasSize;
|
||||
texCanvas.height = canvasSize;
|
||||
texCanvas.style.width = `${max}px`;
|
||||
texCanvas.style.height = `${max}px`;
|
||||
|
||||
const refSize = 250
|
||||
ctx.font = `${props.fontWeight} ${refSize}px ${props.fontFamily}`
|
||||
const width = ctx.measureText(props.text).width
|
||||
ctx.font = `${props.fontWeight} ${(refSize * max) / width}px ${props.fontFamily}`
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.scale(pixelRatio, pixelRatio);
|
||||
ctx.clearRect(0, 0, max, max);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.shadowColor = 'rgba(255,255,255,0.3)';
|
||||
ctx.shadowBlur = 2;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const cx = max / 2
|
||||
const cy = max / 2
|
||||
const refSize = 250;
|
||||
ctx.font = `${props.fontWeight} ${refSize}px ${props.fontFamily}`;
|
||||
const width = ctx.measureText(props.text).width;
|
||||
ctx.font = `${props.fontWeight} ${(refSize * max) / width}px ${props.fontFamily}`;
|
||||
|
||||
const cx = max / 2;
|
||||
const cy = max / 2;
|
||||
const offs = [
|
||||
[0, 0],
|
||||
[0.1, 0],
|
||||
@@ -218,42 +218,40 @@ const drawText = () => {
|
||||
[-0.1, -0.1],
|
||||
[0.1, -0.1],
|
||||
[-0.1, 0.1]
|
||||
]
|
||||
ctx.globalAlpha = 1 / offs.length
|
||||
offs.forEach(([dx, dy]) => ctx.fillText(props.text, cx + dx, cy + dy))
|
||||
ctx.globalAlpha = 1
|
||||
];
|
||||
ctx.globalAlpha = 1 / offs.length;
|
||||
offs.forEach(([dx, dy]) => ctx.fillText(props.text, cx + dx, cy + dy));
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
const tex = new CanvasTexture(texCanvas)
|
||||
tex.generateMipmaps = true
|
||||
tex.minFilter = LinearMipmapLinearFilter
|
||||
tex.magFilter = LinearFilter
|
||||
labelMat.uniforms.sampler.value = tex
|
||||
}
|
||||
const tex = new CanvasTexture(texCanvas);
|
||||
tex.generateMipmaps = true;
|
||||
tex.minFilter = LinearMipmapLinearFilter;
|
||||
tex.magFilter = LinearFilter;
|
||||
labelMat.uniforms.sampler.value = tex;
|
||||
};
|
||||
|
||||
const initThreeJS = async () => {
|
||||
if (!containerRef.value) return
|
||||
if (!containerRef.value) return;
|
||||
|
||||
let { w, h } = getSize()
|
||||
let { w, h } = getSize();
|
||||
|
||||
renderer = new WebGLRenderer({ antialias: true })
|
||||
renderer = new WebGLRenderer({ antialias: true });
|
||||
renderer.setClearColor(
|
||||
typeof props.backgroundColor === 'string'
|
||||
? new Color(props.backgroundColor)
|
||||
: new Color(props.backgroundColor),
|
||||
typeof props.backgroundColor === 'string' ? new Color(props.backgroundColor) : new Color(props.backgroundColor),
|
||||
1
|
||||
)
|
||||
renderer.setPixelRatio(window.devicePixelRatio || 1)
|
||||
renderer.setSize(w, h)
|
||||
containerRef.value.appendChild(renderer.domElement)
|
||||
);
|
||||
renderer.setPixelRatio(window.devicePixelRatio || 1);
|
||||
renderer.setSize(w, h);
|
||||
containerRef.value.appendChild(renderer.domElement);
|
||||
|
||||
scene = new Scene()
|
||||
fluidScene = new Scene()
|
||||
clock = new Clock()
|
||||
cam = new OrthographicCamera(-w / 2, w / 2, h / 2, -h / 2, 0.1, 10)
|
||||
cam.position.z = 1
|
||||
scene = new Scene();
|
||||
fluidScene = new Scene();
|
||||
clock = new Clock();
|
||||
cam = new OrthographicCamera(-w / 2, w / 2, h / 2, -h / 2, 0.1, 10);
|
||||
cam.position.z = 1;
|
||||
|
||||
rt0 = new WebGLRenderTarget(w, h)
|
||||
rt1 = rt0.clone()
|
||||
rt0 = new WebGLRenderTarget(w, h);
|
||||
rt1 = rt0.clone();
|
||||
|
||||
quadMat = new ShaderMaterial({
|
||||
uniforms: {
|
||||
@@ -268,9 +266,9 @@ const initThreeJS = async () => {
|
||||
vertexShader: BASE_VERT,
|
||||
fragmentShader: PERSIST_FRAG,
|
||||
transparent: true
|
||||
})
|
||||
quad = new Mesh(new PlaneGeometry(w, h), quadMat)
|
||||
fluidScene.add(quad)
|
||||
});
|
||||
quad = new Mesh(new PlaneGeometry(w, h), quadMat);
|
||||
fluidScene.add(quad);
|
||||
|
||||
labelMat = new ShaderMaterial({
|
||||
uniforms: {
|
||||
@@ -280,133 +278,132 @@ const initThreeJS = async () => {
|
||||
vertexShader: BASE_VERT,
|
||||
fragmentShader: TEXT_FRAG,
|
||||
transparent: true
|
||||
})
|
||||
label = new Mesh(new PlaneGeometry(Math.min(w, h), Math.min(w, h)), labelMat)
|
||||
scene.add(label)
|
||||
});
|
||||
label = new Mesh(new PlaneGeometry(Math.min(w, h), Math.min(w, h)), labelMat);
|
||||
scene.add(label);
|
||||
|
||||
await loadFont(props.fontFamily)
|
||||
drawText()
|
||||
await loadFont(props.fontFamily);
|
||||
drawText();
|
||||
|
||||
containerRef.value.addEventListener('pointermove', onMove)
|
||||
containerRef.value.addEventListener('pointermove', onMove);
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!containerRef.value || !renderer || !cam || !quad || !rt0 || !rt1 || !label) return
|
||||
|
||||
const size = getSize()
|
||||
w = size.w
|
||||
h = size.h
|
||||
|
||||
renderer.setSize(w, h)
|
||||
cam.left = -w / 2
|
||||
cam.right = w / 2
|
||||
cam.top = h / 2
|
||||
cam.bottom = -h / 2
|
||||
cam.updateProjectionMatrix()
|
||||
quad.geometry.dispose()
|
||||
quad.geometry = new PlaneGeometry(w, h)
|
||||
rt0.setSize(w, h)
|
||||
rt1.setSize(w, h)
|
||||
label.geometry.dispose()
|
||||
label.geometry = new PlaneGeometry(Math.min(w, h), Math.min(w, h))
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
if (!containerRef.value || !renderer || !cam || !quad || !rt0 || !rt1 || !label) return;
|
||||
|
||||
const size = getSize();
|
||||
w = size.w;
|
||||
h = size.h;
|
||||
|
||||
renderer.setSize(w, h);
|
||||
cam.left = -w / 2;
|
||||
cam.right = w / 2;
|
||||
cam.top = h / 2;
|
||||
cam.bottom = -h / 2;
|
||||
cam.updateProjectionMatrix();
|
||||
quad.geometry.dispose();
|
||||
quad.geometry = new PlaneGeometry(w, h);
|
||||
rt0.setSize(w, h);
|
||||
rt1.setSize(w, h);
|
||||
label.geometry.dispose();
|
||||
label.geometry = new PlaneGeometry(Math.min(w, h), Math.min(w, h));
|
||||
});
|
||||
resizeObserver.observe(containerRef.value);
|
||||
|
||||
colorTimer = setInterval(() => {
|
||||
if (!props.textColor) {
|
||||
targetColor.value = [Math.random(), Math.random(), Math.random()]
|
||||
targetColor.value = [Math.random(), Math.random(), Math.random()];
|
||||
}
|
||||
}, props.colorCycleInterval)
|
||||
}, props.colorCycleInterval);
|
||||
|
||||
const animate = () => {
|
||||
if (!renderer || !quadMat || !labelMat || !clock || !scene || !fluidScene || !cam || !rt0 || !rt1) return
|
||||
if (!renderer || !quadMat || !labelMat || !clock || !scene || !fluidScene || !cam || !rt0 || !rt1) return;
|
||||
|
||||
const dt = clock.getDelta()
|
||||
const dt = clock.getDelta();
|
||||
if (props.animateColor && !props.textColor) {
|
||||
for (let i = 0; i < 3; i++)
|
||||
persistColor.value[i] += (targetColor.value[i] - persistColor.value[i]) * dt
|
||||
for (let i = 0; i < 3; i++) persistColor.value[i] += (targetColor.value[i] - persistColor.value[i]) * dt;
|
||||
}
|
||||
const speed = dt * 5
|
||||
mouse[0] += (target[0] - mouse[0]) * speed
|
||||
mouse[1] += (target[1] - mouse[1]) * speed
|
||||
const speed = dt * 5;
|
||||
mouse[0] += (target[0] - mouse[0]) * speed;
|
||||
mouse[1] += (target[1] - mouse[1]) * speed;
|
||||
|
||||
quadMat.uniforms.mousePos.value.set(mouse[0], mouse[1])
|
||||
quadMat.uniforms.sampler.value = rt1.texture
|
||||
quadMat.uniforms.time.value = clock.getElapsedTime()
|
||||
labelMat.uniforms.color.value.set(...persistColor.value)
|
||||
quadMat.uniforms.mousePos.value.set(mouse[0], mouse[1]);
|
||||
quadMat.uniforms.sampler.value = rt1.texture;
|
||||
quadMat.uniforms.time.value = clock.getElapsedTime();
|
||||
labelMat.uniforms.color.value.set(...persistColor.value);
|
||||
|
||||
renderer.autoClearColor = false
|
||||
renderer.setRenderTarget(rt0)
|
||||
renderer.clearColor()
|
||||
renderer.render(fluidScene, cam)
|
||||
renderer.render(scene, cam)
|
||||
renderer.setRenderTarget(null)
|
||||
renderer.render(fluidScene, cam)
|
||||
renderer.render(scene, cam)
|
||||
;[rt0, rt1] = [rt1, rt0]
|
||||
}
|
||||
renderer.autoClearColor = false;
|
||||
renderer.setRenderTarget(rt0);
|
||||
renderer.clearColor();
|
||||
renderer.render(fluidScene, cam);
|
||||
renderer.render(scene, cam);
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.render(fluidScene, cam);
|
||||
renderer.render(scene, cam);
|
||||
[rt0, rt1] = [rt1, rt0];
|
||||
};
|
||||
|
||||
renderer.setAnimationLoop(animate)
|
||||
}
|
||||
renderer.setAnimationLoop(animate);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (renderer) {
|
||||
renderer.setAnimationLoop(null)
|
||||
renderer.setAnimationLoop(null);
|
||||
if (containerRef.value && renderer.domElement.parentNode === containerRef.value) {
|
||||
containerRef.value.removeChild(renderer.domElement)
|
||||
containerRef.value.removeChild(renderer.domElement);
|
||||
}
|
||||
renderer.dispose()
|
||||
renderer = null
|
||||
renderer.dispose();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
|
||||
if (colorTimer) {
|
||||
clearInterval(colorTimer)
|
||||
colorTimer = null
|
||||
clearInterval(colorTimer);
|
||||
colorTimer = null;
|
||||
}
|
||||
|
||||
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('pointermove', onMove)
|
||||
containerRef.value.removeEventListener('pointermove', onMove);
|
||||
}
|
||||
|
||||
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
|
||||
|
||||
if (rt0) {
|
||||
rt0.dispose()
|
||||
rt0 = null
|
||||
rt0.dispose();
|
||||
rt0 = null;
|
||||
}
|
||||
|
||||
|
||||
if (rt1) {
|
||||
rt1.dispose()
|
||||
rt1 = null
|
||||
rt1.dispose();
|
||||
rt1 = null;
|
||||
}
|
||||
|
||||
|
||||
if (quadMat) {
|
||||
quadMat.dispose()
|
||||
quadMat = null
|
||||
quadMat.dispose();
|
||||
quadMat = null;
|
||||
}
|
||||
|
||||
|
||||
if (quad) {
|
||||
quad.geometry.dispose()
|
||||
quad = null
|
||||
quad.geometry.dispose();
|
||||
quad = null;
|
||||
}
|
||||
|
||||
|
||||
if (labelMat) {
|
||||
labelMat.dispose()
|
||||
labelMat = null
|
||||
labelMat.dispose();
|
||||
labelMat = null;
|
||||
}
|
||||
|
||||
|
||||
if (label) {
|
||||
label.geometry.dispose()
|
||||
label = null
|
||||
label.geometry.dispose();
|
||||
label = null;
|
||||
}
|
||||
|
||||
scene = null
|
||||
fluidScene = null
|
||||
clock = null
|
||||
cam = null
|
||||
}
|
||||
|
||||
scene = null;
|
||||
fluidScene = null;
|
||||
clock = null;
|
||||
cam = null;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [
|
||||
@@ -425,25 +422,25 @@ watch(
|
||||
props.supersample
|
||||
],
|
||||
() => {
|
||||
cleanup()
|
||||
cleanup();
|
||||
if (containerRef.value) {
|
||||
persistColor.value = hexToRgb(props.textColor || props.startColor).map((c) => c / 255) as [number, number, number]
|
||||
targetColor.value = [...persistColor.value]
|
||||
initThreeJS()
|
||||
persistColor.value = hexToRgb(props.textColor || props.startColor).map(c => c / 255) as [number, number, number];
|
||||
targetColor.value = [...persistColor.value];
|
||||
initThreeJS();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
initThreeJS()
|
||||
initThreeJS();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
170
src/content/TextAnimations/TrueFocus/TrueFocus.vue
Normal file
170
src/content/TextAnimations/TrueFocus/TrueFocus.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { motion } from 'motion-v';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
interface TrueFocusProps {
|
||||
sentence?: string;
|
||||
manualMode?: boolean;
|
||||
blurAmount?: number;
|
||||
borderColor?: string;
|
||||
glowColor?: string;
|
||||
animationDuration?: number;
|
||||
pauseBetweenAnimations?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TrueFocusProps>(), {
|
||||
sentence: 'True Focus',
|
||||
manualMode: false,
|
||||
blurAmount: 5,
|
||||
borderColor: 'green',
|
||||
glowColor: 'rgba(0, 255, 0, 0.6)',
|
||||
animationDuration: 0.5,
|
||||
pauseBetweenAnimations: 1
|
||||
});
|
||||
|
||||
const words = computed(() => props.sentence.split(' '));
|
||||
const currentIndex = ref(0);
|
||||
const lastActiveIndex = ref<number | null>(null);
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const wordRefs = ref<HTMLSpanElement[]>([]);
|
||||
const focusRect = ref({ x: 0, y: 0, width: 0, height: 0 });
|
||||
|
||||
let interval: number | null = null;
|
||||
|
||||
watch(
|
||||
[() => props.manualMode, () => props.animationDuration, () => props.pauseBetweenAnimations, words],
|
||||
() => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
|
||||
if (!props.manualMode) {
|
||||
interval = setInterval(
|
||||
() => {
|
||||
currentIndex.value = (currentIndex.value + 1) % words.value.length;
|
||||
},
|
||||
(props.animationDuration + props.pauseBetweenAnimations) * 1000
|
||||
);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
[currentIndex, words.value.length],
|
||||
async () => {
|
||||
if (currentIndex.value === null || currentIndex.value === -1) return;
|
||||
if (!wordRefs.value[currentIndex.value] || !containerRef.value) return;
|
||||
|
||||
await nextTick();
|
||||
|
||||
const parentRect = containerRef.value.getBoundingClientRect();
|
||||
const activeRect = wordRefs.value[currentIndex.value].getBoundingClientRect();
|
||||
|
||||
focusRect.value = {
|
||||
x: activeRect.left - parentRect.left,
|
||||
y: activeRect.top - parentRect.top,
|
||||
width: activeRect.width,
|
||||
height: activeRect.height
|
||||
};
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleMouseEnter = (index: number) => {
|
||||
if (props.manualMode) {
|
||||
lastActiveIndex.value = index;
|
||||
currentIndex.value = index;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (props.manualMode) {
|
||||
currentIndex.value = lastActiveIndex.value || 0;
|
||||
}
|
||||
};
|
||||
|
||||
const setWordRef = (el: HTMLSpanElement | null, index: number) => {
|
||||
if (el) {
|
||||
wordRefs.value[index] = el;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
if (wordRefs.value[0] && containerRef.value) {
|
||||
const parentRect = containerRef.value.getBoundingClientRect();
|
||||
const activeRect = wordRefs.value[0].getBoundingClientRect();
|
||||
|
||||
focusRect.value = {
|
||||
x: activeRect.left - parentRect.left,
|
||||
y: activeRect.top - parentRect.top,
|
||||
width: activeRect.width,
|
||||
height: activeRect.height
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-wrap justify-center items-center gap-[1em]" ref="containerRef">
|
||||
<span
|
||||
v-for="(word, index) in words"
|
||||
:key="index"
|
||||
:ref="el => setWordRef(el as HTMLSpanElement, index)"
|
||||
class="relative font-black text-5xl transition-[filter,color] duration-300 ease-in-out cursor-pointer"
|
||||
:style="{
|
||||
filter: index === currentIndex ? 'blur(0px)' : `blur(${blurAmount}px)`,
|
||||
'--border-color': borderColor,
|
||||
'--glow-color': glowColor,
|
||||
transition: `filter ${animationDuration}s ease`
|
||||
}"
|
||||
@mouseenter="handleMouseEnter(index)"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
{{ word }}
|
||||
</span>
|
||||
|
||||
<motion.div
|
||||
class="top-0 left-0 box-content absolute border-none pointer-events-none"
|
||||
:animate="{
|
||||
x: focusRect.x,
|
||||
y: focusRect.y,
|
||||
width: focusRect.width,
|
||||
height: focusRect.height,
|
||||
opacity: currentIndex >= 0 ? 1 : 0
|
||||
}"
|
||||
:transition="{
|
||||
duration: animationDuration
|
||||
}"
|
||||
:style="{
|
||||
'--border-color': borderColor,
|
||||
'--glow-color': glowColor
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="top-[-10px] left-[-10px] absolute [filter:drop-shadow(0_0_4px_var(--border-color,#fff))] border-[3px] border-[var(--border-color,#fff)] border-r-0 border-b-0 rounded-[3px] w-4 h-4 transition-none"
|
||||
></span>
|
||||
|
||||
<span
|
||||
class="top-[-10px] right-[-10px] absolute [filter:drop-shadow(0_0_4px_var(--border-color,#fff))] border-[3px] border-[var(--border-color,#fff)] border-b-0 border-l-0 rounded-[3px] w-4 h-4 transition-none"
|
||||
></span>
|
||||
|
||||
<span
|
||||
class="bottom-[-10px] left-[-10px] absolute [filter:drop-shadow(0_0_4px_var(--border-color,#fff))] border-[3px] border-[var(--border-color,#fff)] border-t-0 border-r-0 rounded-[3px] w-4 h-4 transition-none"
|
||||
></span>
|
||||
|
||||
<span
|
||||
class="right-[-10px] bottom-[-10px] absolute [filter:drop-shadow(0_0_4px_var(--border-color,#fff))] border-[3px] border-[var(--border-color,#fff)] border-t-0 border-l-0 rounded-[3px] w-4 h-4 transition-none"
|
||||
></span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user