mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-09 08:29:30 -06:00
Merge branch 'main' into feat/scroll-reveal
This commit is contained in:
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
interface AnimatedContentProps {
|
||||
distance?: number
|
||||
direction?: 'vertical' | 'horizontal'
|
||||
reverse?: boolean
|
||||
duration?: number
|
||||
ease?: string | ((progress: number) => number)
|
||||
initialOpacity?: number
|
||||
animateOpacity?: boolean
|
||||
scale?: number
|
||||
threshold?: number
|
||||
delay?: number
|
||||
className?: string
|
||||
distance?: number;
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
reverse?: boolean;
|
||||
duration?: number;
|
||||
ease?: string | ((progress: number) => number);
|
||||
initialOpacity?: number;
|
||||
animateOpacity?: boolean;
|
||||
scale?: number;
|
||||
threshold?: number;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<AnimatedContentProps>(), {
|
||||
@@ -31,27 +31,27 @@ const props = withDefaults(defineProps<AnimatedContentProps>(), {
|
||||
threshold: 0.1,
|
||||
delay: 0,
|
||||
className: ''
|
||||
})
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
}>()
|
||||
complete: [];
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
|
||||
onMounted(() => {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
|
||||
const axis = props.direction === 'horizontal' ? 'x' : 'y'
|
||||
const offset = props.reverse ? -props.distance : props.distance
|
||||
const startPct = (1 - props.threshold) * 100
|
||||
const axis = props.direction === 'horizontal' ? 'x' : 'y';
|
||||
const offset = props.reverse ? -props.distance : props.distance;
|
||||
const startPct = (1 - props.threshold) * 100;
|
||||
|
||||
gsap.set(el, {
|
||||
[axis]: offset,
|
||||
scale: props.scale,
|
||||
opacity: props.animateOpacity ? props.initialOpacity : 1,
|
||||
})
|
||||
opacity: props.animateOpacity ? props.initialOpacity : 1
|
||||
});
|
||||
|
||||
gsap.to(el, {
|
||||
[axis]: 0,
|
||||
@@ -65,10 +65,10 @@ onMounted(() => {
|
||||
trigger: el,
|
||||
start: `top ${startPct}%`,
|
||||
toggleActions: 'play none none none',
|
||||
once: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
once: true
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [
|
||||
@@ -81,24 +81,24 @@ watch(
|
||||
props.animateOpacity,
|
||||
props.scale,
|
||||
props.threshold,
|
||||
props.delay,
|
||||
props.delay
|
||||
],
|
||||
() => {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
|
||||
ScrollTrigger.getAll().forEach((t) => t.kill())
|
||||
gsap.killTweensOf(el)
|
||||
ScrollTrigger.getAll().forEach(t => t.kill());
|
||||
gsap.killTweensOf(el);
|
||||
|
||||
const axis = props.direction === 'horizontal' ? 'x' : 'y'
|
||||
const offset = props.reverse ? -props.distance : props.distance
|
||||
const startPct = (1 - props.threshold) * 100
|
||||
const axis = props.direction === 'horizontal' ? 'x' : 'y';
|
||||
const offset = props.reverse ? -props.distance : props.distance;
|
||||
const startPct = (1 - props.threshold) * 100;
|
||||
|
||||
gsap.set(el, {
|
||||
[axis]: offset,
|
||||
scale: props.scale,
|
||||
opacity: props.animateOpacity ? props.initialOpacity : 1,
|
||||
})
|
||||
opacity: props.animateOpacity ? props.initialOpacity : 1
|
||||
});
|
||||
|
||||
gsap.to(el, {
|
||||
[axis]: 0,
|
||||
@@ -112,27 +112,24 @@ watch(
|
||||
trigger: el,
|
||||
start: `top ${startPct}%`,
|
||||
toggleActions: 'play none none none',
|
||||
once: true,
|
||||
},
|
||||
})
|
||||
once: true
|
||||
}
|
||||
});
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
const el = containerRef.value
|
||||
const el = containerRef.value;
|
||||
if (el) {
|
||||
ScrollTrigger.getAll().forEach((t) => t.kill())
|
||||
gsap.killTweensOf(el)
|
||||
ScrollTrigger.getAll().forEach(t => t.kill());
|
||||
gsap.killTweensOf(el);
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="`animated-content ${props.className}`"
|
||||
>
|
||||
<div ref="containerRef" :class="`animated-content ${props.className}`">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative w-full h-full"
|
||||
@click="handleClick"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
/>
|
||||
<div ref="containerRef" class="relative w-full h-full" @click="handleClick">
|
||||
<canvas ref="canvasRef" class="absolute inset-0 pointer-events-none" />
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue';
|
||||
|
||||
interface Spark {
|
||||
x: number
|
||||
y: number
|
||||
angle: number
|
||||
startTime: number
|
||||
x: number;
|
||||
y: number;
|
||||
angle: number;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sparkColor?: string
|
||||
sparkSize?: number
|
||||
sparkRadius?: number
|
||||
sparkCount?: number
|
||||
duration?: number
|
||||
easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out"
|
||||
extraScale?: number
|
||||
sparkColor?: string;
|
||||
sparkSize?: number;
|
||||
sparkRadius?: number;
|
||||
sparkCount?: number;
|
||||
duration?: number;
|
||||
easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
|
||||
extraScale?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -40,149 +34,152 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
duration: 400,
|
||||
easing: 'ease-out',
|
||||
extraScale: 1.0
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const sparks = ref<Spark[]>([])
|
||||
const startTimeRef = ref<number | null>(null)
|
||||
const animationId = ref<number | null>(null)
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const sparks = ref<Spark[]>([]);
|
||||
const startTimeRef = ref<number | null>(null);
|
||||
const animationId = ref<number | null>(null);
|
||||
|
||||
const easeFunc = computed(() => {
|
||||
return (t: number) => {
|
||||
switch (props.easing) {
|
||||
case "linear":
|
||||
return t
|
||||
case "ease-in":
|
||||
return t * t
|
||||
case "ease-in-out":
|
||||
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
|
||||
case 'linear':
|
||||
return t;
|
||||
case 'ease-in':
|
||||
return t * t;
|
||||
case 'ease-in-out':
|
||||
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||
default:
|
||||
return t * (2 - t)
|
||||
return t * (2 - t);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const now = performance.now()
|
||||
const now = performance.now();
|
||||
const newSparks: Spark[] = Array.from({ length: props.sparkCount }, (_, i) => ({
|
||||
x,
|
||||
y,
|
||||
angle: (2 * Math.PI * i) / props.sparkCount,
|
||||
startTime: now,
|
||||
}))
|
||||
startTime: now
|
||||
}));
|
||||
|
||||
sparks.value.push(...newSparks)
|
||||
}
|
||||
sparks.value.push(...newSparks);
|
||||
};
|
||||
|
||||
const draw = (timestamp: number) => {
|
||||
if (!startTimeRef.value) {
|
||||
startTimeRef.value = timestamp
|
||||
startTimeRef.value = timestamp;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.value
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!ctx || !canvas) return
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const canvas = canvasRef.value;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!ctx || !canvas) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
sparks.value = sparks.value.filter((spark: Spark) => {
|
||||
const elapsed = timestamp - spark.startTime
|
||||
const elapsed = timestamp - spark.startTime;
|
||||
if (elapsed >= props.duration) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
const progress = elapsed / props.duration
|
||||
const eased = easeFunc.value(progress)
|
||||
const progress = elapsed / props.duration;
|
||||
const eased = easeFunc.value(progress);
|
||||
|
||||
const distance = eased * props.sparkRadius * props.extraScale
|
||||
const lineLength = props.sparkSize * (1 - eased)
|
||||
const distance = eased * props.sparkRadius * props.extraScale;
|
||||
const lineLength = props.sparkSize * (1 - eased);
|
||||
|
||||
const x1 = spark.x + distance * Math.cos(spark.angle)
|
||||
const y1 = spark.y + distance * Math.sin(spark.angle)
|
||||
const x2 = spark.x + (distance + lineLength) * Math.cos(spark.angle)
|
||||
const y2 = spark.y + (distance + lineLength) * Math.sin(spark.angle)
|
||||
const x1 = spark.x + distance * Math.cos(spark.angle);
|
||||
const y1 = spark.y + distance * Math.sin(spark.angle);
|
||||
const x2 = spark.x + (distance + lineLength) * Math.cos(spark.angle);
|
||||
const y2 = spark.y + (distance + lineLength) * Math.sin(spark.angle);
|
||||
|
||||
ctx.strokeStyle = props.sparkColor
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x1, y1)
|
||||
ctx.lineTo(x2, y2)
|
||||
ctx.stroke()
|
||||
ctx.strokeStyle = props.sparkColor;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
|
||||
return true
|
||||
})
|
||||
return true;
|
||||
});
|
||||
|
||||
animationId.value = requestAnimationFrame(draw)
|
||||
}
|
||||
animationId.value = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
|
||||
const parent = canvas.parentElement
|
||||
if (!parent) return
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const { width, height } = parent.getBoundingClientRect()
|
||||
const { width, height } = parent.getBoundingClientRect();
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let resizeTimeout: number
|
||||
let resizeTimeout: number;
|
||||
|
||||
const handleResize = () => {
|
||||
clearTimeout(resizeTimeout)
|
||||
resizeTimeout = setTimeout(resizeCanvas, 100)
|
||||
}
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(resizeCanvas, 100);
|
||||
};
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
|
||||
const parent = canvas.parentElement
|
||||
if (!parent) return
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
resizeObserver = new ResizeObserver(handleResize)
|
||||
resizeObserver.observe(parent)
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(parent);
|
||||
|
||||
resizeCanvas()
|
||||
resizeCanvas();
|
||||
|
||||
animationId.value = requestAnimationFrame(draw)
|
||||
})
|
||||
animationId.value = requestAnimationFrame(draw);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
clearTimeout(resizeTimeout)
|
||||
|
||||
if (animationId.value) {
|
||||
cancelAnimationFrame(animationId.value)
|
||||
}
|
||||
})
|
||||
clearTimeout(resizeTimeout);
|
||||
|
||||
watch([
|
||||
() => props.sparkColor,
|
||||
() => props.sparkSize,
|
||||
() => props.sparkRadius,
|
||||
() => props.sparkCount,
|
||||
() => props.duration,
|
||||
easeFunc,
|
||||
() => props.extraScale
|
||||
], () => {
|
||||
if (animationId.value) {
|
||||
cancelAnimationFrame(animationId.value)
|
||||
cancelAnimationFrame(animationId.value);
|
||||
}
|
||||
animationId.value = requestAnimationFrame(draw)
|
||||
})
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.sparkColor,
|
||||
() => props.sparkSize,
|
||||
() => props.sparkRadius,
|
||||
() => props.sparkCount,
|
||||
() => props.duration,
|
||||
easeFunc,
|
||||
() => props.extraScale
|
||||
],
|
||||
() => {
|
||||
if (animationId.value) {
|
||||
cancelAnimationFrame(animationId.value);
|
||||
}
|
||||
animationId.value = requestAnimationFrame(draw);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -3,161 +3,164 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
to: number
|
||||
from?: number
|
||||
direction?: "up" | "down"
|
||||
delay?: number
|
||||
duration?: number
|
||||
className?: string
|
||||
startWhen?: boolean
|
||||
separator?: string
|
||||
onStart?: () => void
|
||||
onEnd?: () => void
|
||||
to: number;
|
||||
from?: number;
|
||||
direction?: 'up' | 'down';
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
startWhen?: boolean;
|
||||
separator?: string;
|
||||
onStart?: () => void;
|
||||
onEnd?: () => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
from: 0,
|
||||
direction: "up",
|
||||
direction: 'up',
|
||||
delay: 0,
|
||||
duration: 2,
|
||||
className: "",
|
||||
className: '',
|
||||
startWhen: true,
|
||||
separator: ""
|
||||
})
|
||||
separator: ''
|
||||
});
|
||||
|
||||
const elementRef = ref<HTMLSpanElement | null>(null)
|
||||
const currentValue = ref(props.direction === "down" ? props.to : props.from)
|
||||
const isInView = ref(false)
|
||||
const animationId = ref<number | null>(null)
|
||||
const hasStarted = ref(false)
|
||||
const elementRef = ref<HTMLSpanElement | null>(null);
|
||||
const currentValue = ref(props.direction === 'down' ? props.to : props.from);
|
||||
const isInView = ref(false);
|
||||
const animationId = ref<number | null>(null);
|
||||
const hasStarted = ref(false);
|
||||
|
||||
let intersectionObserver: IntersectionObserver | null = null
|
||||
let intersectionObserver: IntersectionObserver | null = null;
|
||||
|
||||
const damping = computed(() => 20 + 40 * (1 / props.duration))
|
||||
const stiffness = computed(() => 100 * (1 / props.duration))
|
||||
const damping = computed(() => 20 + 40 * (1 / props.duration));
|
||||
const stiffness = computed(() => 100 * (1 / props.duration));
|
||||
|
||||
let velocity = 0
|
||||
let startTime = 0
|
||||
let velocity = 0;
|
||||
let startTime = 0;
|
||||
|
||||
const formatNumber = (value: number) => {
|
||||
const options = {
|
||||
useGrouping: !!props.separator,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}
|
||||
maximumFractionDigits: 0
|
||||
};
|
||||
|
||||
const formattedNumber = Intl.NumberFormat("en-US", options).format(
|
||||
Number(value.toFixed(0))
|
||||
)
|
||||
const formattedNumber = Intl.NumberFormat('en-US', options).format(Number(value.toFixed(0)));
|
||||
|
||||
return props.separator
|
||||
? formattedNumber.replace(/,/g, props.separator)
|
||||
: formattedNumber
|
||||
}
|
||||
return props.separator ? formattedNumber.replace(/,/g, props.separator) : formattedNumber;
|
||||
};
|
||||
|
||||
const updateDisplay = () => {
|
||||
if (elementRef.value) {
|
||||
elementRef.value.textContent = formatNumber(currentValue.value)
|
||||
elementRef.value.textContent = formatNumber(currentValue.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const springAnimation = (timestamp: number) => {
|
||||
if (!startTime) startTime = timestamp
|
||||
if (!startTime) startTime = timestamp;
|
||||
|
||||
const target = props.direction === "down" ? props.from : props.to
|
||||
const current = currentValue.value
|
||||
const target = props.direction === 'down' ? props.from : props.to;
|
||||
const current = currentValue.value;
|
||||
|
||||
const displacement = target - current
|
||||
const springForce = displacement * stiffness.value
|
||||
const dampingForce = velocity * damping.value
|
||||
const acceleration = springForce - dampingForce
|
||||
const displacement = target - current;
|
||||
const springForce = displacement * stiffness.value;
|
||||
const dampingForce = velocity * damping.value;
|
||||
const acceleration = springForce - dampingForce;
|
||||
|
||||
velocity += acceleration * 0.016 // Assuming 60fps
|
||||
currentValue.value += velocity * 0.016
|
||||
velocity += acceleration * 0.016; // Assuming 60fps
|
||||
currentValue.value += velocity * 0.016;
|
||||
|
||||
updateDisplay()
|
||||
updateDisplay();
|
||||
|
||||
if (Math.abs(displacement) > 0.01 || Math.abs(velocity) > 0.01) {
|
||||
animationId.value = requestAnimationFrame(springAnimation)
|
||||
animationId.value = requestAnimationFrame(springAnimation);
|
||||
} else {
|
||||
currentValue.value = target
|
||||
updateDisplay()
|
||||
animationId.value = null
|
||||
currentValue.value = target;
|
||||
updateDisplay();
|
||||
animationId.value = null;
|
||||
|
||||
if (props.onEnd) {
|
||||
props.onEnd()
|
||||
props.onEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startAnimation = () => {
|
||||
if (hasStarted.value || !isInView.value || !props.startWhen) return
|
||||
if (hasStarted.value || !isInView.value || !props.startWhen) return;
|
||||
|
||||
hasStarted.value = true
|
||||
hasStarted.value = true;
|
||||
|
||||
if (props.onStart) {
|
||||
props.onStart()
|
||||
props.onStart();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
startTime = 0
|
||||
velocity = 0
|
||||
animationId.value = requestAnimationFrame(springAnimation)
|
||||
}, props.delay * 1000)
|
||||
}
|
||||
startTime = 0;
|
||||
velocity = 0;
|
||||
animationId.value = requestAnimationFrame(springAnimation);
|
||||
}, props.delay * 1000);
|
||||
};
|
||||
|
||||
const setupIntersectionObserver = () => {
|
||||
if (!elementRef.value) return
|
||||
if (!elementRef.value) return;
|
||||
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && !isInView.value) {
|
||||
isInView.value = true
|
||||
startAnimation()
|
||||
isInView.value = true;
|
||||
startAnimation();
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: 0,
|
||||
rootMargin: "0px"
|
||||
rootMargin: '0px'
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
intersectionObserver.observe(elementRef.value)
|
||||
}
|
||||
intersectionObserver.observe(elementRef.value);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (animationId.value) {
|
||||
cancelAnimationFrame(animationId.value)
|
||||
animationId.value = null
|
||||
cancelAnimationFrame(animationId.value);
|
||||
animationId.value = null;
|
||||
}
|
||||
|
||||
if (intersectionObserver) {
|
||||
intersectionObserver.disconnect()
|
||||
intersectionObserver = null
|
||||
intersectionObserver.disconnect();
|
||||
intersectionObserver = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch([() => props.from, () => props.to, () => props.direction], () => {
|
||||
currentValue.value = props.direction === "down" ? props.to : props.from
|
||||
updateDisplay()
|
||||
hasStarted.value = false
|
||||
}, { immediate: true })
|
||||
watch(
|
||||
[() => props.from, () => props.to, () => props.direction],
|
||||
() => {
|
||||
currentValue.value = props.direction === 'down' ? props.to : props.from;
|
||||
updateDisplay();
|
||||
hasStarted.value = false;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(() => props.startWhen, () => {
|
||||
if (props.startWhen && isInView.value && !hasStarted.value) {
|
||||
startAnimation()
|
||||
watch(
|
||||
() => props.startWhen,
|
||||
() => {
|
||||
if (props.startWhen && isInView.value && !hasStarted.value) {
|
||||
startAnimation();
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
updateDisplay()
|
||||
setupIntersectionObserver()
|
||||
})
|
||||
updateDisplay();
|
||||
setupIntersectionObserver();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,46 +2,74 @@
|
||||
<div class="relative w-1/2 max-md:w-11/12 aspect-square" :style="wrapperStyle">
|
||||
<div ref="sceneRef" class="grid w-full h-full" :style="sceneStyle">
|
||||
<template v-for="(_, r) in cells" :key="`row-${r}`">
|
||||
<div v-for="(__, c) in cells" :key="`${r}-${c}`"
|
||||
class="cube relative w-full h-full aspect-square [transform-style:preserve-3d]" :data-row="r" :data-col="c">
|
||||
<div
|
||||
v-for="(__, c) in cells"
|
||||
:key="`${r}-${c}`"
|
||||
class="cube relative w-full h-full aspect-square [transform-style:preserve-3d]"
|
||||
:data-row="r"
|
||||
:data-col="c"
|
||||
>
|
||||
<span class="absolute pointer-events-none -inset-9" />
|
||||
|
||||
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'translateY(-50%) rotateX(90deg)',
|
||||
}" />
|
||||
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'translateY(50%) rotateX(-90deg)',
|
||||
}" />
|
||||
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'translateX(-50%) rotateY(-90deg)',
|
||||
}" />
|
||||
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'translateX(50%) rotateY(90deg)',
|
||||
}" />
|
||||
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'rotateY(-90deg) translateX(50%) rotateY(90deg)',
|
||||
}" />
|
||||
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'rotateY(90deg) translateX(-50%) rotateY(-90deg)',
|
||||
}" />
|
||||
<div
|
||||
class="cube-face absolute inset-0 flex items-center justify-center"
|
||||
:style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'translateY(-50%) rotateX(90deg)'
|
||||
}"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="cube-face absolute inset-0 flex items-center justify-center"
|
||||
:style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'translateY(50%) rotateX(-90deg)'
|
||||
}"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="cube-face absolute inset-0 flex items-center justify-center"
|
||||
:style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'translateX(-50%) rotateY(-90deg)'
|
||||
}"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="cube-face absolute inset-0 flex items-center justify-center"
|
||||
:style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'translateX(50%) rotateY(90deg)'
|
||||
}"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="cube-face absolute inset-0 flex items-center justify-center"
|
||||
:style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'rotateY(-90deg) translateX(50%) rotateY(90deg)'
|
||||
}"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="cube-face absolute inset-0 flex items-center justify-center"
|
||||
:style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'rotateY(90deg) translateX(-50%) rotateY(-90deg)'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -49,34 +77,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, withDefaults } from 'vue'
|
||||
import gsap from 'gsap'
|
||||
import { ref, computed, onMounted, onUnmounted, withDefaults } from 'vue';
|
||||
import gsap from 'gsap';
|
||||
|
||||
interface Gap {
|
||||
row: number
|
||||
col: number
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
interface Duration {
|
||||
enter: number
|
||||
leave: number
|
||||
enter: number;
|
||||
leave: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
gridSize?: number
|
||||
cubeSize?: number
|
||||
maxAngle?: number
|
||||
radius?: number
|
||||
easing?: gsap.EaseString
|
||||
duration?: Duration
|
||||
cellGap?: number | Gap
|
||||
borderStyle?: string
|
||||
faceColor?: string
|
||||
shadow?: boolean | string
|
||||
autoAnimate?: boolean
|
||||
rippleOnClick?: boolean
|
||||
rippleColor?: string
|
||||
rippleSpeed?: number
|
||||
gridSize?: number;
|
||||
cubeSize?: number;
|
||||
maxAngle?: number;
|
||||
radius?: number;
|
||||
easing?: gsap.EaseString;
|
||||
duration?: Duration;
|
||||
cellGap?: number | Gap;
|
||||
borderStyle?: string;
|
||||
faceColor?: string;
|
||||
shadow?: boolean | string;
|
||||
autoAnimate?: boolean;
|
||||
rippleOnClick?: boolean;
|
||||
rippleColor?: string;
|
||||
rippleSpeed?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -91,37 +119,37 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
autoAnimate: true,
|
||||
rippleOnClick: true,
|
||||
rippleColor: '#fff',
|
||||
rippleSpeed: 2,
|
||||
})
|
||||
rippleSpeed: 2
|
||||
});
|
||||
|
||||
const sceneRef = ref<HTMLDivElement | null>(null)
|
||||
const rafRef = ref<number | null>(null)
|
||||
const idleTimerRef = ref<number | null>(null)
|
||||
const userActiveRef = ref(false)
|
||||
const simPosRef = ref<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const simTargetRef = ref<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const simRAFRef = ref<number | null>(null)
|
||||
const sceneRef = ref<HTMLDivElement | null>(null);
|
||||
const rafRef = ref<number | null>(null);
|
||||
const idleTimerRef = ref<number | null>(null);
|
||||
const userActiveRef = ref(false);
|
||||
const simPosRef = ref<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const simTargetRef = ref<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const simRAFRef = ref<number | null>(null);
|
||||
|
||||
const colGap = computed(() => {
|
||||
return typeof props.cellGap === 'number'
|
||||
? `${props.cellGap}px`
|
||||
: (props.cellGap as Gap)?.col !== undefined
|
||||
? `${(props.cellGap as Gap).col}px`
|
||||
: '5%'
|
||||
})
|
||||
: '5%';
|
||||
});
|
||||
|
||||
const rowGap = computed(() => {
|
||||
return typeof props.cellGap === 'number'
|
||||
? `${props.cellGap}px`
|
||||
: (props.cellGap as Gap)?.row !== undefined
|
||||
? `${(props.cellGap as Gap).row}px`
|
||||
: '5%'
|
||||
})
|
||||
: '5%';
|
||||
});
|
||||
|
||||
const enterDur = computed(() => props.duration.enter)
|
||||
const leaveDur = computed(() => props.duration.leave)
|
||||
const enterDur = computed(() => props.duration.enter);
|
||||
const leaveDur = computed(() => props.duration.leave);
|
||||
|
||||
const cells = computed(() => Array.from({ length: props.gridSize }))
|
||||
const cells = computed(() => Array.from({ length: props.gridSize }));
|
||||
|
||||
const sceneStyle = computed(() => ({
|
||||
gridTemplateColumns: props.cubeSize
|
||||
@@ -133,189 +161,184 @@ const sceneStyle = computed(() => ({
|
||||
columnGap: colGap.value,
|
||||
rowGap: rowGap.value,
|
||||
perspective: '99999999px',
|
||||
gridAutoRows: '1fr',
|
||||
}))
|
||||
gridAutoRows: '1fr'
|
||||
}));
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
'--cube-face-border': props.borderStyle,
|
||||
'--cube-face-bg': props.faceColor,
|
||||
'--cube-face-shadow':
|
||||
props.shadow === true ? '0 0 6px rgba(0,0,0,.5)' : props.shadow || 'none',
|
||||
'--cube-face-shadow': props.shadow === true ? '0 0 6px rgba(0,0,0,.5)' : props.shadow || 'none',
|
||||
...(props.cubeSize
|
||||
? {
|
||||
width: `${props.gridSize * props.cubeSize}px`,
|
||||
height: `${props.gridSize * props.cubeSize}px`,
|
||||
}
|
||||
: {}),
|
||||
}))
|
||||
width: `${props.gridSize * props.cubeSize}px`,
|
||||
height: `${props.gridSize * props.cubeSize}px`
|
||||
}
|
||||
: {})
|
||||
}));
|
||||
|
||||
const tiltAt = (rowCenter: number, colCenter: number) => {
|
||||
if (!sceneRef.value) return
|
||||
if (!sceneRef.value) return;
|
||||
|
||||
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach((cube) => {
|
||||
const r = +(cube.dataset.row!)
|
||||
const c = +(cube.dataset.col!)
|
||||
const dist = Math.hypot(r - rowCenter, c - colCenter)
|
||||
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach(cube => {
|
||||
const r = +cube.dataset.row!;
|
||||
const c = +cube.dataset.col!;
|
||||
const dist = Math.hypot(r - rowCenter, c - colCenter);
|
||||
|
||||
if (dist <= props.radius) {
|
||||
const pct = 1 - dist / props.radius
|
||||
const angle = pct * props.maxAngle
|
||||
const pct = 1 - dist / props.radius;
|
||||
const angle = pct * props.maxAngle;
|
||||
gsap.to(cube, {
|
||||
duration: enterDur.value,
|
||||
ease: props.easing,
|
||||
overwrite: true,
|
||||
rotateX: -angle,
|
||||
rotateY: angle,
|
||||
})
|
||||
rotateY: angle
|
||||
});
|
||||
} else {
|
||||
gsap.to(cube, {
|
||||
duration: leaveDur.value,
|
||||
ease: 'power3.out',
|
||||
overwrite: true,
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
})
|
||||
rotateY: 0
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
userActiveRef.value = true
|
||||
if (idleTimerRef.value) clearTimeout(idleTimerRef.value)
|
||||
userActiveRef.value = true;
|
||||
if (idleTimerRef.value) clearTimeout(idleTimerRef.value);
|
||||
|
||||
const rect = sceneRef.value!.getBoundingClientRect()
|
||||
const cellW = rect.width / props.gridSize
|
||||
const cellH = rect.height / props.gridSize
|
||||
const colCenter = (e.clientX - rect.left) / cellW
|
||||
const rowCenter = (e.clientY - rect.top) / cellH
|
||||
const rect = sceneRef.value!.getBoundingClientRect();
|
||||
const cellW = rect.width / props.gridSize;
|
||||
const cellH = rect.height / props.gridSize;
|
||||
const colCenter = (e.clientX - rect.left) / cellW;
|
||||
const rowCenter = (e.clientY - rect.top) / cellH;
|
||||
|
||||
if (rafRef.value) cancelAnimationFrame(rafRef.value)
|
||||
rafRef.value = requestAnimationFrame(() =>
|
||||
tiltAt(rowCenter, colCenter)
|
||||
)
|
||||
if (rafRef.value) cancelAnimationFrame(rafRef.value);
|
||||
rafRef.value = requestAnimationFrame(() => tiltAt(rowCenter, colCenter));
|
||||
|
||||
idleTimerRef.value = setTimeout(() => {
|
||||
userActiveRef.value = false
|
||||
}, 3000)
|
||||
}
|
||||
userActiveRef.value = false;
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const resetAll = () => {
|
||||
if (!sceneRef.value) return
|
||||
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach((cube) =>
|
||||
if (!sceneRef.value) return;
|
||||
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach(cube =>
|
||||
gsap.to(cube, {
|
||||
duration: leaveDur.value,
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
ease: 'power3.out',
|
||||
ease: 'power3.out'
|
||||
})
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!props.rippleOnClick || !sceneRef.value) return
|
||||
if (!props.rippleOnClick || !sceneRef.value) return;
|
||||
|
||||
const rect = sceneRef.value.getBoundingClientRect()
|
||||
const cellW = rect.width / props.gridSize
|
||||
const cellH = rect.height / props.gridSize
|
||||
const colHit = Math.floor((e.clientX - rect.left) / cellW)
|
||||
const rowHit = Math.floor((e.clientY - rect.top) / cellH)
|
||||
const rect = sceneRef.value.getBoundingClientRect();
|
||||
const cellW = rect.width / props.gridSize;
|
||||
const cellH = rect.height / props.gridSize;
|
||||
const colHit = Math.floor((e.clientX - rect.left) / cellW);
|
||||
const rowHit = Math.floor((e.clientY - rect.top) / cellH);
|
||||
|
||||
const baseRingDelay = 0.15
|
||||
const baseAnimDur = 0.3
|
||||
const baseHold = 0.6
|
||||
const baseRingDelay = 0.15;
|
||||
const baseAnimDur = 0.3;
|
||||
const baseHold = 0.6;
|
||||
|
||||
const spreadDelay = baseRingDelay / props.rippleSpeed
|
||||
const animDuration = baseAnimDur / props.rippleSpeed
|
||||
const holdTime = baseHold / props.rippleSpeed
|
||||
const spreadDelay = baseRingDelay / props.rippleSpeed;
|
||||
const animDuration = baseAnimDur / props.rippleSpeed;
|
||||
const holdTime = baseHold / props.rippleSpeed;
|
||||
|
||||
const rings: Record<number, HTMLDivElement[]> = {}
|
||||
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach((cube) => {
|
||||
const r = +(cube.dataset.row!)
|
||||
const c = +(cube.dataset.col!)
|
||||
const dist = Math.hypot(r - rowHit, c - colHit)
|
||||
const ring = Math.round(dist)
|
||||
if (!rings[ring]) rings[ring] = []
|
||||
rings[ring].push(cube)
|
||||
})
|
||||
const rings: Record<number, HTMLDivElement[]> = {};
|
||||
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach(cube => {
|
||||
const r = +cube.dataset.row!;
|
||||
const c = +cube.dataset.col!;
|
||||
const dist = Math.hypot(r - rowHit, c - colHit);
|
||||
const ring = Math.round(dist);
|
||||
if (!rings[ring]) rings[ring] = [];
|
||||
rings[ring].push(cube);
|
||||
});
|
||||
|
||||
Object.keys(rings)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.forEach((ring) => {
|
||||
const delay = ring * spreadDelay
|
||||
const faces = rings[ring].flatMap((cube) =>
|
||||
Array.from(cube.querySelectorAll<HTMLElement>('.cube-face'))
|
||||
)
|
||||
.forEach(ring => {
|
||||
const delay = ring * spreadDelay;
|
||||
const faces = rings[ring].flatMap(cube => Array.from(cube.querySelectorAll<HTMLElement>('.cube-face')));
|
||||
|
||||
gsap.to(faces, {
|
||||
backgroundColor: props.rippleColor,
|
||||
duration: animDuration,
|
||||
delay,
|
||||
ease: 'power3.out',
|
||||
})
|
||||
ease: 'power3.out'
|
||||
});
|
||||
gsap.to(faces, {
|
||||
backgroundColor: props.faceColor,
|
||||
duration: animDuration,
|
||||
delay: delay + animDuration + holdTime,
|
||||
ease: 'power3.out',
|
||||
})
|
||||
})
|
||||
}
|
||||
ease: 'power3.out'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const startAutoAnimation = () => {
|
||||
if (!props.autoAnimate || !sceneRef.value) return
|
||||
if (!props.autoAnimate || !sceneRef.value) return;
|
||||
|
||||
simPosRef.value = {
|
||||
x: Math.random() * props.gridSize,
|
||||
y: Math.random() * props.gridSize,
|
||||
}
|
||||
y: Math.random() * props.gridSize
|
||||
};
|
||||
simTargetRef.value = {
|
||||
x: Math.random() * props.gridSize,
|
||||
y: Math.random() * props.gridSize,
|
||||
}
|
||||
y: Math.random() * props.gridSize
|
||||
};
|
||||
|
||||
const speed = 0.02
|
||||
const speed = 0.02;
|
||||
const loop = () => {
|
||||
if (!userActiveRef.value) {
|
||||
const pos = simPosRef.value
|
||||
const tgt = simTargetRef.value
|
||||
pos.x += (tgt.x - pos.x) * speed
|
||||
pos.y += (tgt.y - pos.y) * speed
|
||||
tiltAt(pos.y, pos.x)
|
||||
const pos = simPosRef.value;
|
||||
const tgt = simTargetRef.value;
|
||||
pos.x += (tgt.x - pos.x) * speed;
|
||||
pos.y += (tgt.y - pos.y) * speed;
|
||||
tiltAt(pos.y, pos.x);
|
||||
|
||||
if (Math.hypot(pos.x - tgt.x, pos.y - tgt.y) < 0.1) {
|
||||
simTargetRef.value = {
|
||||
x: Math.random() * props.gridSize,
|
||||
y: Math.random() * props.gridSize,
|
||||
}
|
||||
y: Math.random() * props.gridSize
|
||||
};
|
||||
}
|
||||
}
|
||||
simRAFRef.value = requestAnimationFrame(loop)
|
||||
}
|
||||
simRAFRef.value = requestAnimationFrame(loop)
|
||||
}
|
||||
simRAFRef.value = requestAnimationFrame(loop);
|
||||
};
|
||||
simRAFRef.value = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const el = sceneRef.value
|
||||
if (!el) return
|
||||
const el = sceneRef.value;
|
||||
if (!el) return;
|
||||
|
||||
el.addEventListener('pointermove', onPointerMove)
|
||||
el.addEventListener('pointerleave', resetAll)
|
||||
el.addEventListener('click', onClick)
|
||||
el.addEventListener('pointermove', onPointerMove);
|
||||
el.addEventListener('pointerleave', resetAll);
|
||||
el.addEventListener('click', onClick);
|
||||
|
||||
startAutoAnimation()
|
||||
})
|
||||
startAutoAnimation();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
const el = sceneRef.value
|
||||
const el = sceneRef.value;
|
||||
if (el) {
|
||||
el.removeEventListener('pointermove', onPointerMove)
|
||||
el.removeEventListener('pointerleave', resetAll)
|
||||
el.removeEventListener('click', onClick)
|
||||
el.removeEventListener('pointermove', onPointerMove);
|
||||
el.removeEventListener('pointerleave', resetAll);
|
||||
el.removeEventListener('click', onClick);
|
||||
}
|
||||
|
||||
if (rafRef.value !== null) cancelAnimationFrame(rafRef.value)
|
||||
if (idleTimerRef.value !== null) clearTimeout(idleTimerRef.value)
|
||||
if (simRAFRef.value !== null) cancelAnimationFrame(simRAFRef.value)
|
||||
})
|
||||
if (rafRef.value !== null) cancelAnimationFrame(rafRef.value);
|
||||
if (idleTimerRef.value !== null) clearTimeout(idleTimerRef.value);
|
||||
if (simRAFRef.value !== null) cancelAnimationFrame(simRAFRef.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:style="{
|
||||
opacity: inView ? 1 : initialOpacity,
|
||||
transition: `opacity ${duration}ms ${easing}, filter ${duration}ms ${easing}`,
|
||||
filter: blur ? (inView ? 'blur(0px)' : 'blur(10px)') : 'none',
|
||||
filter: blur ? (inView ? 'blur(0px)' : 'blur(10px)') : 'none'
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
@@ -13,16 +13,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
interface Props {
|
||||
blur?: boolean
|
||||
duration?: number
|
||||
easing?: string
|
||||
delay?: number
|
||||
threshold?: number
|
||||
initialOpacity?: number
|
||||
className?: string
|
||||
blur?: boolean;
|
||||
duration?: number;
|
||||
easing?: string;
|
||||
delay?: number;
|
||||
threshold?: number;
|
||||
initialOpacity?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -33,34 +33,34 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
threshold: 0.1,
|
||||
initialOpacity: 0,
|
||||
className: ''
|
||||
})
|
||||
});
|
||||
|
||||
const inView = ref(false)
|
||||
const elementRef = ref<HTMLDivElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
const inView = ref(false);
|
||||
const elementRef = ref<HTMLDivElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
const element = elementRef.value
|
||||
if (!element) return
|
||||
const element = elementRef.value;
|
||||
if (!element) return;
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
observer?.unobserve(element)
|
||||
observer?.unobserve(element);
|
||||
setTimeout(() => {
|
||||
inView.value = true
|
||||
}, props.delay)
|
||||
inView.value = true;
|
||||
}, props.delay);
|
||||
}
|
||||
},
|
||||
{ threshold: props.threshold }
|
||||
)
|
||||
);
|
||||
|
||||
observer.observe(element)
|
||||
})
|
||||
observer.observe(element);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
observer.disconnect();
|
||||
}
|
||||
})
|
||||
</script>
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
interface GlareHoverProps {
|
||||
width?: string
|
||||
height?: string
|
||||
background?: string
|
||||
borderRadius?: string
|
||||
borderColor?: string
|
||||
glareColor?: string
|
||||
glareOpacity?: number
|
||||
glareAngle?: number
|
||||
glareSize?: number
|
||||
transitionDuration?: number
|
||||
playOnce?: boolean
|
||||
className?: string
|
||||
style?: Record<string, string | number>
|
||||
width?: string;
|
||||
height?: string;
|
||||
background?: string;
|
||||
borderRadius?: string;
|
||||
borderColor?: string;
|
||||
glareColor?: string;
|
||||
glareOpacity?: number;
|
||||
glareAngle?: number;
|
||||
glareSize?: number;
|
||||
transitionDuration?: number;
|
||||
playOnce?: boolean;
|
||||
className?: string;
|
||||
style?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<GlareHoverProps>(), {
|
||||
@@ -31,28 +31,28 @@ const props = withDefaults(defineProps<GlareHoverProps>(), {
|
||||
playOnce: false,
|
||||
className: '',
|
||||
style: () => ({})
|
||||
})
|
||||
});
|
||||
|
||||
const overlayRef = ref<HTMLDivElement | null>(null)
|
||||
const overlayRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const rgba = computed(() => {
|
||||
const hex = props.glareColor.replace('#', '')
|
||||
let result = props.glareColor
|
||||
|
||||
const hex = props.glareColor.replace('#', '');
|
||||
let result = props.glareColor;
|
||||
|
||||
if (/^[\dA-Fa-f]{6}$/.test(hex)) {
|
||||
const r = parseInt(hex.slice(0, 2), 16)
|
||||
const g = parseInt(hex.slice(2, 4), 16)
|
||||
const b = parseInt(hex.slice(4, 6), 16)
|
||||
result = `rgba(${r}, ${g}, ${b}, ${props.glareOpacity})`
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
result = `rgba(${r}, ${g}, ${b}, ${props.glareOpacity})`;
|
||||
} else if (/^[\dA-Fa-f]{3}$/.test(hex)) {
|
||||
const r = parseInt(hex[0] + hex[0], 16)
|
||||
const g = parseInt(hex[1] + hex[1], 16)
|
||||
const b = parseInt(hex[2] + hex[2], 16)
|
||||
result = `rgba(${r}, ${g}, ${b}, ${props.glareOpacity})`
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
result = `rgba(${r}, ${g}, ${b}, ${props.glareOpacity})`;
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const overlayStyle = computed(() => ({
|
||||
position: 'absolute' as const,
|
||||
@@ -64,32 +64,32 @@ const overlayStyle = computed(() => ({
|
||||
backgroundSize: `${props.glareSize}% ${props.glareSize}%, 100% 100%`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: '-100% -100%, 0 0',
|
||||
pointerEvents: 'none' as const,
|
||||
}))
|
||||
pointerEvents: 'none' as const
|
||||
}));
|
||||
|
||||
const animateIn = () => {
|
||||
const el = overlayRef.value
|
||||
if (!el) return
|
||||
const el = overlayRef.value;
|
||||
if (!el) return;
|
||||
|
||||
el.style.transition = 'none'
|
||||
el.style.backgroundPosition = '-100% -100%, 0 0'
|
||||
void el.offsetHeight
|
||||
el.style.transition = `${props.transitionDuration}ms ease`
|
||||
el.style.backgroundPosition = '100% 100%, 0 0'
|
||||
}
|
||||
el.style.transition = 'none';
|
||||
el.style.backgroundPosition = '-100% -100%, 0 0';
|
||||
void el.offsetHeight;
|
||||
el.style.transition = `${props.transitionDuration}ms ease`;
|
||||
el.style.backgroundPosition = '100% 100%, 0 0';
|
||||
};
|
||||
|
||||
const animateOut = () => {
|
||||
const el = overlayRef.value
|
||||
if (!el) return
|
||||
const el = overlayRef.value;
|
||||
if (!el) return;
|
||||
|
||||
if (props.playOnce) {
|
||||
el.style.transition = 'none'
|
||||
el.style.backgroundPosition = '-100% -100%, 0 0'
|
||||
el.style.transition = 'none';
|
||||
el.style.backgroundPosition = '-100% -100%, 0 0';
|
||||
} else {
|
||||
el.style.transition = `${props.transitionDuration}ms ease`
|
||||
el.style.backgroundPosition = '-100% -100%, 0 0'
|
||||
el.style.transition = `${props.transitionDuration}ms ease`;
|
||||
el.style.backgroundPosition = '-100% -100%, 0 0';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -101,12 +101,13 @@ const animateOut = () => {
|
||||
background: props.background,
|
||||
borderRadius: props.borderRadius,
|
||||
borderColor: props.borderColor,
|
||||
...props.style,
|
||||
...props.style
|
||||
}"
|
||||
@mouseenter="animateIn"
|
||||
@mouseleave="animateOut"
|
||||
>
|
||||
<div ref="overlayRef" :style="overlayStyle" />
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
<template>
|
||||
<div ref="magnetRef" :class="wrapperClassName" :style="{ position: 'relative', display: 'inline-block' }"
|
||||
v-bind="$attrs">
|
||||
<div :class="innerClassName" :style="{
|
||||
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
|
||||
transition: transitionStyle,
|
||||
willChange: 'transform',
|
||||
}">
|
||||
<div
|
||||
ref="magnetRef"
|
||||
:class="wrapperClassName"
|
||||
:style="{ position: 'relative', display: 'inline-block' }"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div
|
||||
:class="innerClassName"
|
||||
:style="{
|
||||
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
|
||||
transition: transitionStyle,
|
||||
willChange: 'transform'
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
interface Props {
|
||||
padding?: number
|
||||
disabled?: boolean
|
||||
magnetStrength?: number
|
||||
activeTransition?: string
|
||||
inactiveTransition?: string
|
||||
wrapperClassName?: string
|
||||
innerClassName?: string
|
||||
padding?: number;
|
||||
disabled?: boolean;
|
||||
magnetStrength?: number;
|
||||
activeTransition?: string;
|
||||
inactiveTransition?: string;
|
||||
wrapperClassName?: string;
|
||||
innerClassName?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -32,53 +39,54 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
inactiveTransition: 'transform 0.5s ease-in-out',
|
||||
wrapperClassName: '',
|
||||
innerClassName: ''
|
||||
})
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
});
|
||||
|
||||
const magnetRef = ref<HTMLDivElement | null>(null)
|
||||
const isActive = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const magnetRef = ref<HTMLDivElement | null>(null);
|
||||
const isActive = ref(false);
|
||||
const position = ref({ x: 0, y: 0 });
|
||||
|
||||
const transitionStyle = computed(() =>
|
||||
isActive.value ? props.activeTransition : props.inactiveTransition
|
||||
)
|
||||
const transitionStyle = computed(() => (isActive.value ? props.activeTransition : props.inactiveTransition));
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!magnetRef.value || props.disabled) return
|
||||
if (!magnetRef.value || props.disabled) return;
|
||||
|
||||
const { left, top, width, height } = magnetRef.value.getBoundingClientRect()
|
||||
const centerX = left + width / 2
|
||||
const centerY = top + height / 2
|
||||
const { left, top, width, height } = magnetRef.value.getBoundingClientRect();
|
||||
const centerX = left + width / 2;
|
||||
const centerY = top + height / 2;
|
||||
|
||||
const distX = Math.abs(centerX - e.clientX)
|
||||
const distY = Math.abs(centerY - e.clientY)
|
||||
const distX = Math.abs(centerX - e.clientX);
|
||||
const distY = Math.abs(centerY - e.clientY);
|
||||
|
||||
if (distX < width / 2 + props.padding && distY < height / 2 + props.padding) {
|
||||
isActive.value = true
|
||||
const offsetX = (e.clientX - centerX) / props.magnetStrength
|
||||
const offsetY = (e.clientY - centerY) / props.magnetStrength
|
||||
position.value = { x: offsetX, y: offsetY }
|
||||
isActive.value = true;
|
||||
const offsetX = (e.clientX - centerX) / props.magnetStrength;
|
||||
const offsetY = (e.clientY - centerY) / props.magnetStrength;
|
||||
position.value = { x: offsetX, y: offsetY };
|
||||
} else {
|
||||
isActive.value = false
|
||||
position.value = { x: 0, y: 0 }
|
||||
isActive.value = false;
|
||||
position.value = { x: 0, y: 0 };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
})
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
})
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
});
|
||||
|
||||
watch(() => props.disabled, (newDisabled) => {
|
||||
if (newDisabled) {
|
||||
position.value = { x: 0, y: 0 }
|
||||
isActive.value = false
|
||||
watch(
|
||||
() => props.disabled,
|
||||
newDisabled => {
|
||||
if (newDisabled) {
|
||||
position.value = { x: 0, y: 0 };
|
||||
isActive.value = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
|
||||
interface MagnetLinesProps {
|
||||
rows?: number
|
||||
columns?: number
|
||||
containerSize?: string
|
||||
lineColor?: string
|
||||
lineWidth?: string
|
||||
lineHeight?: string
|
||||
baseAngle?: number
|
||||
className?: string
|
||||
style?: Record<string, string | number>
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
containerSize?: string;
|
||||
lineColor?: string;
|
||||
lineWidth?: string;
|
||||
lineHeight?: string;
|
||||
baseAngle?: number;
|
||||
className?: string;
|
||||
style?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<MagnetLinesProps>(), {
|
||||
@@ -23,53 +23,53 @@ const props = withDefaults(defineProps<MagnetLinesProps>(), {
|
||||
baseAngle: -10,
|
||||
className: '',
|
||||
style: () => ({})
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const total = computed(() => props.rows * props.columns)
|
||||
const total = computed(() => props.rows * props.columns);
|
||||
|
||||
const onPointerMove = (pointer: { x: number; y: number }) => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
const items = container.querySelectorAll<HTMLSpanElement>('span')
|
||||
const items = container.querySelectorAll<HTMLSpanElement>('span');
|
||||
|
||||
items.forEach((item) => {
|
||||
const rect = item.getBoundingClientRect()
|
||||
const centerX = rect.x + rect.width / 2
|
||||
const centerY = rect.y + rect.height / 2
|
||||
items.forEach(item => {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const centerX = rect.x + rect.width / 2;
|
||||
const centerY = rect.y + rect.height / 2;
|
||||
|
||||
const b = pointer.x - centerX
|
||||
const a = pointer.y - centerY
|
||||
const c = Math.sqrt(a * a + b * b) || 1
|
||||
const r = ((Math.acos(b / c) * 180) / Math.PI) * (pointer.y > centerY ? 1 : -1)
|
||||
const b = pointer.x - centerX;
|
||||
const a = pointer.y - centerY;
|
||||
const c = Math.sqrt(a * a + b * b) || 1;
|
||||
const r = ((Math.acos(b / c) * 180) / Math.PI) * (pointer.y > centerY ? 1 : -1);
|
||||
|
||||
item.style.setProperty('--rotate', `${r}deg`)
|
||||
})
|
||||
}
|
||||
item.style.setProperty('--rotate', `${r}deg`);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
onPointerMove({ x: e.x, y: e.y })
|
||||
}
|
||||
onPointerMove({ x: e.x, y: e.y });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
|
||||
const items = container.querySelectorAll<HTMLSpanElement>('span')
|
||||
const items = container.querySelectorAll<HTMLSpanElement>('span');
|
||||
if (items.length) {
|
||||
const middleIndex = Math.floor(items.length / 2)
|
||||
const rect = items[middleIndex].getBoundingClientRect()
|
||||
onPointerMove({ x: rect.x, y: rect.y })
|
||||
const middleIndex = Math.floor(items.length / 2);
|
||||
const rect = items[middleIndex].getBoundingClientRect();
|
||||
onPointerMove({ x: rect.x, y: rect.y });
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
})
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,7 +81,7 @@ onUnmounted(() => {
|
||||
gridTemplateRows: `repeat(${props.rows}, 1fr)`,
|
||||
width: props.containerSize,
|
||||
height: props.containerSize,
|
||||
...props.style,
|
||||
...props.style
|
||||
}"
|
||||
>
|
||||
<span
|
||||
@@ -94,7 +94,7 @@ onUnmounted(() => {
|
||||
height: props.lineHeight,
|
||||
'--rotate': `${props.baseAngle}deg`,
|
||||
transform: 'rotate(var(--rotate))',
|
||||
willChange: 'transform',
|
||||
willChange: 'transform'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
import { ref, onMounted, watch, onUnmounted, nextTick } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
interface PixelTransitionProps {
|
||||
gridSize?: number
|
||||
pixelColor?: string
|
||||
animationStepDuration?: number
|
||||
className?: string
|
||||
style?: Record<string, string | number>
|
||||
aspectRatio?: string
|
||||
gridSize?: number;
|
||||
pixelColor?: string;
|
||||
animationStepDuration?: number;
|
||||
className?: string;
|
||||
style?: Record<string, string | number>;
|
||||
aspectRatio?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PixelTransitionProps>(), {
|
||||
@@ -18,114 +18,127 @@ const props = withDefaults(defineProps<PixelTransitionProps>(), {
|
||||
className: '',
|
||||
style: () => ({}),
|
||||
aspectRatio: '100%'
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const pixelGridRef = ref<HTMLDivElement | null>(null)
|
||||
const activeRef = ref<HTMLDivElement | null>(null)
|
||||
const isActive = ref(false)
|
||||
let delayedCall: gsap.core.Tween | null = null
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const pixelGridRef = ref<HTMLDivElement | null>(null);
|
||||
const activeRef = ref<HTMLDivElement | null>(null);
|
||||
const isActive = ref(false);
|
||||
let delayedCall: gsap.core.Tween | null = null;
|
||||
|
||||
const isTouchDevice =
|
||||
typeof window !== 'undefined' &&
|
||||
('ontouchstart' in window ||
|
||||
(navigator && navigator.maxTouchPoints > 0) ||
|
||||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches))
|
||||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches));
|
||||
|
||||
function buildPixelGrid() {
|
||||
const pixelGridEl = pixelGridRef.value
|
||||
if (!pixelGridEl) return
|
||||
pixelGridEl.innerHTML = ''
|
||||
const pixelGridEl = pixelGridRef.value;
|
||||
if (!pixelGridEl) return;
|
||||
pixelGridEl.innerHTML = '';
|
||||
for (let row = 0; row < props.gridSize; row++) {
|
||||
for (let col = 0; col < props.gridSize; col++) {
|
||||
const pixel = document.createElement('div')
|
||||
pixel.classList.add('pixelated-image-card__pixel', 'absolute', 'hidden')
|
||||
pixel.style.backgroundColor = props.pixelColor
|
||||
const size = 100 / props.gridSize
|
||||
pixel.style.width = `${size}%`
|
||||
pixel.style.height = `${size}%`
|
||||
pixel.style.left = `${col * size}%`
|
||||
pixel.style.top = `${row * size}%`
|
||||
pixelGridEl.appendChild(pixel)
|
||||
const pixel = document.createElement('div');
|
||||
pixel.classList.add('pixelated-image-card__pixel', 'absolute', 'hidden');
|
||||
pixel.style.backgroundColor = props.pixelColor;
|
||||
const size = 100 / props.gridSize;
|
||||
pixel.style.width = `${size}%`;
|
||||
pixel.style.height = `${size}%`;
|
||||
pixel.style.left = `${col * size}%`;
|
||||
pixel.style.top = `${row * size}%`;
|
||||
pixelGridEl.appendChild(pixel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function animatePixels(activate: boolean) {
|
||||
isActive.value = activate
|
||||
await nextTick()
|
||||
const pixelGridEl = pixelGridRef.value
|
||||
const activeEl = activeRef.value
|
||||
if (!pixelGridEl || !activeEl) return
|
||||
const pixels = pixelGridEl.querySelectorAll<HTMLDivElement>('.pixelated-image-card__pixel')
|
||||
if (!pixels.length) return
|
||||
gsap.killTweensOf(pixels)
|
||||
if (delayedCall) delayedCall.kill()
|
||||
gsap.set(pixels, { display: 'none' })
|
||||
const totalPixels = pixels.length
|
||||
const staggerDuration = props.animationStepDuration / totalPixels
|
||||
isActive.value = activate;
|
||||
await nextTick();
|
||||
const pixelGridEl = pixelGridRef.value;
|
||||
const activeEl = activeRef.value;
|
||||
if (!pixelGridEl || !activeEl) return;
|
||||
const pixels = pixelGridEl.querySelectorAll<HTMLDivElement>('.pixelated-image-card__pixel');
|
||||
if (!pixels.length) return;
|
||||
gsap.killTweensOf(pixels);
|
||||
if (delayedCall) delayedCall.kill();
|
||||
gsap.set(pixels, { display: 'none' });
|
||||
const totalPixels = pixels.length;
|
||||
const staggerDuration = props.animationStepDuration / totalPixels;
|
||||
gsap.to(pixels, {
|
||||
display: 'block',
|
||||
duration: 0,
|
||||
stagger: {
|
||||
each: staggerDuration,
|
||||
from: 'random',
|
||||
},
|
||||
})
|
||||
from: 'random'
|
||||
}
|
||||
});
|
||||
delayedCall = gsap.delayedCall(props.animationStepDuration, () => {
|
||||
activeEl.style.display = activate ? 'block' : 'none'
|
||||
activeEl.style.pointerEvents = activate ? 'none' : ''
|
||||
})
|
||||
activeEl.style.display = activate ? 'block' : 'none';
|
||||
activeEl.style.pointerEvents = activate ? 'none' : '';
|
||||
});
|
||||
gsap.to(pixels, {
|
||||
display: 'none',
|
||||
duration: 0,
|
||||
delay: props.animationStepDuration,
|
||||
stagger: {
|
||||
each: staggerDuration,
|
||||
from: 'random',
|
||||
},
|
||||
})
|
||||
from: 'random'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
if (isTouchDevice) return
|
||||
if (!isActive.value) animatePixels(true)
|
||||
if (isTouchDevice) return;
|
||||
if (!isActive.value) animatePixels(true);
|
||||
}
|
||||
function handleMouseLeave() {
|
||||
if (isTouchDevice) return
|
||||
if (isActive.value) animatePixels(false)
|
||||
if (isTouchDevice) return;
|
||||
if (isActive.value) animatePixels(false);
|
||||
}
|
||||
function handleClick() {
|
||||
if (!isTouchDevice) return
|
||||
animatePixels(!isActive.value)
|
||||
if (!isTouchDevice) return;
|
||||
animatePixels(!isActive.value);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
buildPixelGrid()
|
||||
})
|
||||
await nextTick();
|
||||
buildPixelGrid();
|
||||
});
|
||||
|
||||
watch(() => [props.gridSize, props.pixelColor], () => {
|
||||
buildPixelGrid()
|
||||
})
|
||||
watch(
|
||||
() => [props.gridSize, props.pixelColor],
|
||||
() => {
|
||||
buildPixelGrid();
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (delayedCall) delayedCall.kill()
|
||||
})
|
||||
if (delayedCall) delayedCall.kill();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" :class="[
|
||||
props.className,
|
||||
'bg-[#222] text-white rounded-[15px] border-2 border-white w-[300px] max-w-full relative overflow-hidden'
|
||||
]" :style="props.style" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" @click="handleClick">
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="[
|
||||
props.className,
|
||||
'bg-[#222] text-white rounded-[15px] border-2 border-white w-[300px] max-w-full relative overflow-hidden'
|
||||
]"
|
||||
:style="props.style"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div :style="{ paddingTop: props.aspectRatio }" />
|
||||
|
||||
<div class="absolute inset-0 w-full h-full">
|
||||
<slot name="firstContent" />
|
||||
</div>
|
||||
<div ref="activeRef" class="absolute inset-0 w-full h-full z-[2]" style="display: none;">
|
||||
|
||||
<div ref="activeRef" class="absolute inset-0 w-full h-full z-[2]" style="display: none">
|
||||
<slot name="secondContent" />
|
||||
</div>
|
||||
|
||||
<div ref="pixelGridRef" class="absolute inset-0 w-full h-full pointer-events-none z-[3]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue'
|
||||
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl'
|
||||
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue';
|
||||
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
|
||||
|
||||
interface AuroraProps {
|
||||
colorStops?: string[]
|
||||
amplitude?: number
|
||||
blend?: number
|
||||
time?: number
|
||||
speed?: number
|
||||
intensity?: number
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
colorStops?: string[];
|
||||
amplitude?: number;
|
||||
blend?: number;
|
||||
time?: number;
|
||||
speed?: number;
|
||||
intensity?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<AuroraProps>(), {
|
||||
@@ -25,16 +25,16 @@ const props = withDefaults(defineProps<AuroraProps>(), {
|
||||
intensity: 1.0,
|
||||
className: '',
|
||||
style: () => ({})
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
|
||||
const VERT = `#version 300 es
|
||||
in vec2 position;
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
@@ -136,56 +136,56 @@ void main() {
|
||||
|
||||
fragColor = vec4(auroraColor * finalAlpha, finalAlpha);
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
let renderer: Renderer | null = null
|
||||
let animateId = 0
|
||||
let renderer: Renderer | null = null;
|
||||
let animateId = 0;
|
||||
|
||||
const initAurora = () => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
renderer = new Renderer({
|
||||
alpha: true,
|
||||
premultipliedAlpha: true,
|
||||
antialias: true,
|
||||
})
|
||||
antialias: true
|
||||
});
|
||||
|
||||
const gl = renderer.gl
|
||||
gl.clearColor(0, 0, 0, 0)
|
||||
gl.enable(gl.BLEND)
|
||||
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
|
||||
gl.canvas.style.backgroundColor = 'transparent'
|
||||
const gl = renderer.gl;
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.canvas.style.backgroundColor = 'transparent';
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let program: Program | undefined
|
||||
let program: Program | undefined;
|
||||
|
||||
const resize = () => {
|
||||
if (!container) return
|
||||
if (!container) return;
|
||||
|
||||
const parentWidth = container.parentElement?.offsetWidth || container.offsetWidth || window.innerWidth
|
||||
const parentHeight = container.parentElement?.offsetHeight || container.offsetHeight || window.innerHeight
|
||||
const parentWidth = container.parentElement?.offsetWidth || container.offsetWidth || window.innerWidth;
|
||||
const parentHeight = container.parentElement?.offsetHeight || container.offsetHeight || window.innerHeight;
|
||||
|
||||
const width = Math.max(parentWidth, 300)
|
||||
const height = Math.max(parentHeight, 300)
|
||||
const width = Math.max(parentWidth, 300);
|
||||
const height = Math.max(parentHeight, 300);
|
||||
|
||||
renderer!.setSize(width, height)
|
||||
renderer!.setSize(width, height);
|
||||
if (program) {
|
||||
program.uniforms.uResolution.value = [width, height]
|
||||
program.uniforms.uResolution.value = [width, height];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', resize)
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const geometry = new Triangle(gl)
|
||||
const geometry = new Triangle(gl);
|
||||
if (geometry.attributes.uv) {
|
||||
delete geometry.attributes.uv
|
||||
delete geometry.attributes.uv;
|
||||
}
|
||||
|
||||
const colorStopsArray = props.colorStops.map((hex) => {
|
||||
const c = new Color(hex)
|
||||
return [c.r, c.g, c.b]
|
||||
})
|
||||
const colorStopsArray = props.colorStops.map(hex => {
|
||||
const c = new Color(hex);
|
||||
return [c.r, c.g, c.b];
|
||||
});
|
||||
|
||||
program = new Program(gl, {
|
||||
vertex: VERT,
|
||||
@@ -194,83 +194,88 @@ const initAurora = () => {
|
||||
uTime: { value: 0 },
|
||||
uAmplitude: { value: props.amplitude },
|
||||
uColorStops: { value: colorStopsArray },
|
||||
uResolution: { value: [Math.max(container.parentElement?.offsetWidth || container.offsetWidth || window.innerWidth, 300), Math.max(container.parentElement?.offsetHeight || container.offsetHeight || window.innerHeight, 300)] },
|
||||
uResolution: {
|
||||
value: [
|
||||
Math.max(container.parentElement?.offsetWidth || container.offsetWidth || window.innerWidth, 300),
|
||||
Math.max(container.parentElement?.offsetHeight || container.offsetHeight || window.innerHeight, 300)
|
||||
]
|
||||
},
|
||||
uBlend: { value: props.blend },
|
||||
uIntensity: { value: props.intensity },
|
||||
},
|
||||
})
|
||||
uIntensity: { value: props.intensity }
|
||||
}
|
||||
});
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program })
|
||||
container.appendChild(gl.canvas)
|
||||
const mesh = new Mesh(gl, { geometry, program });
|
||||
container.appendChild(gl.canvas);
|
||||
|
||||
gl.canvas.style.width = '100%'
|
||||
gl.canvas.style.height = '100%'
|
||||
gl.canvas.style.display = 'block'
|
||||
gl.canvas.style.position = 'absolute'
|
||||
gl.canvas.style.top = '0'
|
||||
gl.canvas.style.left = '0'
|
||||
gl.canvas.style.width = '100%';
|
||||
gl.canvas.style.height = '100%';
|
||||
gl.canvas.style.display = 'block';
|
||||
gl.canvas.style.position = 'absolute';
|
||||
gl.canvas.style.top = '0';
|
||||
gl.canvas.style.left = '0';
|
||||
|
||||
const update = (t: number) => {
|
||||
animateId = requestAnimationFrame(update)
|
||||
const time = props.time ?? t * 0.01
|
||||
const speed = props.speed ?? 1.0
|
||||
animateId = requestAnimationFrame(update);
|
||||
const time = props.time ?? t * 0.01;
|
||||
const speed = props.speed ?? 1.0;
|
||||
if (program) {
|
||||
program.uniforms.uTime.value = time * speed * 0.1
|
||||
program.uniforms.uAmplitude.value = props.amplitude ?? 1.0
|
||||
program.uniforms.uBlend.value = props.blend ?? 0.5
|
||||
program.uniforms.uIntensity.value = props.intensity ?? 1.0
|
||||
const stops = props.colorStops ?? ['#27FF64', '#7cff67', '#27FF64']
|
||||
program.uniforms.uTime.value = time * speed * 0.1;
|
||||
program.uniforms.uAmplitude.value = props.amplitude ?? 1.0;
|
||||
program.uniforms.uBlend.value = props.blend ?? 0.5;
|
||||
program.uniforms.uIntensity.value = props.intensity ?? 1.0;
|
||||
const stops = props.colorStops ?? ['#27FF64', '#7cff67', '#27FF64'];
|
||||
program.uniforms.uColorStops.value = stops.map((hex: string) => {
|
||||
const c = new Color(hex)
|
||||
return [c.r, c.g, c.b]
|
||||
})
|
||||
renderer!.render({ scene: mesh })
|
||||
const c = new Color(hex);
|
||||
return [c.r, c.g, c.b];
|
||||
});
|
||||
renderer!.render({ scene: mesh });
|
||||
}
|
||||
}
|
||||
animateId = requestAnimationFrame(update)
|
||||
};
|
||||
animateId = requestAnimationFrame(update);
|
||||
|
||||
resize()
|
||||
resize();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animateId)
|
||||
window.removeEventListener('resize', resize)
|
||||
cancelAnimationFrame(animateId);
|
||||
window.removeEventListener('resize', resize);
|
||||
if (container && gl.canvas.parentNode === container) {
|
||||
container.removeChild(gl.canvas)
|
||||
container.removeChild(gl.canvas);
|
||||
}
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
}
|
||||
}
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
};
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (animateId) {
|
||||
cancelAnimationFrame(animateId)
|
||||
cancelAnimationFrame(animateId);
|
||||
}
|
||||
if (renderer) {
|
||||
const gl = renderer.gl
|
||||
const container = containerRef.value
|
||||
const gl = renderer.gl;
|
||||
const container = containerRef.value;
|
||||
if (container && gl.canvas.parentNode === container) {
|
||||
container.removeChild(gl.canvas)
|
||||
container.removeChild(gl.canvas);
|
||||
}
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
}
|
||||
renderer = null
|
||||
}
|
||||
renderer = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initAurora()
|
||||
})
|
||||
initAurora();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
cleanup();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.amplitude, props.intensity],
|
||||
() => {
|
||||
cleanup()
|
||||
initAurora()
|
||||
cleanup();
|
||||
initAurora();
|
||||
}
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -289,4 +294,4 @@ div {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import * as THREE from 'three'
|
||||
import { degToRad } from 'three/src/math/MathUtils.js'
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
|
||||
import * as THREE from 'three';
|
||||
import { degToRad } from 'three/src/math/MathUtils.js';
|
||||
|
||||
interface BeamsProps {
|
||||
beamWidth?: number
|
||||
beamHeight?: number
|
||||
beamNumber?: number
|
||||
lightColor?: string
|
||||
speed?: number
|
||||
noiseIntensity?: number
|
||||
scale?: number
|
||||
rotation?: number
|
||||
beamWidth?: number;
|
||||
beamHeight?: number;
|
||||
beamNumber?: number;
|
||||
lightColor?: string;
|
||||
speed?: number;
|
||||
noiseIntensity?: number;
|
||||
scale?: number;
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<BeamsProps>(), {
|
||||
@@ -27,41 +27,41 @@ const props = withDefaults(defineProps<BeamsProps>(), {
|
||||
noiseIntensity: 1.75,
|
||||
scale: 0.2,
|
||||
rotation: 0
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
|
||||
let renderer: THREE.WebGLRenderer | null = null
|
||||
let scene: THREE.Scene | null = null
|
||||
let camera: THREE.PerspectiveCamera | null = null
|
||||
let beamMesh: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial> | null = null
|
||||
let directionalLight: THREE.DirectionalLight | null = null
|
||||
let ambientLight: THREE.AmbientLight | null = null
|
||||
let animationId: number | null = null
|
||||
let renderer: THREE.WebGLRenderer | null = null;
|
||||
let scene: THREE.Scene | null = null;
|
||||
let camera: THREE.PerspectiveCamera | null = null;
|
||||
let beamMesh: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial> | null = null;
|
||||
let directionalLight: THREE.DirectionalLight | null = null;
|
||||
let ambientLight: THREE.AmbientLight | null = null;
|
||||
let animationId: number | null = null;
|
||||
|
||||
type UniformValue = THREE.IUniform<unknown> | unknown
|
||||
type UniformValue = THREE.IUniform<unknown> | unknown;
|
||||
|
||||
interface ExtendMaterialConfig {
|
||||
header: string
|
||||
vertexHeader?: string
|
||||
fragmentHeader?: string
|
||||
material?: THREE.MeshPhysicalMaterialParameters & { fog?: boolean }
|
||||
uniforms?: Record<string, UniformValue>
|
||||
vertex?: Record<string, string>
|
||||
fragment?: Record<string, string>
|
||||
header: string;
|
||||
vertexHeader?: string;
|
||||
fragmentHeader?: string;
|
||||
material?: THREE.MeshPhysicalMaterialParameters & { fog?: boolean };
|
||||
uniforms?: Record<string, UniformValue>;
|
||||
vertex?: Record<string, string>;
|
||||
fragment?: Record<string, string>;
|
||||
}
|
||||
|
||||
type ShaderWithDefines = THREE.ShaderLibShader & {
|
||||
defines?: Record<string, string | number | boolean>
|
||||
}
|
||||
defines?: Record<string, string | number | boolean>;
|
||||
};
|
||||
|
||||
const hexToNormalizedRGB = (hex: string): [number, number, number] => {
|
||||
const clean = hex.replace('#', '')
|
||||
const r = parseInt(clean.substring(0, 2), 16)
|
||||
const g = parseInt(clean.substring(2, 4), 16)
|
||||
const b = parseInt(clean.substring(4, 6), 16)
|
||||
return [r / 255, g / 255, b / 255]
|
||||
}
|
||||
const clean = hex.replace('#', '');
|
||||
const r = parseInt(clean.substring(0, 2), 16);
|
||||
const g = parseInt(clean.substring(2, 4), 16);
|
||||
const b = parseInt(clean.substring(4, 6), 16);
|
||||
return [r / 255, g / 255, b / 255];
|
||||
};
|
||||
|
||||
const noise = `
|
||||
float random (in vec2 st) {
|
||||
@@ -138,53 +138,47 @@ float cnoise(vec3 P){
|
||||
float n_xyz = mix(n_yz.x,n_yz.y,fade_xyz.x);
|
||||
return 2.2 * n_xyz;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
function extendMaterial<T extends THREE.Material = THREE.Material>(
|
||||
BaseMaterial: new (params?: THREE.MaterialParameters) => T,
|
||||
cfg: ExtendMaterialConfig
|
||||
): THREE.ShaderMaterial {
|
||||
const physical = THREE.ShaderLib.physical as ShaderWithDefines
|
||||
const {
|
||||
vertexShader: baseVert,
|
||||
fragmentShader: baseFrag,
|
||||
uniforms: baseUniforms,
|
||||
} = physical
|
||||
const baseDefines = physical.defines ?? {}
|
||||
const physical = THREE.ShaderLib.physical as ShaderWithDefines;
|
||||
const { vertexShader: baseVert, fragmentShader: baseFrag, uniforms: baseUniforms } = physical;
|
||||
const baseDefines = physical.defines ?? {};
|
||||
|
||||
const uniforms: Record<string, THREE.IUniform> =
|
||||
THREE.UniformsUtils.clone(baseUniforms)
|
||||
const uniforms: Record<string, THREE.IUniform> = THREE.UniformsUtils.clone(baseUniforms);
|
||||
|
||||
const defaults = new BaseMaterial(cfg.material || {}) as T & {
|
||||
color?: THREE.Color
|
||||
roughness?: number
|
||||
metalness?: number
|
||||
envMap?: THREE.Texture
|
||||
envMapIntensity?: number
|
||||
}
|
||||
color?: THREE.Color;
|
||||
roughness?: number;
|
||||
metalness?: number;
|
||||
envMap?: THREE.Texture;
|
||||
envMapIntensity?: number;
|
||||
};
|
||||
|
||||
if (defaults.color) uniforms.diffuse.value = defaults.color
|
||||
if ('roughness' in defaults) uniforms.roughness.value = defaults.roughness
|
||||
if ('metalness' in defaults) uniforms.metalness.value = defaults.metalness
|
||||
if ('envMap' in defaults) uniforms.envMap.value = defaults.envMap
|
||||
if ('envMapIntensity' in defaults)
|
||||
uniforms.envMapIntensity.value = defaults.envMapIntensity
|
||||
if (defaults.color) uniforms.diffuse.value = defaults.color;
|
||||
if ('roughness' in defaults) uniforms.roughness.value = defaults.roughness;
|
||||
if ('metalness' in defaults) uniforms.metalness.value = defaults.metalness;
|
||||
if ('envMap' in defaults) uniforms.envMap.value = defaults.envMap;
|
||||
if ('envMapIntensity' in defaults) uniforms.envMapIntensity.value = defaults.envMapIntensity;
|
||||
|
||||
Object.entries(cfg.uniforms ?? {}).forEach(([key, u]) => {
|
||||
uniforms[key] =
|
||||
u !== null && typeof u === 'object' && 'value' in u
|
||||
? (u as THREE.IUniform<unknown>)
|
||||
: ({ value: u } as THREE.IUniform<unknown>)
|
||||
})
|
||||
: ({ value: u } as THREE.IUniform<unknown>);
|
||||
});
|
||||
|
||||
let vert = `${cfg.header}\n${cfg.vertexHeader ?? ''}\n${baseVert}`
|
||||
let frag = `${cfg.header}\n${cfg.fragmentHeader ?? ''}\n${baseFrag}`
|
||||
let vert = `${cfg.header}\n${cfg.vertexHeader ?? ''}\n${baseVert}`;
|
||||
let frag = `${cfg.header}\n${cfg.fragmentHeader ?? ''}\n${baseFrag}`;
|
||||
|
||||
for (const [inc, code] of Object.entries(cfg.vertex ?? {})) {
|
||||
vert = vert.replace(inc, `${inc}\n${code}`)
|
||||
vert = vert.replace(inc, `${inc}\n${code}`);
|
||||
}
|
||||
for (const [inc, code] of Object.entries(cfg.fragment ?? {})) {
|
||||
frag = frag.replace(inc, `${inc}\n${code}`)
|
||||
frag = frag.replace(inc, `${inc}\n${code}`);
|
||||
}
|
||||
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
@@ -193,10 +187,10 @@ function extendMaterial<T extends THREE.Material = THREE.Material>(
|
||||
vertexShader: vert,
|
||||
fragmentShader: frag,
|
||||
lights: true,
|
||||
fog: !!cfg.material?.fog,
|
||||
})
|
||||
fog: !!cfg.material?.fog
|
||||
});
|
||||
|
||||
return mat
|
||||
return mat;
|
||||
}
|
||||
|
||||
function createStackedPlanesBufferGeometry(
|
||||
@@ -206,54 +200,51 @@ function createStackedPlanesBufferGeometry(
|
||||
spacing: number,
|
||||
heightSegments: number
|
||||
): THREE.BufferGeometry {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
const numVertices = n * (heightSegments + 1) * 2
|
||||
const numFaces = n * heightSegments * 2
|
||||
const positions = new Float32Array(numVertices * 3)
|
||||
const indices = new Uint32Array(numFaces * 3)
|
||||
const uvs = new Float32Array(numVertices * 2)
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const numVertices = n * (heightSegments + 1) * 2;
|
||||
const numFaces = n * heightSegments * 2;
|
||||
const positions = new Float32Array(numVertices * 3);
|
||||
const indices = new Uint32Array(numFaces * 3);
|
||||
const uvs = new Float32Array(numVertices * 2);
|
||||
|
||||
let vertexOffset = 0
|
||||
let indexOffset = 0
|
||||
let uvOffset = 0
|
||||
const totalWidth = n * width + (n - 1) * spacing
|
||||
const xOffsetBase = -totalWidth / 2
|
||||
let vertexOffset = 0;
|
||||
let indexOffset = 0;
|
||||
let uvOffset = 0;
|
||||
const totalWidth = n * width + (n - 1) * spacing;
|
||||
const xOffsetBase = -totalWidth / 2;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const xOffset = xOffsetBase + i * (width + spacing)
|
||||
const uvXOffset = Math.random() * 300
|
||||
const uvYOffset = Math.random() * 300
|
||||
const xOffset = xOffsetBase + i * (width + spacing);
|
||||
const uvXOffset = Math.random() * 300;
|
||||
const uvYOffset = Math.random() * 300;
|
||||
|
||||
for (let j = 0; j <= heightSegments; j++) {
|
||||
const y = height * (j / heightSegments - 0.5)
|
||||
const v0 = [xOffset, y, 0]
|
||||
const v1 = [xOffset + width, y, 0]
|
||||
positions.set([...v0, ...v1], vertexOffset * 3)
|
||||
const y = height * (j / heightSegments - 0.5);
|
||||
const v0 = [xOffset, y, 0];
|
||||
const v1 = [xOffset + width, y, 0];
|
||||
positions.set([...v0, ...v1], vertexOffset * 3);
|
||||
|
||||
const uvY = j / heightSegments
|
||||
uvs.set(
|
||||
[uvXOffset, uvY + uvYOffset, uvXOffset + 1, uvY + uvYOffset],
|
||||
uvOffset
|
||||
)
|
||||
const uvY = j / heightSegments;
|
||||
uvs.set([uvXOffset, uvY + uvYOffset, uvXOffset + 1, uvY + uvYOffset], uvOffset);
|
||||
|
||||
if (j < heightSegments) {
|
||||
const a = vertexOffset,
|
||||
b = vertexOffset + 1,
|
||||
c = vertexOffset + 2,
|
||||
d = vertexOffset + 3
|
||||
indices.set([a, b, c, c, b, d], indexOffset)
|
||||
indexOffset += 6
|
||||
d = vertexOffset + 3;
|
||||
indices.set([a, b, c, c, b, d], indexOffset);
|
||||
indexOffset += 6;
|
||||
}
|
||||
vertexOffset += 2
|
||||
uvOffset += 4
|
||||
vertexOffset += 2;
|
||||
uvOffset += 4;
|
||||
}
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2))
|
||||
geometry.setIndex(new THREE.BufferAttribute(indices, 1))
|
||||
geometry.computeVertexNormals()
|
||||
return geometry
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
const beamMaterial = computed(() =>
|
||||
@@ -290,12 +281,12 @@ const beamMaterial = computed(() =>
|
||||
fragmentHeader: '',
|
||||
vertex: {
|
||||
'#include <begin_vertex>': `transformed.z += getPos(transformed.xyz);`,
|
||||
'#include <beginnormal_vertex>': `objectNormal = getNormal(position.xyz);`,
|
||||
'#include <beginnormal_vertex>': `objectNormal = getNormal(position.xyz);`
|
||||
},
|
||||
fragment: {
|
||||
'#include <dithering_fragment>': `
|
||||
float randomNoise = noise(gl_FragCoord.xy);
|
||||
gl_FragColor.rgb -= randomNoise / 15. * uNoiseIntensity;`,
|
||||
gl_FragColor.rgb -= randomNoise / 15. * uNoiseIntensity;`
|
||||
},
|
||||
material: { fog: true },
|
||||
uniforms: {
|
||||
@@ -306,127 +297,120 @@ const beamMaterial = computed(() =>
|
||||
uSpeed: { shared: true, mixed: true, linked: true, value: props.speed },
|
||||
envMapIntensity: 10,
|
||||
uNoiseIntensity: props.noiseIntensity,
|
||||
uScale: props.scale,
|
||||
},
|
||||
uScale: props.scale
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const initThreeJS = () => {
|
||||
if (!containerRef.value) return
|
||||
if (!containerRef.value) return;
|
||||
|
||||
cleanup()
|
||||
cleanup();
|
||||
|
||||
const container = containerRef.value;
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setClearColor(0x000000, 1);
|
||||
|
||||
scene = new THREE.Scene();
|
||||
|
||||
camera = new THREE.PerspectiveCamera(30, 1, 0.1, 1000);
|
||||
camera.position.set(0, 0, 20);
|
||||
|
||||
const geometry = createStackedPlanesBufferGeometry(props.beamNumber, props.beamWidth, props.beamHeight, 0, 100);
|
||||
|
||||
const material = beamMaterial.value;
|
||||
beamMesh = new THREE.Mesh(geometry, material);
|
||||
|
||||
const group = new THREE.Group();
|
||||
group.rotation.z = degToRad(props.rotation);
|
||||
group.add(beamMesh);
|
||||
scene.add(group);
|
||||
|
||||
directionalLight = new THREE.DirectionalLight(new THREE.Color(props.lightColor), 1);
|
||||
directionalLight.position.set(0, 3, 10);
|
||||
const shadowCamera = directionalLight.shadow.camera as THREE.OrthographicCamera;
|
||||
shadowCamera.top = 24;
|
||||
shadowCamera.bottom = -24;
|
||||
shadowCamera.left = -24;
|
||||
shadowCamera.right = 24;
|
||||
shadowCamera.far = 64;
|
||||
directionalLight.shadow.bias = -0.004;
|
||||
scene.add(directionalLight);
|
||||
|
||||
ambientLight = new THREE.AmbientLight(0xffffff, 1);
|
||||
scene.add(ambientLight);
|
||||
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const container = containerRef.value
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true })
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.setClearColor(0x000000, 1)
|
||||
|
||||
scene = new THREE.Scene()
|
||||
|
||||
camera = new THREE.PerspectiveCamera(30, 1, 0.1, 1000)
|
||||
camera.position.set(0, 0, 20)
|
||||
|
||||
const geometry = createStackedPlanesBufferGeometry(
|
||||
props.beamNumber,
|
||||
props.beamWidth,
|
||||
props.beamHeight,
|
||||
0,
|
||||
100
|
||||
)
|
||||
|
||||
const material = beamMaterial.value
|
||||
beamMesh = new THREE.Mesh(geometry, material)
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.rotation.z = degToRad(props.rotation)
|
||||
group.add(beamMesh)
|
||||
scene.add(group)
|
||||
|
||||
directionalLight = new THREE.DirectionalLight(new THREE.Color(props.lightColor), 1)
|
||||
directionalLight.position.set(0, 3, 10)
|
||||
const shadowCamera = directionalLight.shadow.camera as THREE.OrthographicCamera
|
||||
shadowCamera.top = 24
|
||||
shadowCamera.bottom = -24
|
||||
shadowCamera.left = -24
|
||||
shadowCamera.right = 24
|
||||
shadowCamera.far = 64
|
||||
directionalLight.shadow.bias = -0.004
|
||||
scene.add(directionalLight)
|
||||
|
||||
ambientLight = new THREE.AmbientLight(0xffffff, 1)
|
||||
scene.add(ambientLight)
|
||||
|
||||
container.appendChild(renderer.domElement)
|
||||
|
||||
const resize = () => {
|
||||
if (!container || !renderer || !camera) return
|
||||
|
||||
const width = container.offsetWidth
|
||||
const height = container.offsetHeight
|
||||
|
||||
renderer.setSize(width, height)
|
||||
camera.aspect = width / height
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(resize)
|
||||
resizeObserver.observe(container)
|
||||
|
||||
resize()
|
||||
|
||||
if (!container || !renderer || !camera) return;
|
||||
|
||||
const width = container.offsetWidth;
|
||||
const height = container.offsetHeight;
|
||||
|
||||
renderer.setSize(width, height);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
resize();
|
||||
|
||||
const animate = () => {
|
||||
animationId = requestAnimationFrame(animate)
|
||||
|
||||
animationId = requestAnimationFrame(animate);
|
||||
|
||||
if (beamMesh && beamMesh.material) {
|
||||
beamMesh.material.uniforms.time.value += 0.1 * 0.016
|
||||
beamMesh.material.uniforms.time.value += 0.1 * 0.016;
|
||||
}
|
||||
|
||||
|
||||
if (renderer && scene && camera) {
|
||||
renderer.render(scene, camera)
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(animate)
|
||||
|
||||
;(container as HTMLDivElement & { _resizeObserver?: ResizeObserver })._resizeObserver = resizeObserver
|
||||
}
|
||||
};
|
||||
|
||||
animationId = requestAnimationFrame(animate);
|
||||
(container as HTMLDivElement & { _resizeObserver?: ResizeObserver })._resizeObserver = resizeObserver;
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId)
|
||||
animationId = null
|
||||
cancelAnimationFrame(animationId);
|
||||
animationId = null;
|
||||
}
|
||||
|
||||
|
||||
if (containerRef.value) {
|
||||
const container = containerRef.value as HTMLDivElement & { _resizeObserver?: ResizeObserver }
|
||||
|
||||
const container = containerRef.value as HTMLDivElement & { _resizeObserver?: ResizeObserver };
|
||||
|
||||
if (container._resizeObserver) {
|
||||
container._resizeObserver.disconnect()
|
||||
delete container._resizeObserver
|
||||
container._resizeObserver.disconnect();
|
||||
delete container._resizeObserver;
|
||||
}
|
||||
|
||||
|
||||
if (renderer && renderer.domElement.parentNode === container) {
|
||||
container.removeChild(renderer.domElement)
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (beamMesh) {
|
||||
if (beamMesh.geometry) beamMesh.geometry.dispose()
|
||||
if (beamMesh.material) beamMesh.material.dispose()
|
||||
beamMesh = null
|
||||
if (beamMesh.geometry) beamMesh.geometry.dispose();
|
||||
if (beamMesh.material) beamMesh.material.dispose();
|
||||
beamMesh = null;
|
||||
}
|
||||
|
||||
|
||||
if (renderer) {
|
||||
renderer.dispose()
|
||||
renderer = null
|
||||
renderer.dispose();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
scene = null
|
||||
camera = null
|
||||
directionalLight = null
|
||||
ambientLight = null
|
||||
}
|
||||
|
||||
scene = null;
|
||||
camera = null;
|
||||
directionalLight = null;
|
||||
ambientLight = null;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [
|
||||
@@ -437,19 +421,19 @@ watch(
|
||||
props.speed,
|
||||
props.noiseIntensity,
|
||||
props.scale,
|
||||
props.rotation,
|
||||
props.rotation
|
||||
],
|
||||
() => {
|
||||
initThreeJS()
|
||||
initThreeJS();
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
initThreeJS()
|
||||
})
|
||||
initThreeJS();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -7,45 +7,45 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
import { InertiaPlugin } from 'gsap/InertiaPlugin'
|
||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
import { InertiaPlugin } from 'gsap/InertiaPlugin';
|
||||
|
||||
gsap.registerPlugin(InertiaPlugin)
|
||||
gsap.registerPlugin(InertiaPlugin);
|
||||
|
||||
const throttle = <T extends unknown[]>(func: (...args: T) => void, limit: number) => {
|
||||
let lastCall = 0
|
||||
let lastCall = 0;
|
||||
return function (this: unknown, ...args: T) {
|
||||
const now = performance.now()
|
||||
const now = performance.now();
|
||||
if (now - lastCall >= limit) {
|
||||
lastCall = now
|
||||
func.apply(this, args)
|
||||
lastCall = now;
|
||||
func.apply(this, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
interface Dot {
|
||||
cx: number
|
||||
cy: number
|
||||
xOffset: number
|
||||
yOffset: number
|
||||
_inertiaApplied: boolean
|
||||
cx: number;
|
||||
cy: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
_inertiaApplied: boolean;
|
||||
}
|
||||
|
||||
export interface DotGridProps {
|
||||
dotSize?: number
|
||||
gap?: number
|
||||
baseColor?: string
|
||||
activeColor?: string
|
||||
proximity?: number
|
||||
speedTrigger?: number
|
||||
shockRadius?: number
|
||||
shockStrength?: number
|
||||
maxSpeed?: number
|
||||
resistance?: number
|
||||
returnDuration?: number
|
||||
className?: string
|
||||
style?: Record<string, string | number>
|
||||
dotSize?: number;
|
||||
gap?: number;
|
||||
baseColor?: string;
|
||||
activeColor?: string;
|
||||
proximity?: number;
|
||||
speedTrigger?: number;
|
||||
shockRadius?: number;
|
||||
shockStrength?: number;
|
||||
maxSpeed?: number;
|
||||
resistance?: number;
|
||||
returnDuration?: number;
|
||||
className?: string;
|
||||
style?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DotGridProps>(), {
|
||||
@@ -62,11 +62,11 @@ const props = withDefaults(defineProps<DotGridProps>(), {
|
||||
returnDuration: 1.5,
|
||||
className: '',
|
||||
style: () => ({})
|
||||
})
|
||||
});
|
||||
|
||||
const wrapperRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const dots = ref<Dot[]>([])
|
||||
const wrapperRef = ref<HTMLDivElement>();
|
||||
const canvasRef = ref<HTMLCanvasElement>();
|
||||
const dots = ref<Dot[]>([]);
|
||||
const pointer = ref({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -75,146 +75,146 @@ const pointer = ref({
|
||||
speed: 0,
|
||||
lastTime: 0,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
})
|
||||
lastY: 0
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
|
||||
if (!m) return { r: 0, g: 0, b: 0 }
|
||||
const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
||||
if (!m) return { r: 0, g: 0, b: 0 };
|
||||
return {
|
||||
r: parseInt(m[1], 16),
|
||||
g: parseInt(m[2], 16),
|
||||
b: parseInt(m[3], 16),
|
||||
}
|
||||
b: parseInt(m[3], 16)
|
||||
};
|
||||
}
|
||||
|
||||
const baseRgb = computed(() => hexToRgb(props.baseColor))
|
||||
const activeRgb = computed(() => hexToRgb(props.activeColor))
|
||||
const baseRgb = computed(() => hexToRgb(props.baseColor));
|
||||
const activeRgb = computed(() => hexToRgb(props.activeColor));
|
||||
|
||||
const circlePath = computed(() => {
|
||||
if (typeof window === 'undefined' || !window.Path2D) return null
|
||||
if (typeof window === 'undefined' || !window.Path2D) return null;
|
||||
|
||||
const p = new Path2D()
|
||||
p.arc(0, 0, props.dotSize / 2, 0, Math.PI * 2)
|
||||
return p
|
||||
})
|
||||
const p = new Path2D();
|
||||
p.arc(0, 0, props.dotSize / 2, 0, Math.PI * 2);
|
||||
return p;
|
||||
});
|
||||
|
||||
const buildGrid = () => {
|
||||
const wrap = wrapperRef.value
|
||||
const canvas = canvasRef.value
|
||||
if (!wrap || !canvas) return
|
||||
const wrap = wrapperRef.value;
|
||||
const canvas = canvasRef.value;
|
||||
if (!wrap || !canvas) return;
|
||||
|
||||
const { width, height } = wrap.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const { width, height } = wrap.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) ctx.scale(dpr, dpr)
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) ctx.scale(dpr, dpr);
|
||||
|
||||
const cols = Math.floor((width + props.gap) / (props.dotSize + props.gap))
|
||||
const rows = Math.floor((height + props.gap) / (props.dotSize + props.gap))
|
||||
const cell = props.dotSize + props.gap
|
||||
const cols = Math.floor((width + props.gap) / (props.dotSize + props.gap));
|
||||
const rows = Math.floor((height + props.gap) / (props.dotSize + props.gap));
|
||||
const cell = props.dotSize + props.gap;
|
||||
|
||||
const gridW = cell * cols - props.gap
|
||||
const gridH = cell * rows - props.gap
|
||||
const gridW = cell * cols - props.gap;
|
||||
const gridH = cell * rows - props.gap;
|
||||
|
||||
const extraX = width - gridW
|
||||
const extraY = height - gridH
|
||||
const extraX = width - gridW;
|
||||
const extraY = height - gridH;
|
||||
|
||||
const startX = extraX / 2 + props.dotSize / 2
|
||||
const startY = extraY / 2 + props.dotSize / 2
|
||||
const startX = extraX / 2 + props.dotSize / 2;
|
||||
const startY = extraY / 2 + props.dotSize / 2;
|
||||
|
||||
const newDots: Dot[] = []
|
||||
const newDots: Dot[] = [];
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const cx = startX + x * cell
|
||||
const cy = startY + y * cell
|
||||
newDots.push({ cx, cy, xOffset: 0, yOffset: 0, _inertiaApplied: false })
|
||||
const cx = startX + x * cell;
|
||||
const cy = startY + y * cell;
|
||||
newDots.push({ cx, cy, xOffset: 0, yOffset: 0, _inertiaApplied: false });
|
||||
}
|
||||
}
|
||||
dots.value = newDots
|
||||
}
|
||||
dots.value = newDots;
|
||||
};
|
||||
|
||||
let rafId: number
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let rafId: number;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const { x: px, y: py } = pointer.value
|
||||
const proxSq = props.proximity * props.proximity
|
||||
const { x: px, y: py } = pointer.value;
|
||||
const proxSq = props.proximity * props.proximity;
|
||||
|
||||
for (const dot of dots.value) {
|
||||
const ox = dot.cx + dot.xOffset
|
||||
const oy = dot.cy + dot.yOffset
|
||||
const dx = dot.cx - px
|
||||
const dy = dot.cy - py
|
||||
const dsq = dx * dx + dy * dy
|
||||
const ox = dot.cx + dot.xOffset;
|
||||
const oy = dot.cy + dot.yOffset;
|
||||
const dx = dot.cx - px;
|
||||
const dy = dot.cy - py;
|
||||
const dsq = dx * dx + dy * dy;
|
||||
|
||||
let style = props.baseColor
|
||||
let style = props.baseColor;
|
||||
if (dsq <= proxSq) {
|
||||
const dist = Math.sqrt(dsq)
|
||||
const t = 1 - dist / props.proximity
|
||||
const r = Math.round(baseRgb.value.r + (activeRgb.value.r - baseRgb.value.r) * t)
|
||||
const g = Math.round(baseRgb.value.g + (activeRgb.value.g - baseRgb.value.g) * t)
|
||||
const b = Math.round(baseRgb.value.b + (activeRgb.value.b - baseRgb.value.b) * t)
|
||||
style = `rgb(${r},${g},${b})`
|
||||
const dist = Math.sqrt(dsq);
|
||||
const t = 1 - dist / props.proximity;
|
||||
const r = Math.round(baseRgb.value.r + (activeRgb.value.r - baseRgb.value.r) * t);
|
||||
const g = Math.round(baseRgb.value.g + (activeRgb.value.g - baseRgb.value.g) * t);
|
||||
const b = Math.round(baseRgb.value.b + (activeRgb.value.b - baseRgb.value.b) * t);
|
||||
style = `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
if (circlePath.value) {
|
||||
ctx.save()
|
||||
ctx.translate(ox, oy)
|
||||
ctx.fillStyle = style
|
||||
ctx.fill(circlePath.value)
|
||||
ctx.restore()
|
||||
ctx.save();
|
||||
ctx.translate(ox, oy);
|
||||
ctx.fillStyle = style;
|
||||
ctx.fill(circlePath.value);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(draw)
|
||||
}
|
||||
rafId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const now = performance.now()
|
||||
const pr = pointer.value
|
||||
const dt = pr.lastTime ? now - pr.lastTime : 16
|
||||
const dx = e.clientX - pr.lastX
|
||||
const dy = e.clientY - pr.lastY
|
||||
let vx = (dx / dt) * 1000
|
||||
let vy = (dy / dt) * 1000
|
||||
let speed = Math.hypot(vx, vy)
|
||||
const now = performance.now();
|
||||
const pr = pointer.value;
|
||||
const dt = pr.lastTime ? now - pr.lastTime : 16;
|
||||
const dx = e.clientX - pr.lastX;
|
||||
const dy = e.clientY - pr.lastY;
|
||||
let vx = (dx / dt) * 1000;
|
||||
let vy = (dy / dt) * 1000;
|
||||
let speed = Math.hypot(vx, vy);
|
||||
if (speed > props.maxSpeed) {
|
||||
const scale = props.maxSpeed / speed
|
||||
vx *= scale
|
||||
vy *= scale
|
||||
speed = props.maxSpeed
|
||||
const scale = props.maxSpeed / speed;
|
||||
vx *= scale;
|
||||
vy *= scale;
|
||||
speed = props.maxSpeed;
|
||||
}
|
||||
pr.lastTime = now
|
||||
pr.lastX = e.clientX
|
||||
pr.lastY = e.clientY
|
||||
pr.vx = vx
|
||||
pr.vy = vy
|
||||
pr.speed = speed
|
||||
pr.lastTime = now;
|
||||
pr.lastX = e.clientX;
|
||||
pr.lastY = e.clientY;
|
||||
pr.vx = vx;
|
||||
pr.vy = vy;
|
||||
pr.speed = speed;
|
||||
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
pr.x = e.clientX - rect.left
|
||||
pr.y = e.clientY - rect.top
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
pr.x = e.clientX - rect.left;
|
||||
pr.y = e.clientY - rect.top;
|
||||
|
||||
for (const dot of dots.value) {
|
||||
const dist = Math.hypot(dot.cx - pr.x, dot.cy - pr.y)
|
||||
const dist = Math.hypot(dot.cx - pr.x, dot.cy - pr.y);
|
||||
if (speed > props.speedTrigger && dist < props.proximity && !dot._inertiaApplied) {
|
||||
dot._inertiaApplied = true
|
||||
gsap.killTweensOf(dot)
|
||||
const pushX = dot.cx - pr.x + vx * 0.005
|
||||
const pushY = dot.cy - pr.y + vy * 0.005
|
||||
dot._inertiaApplied = true;
|
||||
gsap.killTweensOf(dot);
|
||||
const pushX = dot.cx - pr.x + vx * 0.005;
|
||||
const pushY = dot.cy - pr.y + vy * 0.005;
|
||||
gsap.to(dot, {
|
||||
inertia: { xOffset: pushX, yOffset: pushY, resistance: props.resistance },
|
||||
onComplete: () => {
|
||||
@@ -222,29 +222,29 @@ const onMove = (e: MouseEvent) => {
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
duration: props.returnDuration,
|
||||
ease: 'elastic.out(1,0.75)',
|
||||
})
|
||||
dot._inertiaApplied = false
|
||||
},
|
||||
})
|
||||
ease: 'elastic.out(1,0.75)'
|
||||
});
|
||||
dot._inertiaApplied = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const cx = e.clientX - rect.left
|
||||
const cy = e.clientY - rect.top
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
for (const dot of dots.value) {
|
||||
const dist = Math.hypot(dot.cx - cx, dot.cy - cy)
|
||||
const dist = Math.hypot(dot.cx - cx, dot.cy - cy);
|
||||
if (dist < props.shockRadius && !dot._inertiaApplied) {
|
||||
dot._inertiaApplied = true
|
||||
gsap.killTweensOf(dot)
|
||||
const falloff = Math.max(0, 1 - dist / props.shockRadius)
|
||||
const pushX = (dot.cx - cx) * props.shockStrength * falloff
|
||||
const pushY = (dot.cy - cy) * props.shockStrength * falloff
|
||||
dot._inertiaApplied = true;
|
||||
gsap.killTweensOf(dot);
|
||||
const falloff = Math.max(0, 1 - dist / props.shockRadius);
|
||||
const pushX = (dot.cx - cx) * props.shockStrength * falloff;
|
||||
const pushY = (dot.cy - cy) * props.shockStrength * falloff;
|
||||
gsap.to(dot, {
|
||||
inertia: { xOffset: pushX, yOffset: pushY, resistance: props.resistance },
|
||||
onComplete: () => {
|
||||
@@ -252,64 +252,64 @@ const onClick = (e: MouseEvent) => {
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
duration: props.returnDuration,
|
||||
ease: 'elastic.out(1,0.75)',
|
||||
})
|
||||
dot._inertiaApplied = false
|
||||
},
|
||||
})
|
||||
ease: 'elastic.out(1,0.75)'
|
||||
});
|
||||
dot._inertiaApplied = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const throttledMove = throttle(onMove, 50)
|
||||
const throttledMove = throttle(onMove, 50);
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
await nextTick();
|
||||
|
||||
buildGrid()
|
||||
buildGrid();
|
||||
|
||||
if (circlePath.value) {
|
||||
draw()
|
||||
draw();
|
||||
}
|
||||
|
||||
if ('ResizeObserver' in window) {
|
||||
resizeObserver = new ResizeObserver(buildGrid)
|
||||
resizeObserver = new ResizeObserver(buildGrid);
|
||||
if (wrapperRef.value) {
|
||||
resizeObserver.observe(wrapperRef.value)
|
||||
resizeObserver.observe(wrapperRef.value);
|
||||
}
|
||||
} else {
|
||||
(window as Window).addEventListener('resize', buildGrid)
|
||||
(window as Window).addEventListener('resize', buildGrid);
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', throttledMove, { passive: true })
|
||||
window.addEventListener('click', onClick)
|
||||
})
|
||||
window.addEventListener('mousemove', throttledMove, { passive: true });
|
||||
window.addEventListener('click', onClick);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver.disconnect();
|
||||
} else {
|
||||
window.removeEventListener('resize', buildGrid)
|
||||
window.removeEventListener('resize', buildGrid);
|
||||
}
|
||||
|
||||
window.removeEventListener('mousemove', throttledMove)
|
||||
window.removeEventListener('click', onClick)
|
||||
})
|
||||
window.removeEventListener('mousemove', throttledMove);
|
||||
window.removeEventListener('click', onClick);
|
||||
});
|
||||
|
||||
watch([() => props.dotSize, () => props.gap], () => {
|
||||
buildGrid()
|
||||
})
|
||||
buildGrid();
|
||||
});
|
||||
|
||||
watch([() => props.proximity, () => props.baseColor, activeRgb, baseRgb, circlePath], () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
if (circlePath.value) {
|
||||
draw()
|
||||
draw();
|
||||
}
|
||||
})
|
||||
</script>
|
||||
});
|
||||
</script>
|
||||
|
||||
115
src/content/Backgrounds/GridMotion/GridMotion.vue
Normal file
115
src/content/Backgrounds/GridMotion/GridMotion.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
interface GridMotionProps {
|
||||
items?: string[];
|
||||
gradientColor?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<GridMotionProps>(), {
|
||||
items: () => [],
|
||||
gradientColor: 'black'
|
||||
});
|
||||
|
||||
const gridRef = ref<HTMLElement | null>(null);
|
||||
const rowRefs = ref<HTMLElement[]>([]);
|
||||
const mouseX = ref(window.innerWidth / 2);
|
||||
|
||||
const totalItems = 28;
|
||||
const defaultItems = Array.from({ length: totalItems }, (_, i) => `Item ${i + 1}`);
|
||||
const combinedItems = computed(() => (props.items.length > 0 ? props.items.slice(0, totalItems) : defaultItems));
|
||||
|
||||
function isImage(item: string) {
|
||||
return typeof item === 'string' && item.startsWith('http');
|
||||
}
|
||||
|
||||
function isTag(item: string) {
|
||||
return typeof item === 'string' && item.startsWith('<') && item.endsWith('>');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
mouseX.value = e.clientX;
|
||||
};
|
||||
|
||||
const updateMotion = () => {
|
||||
const maxMoveAmount = 300;
|
||||
const baseDuration = 0.8;
|
||||
const inertiaFactors = [0.6, 0.4, 0.3, 0.2];
|
||||
|
||||
rowRefs.value.forEach((row, index) => {
|
||||
const direction = index % 2 === 0 ? 1 : -1;
|
||||
const moveAmount = ((mouseX.value / window.innerWidth) * maxMoveAmount - maxMoveAmount / 2) * direction;
|
||||
|
||||
gsap.to(row, {
|
||||
x: moveAmount,
|
||||
duration: baseDuration + inertiaFactors[index % inertiaFactors.length],
|
||||
ease: 'power3.out',
|
||||
overwrite: 'auto'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const removeAnimation = gsap.ticker.add(updateMotion);
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
removeAnimation();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="gridRef" class="w-full h-full overflow-hidden">
|
||||
<section
|
||||
class="relative flex justify-center items-center w-full h-screen overflow-hidden"
|
||||
:style="{
|
||||
background: `radial-gradient(circle, ${gradientColor} 0%, transparent 100%)`
|
||||
}"
|
||||
>
|
||||
<div class="z-[4] absolute inset-0 bg-[length:250px] pointer-events-none"></div>
|
||||
|
||||
<div
|
||||
class="z-[2] relative flex-none gap-4 grid grid-cols-1 grid-rows-4 w-[150vw] h-[150vh] rotate-[-15deg] origin-center"
|
||||
>
|
||||
<div
|
||||
v-for="rowIndex in 4"
|
||||
:key="rowIndex"
|
||||
class="gap-4 grid grid-cols-7"
|
||||
:style="{ willChange: 'transform, filter' }"
|
||||
ref="rowRefs"
|
||||
>
|
||||
<div v-for="itemIndex in 7" :key="itemIndex" class="relative">
|
||||
<div
|
||||
class="relative flex justify-center items-center bg-[#111] rounded-[10px] w-full h-full overflow-hidden text-[1.5rem] text-white"
|
||||
>
|
||||
<div
|
||||
v-if="isImage(combinedItems[(rowIndex - 1) * 7 + (itemIndex - 1)])"
|
||||
class="top-0 left-0 absolute bg-cover bg-center w-full h-full"
|
||||
:style="{
|
||||
backgroundImage: `url(${combinedItems[(rowIndex - 1) * 7 + (itemIndex - 1)]})`
|
||||
}"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-else-if="isTag(combinedItems[(rowIndex - 1) * 7 + (itemIndex - 1)])"
|
||||
class="z-[2] p-4 text-center"
|
||||
v-html="combinedItems[(rowIndex - 1) * 7 + (itemIndex - 1)]"
|
||||
></div>
|
||||
|
||||
<div v-else class="z-[1] p-4 text-center">
|
||||
{{ combinedItems[(rowIndex - 1) * 7 + (itemIndex - 1)] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="top-0 left-0 relative w-full h-full pointer-events-none"></div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,15 +3,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl'
|
||||
import type { OGLRenderingContext } from 'ogl'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
|
||||
import type { OGLRenderingContext } from 'ogl';
|
||||
|
||||
interface Props {
|
||||
color?: [number, number, number]
|
||||
speed?: number
|
||||
amplitude?: number
|
||||
mouseReact?: boolean
|
||||
color?: [number, number, number];
|
||||
speed?: number;
|
||||
amplitude?: number;
|
||||
mouseReact?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -19,16 +19,16 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
speed: 1.0,
|
||||
amplitude: 0.1,
|
||||
mouseReact: true
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const mousePos = ref({ x: 0.5, y: 0.5 })
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const mousePos = ref({ x: 0.5, y: 0.5 });
|
||||
|
||||
let renderer: Renderer | null = null
|
||||
let gl: OGLRenderingContext | null = null
|
||||
let program: Program | null = null
|
||||
let mesh: Mesh | null = null
|
||||
let animationId: number | null = null
|
||||
let renderer: Renderer | null = null;
|
||||
let gl: OGLRenderingContext | null = null;
|
||||
let program: Program | null = null;
|
||||
let mesh: Mesh | null = null;
|
||||
let animationId: number | null = null;
|
||||
|
||||
const vertexShader = `
|
||||
attribute vec2 uv;
|
||||
@@ -40,7 +40,7 @@ void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0, 1);
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
@@ -71,57 +71,57 @@ void main() {
|
||||
col = cos(col * cos(vec3(d, a, 2.5)) * 0.5 + 0.5) * uColor;
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const resize = () => {
|
||||
if (!containerRef.value || !renderer || !program || !gl) return
|
||||
if (!containerRef.value || !renderer || !program || !gl) return;
|
||||
|
||||
const container = containerRef.value
|
||||
const scale = 1
|
||||
renderer.setSize(container.offsetWidth * scale, container.offsetHeight * scale)
|
||||
const container = containerRef.value;
|
||||
const scale = 1;
|
||||
renderer.setSize(container.offsetWidth * scale, container.offsetHeight * scale);
|
||||
|
||||
if (program) {
|
||||
program.uniforms.uResolution.value = new Color(
|
||||
gl.canvas.width,
|
||||
gl.canvas.height,
|
||||
gl.canvas.width / gl.canvas.height
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.value || !program) return
|
||||
if (!containerRef.value || !program) return;
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
const x = (e.clientX - rect.left) / rect.width
|
||||
const y = 1.0 - (e.clientY - rect.top) / rect.height
|
||||
const rect = containerRef.value.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = 1.0 - (e.clientY - rect.top) / rect.height;
|
||||
|
||||
mousePos.value = { x, y }
|
||||
mousePos.value = { x, y };
|
||||
if (program.uniforms.uMouse.value) {
|
||||
program.uniforms.uMouse.value[0] = x
|
||||
program.uniforms.uMouse.value[1] = y
|
||||
program.uniforms.uMouse.value[0] = x;
|
||||
program.uniforms.uMouse.value[1] = y;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const update = (t: number) => {
|
||||
if (!program || !renderer || !mesh) return
|
||||
if (!program || !renderer || !mesh) return;
|
||||
|
||||
animationId = requestAnimationFrame(update)
|
||||
program.uniforms.uTime.value = t * 0.001
|
||||
renderer.render({ scene: mesh })
|
||||
}
|
||||
animationId = requestAnimationFrame(update);
|
||||
program.uniforms.uTime.value = t * 0.001;
|
||||
renderer.render({ scene: mesh });
|
||||
};
|
||||
|
||||
const initializeScene = () => {
|
||||
if (!containerRef.value) return
|
||||
if (!containerRef.value) return;
|
||||
|
||||
cleanup()
|
||||
cleanup();
|
||||
|
||||
const container = containerRef.value
|
||||
renderer = new Renderer()
|
||||
gl = renderer.gl
|
||||
gl.clearColor(1, 1, 1, 1)
|
||||
const container = containerRef.value;
|
||||
renderer = new Renderer();
|
||||
gl = renderer.gl;
|
||||
gl.clearColor(1, 1, 1, 1);
|
||||
|
||||
const geometry = new Triangle(gl)
|
||||
const geometry = new Triangle(gl);
|
||||
program = new Program(gl, {
|
||||
vertex: vertexShader,
|
||||
fragment: fragmentShader,
|
||||
@@ -129,76 +129,72 @@ const initializeScene = () => {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new Color(...props.color) },
|
||||
uResolution: {
|
||||
value: new Color(
|
||||
gl.canvas.width,
|
||||
gl.canvas.height,
|
||||
gl.canvas.width / gl.canvas.height
|
||||
)
|
||||
value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
|
||||
},
|
||||
uMouse: { value: new Float32Array([mousePos.value.x, mousePos.value.y]) },
|
||||
uAmplitude: { value: props.amplitude },
|
||||
uSpeed: { value: props.speed }
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
mesh = new Mesh(gl, { geometry, program })
|
||||
mesh = new Mesh(gl, { geometry, program });
|
||||
|
||||
const canvas = gl.canvas as HTMLCanvasElement
|
||||
canvas.style.width = '100%'
|
||||
canvas.style.height = '100%'
|
||||
canvas.style.display = 'block'
|
||||
const canvas = gl.canvas as HTMLCanvasElement;
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.display = 'block';
|
||||
|
||||
container.appendChild(canvas)
|
||||
container.appendChild(canvas);
|
||||
|
||||
window.addEventListener('resize', resize)
|
||||
window.addEventListener('resize', resize);
|
||||
if (props.mouseReact) {
|
||||
container.addEventListener('mousemove', handleMouseMove)
|
||||
container.addEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
|
||||
resize()
|
||||
animationId = requestAnimationFrame(update)
|
||||
}
|
||||
resize();
|
||||
animationId = requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId)
|
||||
animationId = null
|
||||
cancelAnimationFrame(animationId);
|
||||
animationId = null;
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', resize)
|
||||
window.removeEventListener('resize', resize);
|
||||
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('mousemove', handleMouseMove)
|
||||
containerRef.value.removeEventListener('mousemove', handleMouseMove);
|
||||
|
||||
const canvas = containerRef.value.querySelector('canvas')
|
||||
const canvas = containerRef.value.querySelector('canvas');
|
||||
if (canvas) {
|
||||
containerRef.value.removeChild(canvas)
|
||||
containerRef.value.removeChild(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
if (gl) {
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
}
|
||||
|
||||
renderer = null
|
||||
gl = null
|
||||
program = null
|
||||
mesh = null
|
||||
}
|
||||
renderer = null;
|
||||
gl = null;
|
||||
program = null;
|
||||
mesh = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeScene()
|
||||
})
|
||||
initializeScene();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
cleanup();
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.color, () => props.speed, () => props.amplitude, () => props.mouseReact],
|
||||
() => {
|
||||
initializeScene()
|
||||
initializeScene();
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,79 +1,140 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden">
|
||||
<canvas ref="canvasRef" class="absolute top-0 left-0 w-full h-full" />
|
||||
<div v-if="outerVignette"
|
||||
class="absolute top-0 left-0 w-full h-full pointer-events-none bg-[radial-gradient(circle,_rgba(0,0,0,0)_60%,_rgba(0,0,0,1)_100%)]" />
|
||||
<div v-if="centerVignette"
|
||||
class="absolute top-0 left-0 w-full h-full pointer-events-none bg-[radial-gradient(circle,_rgba(0,0,0,0.8)_0%,_rgba(0,0,0,0)_60%)]" />
|
||||
|
||||
<div
|
||||
v-if="outerVignette"
|
||||
class="absolute top-0 left-0 w-full h-full pointer-events-none bg-[radial-gradient(circle,_rgba(0,0,0,0)_60%,_rgba(0,0,0,1)_100%)]"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="centerVignette"
|
||||
class="absolute top-0 left-0 w-full h-full pointer-events-none bg-[radial-gradient(circle,_rgba(0,0,0,0.8)_0%,_rgba(0,0,0,0)_60%)]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
interface Props {
|
||||
glitchColors?: string[]
|
||||
glitchSpeed?: number
|
||||
centerVignette?: boolean
|
||||
outerVignette?: boolean
|
||||
smooth?: boolean
|
||||
glitchColors?: string[];
|
||||
glitchSpeed?: number;
|
||||
centerVignette?: boolean;
|
||||
outerVignette?: boolean;
|
||||
smooth?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
glitchColors: () => ["#2b4539", "#61dca3", "#61b3dc"],
|
||||
glitchColors: () => ['#2b4539', '#61dca3', '#61b3dc'],
|
||||
glitchSpeed: 50,
|
||||
centerVignette: false,
|
||||
outerVignette: false,
|
||||
smooth: true
|
||||
})
|
||||
});
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const animationRef = ref<number | null>(null)
|
||||
const letters = ref<{
|
||||
char: string
|
||||
color: string
|
||||
targetColor: string
|
||||
colorProgress: number
|
||||
}[]>([])
|
||||
const grid = ref({ columns: 0, rows: 0 })
|
||||
const context = ref<CanvasRenderingContext2D | null>(null)
|
||||
const lastGlitchTime = ref(Date.now())
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const animationRef = ref<number | null>(null);
|
||||
const letters = ref<
|
||||
{
|
||||
char: string;
|
||||
color: string;
|
||||
targetColor: string;
|
||||
colorProgress: number;
|
||||
}[]
|
||||
>([]);
|
||||
const grid = ref({ columns: 0, rows: 0 });
|
||||
const context = ref<CanvasRenderingContext2D | null>(null);
|
||||
const lastGlitchTime = ref(Date.now());
|
||||
|
||||
const fontSize = 16
|
||||
const charWidth = 10
|
||||
const charHeight = 20
|
||||
const fontSize = 16;
|
||||
const charWidth = 10;
|
||||
const charHeight = 20;
|
||||
|
||||
const lettersAndSymbols = [
|
||||
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
|
||||
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
|
||||
"!", "@", "#", "$", "&", "*", "(", ")", "-", "_", "+", "=", "/",
|
||||
"[", "]", "{", "}", ";", ":", "<", ">", ",", "0", "1", "2", "3",
|
||||
"4", "5", "6", "7", "8", "9"
|
||||
]
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
'D',
|
||||
'E',
|
||||
'F',
|
||||
'G',
|
||||
'H',
|
||||
'I',
|
||||
'J',
|
||||
'K',
|
||||
'L',
|
||||
'M',
|
||||
'N',
|
||||
'O',
|
||||
'P',
|
||||
'Q',
|
||||
'R',
|
||||
'S',
|
||||
'T',
|
||||
'U',
|
||||
'V',
|
||||
'W',
|
||||
'X',
|
||||
'Y',
|
||||
'Z',
|
||||
'!',
|
||||
'@',
|
||||
'#',
|
||||
'$',
|
||||
'&',
|
||||
'*',
|
||||
'(',
|
||||
')',
|
||||
'-',
|
||||
'_',
|
||||
'+',
|
||||
'=',
|
||||
'/',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
';',
|
||||
':',
|
||||
'<',
|
||||
'>',
|
||||
',',
|
||||
'0',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9'
|
||||
];
|
||||
|
||||
const getRandomChar = () => {
|
||||
return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)]
|
||||
}
|
||||
return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];
|
||||
};
|
||||
|
||||
const getRandomColor = () => {
|
||||
return props.glitchColors[Math.floor(Math.random() * props.glitchColors.length)]
|
||||
}
|
||||
return props.glitchColors[Math.floor(Math.random() * props.glitchColors.length)];
|
||||
};
|
||||
|
||||
const hexToRgb = (hex: string) => {
|
||||
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
|
||||
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
||||
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
|
||||
return r + r + g + g + b + b
|
||||
})
|
||||
return r + r + g + g + b + b;
|
||||
});
|
||||
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null
|
||||
}
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
const interpolateColor = (
|
||||
start: { r: number; g: number; b: number },
|
||||
@@ -83,171 +144,167 @@ const interpolateColor = (
|
||||
const result = {
|
||||
r: Math.round(start.r + (end.r - start.r) * factor),
|
||||
g: Math.round(start.g + (end.g - start.g) * factor),
|
||||
b: Math.round(start.b + (end.b - start.b) * factor),
|
||||
}
|
||||
return `rgb(${result.r}, ${result.g}, ${result.b})`
|
||||
}
|
||||
b: Math.round(start.b + (end.b - start.b) * factor)
|
||||
};
|
||||
return `rgb(${result.r}, ${result.g}, ${result.b})`;
|
||||
};
|
||||
|
||||
const calculateGrid = (width: number, height: number) => {
|
||||
const columns = Math.ceil(width / charWidth)
|
||||
const rows = Math.ceil(height / charHeight)
|
||||
return { columns, rows }
|
||||
}
|
||||
const columns = Math.ceil(width / charWidth);
|
||||
const rows = Math.ceil(height / charHeight);
|
||||
return { columns, rows };
|
||||
};
|
||||
|
||||
const initializeLetters = (columns: number, rows: number) => {
|
||||
grid.value = { columns, rows }
|
||||
const totalLetters = columns * rows
|
||||
grid.value = { columns, rows };
|
||||
const totalLetters = columns * rows;
|
||||
letters.value = Array.from({ length: totalLetters }, () => ({
|
||||
char: getRandomChar(),
|
||||
color: getRandomColor(),
|
||||
targetColor: getRandomColor(),
|
||||
colorProgress: 1,
|
||||
}))
|
||||
}
|
||||
colorProgress: 1
|
||||
}));
|
||||
};
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const parent = canvas.parentElement
|
||||
if (!parent) return
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
const parentWidth = parent.parentElement?.offsetWidth || parent.offsetWidth || window.innerWidth
|
||||
const parentHeight = parent.parentElement?.offsetHeight || parent.offsetHeight || window.innerHeight
|
||||
|
||||
const width = Math.max(parentWidth, 300)
|
||||
const height = Math.max(parentHeight, 300)
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
const parentWidth = parent.parentElement?.offsetWidth || parent.offsetWidth || window.innerWidth;
|
||||
const parentHeight = parent.parentElement?.offsetHeight || parent.offsetHeight || window.innerHeight;
|
||||
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
const width = Math.max(parentWidth, 300);
|
||||
const height = Math.max(parentHeight, 300);
|
||||
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
|
||||
if (context.value) {
|
||||
context.value.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
context.value.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
const { columns, rows } = calculateGrid(width, height)
|
||||
initializeLetters(columns, rows)
|
||||
drawLetters()
|
||||
}
|
||||
const { columns, rows } = calculateGrid(width, height);
|
||||
initializeLetters(columns, rows);
|
||||
drawLetters();
|
||||
};
|
||||
|
||||
const drawLetters = () => {
|
||||
if (!context.value || letters.value.length === 0) return
|
||||
const ctx = context.value
|
||||
const { width, height } = canvasRef.value!.getBoundingClientRect()
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
ctx.font = `${fontSize}px monospace`
|
||||
ctx.textBaseline = "top"
|
||||
if (!context.value || letters.value.length === 0) return;
|
||||
const ctx = context.value;
|
||||
const { width, height } = canvasRef.value!.getBoundingClientRect();
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.font = `${fontSize}px monospace`;
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
letters.value.forEach((letter, index) => {
|
||||
const x = (index % grid.value.columns) * charWidth
|
||||
const y = Math.floor(index / grid.value.columns) * charHeight
|
||||
ctx.fillStyle = letter.color
|
||||
ctx.fillText(letter.char, x, y)
|
||||
})
|
||||
}
|
||||
const x = (index % grid.value.columns) * charWidth;
|
||||
const y = Math.floor(index / grid.value.columns) * charHeight;
|
||||
ctx.fillStyle = letter.color;
|
||||
ctx.fillText(letter.char, x, y);
|
||||
});
|
||||
};
|
||||
|
||||
const updateLetters = () => {
|
||||
if (!letters.value || letters.value.length === 0) return
|
||||
if (!letters.value || letters.value.length === 0) return;
|
||||
|
||||
const updateCount = Math.max(1, Math.floor(letters.value.length * 0.05))
|
||||
const updateCount = Math.max(1, Math.floor(letters.value.length * 0.05));
|
||||
|
||||
for (let i = 0; i < updateCount; i++) {
|
||||
const index = Math.floor(Math.random() * letters.value.length)
|
||||
if (!letters.value[index]) continue
|
||||
const index = Math.floor(Math.random() * letters.value.length);
|
||||
if (!letters.value[index]) continue;
|
||||
|
||||
letters.value[index].char = getRandomChar()
|
||||
letters.value[index].targetColor = getRandomColor()
|
||||
letters.value[index].char = getRandomChar();
|
||||
letters.value[index].targetColor = getRandomColor();
|
||||
|
||||
if (!props.smooth) {
|
||||
letters.value[index].color = letters.value[index].targetColor
|
||||
letters.value[index].colorProgress = 1
|
||||
letters.value[index].color = letters.value[index].targetColor;
|
||||
letters.value[index].colorProgress = 1;
|
||||
} else {
|
||||
letters.value[index].colorProgress = 0
|
||||
letters.value[index].colorProgress = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSmoothTransitions = () => {
|
||||
let needsRedraw = false
|
||||
letters.value.forEach((letter) => {
|
||||
let needsRedraw = false;
|
||||
letters.value.forEach(letter => {
|
||||
if (letter.colorProgress < 1) {
|
||||
letter.colorProgress += 0.05
|
||||
if (letter.colorProgress > 1) letter.colorProgress = 1
|
||||
letter.colorProgress += 0.05;
|
||||
if (letter.colorProgress > 1) letter.colorProgress = 1;
|
||||
|
||||
const startRgb = hexToRgb(letter.color)
|
||||
const endRgb = hexToRgb(letter.targetColor)
|
||||
const startRgb = hexToRgb(letter.color);
|
||||
const endRgb = hexToRgb(letter.targetColor);
|
||||
if (startRgb && endRgb) {
|
||||
letter.color = interpolateColor(
|
||||
startRgb,
|
||||
endRgb,
|
||||
letter.colorProgress
|
||||
)
|
||||
needsRedraw = true
|
||||
letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress);
|
||||
needsRedraw = true;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (needsRedraw) {
|
||||
drawLetters()
|
||||
drawLetters();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
const now = Date.now()
|
||||
const now = Date.now();
|
||||
if (now - lastGlitchTime.value >= props.glitchSpeed) {
|
||||
updateLetters()
|
||||
drawLetters()
|
||||
lastGlitchTime.value = now
|
||||
updateLetters();
|
||||
drawLetters();
|
||||
lastGlitchTime.value = now;
|
||||
}
|
||||
|
||||
if (props.smooth) {
|
||||
handleSmoothTransitions()
|
||||
handleSmoothTransitions();
|
||||
}
|
||||
|
||||
animationRef.value = requestAnimationFrame(animate)
|
||||
}
|
||||
animationRef.value = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
let resizeTimeout: number
|
||||
let resizeTimeout: number;
|
||||
|
||||
const handleResize = () => {
|
||||
clearTimeout(resizeTimeout)
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
if (animationRef.value) {
|
||||
cancelAnimationFrame(animationRef.value)
|
||||
cancelAnimationFrame(animationRef.value);
|
||||
}
|
||||
resizeCanvas()
|
||||
animate()
|
||||
}, 100)
|
||||
}
|
||||
resizeCanvas();
|
||||
animate();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
|
||||
context.value = canvas.getContext("2d")
|
||||
resizeCanvas()
|
||||
animate()
|
||||
context.value = canvas.getContext('2d');
|
||||
resizeCanvas();
|
||||
animate();
|
||||
|
||||
window.addEventListener("resize", handleResize)
|
||||
})
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationRef.value) {
|
||||
cancelAnimationFrame(animationRef.value)
|
||||
cancelAnimationFrame(animationRef.value);
|
||||
}
|
||||
window.removeEventListener("resize", handleResize)
|
||||
})
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
watch([() => props.glitchSpeed, () => props.smooth], () => {
|
||||
if (animationRef.value) {
|
||||
cancelAnimationFrame(animationRef.value)
|
||||
cancelAnimationFrame(animationRef.value);
|
||||
}
|
||||
animate()
|
||||
})
|
||||
animate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -266,4 +323,4 @@ div {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
interface LightningProps {
|
||||
hue?: number
|
||||
xOffset?: number
|
||||
speed?: number
|
||||
intensity?: number
|
||||
size?: number
|
||||
hue?: number;
|
||||
xOffset?: number;
|
||||
speed?: number;
|
||||
intensity?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<LightningProps>(), {
|
||||
@@ -19,20 +19,20 @@ const props = withDefaults(defineProps<LightningProps>(), {
|
||||
speed: 1,
|
||||
intensity: 1,
|
||||
size: 1
|
||||
})
|
||||
});
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
let animationId = 0
|
||||
let gl: WebGLRenderingContext | null = null
|
||||
let program: WebGLProgram | null = null
|
||||
let startTime = 0
|
||||
const canvasRef = ref<HTMLCanvasElement>();
|
||||
let animationId = 0;
|
||||
let gl: WebGLRenderingContext | null = null;
|
||||
let program: WebGLProgram | null = null;
|
||||
let startTime = 0;
|
||||
|
||||
const vertexShaderSource = `
|
||||
attribute vec2 aPosition;
|
||||
void main() {
|
||||
gl_Position = vec4(aPosition, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const fragmentShaderSource = `
|
||||
precision mediump float;
|
||||
@@ -112,155 +112,153 @@ void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
|
||||
void main() {
|
||||
mainImage(gl_FragColor, gl_FragCoord.xy);
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const compileShader = (source: string, type: number): WebGLShader | null => {
|
||||
if (!gl) return null
|
||||
const shader = gl.createShader(type)
|
||||
if (!shader) return null
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
if (!gl) return null;
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) return null;
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.error('Shader compile error:', gl.getShaderInfoLog(shader))
|
||||
gl.deleteShader(shader)
|
||||
return null
|
||||
console.error('Shader compile error:', gl.getShaderInfoLog(shader));
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
return shader
|
||||
}
|
||||
return shader;
|
||||
};
|
||||
|
||||
const initWebGL = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
let width = rect.width
|
||||
let height = rect.height
|
||||
|
||||
let parent = canvas.parentElement
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
let width = rect.width;
|
||||
let height = rect.height;
|
||||
|
||||
let parent = canvas.parentElement;
|
||||
while (parent && (!width || !height)) {
|
||||
if (parent.offsetWidth && parent.offsetHeight) {
|
||||
width = parent.offsetWidth
|
||||
height = parent.offsetHeight
|
||||
break
|
||||
width = parent.offsetWidth;
|
||||
height = parent.offsetHeight;
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
|
||||
if (!width || !height) {
|
||||
width = window.innerWidth
|
||||
height = window.innerHeight
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
}
|
||||
|
||||
width = Math.max(width, 300)
|
||||
height = Math.max(height, 300)
|
||||
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
|
||||
canvas.style.width = '100%'
|
||||
canvas.style.height = '100%'
|
||||
canvas.style.display = 'block'
|
||||
canvas.style.position = 'absolute'
|
||||
canvas.style.top = '0'
|
||||
canvas.style.left = '0'
|
||||
}
|
||||
|
||||
resizeCanvas()
|
||||
window.addEventListener('resize', resizeCanvas)
|
||||
width = Math.max(width, 300);
|
||||
height = Math.max(height, 300);
|
||||
|
||||
gl = canvas.getContext('webgl')
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
};
|
||||
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
gl = canvas.getContext('webgl');
|
||||
if (!gl) {
|
||||
console.error('WebGL not supported')
|
||||
return
|
||||
console.error('WebGL not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const vertexShader = compileShader(vertexShaderSource, gl.VERTEX_SHADER)
|
||||
const fragmentShader = compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER)
|
||||
if (!vertexShader || !fragmentShader) return
|
||||
const vertexShader = compileShader(vertexShaderSource, gl.VERTEX_SHADER);
|
||||
const fragmentShader = compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER);
|
||||
if (!vertexShader || !fragmentShader) return;
|
||||
|
||||
program = gl.createProgram()
|
||||
if (!program) return
|
||||
gl.attachShader(program, vertexShader)
|
||||
gl.attachShader(program, fragmentShader)
|
||||
gl.linkProgram(program)
|
||||
program = gl.createProgram();
|
||||
if (!program) return;
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
console.error('Program linking error:', gl.getProgramInfoLog(program))
|
||||
return
|
||||
console.error('Program linking error:', gl.getProgramInfoLog(program));
|
||||
return;
|
||||
}
|
||||
gl.useProgram(program)
|
||||
gl.useProgram(program);
|
||||
|
||||
const vertices = new Float32Array([
|
||||
-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1,
|
||||
])
|
||||
const vertexBuffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW)
|
||||
const vertices = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]);
|
||||
const vertexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
|
||||
|
||||
const aPosition = gl.getAttribLocation(program, 'aPosition')
|
||||
gl.enableVertexAttribArray(aPosition)
|
||||
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0)
|
||||
const aPosition = gl.getAttribLocation(program, 'aPosition');
|
||||
gl.enableVertexAttribArray(aPosition);
|
||||
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
startTime = performance.now()
|
||||
render()
|
||||
startTime = performance.now();
|
||||
render();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeCanvas)
|
||||
}
|
||||
}
|
||||
window.removeEventListener('resize', resizeCanvas);
|
||||
};
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
if (!gl || !program || !canvasRef.value) return
|
||||
if (!gl || !program || !canvasRef.value) return;
|
||||
|
||||
const canvas = canvasRef.value
|
||||
const canvas = canvasRef.value;
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (canvas.width !== rect.width || canvas.height !== rect.height) {
|
||||
canvas.width = rect.width
|
||||
canvas.height = rect.height
|
||||
canvas.style.width = rect.width + 'px'
|
||||
canvas.style.height = rect.height + 'px'
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
canvas.style.width = rect.width + 'px';
|
||||
canvas.style.height = rect.height + 'px';
|
||||
}
|
||||
|
||||
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const iResolutionLocation = gl.getUniformLocation(program, 'iResolution')
|
||||
const iTimeLocation = gl.getUniformLocation(program, 'iTime')
|
||||
const uHueLocation = gl.getUniformLocation(program, 'uHue')
|
||||
const uXOffsetLocation = gl.getUniformLocation(program, 'uXOffset')
|
||||
const uSpeedLocation = gl.getUniformLocation(program, 'uSpeed')
|
||||
const uIntensityLocation = gl.getUniformLocation(program, 'uIntensity')
|
||||
const uSizeLocation = gl.getUniformLocation(program, 'uSize')
|
||||
const iResolutionLocation = gl.getUniformLocation(program, 'iResolution');
|
||||
const iTimeLocation = gl.getUniformLocation(program, 'iTime');
|
||||
const uHueLocation = gl.getUniformLocation(program, 'uHue');
|
||||
const uXOffsetLocation = gl.getUniformLocation(program, 'uXOffset');
|
||||
const uSpeedLocation = gl.getUniformLocation(program, 'uSpeed');
|
||||
const uIntensityLocation = gl.getUniformLocation(program, 'uIntensity');
|
||||
const uSizeLocation = gl.getUniformLocation(program, 'uSize');
|
||||
|
||||
gl.uniform2f(iResolutionLocation, canvas.width, canvas.height)
|
||||
const currentTime = performance.now()
|
||||
gl.uniform1f(iTimeLocation, (currentTime - startTime) / 1000.0)
|
||||
gl.uniform1f(uHueLocation, props.hue)
|
||||
gl.uniform1f(uXOffsetLocation, props.xOffset)
|
||||
gl.uniform1f(uSpeedLocation, props.speed)
|
||||
gl.uniform1f(uIntensityLocation, props.intensity)
|
||||
gl.uniform1f(uSizeLocation, props.size)
|
||||
gl.uniform2f(iResolutionLocation, canvas.width, canvas.height);
|
||||
const currentTime = performance.now();
|
||||
gl.uniform1f(iTimeLocation, (currentTime - startTime) / 1000.0);
|
||||
gl.uniform1f(uHueLocation, props.hue);
|
||||
gl.uniform1f(uXOffsetLocation, props.xOffset);
|
||||
gl.uniform1f(uSpeedLocation, props.speed);
|
||||
gl.uniform1f(uIntensityLocation, props.intensity);
|
||||
gl.uniform1f(uSizeLocation, props.size);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6)
|
||||
animationId = requestAnimationFrame(render)
|
||||
}
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
animationId = requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initWebGL()
|
||||
})
|
||||
initWebGL();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId)
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.hue, props.xOffset, props.speed, props.intensity, props.size],
|
||||
() => {}
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Renderer, Camera, Geometry, Program, Mesh } from 'ogl'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { Renderer, Camera, Geometry, Program, Mesh } from 'ogl';
|
||||
|
||||
interface ParticlesProps {
|
||||
particleCount?: number
|
||||
particleSpread?: number
|
||||
speed?: number
|
||||
particleColors?: string[]
|
||||
moveParticlesOnHover?: boolean
|
||||
particleHoverFactor?: number
|
||||
alphaParticles?: boolean
|
||||
particleBaseSize?: number
|
||||
sizeRandomness?: number
|
||||
cameraDistance?: number
|
||||
disableRotation?: boolean
|
||||
className?: string
|
||||
particleCount?: number;
|
||||
particleSpread?: number;
|
||||
speed?: number;
|
||||
particleColors?: string[];
|
||||
moveParticlesOnHover?: boolean;
|
||||
particleHoverFactor?: number;
|
||||
alphaParticles?: boolean;
|
||||
particleBaseSize?: number;
|
||||
sizeRandomness?: number;
|
||||
cameraDistance?: number;
|
||||
disableRotation?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ParticlesProps>(), {
|
||||
@@ -34,32 +34,35 @@ const props = withDefaults(defineProps<ParticlesProps>(), {
|
||||
cameraDistance: 20,
|
||||
disableRotation: false,
|
||||
className: ''
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const mouseRef = ref({ x: 0, y: 0 })
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const mouseRef = ref({ x: 0, y: 0 });
|
||||
|
||||
let renderer: Renderer | null = null
|
||||
let camera: Camera | null = null
|
||||
let particles: Mesh | null = null
|
||||
let program: Program | null = null
|
||||
let animationFrameId: number | null = null
|
||||
let lastTime = 0
|
||||
let elapsed = 0
|
||||
let renderer: Renderer | null = null;
|
||||
let camera: Camera | null = null;
|
||||
let particles: Mesh | null = null;
|
||||
let program: Program | null = null;
|
||||
let animationFrameId: number | null = null;
|
||||
let lastTime = 0;
|
||||
let elapsed = 0;
|
||||
|
||||
const defaultColors = ['#ffffff', '#ffffff', '#ffffff']
|
||||
const defaultColors = ['#ffffff', '#ffffff', '#ffffff'];
|
||||
|
||||
const hexToRgb = (hex: string): [number, number, number] => {
|
||||
hex = hex.replace(/^#/, '')
|
||||
hex = hex.replace(/^#/, '');
|
||||
if (hex.length === 3) {
|
||||
hex = hex.split('').map((c) => c + c).join('')
|
||||
hex = hex
|
||||
.split('')
|
||||
.map(c => c + c)
|
||||
.join('');
|
||||
}
|
||||
const int = parseInt(hex, 16)
|
||||
const r = ((int >> 16) & 255) / 255
|
||||
const g = ((int >> 8) & 255) / 255
|
||||
const b = (int & 255) / 255
|
||||
return [r, g, b]
|
||||
}
|
||||
const int = parseInt(hex, 16);
|
||||
const r = ((int >> 16) & 255) / 255;
|
||||
const g = ((int >> 8) & 255) / 255;
|
||||
const b = (int & 255) / 255;
|
||||
return [r, g, b];
|
||||
};
|
||||
|
||||
const vertex = /* glsl */ `
|
||||
attribute vec3 position;
|
||||
@@ -94,7 +97,7 @@ const vertex = /* glsl */ `
|
||||
gl_PointSize = (uBaseSize * (1.0 + uSizeRandomness * (random.x - 0.5))) / length(mvPos.xyz);
|
||||
gl_Position = projectionMatrix * mvPos;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const fragment = /* glsl */ `
|
||||
precision highp float;
|
||||
@@ -118,89 +121,89 @@ const fragment = /* glsl */ `
|
||||
gl_FragColor = vec4(vColor + 0.2 * sin(uv.yxx + uTime + vRandom.y * 6.28), circle);
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1
|
||||
const y = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
|
||||
mouseRef.value = { x, y }
|
||||
}
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
const y = -(((e.clientY - rect.top) / rect.height) * 2 - 1);
|
||||
mouseRef.value = { x, y };
|
||||
};
|
||||
|
||||
const initParticles = () => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
renderer = new Renderer({ depth: false, alpha: true })
|
||||
const gl = renderer.gl
|
||||
container.appendChild(gl.canvas)
|
||||
gl.clearColor(0, 0, 0, 0)
|
||||
renderer = new Renderer({ depth: false, alpha: true });
|
||||
const gl = renderer.gl;
|
||||
container.appendChild(gl.canvas);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
|
||||
gl.canvas.style.width = '100%'
|
||||
gl.canvas.style.height = '100%'
|
||||
gl.canvas.style.display = 'block'
|
||||
gl.canvas.style.position = 'absolute'
|
||||
gl.canvas.style.top = '0'
|
||||
gl.canvas.style.left = '0'
|
||||
gl.canvas.style.width = '100%';
|
||||
gl.canvas.style.height = '100%';
|
||||
gl.canvas.style.display = 'block';
|
||||
gl.canvas.style.position = 'absolute';
|
||||
gl.canvas.style.top = '0';
|
||||
gl.canvas.style.left = '0';
|
||||
|
||||
camera = new Camera(gl, { fov: 15 })
|
||||
camera.position.set(0, 0, props.cameraDistance)
|
||||
camera = new Camera(gl, { fov: 15 });
|
||||
camera.position.set(0, 0, props.cameraDistance);
|
||||
|
||||
const resize = () => {
|
||||
if (!container) return
|
||||
|
||||
const parentWidth = container.parentElement?.offsetWidth || container.offsetWidth || window.innerWidth
|
||||
const parentHeight = container.parentElement?.offsetHeight || container.offsetHeight || window.innerHeight
|
||||
|
||||
const width = Math.max(parentWidth, 300)
|
||||
const height = Math.max(parentHeight, 300)
|
||||
|
||||
renderer!.setSize(width, height)
|
||||
camera!.perspective({ aspect: width / height })
|
||||
if (!container) return;
|
||||
|
||||
gl.canvas.style.width = '100%'
|
||||
gl.canvas.style.height = '100%'
|
||||
gl.canvas.style.display = 'block'
|
||||
gl.canvas.style.position = 'absolute'
|
||||
gl.canvas.style.top = '0'
|
||||
gl.canvas.style.left = '0'
|
||||
}
|
||||
window.addEventListener('resize', resize, false)
|
||||
resize()
|
||||
const parentWidth = container.parentElement?.offsetWidth || container.offsetWidth || window.innerWidth;
|
||||
const parentHeight = container.parentElement?.offsetHeight || container.offsetHeight || window.innerHeight;
|
||||
|
||||
const width = Math.max(parentWidth, 300);
|
||||
const height = Math.max(parentHeight, 300);
|
||||
|
||||
renderer!.setSize(width, height);
|
||||
camera!.perspective({ aspect: width / height });
|
||||
|
||||
gl.canvas.style.width = '100%';
|
||||
gl.canvas.style.height = '100%';
|
||||
gl.canvas.style.display = 'block';
|
||||
gl.canvas.style.position = 'absolute';
|
||||
gl.canvas.style.top = '0';
|
||||
gl.canvas.style.left = '0';
|
||||
};
|
||||
window.addEventListener('resize', resize, false);
|
||||
resize();
|
||||
|
||||
if (props.moveParticlesOnHover) {
|
||||
container.addEventListener('mousemove', handleMouseMove)
|
||||
container.addEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
|
||||
const count = props.particleCount
|
||||
const positions = new Float32Array(count * 3)
|
||||
const randoms = new Float32Array(count * 4)
|
||||
const colors = new Float32Array(count * 3)
|
||||
const palette = props.particleColors && props.particleColors.length > 0 ? props.particleColors : defaultColors
|
||||
const count = props.particleCount;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const randoms = new Float32Array(count * 4);
|
||||
const colors = new Float32Array(count * 3);
|
||||
const palette = props.particleColors && props.particleColors.length > 0 ? props.particleColors : defaultColors;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
let x: number, y: number, z: number, len: number
|
||||
let x: number, y: number, z: number, len: number;
|
||||
do {
|
||||
x = Math.random() * 2 - 1
|
||||
y = Math.random() * 2 - 1
|
||||
z = Math.random() * 2 - 1
|
||||
len = x * x + y * y + z * z
|
||||
} while (len > 1 || len === 0)
|
||||
const r = Math.cbrt(Math.random())
|
||||
positions.set([x * r, y * r, z * r], i * 3)
|
||||
randoms.set([Math.random(), Math.random(), Math.random(), Math.random()], i * 4)
|
||||
const col = hexToRgb(palette[Math.floor(Math.random() * palette.length)])
|
||||
colors.set(col, i * 3)
|
||||
x = Math.random() * 2 - 1;
|
||||
y = Math.random() * 2 - 1;
|
||||
z = Math.random() * 2 - 1;
|
||||
len = x * x + y * y + z * z;
|
||||
} while (len > 1 || len === 0);
|
||||
const r = Math.cbrt(Math.random());
|
||||
positions.set([x * r, y * r, z * r], i * 3);
|
||||
randoms.set([Math.random(), Math.random(), Math.random(), Math.random()], i * 4);
|
||||
const col = hexToRgb(palette[Math.floor(Math.random() * palette.length)]);
|
||||
colors.set(col, i * 3);
|
||||
}
|
||||
|
||||
const geometry = new Geometry(gl, {
|
||||
position: { size: 3, data: positions },
|
||||
random: { size: 4, data: randoms },
|
||||
color: { size: 3, data: colors },
|
||||
})
|
||||
color: { size: 3, data: colors }
|
||||
});
|
||||
|
||||
program = new Program(gl, {
|
||||
vertex,
|
||||
@@ -210,105 +213,105 @@ const initParticles = () => {
|
||||
uSpread: { value: props.particleSpread },
|
||||
uBaseSize: { value: props.particleBaseSize },
|
||||
uSizeRandomness: { value: props.sizeRandomness },
|
||||
uAlphaParticles: { value: props.alphaParticles ? 1 : 0 },
|
||||
uAlphaParticles: { value: props.alphaParticles ? 1 : 0 }
|
||||
},
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
})
|
||||
depthTest: false
|
||||
});
|
||||
|
||||
particles = new Mesh(gl, { mode: gl.POINTS, geometry, program })
|
||||
particles = new Mesh(gl, { mode: gl.POINTS, geometry, program });
|
||||
|
||||
lastTime = performance.now()
|
||||
elapsed = 0
|
||||
lastTime = performance.now();
|
||||
elapsed = 0;
|
||||
|
||||
const update = (t: number) => {
|
||||
if (!animationFrameId) return
|
||||
animationFrameId = requestAnimationFrame(update)
|
||||
const delta = t - lastTime
|
||||
lastTime = t
|
||||
elapsed += delta * props.speed
|
||||
if (!animationFrameId) return;
|
||||
animationFrameId = requestAnimationFrame(update);
|
||||
const delta = t - lastTime;
|
||||
lastTime = t;
|
||||
elapsed += delta * props.speed;
|
||||
|
||||
if (program) {
|
||||
program.uniforms.uTime.value = elapsed * 0.001
|
||||
program.uniforms.uSpread.value = props.particleSpread
|
||||
program.uniforms.uBaseSize.value = props.particleBaseSize
|
||||
program.uniforms.uSizeRandomness.value = props.sizeRandomness
|
||||
program.uniforms.uAlphaParticles.value = props.alphaParticles ? 1 : 0
|
||||
program.uniforms.uTime.value = elapsed * 0.001;
|
||||
program.uniforms.uSpread.value = props.particleSpread;
|
||||
program.uniforms.uBaseSize.value = props.particleBaseSize;
|
||||
program.uniforms.uSizeRandomness.value = props.sizeRandomness;
|
||||
program.uniforms.uAlphaParticles.value = props.alphaParticles ? 1 : 0;
|
||||
}
|
||||
|
||||
if (particles) {
|
||||
if (props.moveParticlesOnHover) {
|
||||
particles.position.x = -mouseRef.value.x * props.particleHoverFactor
|
||||
particles.position.y = -mouseRef.value.y * props.particleHoverFactor
|
||||
particles.position.x = -mouseRef.value.x * props.particleHoverFactor;
|
||||
particles.position.y = -mouseRef.value.y * props.particleHoverFactor;
|
||||
} else {
|
||||
particles.position.x = 0
|
||||
particles.position.y = 0
|
||||
particles.position.x = 0;
|
||||
particles.position.y = 0;
|
||||
}
|
||||
|
||||
if (!props.disableRotation) {
|
||||
particles.rotation.x = Math.sin(elapsed * 0.0002) * 0.1
|
||||
particles.rotation.y = Math.cos(elapsed * 0.0005) * 0.15
|
||||
particles.rotation.z += 0.01 * props.speed
|
||||
particles.rotation.x = Math.sin(elapsed * 0.0002) * 0.1;
|
||||
particles.rotation.y = Math.cos(elapsed * 0.0005) * 0.15;
|
||||
particles.rotation.z += 0.01 * props.speed;
|
||||
}
|
||||
}
|
||||
|
||||
if (renderer && camera && particles) {
|
||||
renderer.render({ scene: particles, camera })
|
||||
renderer.render({ scene: particles, camera });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(update)
|
||||
animationFrameId = requestAnimationFrame(update);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize)
|
||||
window.removeEventListener('resize', resize);
|
||||
if (props.moveParticlesOnHover) {
|
||||
container.removeEventListener('mousemove', handleMouseMove)
|
||||
container.removeEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
if (container.contains(gl.canvas)) {
|
||||
container.removeChild(gl.canvas)
|
||||
container.removeChild(gl.canvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
if (renderer) {
|
||||
const container = containerRef.value
|
||||
const gl = renderer.gl
|
||||
const container = containerRef.value;
|
||||
const gl = renderer.gl;
|
||||
if (container && gl.canvas.parentNode === container) {
|
||||
container.removeChild(gl.canvas)
|
||||
container.removeChild(gl.canvas);
|
||||
}
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
}
|
||||
renderer = null
|
||||
camera = null
|
||||
particles = null
|
||||
program = null
|
||||
}
|
||||
renderer = null;
|
||||
camera = null;
|
||||
particles = null;
|
||||
program = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initParticles()
|
||||
})
|
||||
initParticles();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
cleanup();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.particleCount, props.particleColors],
|
||||
() => {
|
||||
cleanup()
|
||||
initParticles()
|
||||
cleanup();
|
||||
initParticles();
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [
|
||||
@@ -322,7 +325,7 @@ watch(
|
||||
props.disableRotation
|
||||
],
|
||||
() => {}
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue'
|
||||
import { Renderer, Program, Mesh, Plane, Camera } from 'ogl'
|
||||
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue';
|
||||
import { Renderer, Program, Mesh, Plane, Camera } from 'ogl';
|
||||
|
||||
interface SilkProps {
|
||||
speed?: number
|
||||
scale?: number
|
||||
color?: string
|
||||
noiseIntensity?: number
|
||||
rotation?: number
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
speed?: number;
|
||||
scale?: number;
|
||||
color?: string;
|
||||
noiseIntensity?: number;
|
||||
rotation?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SilkProps>(), {
|
||||
@@ -24,17 +24,17 @@ const props = withDefaults(defineProps<SilkProps>(), {
|
||||
rotation: 0,
|
||||
className: '',
|
||||
style: () => ({})
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
|
||||
const hexToNormalizedRGB = (hex: string): [number, number, number] => {
|
||||
const clean = hex.replace('#', '')
|
||||
const r = parseInt(clean.slice(0, 2), 16) / 255
|
||||
const g = parseInt(clean.slice(2, 4), 16) / 255
|
||||
const b = parseInt(clean.slice(4, 6), 16) / 255
|
||||
return [r, g, b]
|
||||
}
|
||||
const clean = hex.replace('#', '');
|
||||
const r = parseInt(clean.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(clean.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(clean.slice(4, 6), 16) / 255;
|
||||
return [r, g, b];
|
||||
};
|
||||
|
||||
const vertexShader = `
|
||||
attribute vec2 uv;
|
||||
@@ -51,7 +51,7 @@ void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
@@ -99,75 +99,75 @@ void main() {
|
||||
col.a = 1.0;
|
||||
gl_FragColor = col;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
let renderer: Renderer | null = null
|
||||
let mesh: Mesh | null = null
|
||||
let program: Program | null = null
|
||||
let camera: Camera | null = null
|
||||
let animateId = 0
|
||||
let renderer: Renderer | null = null;
|
||||
let mesh: Mesh | null = null;
|
||||
let program: Program | null = null;
|
||||
let camera: Camera | null = null;
|
||||
let animateId = 0;
|
||||
|
||||
const initSilk = () => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
renderer = new Renderer({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
})
|
||||
antialias: true
|
||||
});
|
||||
|
||||
const gl = renderer.gl
|
||||
gl.clearColor(0, 0, 0, 0)
|
||||
gl.canvas.style.backgroundColor = 'transparent'
|
||||
const gl = renderer.gl;
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.canvas.style.backgroundColor = 'transparent';
|
||||
|
||||
camera = new Camera(gl, { fov: 75 })
|
||||
camera.position.z = 1
|
||||
camera = new Camera(gl, { fov: 75 });
|
||||
camera.position.z = 1;
|
||||
|
||||
const resize = () => {
|
||||
if (!container || !camera) return
|
||||
|
||||
let width = container.offsetWidth
|
||||
let height = container.offsetHeight
|
||||
|
||||
let parent = container.parentElement
|
||||
if (!container || !camera) return;
|
||||
|
||||
let width = container.offsetWidth;
|
||||
let height = container.offsetHeight;
|
||||
|
||||
let parent = container.parentElement;
|
||||
while (parent && (!width || !height)) {
|
||||
if (parent.offsetWidth && parent.offsetHeight) {
|
||||
width = parent.offsetWidth
|
||||
height = parent.offsetHeight
|
||||
break
|
||||
width = parent.offsetWidth;
|
||||
height = parent.offsetHeight;
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
|
||||
if (!width || !height) {
|
||||
width = window.innerWidth
|
||||
height = window.innerHeight
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
}
|
||||
|
||||
width = Math.max(width, 300)
|
||||
height = Math.max(height, 300)
|
||||
|
||||
renderer!.setSize(width, height)
|
||||
camera.perspective({ aspect: width / height })
|
||||
|
||||
width = Math.max(width, 300);
|
||||
height = Math.max(height, 300);
|
||||
|
||||
renderer!.setSize(width, height);
|
||||
camera.perspective({ aspect: width / height });
|
||||
|
||||
if (mesh) {
|
||||
const distance = camera.position.z
|
||||
const fov = camera.fov * (Math.PI / 180)
|
||||
const height2 = 2 * Math.tan(fov / 2) * distance
|
||||
const width2 = height2 * (width / height)
|
||||
const distance = camera.position.z;
|
||||
const fov = camera.fov * (Math.PI / 180);
|
||||
const height2 = 2 * Math.tan(fov / 2) * distance;
|
||||
const width2 = height2 * (width / height);
|
||||
|
||||
mesh.scale.set(width2, height2, 1)
|
||||
mesh.scale.set(width2, height2, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', resize)
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const geometry = new Plane(gl, {
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
height: 1
|
||||
});
|
||||
|
||||
const colorRGB = hexToNormalizedRGB(props.color)
|
||||
const colorRGB = hexToNormalizedRGB(props.color);
|
||||
|
||||
program = new Program(gl, {
|
||||
vertex: vertexShader,
|
||||
@@ -178,81 +178,81 @@ const initSilk = () => {
|
||||
uNoiseIntensity: { value: props.noiseIntensity },
|
||||
uColor: { value: colorRGB },
|
||||
uRotation: { value: props.rotation },
|
||||
uTime: { value: 0 },
|
||||
},
|
||||
})
|
||||
uTime: { value: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
mesh = new Mesh(gl, { geometry, program })
|
||||
container.appendChild(gl.canvas)
|
||||
mesh = new Mesh(gl, { geometry, program });
|
||||
container.appendChild(gl.canvas);
|
||||
|
||||
gl.canvas.style.width = '100%'
|
||||
gl.canvas.style.height = '100%'
|
||||
gl.canvas.style.display = 'block'
|
||||
gl.canvas.style.position = 'absolute'
|
||||
gl.canvas.style.top = '0'
|
||||
gl.canvas.style.left = '0'
|
||||
gl.canvas.style.zIndex = '1'
|
||||
gl.canvas.style.width = '100%';
|
||||
gl.canvas.style.height = '100%';
|
||||
gl.canvas.style.display = 'block';
|
||||
gl.canvas.style.position = 'absolute';
|
||||
gl.canvas.style.top = '0';
|
||||
gl.canvas.style.left = '0';
|
||||
gl.canvas.style.zIndex = '1';
|
||||
|
||||
let lastTime = 0
|
||||
let lastTime = 0;
|
||||
const update = (t: number) => {
|
||||
animateId = requestAnimationFrame(update)
|
||||
const deltaTime = (t - lastTime) / 1000
|
||||
lastTime = t
|
||||
animateId = requestAnimationFrame(update);
|
||||
const deltaTime = (t - lastTime) / 1000;
|
||||
lastTime = t;
|
||||
|
||||
if (program && mesh && camera) {
|
||||
program.uniforms.uTime.value += 0.1 * deltaTime
|
||||
program.uniforms.uSpeed.value = props.speed
|
||||
program.uniforms.uScale.value = props.scale
|
||||
program.uniforms.uNoiseIntensity.value = props.noiseIntensity
|
||||
program.uniforms.uColor.value = hexToNormalizedRGB(props.color)
|
||||
program.uniforms.uRotation.value = props.rotation
|
||||
renderer!.render({ scene: mesh, camera })
|
||||
program.uniforms.uTime.value += 0.1 * deltaTime;
|
||||
program.uniforms.uSpeed.value = props.speed;
|
||||
program.uniforms.uScale.value = props.scale;
|
||||
program.uniforms.uNoiseIntensity.value = props.noiseIntensity;
|
||||
program.uniforms.uColor.value = hexToNormalizedRGB(props.color);
|
||||
program.uniforms.uRotation.value = props.rotation;
|
||||
renderer!.render({ scene: mesh, camera });
|
||||
}
|
||||
}
|
||||
animateId = requestAnimationFrame(update)
|
||||
};
|
||||
animateId = requestAnimationFrame(update);
|
||||
|
||||
resize()
|
||||
resize();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animateId)
|
||||
window.removeEventListener('resize', resize)
|
||||
cancelAnimationFrame(animateId);
|
||||
window.removeEventListener('resize', resize);
|
||||
if (container && gl.canvas.parentNode === container) {
|
||||
container.removeChild(gl.canvas)
|
||||
container.removeChild(gl.canvas);
|
||||
}
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
}
|
||||
}
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
};
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (animateId) {
|
||||
cancelAnimationFrame(animateId)
|
||||
cancelAnimationFrame(animateId);
|
||||
}
|
||||
if (renderer) {
|
||||
const gl = renderer.gl
|
||||
const container = containerRef.value
|
||||
const gl = renderer.gl;
|
||||
const container = containerRef.value;
|
||||
if (container && gl.canvas.parentNode === container) {
|
||||
container.removeChild(gl.canvas)
|
||||
container.removeChild(gl.canvas);
|
||||
}
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
}
|
||||
renderer = null
|
||||
mesh = null
|
||||
camera = null
|
||||
program = null
|
||||
}
|
||||
renderer = null;
|
||||
mesh = null;
|
||||
camera = null;
|
||||
program = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initSilk()
|
||||
})
|
||||
initSilk();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
cleanup();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.speed, props.scale, props.color, props.noiseIntensity, props.rotation],
|
||||
() => {}
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,75 +3,75 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
type CanvasStrokeStyle = string | CanvasGradient | CanvasPattern
|
||||
type CanvasStrokeStyle = string | CanvasGradient | CanvasPattern;
|
||||
|
||||
interface GridOffset {
|
||||
x: number
|
||||
y: number
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
direction?: "diagonal" | "up" | "right" | "down" | "left"
|
||||
speed?: number
|
||||
borderColor?: CanvasStrokeStyle
|
||||
squareSize?: number
|
||||
hoverFillColor?: CanvasStrokeStyle
|
||||
direction?: 'diagonal' | 'up' | 'right' | 'down' | 'left';
|
||||
speed?: number;
|
||||
borderColor?: CanvasStrokeStyle;
|
||||
squareSize?: number;
|
||||
hoverFillColor?: CanvasStrokeStyle;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
direction: "right",
|
||||
direction: 'right',
|
||||
speed: 1,
|
||||
borderColor: "#999",
|
||||
borderColor: '#999',
|
||||
squareSize: 40,
|
||||
hoverFillColor: "#222"
|
||||
})
|
||||
hoverFillColor: '#222'
|
||||
});
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const requestRef = ref<number | null>(null)
|
||||
const numSquaresX = ref<number>(0)
|
||||
const numSquaresY = ref<number>(0)
|
||||
const gridOffset = ref<GridOffset>({ x: 0, y: 0 })
|
||||
const hoveredSquareRef = ref<GridOffset | null>(null)
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const requestRef = ref<number | null>(null);
|
||||
const numSquaresX = ref<number>(0);
|
||||
const numSquaresY = ref<number>(0);
|
||||
const gridOffset = ref<GridOffset>({ x: 0, y: 0 });
|
||||
const hoveredSquareRef = ref<GridOffset | null>(null);
|
||||
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.width = canvas.offsetWidth
|
||||
canvas.height = canvas.offsetHeight
|
||||
numSquaresX.value = Math.ceil(canvas.width / props.squareSize) + 1
|
||||
numSquaresY.value = Math.ceil(canvas.height / props.squareSize) + 1
|
||||
}
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
numSquaresX.value = Math.ceil(canvas.width / props.squareSize) + 1;
|
||||
numSquaresY.value = Math.ceil(canvas.height / props.squareSize) + 1;
|
||||
};
|
||||
|
||||
const drawGrid = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!ctx || !canvas) return
|
||||
const canvas = canvasRef.value;
|
||||
if (!ctx || !canvas) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const startX = Math.floor(gridOffset.value.x / props.squareSize) * props.squareSize
|
||||
const startY = Math.floor(gridOffset.value.y / props.squareSize) * props.squareSize
|
||||
const startX = Math.floor(gridOffset.value.x / props.squareSize) * props.squareSize;
|
||||
const startY = Math.floor(gridOffset.value.y / props.squareSize) * props.squareSize;
|
||||
|
||||
for (let x = startX; x < canvas.width + props.squareSize; x += props.squareSize) {
|
||||
for (let y = startY; y < canvas.height + props.squareSize; y += props.squareSize) {
|
||||
const squareX = x - (gridOffset.value.x % props.squareSize)
|
||||
const squareY = y - (gridOffset.value.y % props.squareSize)
|
||||
const squareX = x - (gridOffset.value.x % props.squareSize);
|
||||
const squareY = y - (gridOffset.value.y % props.squareSize);
|
||||
|
||||
if (
|
||||
hoveredSquareRef.value &&
|
||||
Math.floor((x - startX) / props.squareSize) === hoveredSquareRef.value.x &&
|
||||
Math.floor((y - startY) / props.squareSize) === hoveredSquareRef.value.y
|
||||
) {
|
||||
ctx.fillStyle = props.hoverFillColor
|
||||
ctx.fillRect(squareX, squareY, props.squareSize, props.squareSize)
|
||||
ctx.fillStyle = props.hoverFillColor;
|
||||
ctx.fillRect(squareX, squareY, props.squareSize, props.squareSize);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = props.borderColor
|
||||
ctx.strokeRect(squareX, squareY, props.squareSize, props.squareSize)
|
||||
ctx.strokeStyle = props.borderColor;
|
||||
ctx.strokeRect(squareX, squareY, props.squareSize, props.squareSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,116 +82,118 @@ const drawGrid = () => {
|
||||
canvas.width / 2,
|
||||
canvas.height / 2,
|
||||
Math.sqrt(canvas.width ** 2 + canvas.height ** 2) / 2
|
||||
)
|
||||
gradient.addColorStop(0, "rgba(0, 0, 0, 0)")
|
||||
gradient.addColorStop(1, "#0b0b0b")
|
||||
);
|
||||
gradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
|
||||
gradient.addColorStop(1, '#0b0b0b');
|
||||
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
const updateAnimation = () => {
|
||||
const effectiveSpeed = Math.max(props.speed, 0.1)
|
||||
const effectiveSpeed = Math.max(props.speed, 0.1);
|
||||
|
||||
switch (props.direction) {
|
||||
case "right":
|
||||
gridOffset.value.x = (gridOffset.value.x - effectiveSpeed + props.squareSize) % props.squareSize
|
||||
break
|
||||
case "left":
|
||||
gridOffset.value.x = (gridOffset.value.x + effectiveSpeed + props.squareSize) % props.squareSize
|
||||
break
|
||||
case "up":
|
||||
gridOffset.value.y = (gridOffset.value.y + effectiveSpeed + props.squareSize) % props.squareSize
|
||||
break
|
||||
case "down":
|
||||
gridOffset.value.y = (gridOffset.value.y - effectiveSpeed + props.squareSize) % props.squareSize
|
||||
break
|
||||
case "diagonal":
|
||||
gridOffset.value.x = (gridOffset.value.x - effectiveSpeed + props.squareSize) % props.squareSize
|
||||
gridOffset.value.y = (gridOffset.value.y - effectiveSpeed + props.squareSize) % props.squareSize
|
||||
break
|
||||
case 'right':
|
||||
gridOffset.value.x = (gridOffset.value.x - effectiveSpeed + props.squareSize) % props.squareSize;
|
||||
break;
|
||||
case 'left':
|
||||
gridOffset.value.x = (gridOffset.value.x + effectiveSpeed + props.squareSize) % props.squareSize;
|
||||
break;
|
||||
case 'up':
|
||||
gridOffset.value.y = (gridOffset.value.y + effectiveSpeed + props.squareSize) % props.squareSize;
|
||||
break;
|
||||
case 'down':
|
||||
gridOffset.value.y = (gridOffset.value.y - effectiveSpeed + props.squareSize) % props.squareSize;
|
||||
break;
|
||||
case 'diagonal':
|
||||
gridOffset.value.x = (gridOffset.value.x - effectiveSpeed + props.squareSize) % props.squareSize;
|
||||
gridOffset.value.y = (gridOffset.value.y - effectiveSpeed + props.squareSize) % props.squareSize;
|
||||
break;
|
||||
default:
|
||||
break
|
||||
break;
|
||||
}
|
||||
|
||||
drawGrid()
|
||||
requestRef.value = requestAnimationFrame(updateAnimation)
|
||||
}
|
||||
drawGrid();
|
||||
requestRef.value = requestAnimationFrame(updateAnimation);
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const mouseX = event.clientX - rect.left
|
||||
const mouseY = event.clientY - rect.top
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
|
||||
const startX = Math.floor(gridOffset.value.x / props.squareSize) * props.squareSize
|
||||
const startY = Math.floor(gridOffset.value.y / props.squareSize) * props.squareSize
|
||||
const startX = Math.floor(gridOffset.value.x / props.squareSize) * props.squareSize;
|
||||
const startY = Math.floor(gridOffset.value.y / props.squareSize) * props.squareSize;
|
||||
|
||||
const hoveredSquareX = Math.floor(
|
||||
(mouseX + gridOffset.value.x - startX) / props.squareSize
|
||||
)
|
||||
const hoveredSquareY = Math.floor(
|
||||
(mouseY + gridOffset.value.y - startY) / props.squareSize
|
||||
)
|
||||
const hoveredSquareX = Math.floor((mouseX + gridOffset.value.x - startX) / props.squareSize);
|
||||
const hoveredSquareY = Math.floor((mouseY + gridOffset.value.y - startY) / props.squareSize);
|
||||
|
||||
if (
|
||||
!hoveredSquareRef.value ||
|
||||
hoveredSquareRef.value.x !== hoveredSquareX ||
|
||||
hoveredSquareRef.value.y !== hoveredSquareY
|
||||
) {
|
||||
hoveredSquareRef.value = { x: hoveredSquareX, y: hoveredSquareY }
|
||||
hoveredSquareRef.value = { x: hoveredSquareX, y: hoveredSquareY };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
hoveredSquareRef.value = null
|
||||
}
|
||||
hoveredSquareRef.value = null;
|
||||
};
|
||||
|
||||
const initializeCanvas = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
|
||||
ctx = canvas.getContext("2d")
|
||||
resizeCanvas()
|
||||
ctx = canvas.getContext('2d');
|
||||
resizeCanvas();
|
||||
|
||||
canvas.addEventListener("mousemove", handleMouseMove)
|
||||
canvas.addEventListener("mouseleave", handleMouseLeave)
|
||||
window.addEventListener("resize", resizeCanvas)
|
||||
canvas.addEventListener('mousemove', handleMouseMove);
|
||||
canvas.addEventListener('mouseleave', handleMouseLeave);
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
requestRef.value = requestAnimationFrame(updateAnimation)
|
||||
}
|
||||
requestRef.value = requestAnimationFrame(updateAnimation);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
const canvas = canvasRef.value
|
||||
const canvas = canvasRef.value;
|
||||
|
||||
if (requestRef.value) {
|
||||
cancelAnimationFrame(requestRef.value)
|
||||
requestRef.value = null
|
||||
cancelAnimationFrame(requestRef.value);
|
||||
requestRef.value = null;
|
||||
}
|
||||
|
||||
if (canvas) {
|
||||
canvas.removeEventListener("mousemove", handleMouseMove)
|
||||
canvas.removeEventListener("mouseleave", handleMouseLeave)
|
||||
canvas.removeEventListener('mousemove', handleMouseMove);
|
||||
canvas.removeEventListener('mouseleave', handleMouseLeave);
|
||||
}
|
||||
|
||||
window.removeEventListener("resize", resizeCanvas)
|
||||
}
|
||||
window.removeEventListener('resize', resizeCanvas);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeCanvas()
|
||||
})
|
||||
initializeCanvas();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
cleanup();
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.direction, () => props.speed, () => props.borderColor, () => props.hoverFillColor, () => props.squareSize],
|
||||
[
|
||||
() => props.direction,
|
||||
() => props.speed,
|
||||
() => props.borderColor,
|
||||
() => props.hoverFillColor,
|
||||
() => props.squareSize
|
||||
],
|
||||
() => {
|
||||
cleanup()
|
||||
initializeCanvas()
|
||||
cleanup();
|
||||
initializeCanvas();
|
||||
}
|
||||
)
|
||||
</script>
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Renderer, Program, Mesh, Triangle, Color } from 'ogl'
|
||||
import type { OGLRenderingContext } from 'ogl'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { Renderer, Program, Mesh, Triangle, Color } from 'ogl';
|
||||
import type { OGLRenderingContext } from 'ogl';
|
||||
|
||||
interface Props {
|
||||
color?: [number, number, number]
|
||||
amplitude?: number
|
||||
distance?: number
|
||||
enableMouseInteraction?: boolean
|
||||
color?: [number, number, number];
|
||||
amplitude?: number;
|
||||
distance?: number;
|
||||
enableMouseInteraction?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -19,17 +19,17 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
amplitude: 1,
|
||||
distance: 0,
|
||||
enableMouseInteraction: false
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
let renderer: Renderer | null = null
|
||||
let gl: OGLRenderingContext | null = null
|
||||
let program: Program | null = null
|
||||
let mesh: Mesh | null = null
|
||||
let animationId: number | null = null
|
||||
let currentMouse = [0.5, 0.5]
|
||||
let targetMouse = [0.5, 0.5]
|
||||
let renderer: Renderer | null = null;
|
||||
let gl: OGLRenderingContext | null = null;
|
||||
let program: Program | null = null;
|
||||
let mesh: Mesh | null = null;
|
||||
let animationId: number | null = null;
|
||||
let currentMouse = [0.5, 0.5];
|
||||
let targetMouse = [0.5, 0.5];
|
||||
|
||||
const vertexShader = `
|
||||
attribute vec2 position;
|
||||
@@ -39,7 +39,7 @@ void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
@@ -146,145 +146,141 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
void main() {
|
||||
mainImage(gl_FragColor, gl_FragCoord.xy);
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const resize = () => {
|
||||
if (!containerRef.value || !renderer || !program) return
|
||||
if (!containerRef.value || !renderer || !program) return;
|
||||
|
||||
const container = containerRef.value
|
||||
const { clientWidth, clientHeight } = container
|
||||
renderer.setSize(clientWidth, clientHeight)
|
||||
program.uniforms.iResolution.value.r = clientWidth
|
||||
program.uniforms.iResolution.value.g = clientHeight
|
||||
program.uniforms.iResolution.value.b = clientWidth / clientHeight
|
||||
}
|
||||
const container = containerRef.value;
|
||||
const { clientWidth, clientHeight } = container;
|
||||
renderer.setSize(clientWidth, clientHeight);
|
||||
program.uniforms.iResolution.value.r = clientWidth;
|
||||
program.uniforms.iResolution.value.g = clientHeight;
|
||||
program.uniforms.iResolution.value.b = clientWidth / clientHeight;
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.value) return
|
||||
if (!containerRef.value) return;
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
const x = (e.clientX - rect.left) / rect.width
|
||||
const y = 1.0 - (e.clientY - rect.top) / rect.height
|
||||
targetMouse = [x, y]
|
||||
}
|
||||
const rect = containerRef.value.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = 1.0 - (e.clientY - rect.top) / rect.height;
|
||||
targetMouse = [x, y];
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
targetMouse = [0.5, 0.5]
|
||||
}
|
||||
targetMouse = [0.5, 0.5];
|
||||
};
|
||||
|
||||
const update = (t: number) => {
|
||||
if (!program || !renderer || !mesh) return
|
||||
if (!program || !renderer || !mesh) return;
|
||||
|
||||
if (props.enableMouseInteraction) {
|
||||
const smoothing = 0.05
|
||||
currentMouse[0] += smoothing * (targetMouse[0] - currentMouse[0])
|
||||
currentMouse[1] += smoothing * (targetMouse[1] - currentMouse[1])
|
||||
program.uniforms.uMouse.value[0] = currentMouse[0]
|
||||
program.uniforms.uMouse.value[1] = currentMouse[1]
|
||||
const smoothing = 0.05;
|
||||
currentMouse[0] += smoothing * (targetMouse[0] - currentMouse[0]);
|
||||
currentMouse[1] += smoothing * (targetMouse[1] - currentMouse[1]);
|
||||
program.uniforms.uMouse.value[0] = currentMouse[0];
|
||||
program.uniforms.uMouse.value[1] = currentMouse[1];
|
||||
} else {
|
||||
program.uniforms.uMouse.value[0] = 0.5
|
||||
program.uniforms.uMouse.value[1] = 0.5
|
||||
program.uniforms.uMouse.value[0] = 0.5;
|
||||
program.uniforms.uMouse.value[1] = 0.5;
|
||||
}
|
||||
|
||||
program.uniforms.iTime.value = t * 0.001
|
||||
renderer.render({ scene: mesh })
|
||||
animationId = requestAnimationFrame(update)
|
||||
}
|
||||
program.uniforms.iTime.value = t * 0.001;
|
||||
renderer.render({ scene: mesh });
|
||||
animationId = requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
const initializeScene = () => {
|
||||
if (!containerRef.value) return
|
||||
if (!containerRef.value) return;
|
||||
|
||||
cleanup()
|
||||
cleanup();
|
||||
|
||||
const container = containerRef.value
|
||||
renderer = new Renderer({ alpha: true })
|
||||
gl = renderer.gl
|
||||
gl.clearColor(0, 0, 0, 0)
|
||||
gl.enable(gl.BLEND)
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
const container = containerRef.value;
|
||||
renderer = new Renderer({ alpha: true });
|
||||
gl = renderer.gl;
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
const geometry = new Triangle(gl)
|
||||
const geometry = new Triangle(gl);
|
||||
program = new Program(gl, {
|
||||
vertex: vertexShader,
|
||||
fragment: fragmentShader,
|
||||
uniforms: {
|
||||
iTime: { value: 0 },
|
||||
iResolution: {
|
||||
value: new Color(
|
||||
gl.canvas.width,
|
||||
gl.canvas.height,
|
||||
gl.canvas.width / gl.canvas.height
|
||||
)
|
||||
value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
|
||||
},
|
||||
uColor: { value: new Color(...props.color) },
|
||||
uAmplitude: { value: props.amplitude },
|
||||
uDistance: { value: props.distance },
|
||||
uMouse: { value: new Float32Array([0.5, 0.5]) }
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
mesh = new Mesh(gl, { geometry, program })
|
||||
mesh = new Mesh(gl, { geometry, program });
|
||||
|
||||
const canvas = gl.canvas as HTMLCanvasElement
|
||||
canvas.style.width = '100%'
|
||||
canvas.style.height = '100%'
|
||||
canvas.style.display = 'block'
|
||||
const canvas = gl.canvas as HTMLCanvasElement;
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.display = 'block';
|
||||
|
||||
container.appendChild(canvas)
|
||||
container.appendChild(canvas);
|
||||
|
||||
window.addEventListener('resize', resize)
|
||||
window.addEventListener('resize', resize);
|
||||
if (props.enableMouseInteraction) {
|
||||
container.addEventListener('mousemove', handleMouseMove)
|
||||
container.addEventListener('mouseleave', handleMouseLeave)
|
||||
container.addEventListener('mousemove', handleMouseMove);
|
||||
container.addEventListener('mouseleave', handleMouseLeave);
|
||||
}
|
||||
|
||||
resize()
|
||||
animationId = requestAnimationFrame(update)
|
||||
}
|
||||
resize();
|
||||
animationId = requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId)
|
||||
animationId = null
|
||||
cancelAnimationFrame(animationId);
|
||||
animationId = null;
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', resize)
|
||||
window.removeEventListener('resize', resize);
|
||||
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('mousemove', handleMouseMove)
|
||||
containerRef.value.removeEventListener('mouseleave', handleMouseLeave)
|
||||
containerRef.value.removeEventListener('mousemove', handleMouseMove);
|
||||
containerRef.value.removeEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
const canvas = containerRef.value.querySelector('canvas')
|
||||
const canvas = containerRef.value.querySelector('canvas');
|
||||
if (canvas) {
|
||||
containerRef.value.removeChild(canvas)
|
||||
containerRef.value.removeChild(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
if (gl) {
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
}
|
||||
|
||||
renderer = null
|
||||
gl = null
|
||||
program = null
|
||||
mesh = null
|
||||
currentMouse = [0.5, 0.5]
|
||||
targetMouse = [0.5, 0.5]
|
||||
}
|
||||
renderer = null;
|
||||
gl = null;
|
||||
program = null;
|
||||
mesh = null;
|
||||
currentMouse = [0.5, 0.5];
|
||||
targetMouse = [0.5, 0.5];
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeScene()
|
||||
})
|
||||
initializeScene();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
cleanup();
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.color, () => props.amplitude, () => props.distance, () => props.enableMouseInteraction],
|
||||
() => {
|
||||
initializeScene()
|
||||
initializeScene();
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,38 +1,46 @@
|
||||
<template>
|
||||
<div ref="containerRef" :class="className" :style="{ backgroundColor, ...style }"
|
||||
class="absolute top-0 left-0 w-full h-full overflow-hidden">
|
||||
<div class="absolute top-0 left-0 bg-[#160000] rounded-full w-[0.5rem] h-[0.5rem]" :style="{
|
||||
transform: 'translate3d(calc(var(--x) - 50%), calc(var(--y) - 50%), 0)',
|
||||
willChange: 'transform',
|
||||
}" />
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="className"
|
||||
:style="{ backgroundColor, ...style }"
|
||||
class="absolute top-0 left-0 w-full h-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 bg-[#160000] rounded-full w-[0.5rem] h-[0.5rem]"
|
||||
:style="{
|
||||
transform: 'translate3d(calc(var(--x) - 50%), calc(var(--y) - 50%), 0)',
|
||||
willChange: 'transform'
|
||||
}"
|
||||
/>
|
||||
|
||||
<canvas ref="canvasRef" class="block w-full h-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue';
|
||||
|
||||
class Grad {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
|
||||
constructor(x: number, y: number, z: number) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.z = z
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
}
|
||||
|
||||
dot2(x: number, y: number): number {
|
||||
return this.x * x + this.y * y
|
||||
return this.x * x + this.y * y;
|
||||
}
|
||||
}
|
||||
|
||||
class Noise {
|
||||
grad3: Grad[]
|
||||
p: number[]
|
||||
perm: number[]
|
||||
gradP: Grad[]
|
||||
grad3: Grad[];
|
||||
p: number[];
|
||||
perm: number[];
|
||||
gradP: Grad[];
|
||||
|
||||
constructor(seed = 0) {
|
||||
this.grad3 = [
|
||||
@@ -47,122 +55,113 @@ class Noise {
|
||||
new Grad(0, 1, 1),
|
||||
new Grad(0, -1, 1),
|
||||
new Grad(0, 1, -1),
|
||||
new Grad(0, -1, -1),
|
||||
]
|
||||
new Grad(0, -1, -1)
|
||||
];
|
||||
|
||||
this.p = [
|
||||
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225,
|
||||
140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148, 247,
|
||||
120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177,
|
||||
33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165,
|
||||
71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122, 60, 211,
|
||||
133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25,
|
||||
63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196,
|
||||
135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64, 52, 217,
|
||||
226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206,
|
||||
59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248,
|
||||
152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9, 129, 22,
|
||||
39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218,
|
||||
246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241,
|
||||
81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157,
|
||||
184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93,
|
||||
222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180,
|
||||
]
|
||||
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240,
|
||||
21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33, 88,
|
||||
237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83,
|
||||
111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216,
|
||||
80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186,
|
||||
3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58,
|
||||
17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9,
|
||||
129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193,
|
||||
238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157,
|
||||
184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128,
|
||||
195, 78, 66, 215, 61, 156, 180
|
||||
];
|
||||
|
||||
this.perm = new Array(512)
|
||||
this.gradP = new Array(512)
|
||||
this.seed(seed)
|
||||
this.perm = new Array(512);
|
||||
this.gradP = new Array(512);
|
||||
this.seed(seed);
|
||||
}
|
||||
|
||||
seed(seed: number) {
|
||||
if (seed > 0 && seed < 1) seed *= 65536
|
||||
seed = Math.floor(seed)
|
||||
if (seed < 256) seed |= seed << 8
|
||||
if (seed > 0 && seed < 1) seed *= 65536;
|
||||
seed = Math.floor(seed);
|
||||
if (seed < 256) seed |= seed << 8;
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const v = i & 1 ? this.p[i] ^ (seed & 255) : this.p[i] ^ ((seed >> 8) & 255)
|
||||
this.perm[i] = this.perm[i + 256] = v
|
||||
this.gradP[i] = this.gradP[i + 256] = this.grad3[v % 12]
|
||||
const v = i & 1 ? this.p[i] ^ (seed & 255) : this.p[i] ^ ((seed >> 8) & 255);
|
||||
this.perm[i] = this.perm[i + 256] = v;
|
||||
this.gradP[i] = this.gradP[i + 256] = this.grad3[v % 12];
|
||||
}
|
||||
}
|
||||
|
||||
fade(t: number): number {
|
||||
return t * t * t * (t * (t * 6 - 15) + 10)
|
||||
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||
}
|
||||
|
||||
lerp(a: number, b: number, t: number): number {
|
||||
return (1 - t) * a + t * b
|
||||
return (1 - t) * a + t * b;
|
||||
}
|
||||
|
||||
perlin2(x: number, y: number): number {
|
||||
let X = Math.floor(x),
|
||||
Y = Math.floor(y)
|
||||
x -= X
|
||||
y -= Y
|
||||
X &= 255
|
||||
Y &= 255
|
||||
Y = Math.floor(y);
|
||||
x -= X;
|
||||
y -= Y;
|
||||
X &= 255;
|
||||
Y &= 255;
|
||||
|
||||
const n00 = this.gradP[X + this.perm[Y]].dot2(x, y)
|
||||
const n01 = this.gradP[X + this.perm[Y + 1]].dot2(x, y - 1)
|
||||
const n10 = this.gradP[X + 1 + this.perm[Y]].dot2(x - 1, y)
|
||||
const n11 = this.gradP[X + 1 + this.perm[Y + 1]].dot2(x - 1, y - 1)
|
||||
const u = this.fade(x)
|
||||
const n00 = this.gradP[X + this.perm[Y]].dot2(x, y);
|
||||
const n01 = this.gradP[X + this.perm[Y + 1]].dot2(x, y - 1);
|
||||
const n10 = this.gradP[X + 1 + this.perm[Y]].dot2(x - 1, y);
|
||||
const n11 = this.gradP[X + 1 + this.perm[Y + 1]].dot2(x - 1, y - 1);
|
||||
const u = this.fade(x);
|
||||
|
||||
return this.lerp(
|
||||
this.lerp(n00, n10, u),
|
||||
this.lerp(n01, n11, u),
|
||||
this.fade(y)
|
||||
)
|
||||
return this.lerp(this.lerp(n00, n10, u), this.lerp(n01, n11, u), this.fade(y));
|
||||
}
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
wave: { x: number; y: number }
|
||||
cursor: { x: number; y: number; vx: number; vy: number }
|
||||
x: number;
|
||||
y: number;
|
||||
wave: { x: number; y: number };
|
||||
cursor: { x: number; y: number; vx: number; vy: number };
|
||||
}
|
||||
|
||||
interface Mouse {
|
||||
x: number
|
||||
y: number
|
||||
lx: number
|
||||
ly: number
|
||||
sx: number
|
||||
sy: number
|
||||
v: number
|
||||
vs: number
|
||||
a: number
|
||||
set: boolean
|
||||
x: number;
|
||||
y: number;
|
||||
lx: number;
|
||||
ly: number;
|
||||
sx: number;
|
||||
sy: number;
|
||||
v: number;
|
||||
vs: number;
|
||||
a: number;
|
||||
set: boolean;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
lineColor: string
|
||||
waveSpeedX: number
|
||||
waveSpeedY: number
|
||||
waveAmpX: number
|
||||
waveAmpY: number
|
||||
friction: number
|
||||
tension: number
|
||||
maxCursorMove: number
|
||||
xGap: number
|
||||
yGap: number
|
||||
lineColor: string;
|
||||
waveSpeedX: number;
|
||||
waveSpeedY: number;
|
||||
waveAmpX: number;
|
||||
waveAmpY: number;
|
||||
friction: number;
|
||||
tension: number;
|
||||
maxCursorMove: number;
|
||||
xGap: number;
|
||||
yGap: number;
|
||||
}
|
||||
|
||||
interface WavesProps {
|
||||
lineColor?: string
|
||||
backgroundColor?: string
|
||||
waveSpeedX?: number
|
||||
waveSpeedY?: number
|
||||
waveAmpX?: number
|
||||
waveAmpY?: number
|
||||
xGap?: number
|
||||
yGap?: number
|
||||
friction?: number
|
||||
tension?: number
|
||||
maxCursorMove?: number
|
||||
style?: CSSProperties
|
||||
className?: string
|
||||
lineColor?: string;
|
||||
backgroundColor?: string;
|
||||
waveSpeedX?: number;
|
||||
waveSpeedY?: number;
|
||||
waveAmpX?: number;
|
||||
waveAmpY?: number;
|
||||
xGap?: number;
|
||||
yGap?: number;
|
||||
friction?: number;
|
||||
tension?: number;
|
||||
maxCursorMove?: number;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<WavesProps>(), {
|
||||
@@ -179,15 +178,15 @@ const props = withDefaults(defineProps<WavesProps>(), {
|
||||
maxCursorMove: 100,
|
||||
style: () => ({}),
|
||||
className: ''
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const canvasRef = ref<HTMLCanvasElement>();
|
||||
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
let bounding = { width: 0, height: 0, left: 0, top: 0 }
|
||||
let noise: Noise | null = null
|
||||
let lines: Point[][] = []
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let bounding = { width: 0, height: 0, left: 0, top: 0 };
|
||||
let noise: Noise | null = null;
|
||||
let lines: Point[][] = [];
|
||||
const mouse: Mouse = {
|
||||
x: -10,
|
||||
y: 0,
|
||||
@@ -198,8 +197,8 @@ const mouse: Mouse = {
|
||||
v: 0,
|
||||
vs: 0,
|
||||
a: 0,
|
||||
set: false,
|
||||
}
|
||||
set: false
|
||||
};
|
||||
let config: Config = {
|
||||
lineColor: props.lineColor,
|
||||
waveSpeedX: props.waveSpeedX,
|
||||
@@ -210,207 +209,187 @@ let config: Config = {
|
||||
tension: props.tension,
|
||||
maxCursorMove: props.maxCursorMove,
|
||||
xGap: props.xGap,
|
||||
yGap: props.yGap,
|
||||
}
|
||||
let frameId: number | null = null
|
||||
yGap: props.yGap
|
||||
};
|
||||
let frameId: number | null = null;
|
||||
|
||||
const setSize = () => {
|
||||
const container = containerRef.value
|
||||
const canvas = canvasRef.value
|
||||
if (!container || !canvas) return
|
||||
const container = containerRef.value;
|
||||
const canvas = canvasRef.value;
|
||||
if (!container || !canvas) return;
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
const rect = container.getBoundingClientRect();
|
||||
bounding = {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
}
|
||||
canvas.width = rect.width
|
||||
canvas.height = rect.height
|
||||
}
|
||||
top: rect.top
|
||||
};
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
};
|
||||
|
||||
const setLines = () => {
|
||||
const { width, height } = bounding
|
||||
lines = []
|
||||
const { width, height } = bounding;
|
||||
lines = [];
|
||||
const oWidth = width + 200,
|
||||
oHeight = height + 30
|
||||
const { xGap, yGap } = config
|
||||
const totalLines = Math.ceil(oWidth / xGap)
|
||||
const totalPoints = Math.ceil(oHeight / yGap)
|
||||
const xStart = (width - xGap * totalLines) / 2
|
||||
const yStart = (height - yGap * totalPoints) / 2
|
||||
oHeight = height + 30;
|
||||
const { xGap, yGap } = config;
|
||||
const totalLines = Math.ceil(oWidth / xGap);
|
||||
const totalPoints = Math.ceil(oHeight / yGap);
|
||||
const xStart = (width - xGap * totalLines) / 2;
|
||||
const yStart = (height - yGap * totalPoints) / 2;
|
||||
|
||||
for (let i = 0; i <= totalLines; i++) {
|
||||
const pts: Point[] = []
|
||||
const pts: Point[] = [];
|
||||
for (let j = 0; j <= totalPoints; j++) {
|
||||
pts.push({
|
||||
x: xStart + xGap * i,
|
||||
y: yStart + yGap * j,
|
||||
wave: { x: 0, y: 0 },
|
||||
cursor: { x: 0, y: 0, vx: 0, vy: 0 },
|
||||
})
|
||||
cursor: { x: 0, y: 0, vx: 0, vy: 0 }
|
||||
});
|
||||
}
|
||||
lines.push(pts)
|
||||
lines.push(pts);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const movePoints = (time: number) => {
|
||||
if (!noise) return
|
||||
if (!noise) return;
|
||||
|
||||
const {
|
||||
waveSpeedX,
|
||||
waveSpeedY,
|
||||
waveAmpX,
|
||||
waveAmpY,
|
||||
friction,
|
||||
tension,
|
||||
maxCursorMove,
|
||||
} = config
|
||||
const { waveSpeedX, waveSpeedY, waveAmpX, waveAmpY, friction, tension, maxCursorMove } = config;
|
||||
|
||||
lines.forEach((pts) => {
|
||||
pts.forEach((p) => {
|
||||
const move = noise!.perlin2(
|
||||
(p.x + time * waveSpeedX) * 0.002,
|
||||
(p.y + time * waveSpeedY) * 0.0015
|
||||
) * 12
|
||||
p.wave.x = Math.cos(move) * waveAmpX
|
||||
p.wave.y = Math.sin(move) * waveAmpY
|
||||
lines.forEach(pts => {
|
||||
pts.forEach(p => {
|
||||
const move = noise!.perlin2((p.x + time * waveSpeedX) * 0.002, (p.y + time * waveSpeedY) * 0.0015) * 12;
|
||||
p.wave.x = Math.cos(move) * waveAmpX;
|
||||
p.wave.y = Math.sin(move) * waveAmpY;
|
||||
|
||||
const dx = p.x - mouse.sx,
|
||||
dy = p.y - mouse.sy
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const l = Math.max(175, mouse.vs)
|
||||
dy = p.y - mouse.sy;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const l = Math.max(175, mouse.vs);
|
||||
if (dist < l) {
|
||||
const s = 1 - dist / l
|
||||
const f = Math.cos(dist * 0.001) * s
|
||||
p.cursor.vx += Math.cos(mouse.a) * f * l * mouse.vs * 0.00065
|
||||
p.cursor.vy += Math.sin(mouse.a) * f * l * mouse.vs * 0.00065
|
||||
const s = 1 - dist / l;
|
||||
const f = Math.cos(dist * 0.001) * s;
|
||||
p.cursor.vx += Math.cos(mouse.a) * f * l * mouse.vs * 0.00065;
|
||||
p.cursor.vy += Math.sin(mouse.a) * f * l * mouse.vs * 0.00065;
|
||||
}
|
||||
|
||||
p.cursor.vx += (0 - p.cursor.x) * tension
|
||||
p.cursor.vy += (0 - p.cursor.y) * tension
|
||||
p.cursor.vx *= friction
|
||||
p.cursor.vy *= friction
|
||||
p.cursor.x += p.cursor.vx * 2
|
||||
p.cursor.y += p.cursor.vy * 2
|
||||
p.cursor.x = Math.min(
|
||||
maxCursorMove,
|
||||
Math.max(-maxCursorMove, p.cursor.x)
|
||||
)
|
||||
p.cursor.y = Math.min(
|
||||
maxCursorMove,
|
||||
Math.max(-maxCursorMove, p.cursor.y)
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
p.cursor.vx += (0 - p.cursor.x) * tension;
|
||||
p.cursor.vy += (0 - p.cursor.y) * tension;
|
||||
p.cursor.vx *= friction;
|
||||
p.cursor.vy *= friction;
|
||||
p.cursor.x += p.cursor.vx * 2;
|
||||
p.cursor.y += p.cursor.vy * 2;
|
||||
p.cursor.x = Math.min(maxCursorMove, Math.max(-maxCursorMove, p.cursor.x));
|
||||
p.cursor.y = Math.min(maxCursorMove, Math.max(-maxCursorMove, p.cursor.y));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const moved = (point: Point, withCursor = true): { x: number; y: number } => {
|
||||
const x = point.x + point.wave.x + (withCursor ? point.cursor.x : 0)
|
||||
const y = point.y + point.wave.y + (withCursor ? point.cursor.y : 0)
|
||||
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }
|
||||
}
|
||||
const x = point.x + point.wave.x + (withCursor ? point.cursor.x : 0);
|
||||
const y = point.y + point.wave.y + (withCursor ? point.cursor.y : 0);
|
||||
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 };
|
||||
};
|
||||
|
||||
const drawLines = () => {
|
||||
const { width, height } = bounding
|
||||
if (!ctx) return
|
||||
const { width, height } = bounding;
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = config.lineColor
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = config.lineColor;
|
||||
|
||||
lines.forEach((points) => {
|
||||
let p1 = moved(points[0], false)
|
||||
ctx!.moveTo(p1.x, p1.y)
|
||||
lines.forEach(points => {
|
||||
let p1 = moved(points[0], false);
|
||||
ctx!.moveTo(p1.x, p1.y);
|
||||
points.forEach((p, idx) => {
|
||||
const isLast = idx === points.length - 1
|
||||
p1 = moved(p, !isLast)
|
||||
const p2 = moved(
|
||||
points[idx + 1] || points[points.length - 1],
|
||||
!isLast
|
||||
)
|
||||
ctx!.lineTo(p1.x, p1.y)
|
||||
if (isLast) ctx!.moveTo(p2.x, p2.y)
|
||||
})
|
||||
})
|
||||
ctx.stroke()
|
||||
}
|
||||
const isLast = idx === points.length - 1;
|
||||
p1 = moved(p, !isLast);
|
||||
const p2 = moved(points[idx + 1] || points[points.length - 1], !isLast);
|
||||
ctx!.lineTo(p1.x, p1.y);
|
||||
if (isLast) ctx!.moveTo(p2.x, p2.y);
|
||||
});
|
||||
});
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const tick = (t: number) => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
mouse.sx += (mouse.x - mouse.sx) * 0.1
|
||||
mouse.sy += (mouse.y - mouse.sy) * 0.1
|
||||
mouse.sx += (mouse.x - mouse.sx) * 0.1;
|
||||
mouse.sy += (mouse.y - mouse.sy) * 0.1;
|
||||
const dx = mouse.x - mouse.lx,
|
||||
dy = mouse.y - mouse.ly
|
||||
const d = Math.hypot(dx, dy)
|
||||
mouse.v = d
|
||||
mouse.vs += (d - mouse.vs) * 0.1
|
||||
mouse.vs = Math.min(100, mouse.vs)
|
||||
mouse.lx = mouse.x
|
||||
mouse.ly = mouse.y
|
||||
mouse.a = Math.atan2(dy, dx)
|
||||
container.style.setProperty('--x', `${mouse.sx}px`)
|
||||
container.style.setProperty('--y', `${mouse.sy}px`)
|
||||
dy = mouse.y - mouse.ly;
|
||||
const d = Math.hypot(dx, dy);
|
||||
mouse.v = d;
|
||||
mouse.vs += (d - mouse.vs) * 0.1;
|
||||
mouse.vs = Math.min(100, mouse.vs);
|
||||
mouse.lx = mouse.x;
|
||||
mouse.ly = mouse.y;
|
||||
mouse.a = Math.atan2(dy, dx);
|
||||
container.style.setProperty('--x', `${mouse.sx}px`);
|
||||
container.style.setProperty('--y', `${mouse.sy}px`);
|
||||
|
||||
movePoints(t)
|
||||
drawLines()
|
||||
frameId = requestAnimationFrame(tick)
|
||||
}
|
||||
movePoints(t);
|
||||
drawLines();
|
||||
frameId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const onResize = () => {
|
||||
setSize()
|
||||
setLines()
|
||||
}
|
||||
setSize();
|
||||
setLines();
|
||||
};
|
||||
|
||||
const updateMouse = (x: number, y: number) => {
|
||||
mouse.x = x - bounding.left
|
||||
mouse.y = y - bounding.top
|
||||
mouse.x = x - bounding.left;
|
||||
mouse.y = y - bounding.top;
|
||||
if (!mouse.set) {
|
||||
mouse.sx = mouse.x
|
||||
mouse.sy = mouse.y
|
||||
mouse.lx = mouse.x
|
||||
mouse.ly = mouse.y
|
||||
mouse.set = true
|
||||
mouse.sx = mouse.x;
|
||||
mouse.sy = mouse.y;
|
||||
mouse.lx = mouse.x;
|
||||
mouse.ly = mouse.y;
|
||||
mouse.set = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
updateMouse(e.clientX, e.clientY)
|
||||
}
|
||||
updateMouse(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
const touch = e.touches[0]
|
||||
updateMouse(touch.clientX, touch.clientY)
|
||||
}
|
||||
const touch = e.touches[0];
|
||||
updateMouse(touch.clientX, touch.clientY);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value
|
||||
const container = containerRef.value
|
||||
if (!canvas || !container) return
|
||||
const canvas = canvasRef.value;
|
||||
const container = containerRef.value;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
ctx = canvas.getContext('2d')
|
||||
noise = new Noise(Math.random())
|
||||
ctx = canvas.getContext('2d');
|
||||
noise = new Noise(Math.random());
|
||||
|
||||
setSize()
|
||||
setLines()
|
||||
frameId = requestAnimationFrame(tick)
|
||||
setSize();
|
||||
setLines();
|
||||
frameId = requestAnimationFrame(tick);
|
||||
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
})
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('touchmove', onTouchMove)
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId)
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [
|
||||
@@ -423,7 +402,7 @@ watch(
|
||||
props.tension,
|
||||
props.maxCursorMove,
|
||||
props.xGap,
|
||||
props.yGap,
|
||||
props.yGap
|
||||
],
|
||||
() => {
|
||||
config = {
|
||||
@@ -436,8 +415,8 @@ watch(
|
||||
tension: props.tension,
|
||||
maxCursorMove: props.maxCursorMove,
|
||||
xGap: props.xGap,
|
||||
yGap: props.yGap,
|
||||
}
|
||||
yGap: props.yGap
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="card-swap-container absolute bottom-0 right-0 transform translate-x-[5%] translate-y-[20%] origin-bottom-right perspective-[900px] overflow-visible max-[768px]:translate-x-[25%] max-[768px]:translate-y-[25%] max-[768px]:scale-[0.75] max-[480px]:translate-x-[25%] max-[480px]:translate-y-[25%] max-[480px]:scale-[0.55]"
|
||||
:style="{ width: typeof width === 'number' ? `${width}px` : width, height: typeof height === 'number' ? `${height}px` : height }"
|
||||
:style="{
|
||||
width: typeof width === 'number' ? `${width}px` : width,
|
||||
height: typeof height === 'number' ? `${height}px` : height
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(_, index) in 3"
|
||||
:key="index"
|
||||
ref="cardRefs"
|
||||
class="card-swap-card absolute top-1/2 left-1/2 rounded-xl border border-white bg-black [transform-style:preserve-3d] [will-change:transform] [backface-visibility:hidden]"
|
||||
:style="{ width: typeof width === 'number' ? `${width}px` : width, height: typeof height === 'number' ? `${height}px` : height }"
|
||||
:style="{
|
||||
width: typeof width === 'number' ? `${width}px` : width,
|
||||
height: typeof height === 'number' ? `${height}px` : height
|
||||
}"
|
||||
@click="handleCardClick(index)"
|
||||
>
|
||||
<slot :name="`card-${index}`" :index="index" />
|
||||
@@ -18,38 +24,33 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import gsap from 'gsap'
|
||||
import gsap from 'gsap';
|
||||
|
||||
export interface CardSwapProps {
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
cardDistance?: number
|
||||
verticalDistance?: number
|
||||
delay?: number
|
||||
pauseOnHover?: boolean
|
||||
onCardClick?: (idx: number) => void
|
||||
skewAmount?: number
|
||||
easing?: 'linear' | 'elastic'
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
cardDistance?: number;
|
||||
verticalDistance?: number;
|
||||
delay?: number;
|
||||
pauseOnHover?: boolean;
|
||||
onCardClick?: (idx: number) => void;
|
||||
skewAmount?: number;
|
||||
easing?: 'linear' | 'elastic';
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
zIndex: number
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
const makeSlot = (
|
||||
i: number,
|
||||
distX: number,
|
||||
distY: number,
|
||||
total: number
|
||||
): Slot => ({
|
||||
const makeSlot = (i: number, distX: number, distY: number, total: number): Slot => ({
|
||||
x: i * distX,
|
||||
y: -i * distY,
|
||||
z: -i * distX * 1.5,
|
||||
zIndex: total - i,
|
||||
})
|
||||
zIndex: total - i
|
||||
});
|
||||
|
||||
const placeNow = (el: HTMLElement, slot: Slot, skew: number) => {
|
||||
gsap.set(el, {
|
||||
@@ -61,15 +62,15 @@ const placeNow = (el: HTMLElement, slot: Slot, skew: number) => {
|
||||
skewY: skew,
|
||||
transformOrigin: 'center center',
|
||||
zIndex: slot.zIndex,
|
||||
force3D: true,
|
||||
})
|
||||
}
|
||||
force3D: true
|
||||
});
|
||||
};
|
||||
|
||||
export { makeSlot, placeNow }
|
||||
export { makeSlot, placeNow };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<CardSwapProps>(), {
|
||||
width: 500,
|
||||
@@ -79,23 +80,23 @@ const props = withDefaults(defineProps<CardSwapProps>(), {
|
||||
delay: 5000,
|
||||
pauseOnHover: false,
|
||||
skewAmount: 6,
|
||||
easing: 'elastic',
|
||||
})
|
||||
easing: 'elastic'
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'card-click': [index: number]
|
||||
}>()
|
||||
'card-click': [index: number];
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const cardRefs = ref<HTMLElement[]>([])
|
||||
const order = ref<number[]>([0, 1, 2])
|
||||
const tlRef = ref<gsap.core.Timeline | null>(null)
|
||||
const intervalRef = ref<number>()
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const cardRefs = ref<HTMLElement[]>([]);
|
||||
const order = ref<number[]>([0, 1, 2]);
|
||||
const tlRef = ref<gsap.core.Timeline | null>(null);
|
||||
const intervalRef = ref<number>();
|
||||
|
||||
const handleCardClick = (index: number) => {
|
||||
emit('card-click', index)
|
||||
props.onCardClick?.(index)
|
||||
}
|
||||
emit('card-click', index);
|
||||
props.onCardClick?.(index);
|
||||
};
|
||||
|
||||
const config = computed(() => {
|
||||
return props.easing === 'elastic'
|
||||
@@ -105,7 +106,7 @@ const config = computed(() => {
|
||||
durMove: 2,
|
||||
durReturn: 2,
|
||||
promoteOverlap: 0.9,
|
||||
returnDelay: 0.05,
|
||||
returnDelay: 0.05
|
||||
}
|
||||
: {
|
||||
ease: 'power1.inOut',
|
||||
@@ -113,63 +114,63 @@ const config = computed(() => {
|
||||
durMove: 0.8,
|
||||
durReturn: 0.8,
|
||||
promoteOverlap: 0.45,
|
||||
returnDelay: 0.2,
|
||||
}
|
||||
})
|
||||
returnDelay: 0.2
|
||||
};
|
||||
});
|
||||
|
||||
const initializeCards = () => {
|
||||
if (!cardRefs.value.length) return
|
||||
|
||||
const total = cardRefs.value.length
|
||||
|
||||
if (!cardRefs.value.length) return;
|
||||
|
||||
const total = cardRefs.value.length;
|
||||
|
||||
cardRefs.value.forEach((el, i) => {
|
||||
if (el) {
|
||||
placeNow(el, makeSlot(i, props.cardDistance, props.verticalDistance, total), props.skewAmount)
|
||||
placeNow(el, makeSlot(i, props.cardDistance, props.verticalDistance, total), props.skewAmount);
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateCardPositions = () => {
|
||||
if (!cardRefs.value.length) return
|
||||
|
||||
const total = cardRefs.value.length
|
||||
|
||||
if (!cardRefs.value.length) return;
|
||||
|
||||
const total = cardRefs.value.length;
|
||||
|
||||
cardRefs.value.forEach((el, i) => {
|
||||
if (el) {
|
||||
const slot = makeSlot(i, props.cardDistance, props.verticalDistance, total)
|
||||
const slot = makeSlot(i, props.cardDistance, props.verticalDistance, total);
|
||||
gsap.set(el, {
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
skewY: props.skewAmount,
|
||||
})
|
||||
skewY: props.skewAmount
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const swap = () => {
|
||||
if (order.value.length < 2) return
|
||||
if (order.value.length < 2) return;
|
||||
|
||||
const [front, ...rest] = order.value
|
||||
const elFront = cardRefs.value[front]
|
||||
if (!elFront) return
|
||||
const [front, ...rest] = order.value;
|
||||
const elFront = cardRefs.value[front];
|
||||
if (!elFront) return;
|
||||
|
||||
const tl = gsap.timeline()
|
||||
tlRef.value = tl
|
||||
const tl = gsap.timeline();
|
||||
tlRef.value = tl;
|
||||
|
||||
tl.to(elFront, {
|
||||
y: '+=500',
|
||||
duration: config.value.durDrop,
|
||||
ease: config.value.ease,
|
||||
})
|
||||
ease: config.value.ease
|
||||
});
|
||||
|
||||
tl.addLabel('promote', `-=${config.value.durDrop * config.value.promoteOverlap}`)
|
||||
tl.addLabel('promote', `-=${config.value.durDrop * config.value.promoteOverlap}`);
|
||||
rest.forEach((idx, i) => {
|
||||
const el = cardRefs.value[idx]
|
||||
if (!el) return
|
||||
|
||||
const slot = makeSlot(i, props.cardDistance, props.verticalDistance, cardRefs.value.length)
|
||||
tl.set(el, { zIndex: slot.zIndex }, 'promote')
|
||||
const el = cardRefs.value[idx];
|
||||
if (!el) return;
|
||||
|
||||
const slot = makeSlot(i, props.cardDistance, props.verticalDistance, cardRefs.value.length);
|
||||
tl.set(el, { zIndex: slot.zIndex }, 'promote');
|
||||
tl.to(
|
||||
el,
|
||||
{
|
||||
@@ -177,114 +178,114 @@ const swap = () => {
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
duration: config.value.durMove,
|
||||
ease: config.value.ease,
|
||||
ease: config.value.ease
|
||||
},
|
||||
`promote+=${i * 0.15}`
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const backSlot = makeSlot(
|
||||
cardRefs.value.length - 1,
|
||||
props.cardDistance,
|
||||
props.verticalDistance,
|
||||
cardRefs.value.length
|
||||
)
|
||||
tl.addLabel('return', `promote+=${config.value.durMove * config.value.returnDelay}`)
|
||||
);
|
||||
tl.addLabel('return', `promote+=${config.value.durMove * config.value.returnDelay}`);
|
||||
tl.call(
|
||||
() => {
|
||||
gsap.set(elFront, { zIndex: backSlot.zIndex })
|
||||
gsap.set(elFront, { zIndex: backSlot.zIndex });
|
||||
},
|
||||
undefined,
|
||||
'return'
|
||||
)
|
||||
tl.set(elFront, { x: backSlot.x, z: backSlot.z }, 'return')
|
||||
);
|
||||
tl.set(elFront, { x: backSlot.x, z: backSlot.z }, 'return');
|
||||
tl.to(
|
||||
elFront,
|
||||
{
|
||||
y: backSlot.y,
|
||||
duration: config.value.durReturn,
|
||||
ease: config.value.ease,
|
||||
ease: config.value.ease
|
||||
},
|
||||
'return'
|
||||
)
|
||||
);
|
||||
|
||||
tl.call(() => {
|
||||
order.value = [...rest, front]
|
||||
})
|
||||
}
|
||||
order.value = [...rest, front];
|
||||
});
|
||||
};
|
||||
|
||||
const startAnimation = () => {
|
||||
stopAnimation()
|
||||
swap()
|
||||
intervalRef.value = window.setInterval(swap, props.delay)
|
||||
}
|
||||
stopAnimation();
|
||||
swap();
|
||||
intervalRef.value = window.setInterval(swap, props.delay);
|
||||
};
|
||||
|
||||
const stopAnimation = () => {
|
||||
tlRef.value?.kill()
|
||||
tlRef.value?.kill();
|
||||
if (intervalRef.value) {
|
||||
clearInterval(intervalRef.value)
|
||||
clearInterval(intervalRef.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resumeAnimation = () => {
|
||||
tlRef.value?.play()
|
||||
intervalRef.value = window.setInterval(swap, props.delay)
|
||||
}
|
||||
tlRef.value?.play();
|
||||
intervalRef.value = window.setInterval(swap, props.delay);
|
||||
};
|
||||
|
||||
const setupHoverListeners = () => {
|
||||
if (props.pauseOnHover && containerRef.value) {
|
||||
containerRef.value.addEventListener('mouseenter', stopAnimation)
|
||||
containerRef.value.addEventListener('mouseleave', resumeAnimation)
|
||||
containerRef.value.addEventListener('mouseenter', stopAnimation);
|
||||
containerRef.value.addEventListener('mouseleave', resumeAnimation);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeHoverListeners = () => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('mouseenter', stopAnimation)
|
||||
containerRef.value.removeEventListener('mouseleave', resumeAnimation)
|
||||
containerRef.value.removeEventListener('mouseenter', stopAnimation);
|
||||
containerRef.value.removeEventListener('mouseleave', resumeAnimation);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.cardDistance, props.verticalDistance, props.skewAmount],
|
||||
() => {
|
||||
updateCardPositions()
|
||||
updateCardPositions();
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.delay,
|
||||
() => {
|
||||
if (intervalRef.value) {
|
||||
clearInterval(intervalRef.value)
|
||||
intervalRef.value = window.setInterval(swap, props.delay)
|
||||
clearInterval(intervalRef.value);
|
||||
intervalRef.value = window.setInterval(swap, props.delay);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.pauseOnHover,
|
||||
() => {
|
||||
removeHoverListeners()
|
||||
setupHoverListeners()
|
||||
removeHoverListeners();
|
||||
setupHoverListeners();
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.easing,
|
||||
() => {}
|
||||
)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initializeCards()
|
||||
startAnimation()
|
||||
setupHoverListeners()
|
||||
})
|
||||
})
|
||||
initializeCards();
|
||||
startAnimation();
|
||||
setupHoverListeners();
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAnimation()
|
||||
removeHoverListeners()
|
||||
})
|
||||
stopAnimation();
|
||||
removeHoverListeners();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,62 +1,86 @@
|
||||
<template>
|
||||
<div ref="containerRef" :class="[
|
||||
'relative overflow-hidden p-4',
|
||||
round
|
||||
? 'rounded-full border border-[#333]'
|
||||
: 'rounded-[24px] border border-[#333]'
|
||||
]" :style="{
|
||||
width: `${baseWidth}px`,
|
||||
...(round && { height: `${baseWidth}px` }),
|
||||
}">
|
||||
<Motion tag="div" class="flex" drag="x" :dragConstraints="dragConstraints" :style="{
|
||||
width: itemWidth + 'px',
|
||||
gap: `${GAP}px`,
|
||||
perspective: 1000,
|
||||
perspectiveOrigin: `${currentIndex * trackItemOffset + itemWidth / 2}px 50%`,
|
||||
x: motionX,
|
||||
}" @dragEnd="handleDragEnd" :animate="{ x: -(currentIndex * trackItemOffset) }" :transition="effectiveTransition"
|
||||
@animationComplete="handleAnimationComplete">
|
||||
<Motion v-for="(item, index) in carouselItems" :key="index" tag="div" :class="[
|
||||
'relative shrink-0 flex flex-col overflow-hidden cursor-grab active:cursor-grabbing',
|
||||
round
|
||||
? 'items-center justify-center text-center bg-[#111] border border-[#333] rounded-full'
|
||||
: 'items-start justify-between bg-[#111] border border-[#333] rounded-[12px]'
|
||||
]" :style="{
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="[
|
||||
'relative overflow-hidden p-4',
|
||||
round ? 'rounded-full border border-[#333]' : 'rounded-[24px] border border-[#333]'
|
||||
]"
|
||||
:style="{
|
||||
width: `${baseWidth}px`,
|
||||
...(round && { height: `${baseWidth}px` })
|
||||
}"
|
||||
>
|
||||
<Motion
|
||||
tag="div"
|
||||
class="flex"
|
||||
drag="x"
|
||||
:dragConstraints="dragConstraints"
|
||||
:style="{
|
||||
width: itemWidth + 'px',
|
||||
height: round ? itemWidth + 'px' : '100%',
|
||||
rotateY: getRotateY(index),
|
||||
...(round && { borderRadius: '50%' }),
|
||||
}" :transition="effectiveTransition">
|
||||
gap: `${GAP}px`,
|
||||
perspective: 1000,
|
||||
perspectiveOrigin: `${currentIndex * trackItemOffset + itemWidth / 2}px 50%`,
|
||||
x: motionX
|
||||
}"
|
||||
@dragEnd="handleDragEnd"
|
||||
:animate="{ x: -(currentIndex * trackItemOffset) }"
|
||||
:transition="effectiveTransition"
|
||||
@animationComplete="handleAnimationComplete"
|
||||
>
|
||||
<Motion
|
||||
v-for="(item, index) in carouselItems"
|
||||
:key="index"
|
||||
tag="div"
|
||||
:class="[
|
||||
'relative shrink-0 flex flex-col overflow-hidden cursor-grab active:cursor-grabbing',
|
||||
round
|
||||
? 'items-center justify-center text-center bg-[#111] border border-[#333] rounded-full'
|
||||
: 'items-start justify-between bg-[#111] border border-[#333] rounded-[12px]'
|
||||
]"
|
||||
:style="{
|
||||
width: itemWidth + 'px',
|
||||
height: round ? itemWidth + 'px' : '100%',
|
||||
rotateY: getRotateY(index),
|
||||
...(round && { borderRadius: '50%' })
|
||||
}"
|
||||
:transition="effectiveTransition"
|
||||
>
|
||||
<div :class="round ? 'p-0 m-0' : 'mb-4 p-5'">
|
||||
<span class="flex h-[28px] w-[28px] items-center justify-center rounded-full bg-[#060010]">
|
||||
<i :class="item.icon" class="text-white text-base"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<div class="mb-1 font-black text-lg text-white">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="mb-1 font-black text-lg text-white">{{ item.title }}</div>
|
||||
|
||||
<p class="text-sm text-white">{{ item.description }}</p>
|
||||
</div>
|
||||
</Motion>
|
||||
</Motion>
|
||||
<div :class="[
|
||||
'flex w-full justify-center',
|
||||
round ? 'absolute z-20 bottom-12 left-1/2 -translate-x-1/2' : ''
|
||||
]">
|
||||
|
||||
<div :class="['flex w-full justify-center', round ? 'absolute z-20 bottom-12 left-1/2 -translate-x-1/2' : '']">
|
||||
<div class="mt-4 flex w-[150px] justify-between px-8">
|
||||
<Motion v-for="(_, index) in items" :key="index" tag="div" :class="[
|
||||
'h-2 w-2 rounded-full cursor-pointer transition-colors duration-150',
|
||||
currentIndex % items.length === index
|
||||
? round
|
||||
? 'bg-white'
|
||||
: 'bg-[#333333]'
|
||||
: round
|
||||
? 'bg-[#555]'
|
||||
: 'bg-[rgba(51,51,51,0.4)]'
|
||||
]" :animate="{
|
||||
scale: currentIndex % items.length === index ? 1.2 : 1,
|
||||
}" @click="() => setCurrentIndex(index)" :transition="{ duration: 0.15 }" />
|
||||
<Motion
|
||||
v-for="(_, index) in items"
|
||||
:key="index"
|
||||
tag="div"
|
||||
:class="[
|
||||
'h-2 w-2 rounded-full cursor-pointer transition-colors duration-150',
|
||||
currentIndex % items.length === index
|
||||
? round
|
||||
? 'bg-white'
|
||||
: 'bg-[#333333]'
|
||||
: round
|
||||
? 'bg-[#555]'
|
||||
: 'bg-[rgba(51,51,51,0.4)]'
|
||||
]"
|
||||
:animate="{
|
||||
scale: currentIndex % items.length === index ? 1.2 : 1
|
||||
}"
|
||||
@click="() => setCurrentIndex(index)"
|
||||
:transition="{ duration: 0.15 }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,64 +88,64 @@
|
||||
|
||||
<script lang="ts">
|
||||
export interface CarouselItem {
|
||||
title: string
|
||||
description: string
|
||||
id: number
|
||||
icon: string
|
||||
title: string;
|
||||
description: string;
|
||||
id: number;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface CarouselProps {
|
||||
items?: CarouselItem[]
|
||||
baseWidth?: number
|
||||
autoplay?: boolean
|
||||
autoplayDelay?: number
|
||||
pauseOnHover?: boolean
|
||||
loop?: boolean
|
||||
round?: boolean
|
||||
items?: CarouselItem[];
|
||||
baseWidth?: number;
|
||||
autoplay?: boolean;
|
||||
autoplayDelay?: number;
|
||||
pauseOnHover?: boolean;
|
||||
loop?: boolean;
|
||||
round?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_ITEMS: CarouselItem[] = [
|
||||
{
|
||||
title: "Text Animations",
|
||||
description: "Cool text animations for your projects.",
|
||||
title: 'Text Animations',
|
||||
description: 'Cool text animations for your projects.',
|
||||
id: 1,
|
||||
icon: "pi pi-file",
|
||||
icon: 'pi pi-file'
|
||||
},
|
||||
{
|
||||
title: "Animations",
|
||||
description: "Smooth animations for your projects.",
|
||||
title: 'Animations',
|
||||
description: 'Smooth animations for your projects.',
|
||||
id: 2,
|
||||
icon: "pi pi-circle",
|
||||
icon: 'pi pi-circle'
|
||||
},
|
||||
{
|
||||
title: "Components",
|
||||
description: "Reusable components for your projects.",
|
||||
title: 'Components',
|
||||
description: 'Reusable components for your projects.',
|
||||
id: 3,
|
||||
icon: "pi pi-objects-column",
|
||||
icon: 'pi pi-objects-column'
|
||||
},
|
||||
{
|
||||
title: "Backgrounds",
|
||||
description: "Beautiful backgrounds and patterns for your projects.",
|
||||
title: 'Backgrounds',
|
||||
description: 'Beautiful backgrounds and patterns for your projects.',
|
||||
id: 4,
|
||||
icon: "pi pi-table",
|
||||
icon: 'pi pi-table'
|
||||
},
|
||||
{
|
||||
title: "Common UI",
|
||||
description: "Common UI components are coming soon!",
|
||||
title: 'Common UI',
|
||||
description: 'Common UI components are coming soon!',
|
||||
id: 5,
|
||||
icon: "pi pi-code",
|
||||
},
|
||||
]
|
||||
icon: 'pi pi-code'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Motion, useMotionValue, useTransform } from 'motion-v'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { Motion, useMotionValue, useTransform } from 'motion-v';
|
||||
|
||||
const DRAG_BUFFER = 0
|
||||
const VELOCITY_THRESHOLD = 500
|
||||
const GAP = 16
|
||||
const SPRING_OPTIONS = { type: "spring" as const, stiffness: 300, damping: 30 }
|
||||
const DRAG_BUFFER = 0;
|
||||
const VELOCITY_THRESHOLD = 500;
|
||||
const GAP = 16;
|
||||
const SPRING_OPTIONS = { type: 'spring' as const, stiffness: 300, damping: 30 };
|
||||
|
||||
const props = withDefaults(defineProps<CarouselProps>(), {
|
||||
items: () => DEFAULT_ITEMS,
|
||||
@@ -130,148 +154,154 @@ const props = withDefaults(defineProps<CarouselProps>(), {
|
||||
autoplayDelay: 3000,
|
||||
pauseOnHover: false,
|
||||
loop: false,
|
||||
round: false,
|
||||
})
|
||||
round: false
|
||||
});
|
||||
|
||||
const containerPadding = 16
|
||||
const itemWidth = computed(() => props.baseWidth - containerPadding * 2)
|
||||
const trackItemOffset = computed(() => itemWidth.value + GAP)
|
||||
const containerPadding = 16;
|
||||
const itemWidth = computed(() => props.baseWidth - containerPadding * 2);
|
||||
const trackItemOffset = computed(() => itemWidth.value + GAP);
|
||||
|
||||
const carouselItems = computed(() => props.loop ? [...props.items, props.items[0]] : props.items)
|
||||
const currentIndex = ref<number>(0)
|
||||
const motionX = useMotionValue(0)
|
||||
const isHovered = ref<boolean>(false)
|
||||
const isResetting = ref<boolean>(false)
|
||||
const carouselItems = computed(() => (props.loop ? [...props.items, props.items[0]] : props.items));
|
||||
const currentIndex = ref<number>(0);
|
||||
const motionX = useMotionValue(0);
|
||||
const isHovered = ref<boolean>(false);
|
||||
const isResetting = ref<boolean>(false);
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
let autoplayTimer: number | null = null
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
let autoplayTimer: number | null = null;
|
||||
|
||||
const dragConstraints = computed(() => {
|
||||
return props.loop
|
||||
? {}
|
||||
: {
|
||||
left: -trackItemOffset.value * (carouselItems.value.length - 1),
|
||||
right: 0,
|
||||
}
|
||||
})
|
||||
left: -trackItemOffset.value * (carouselItems.value.length - 1),
|
||||
right: 0
|
||||
};
|
||||
});
|
||||
|
||||
const effectiveTransition = computed(() =>
|
||||
isResetting.value ? { duration: 0 } : SPRING_OPTIONS
|
||||
)
|
||||
const effectiveTransition = computed(() => (isResetting.value ? { duration: 0 } : SPRING_OPTIONS));
|
||||
|
||||
const maxItems = Math.max(props.items.length + 1, 10)
|
||||
const maxItems = Math.max(props.items.length + 1, 10);
|
||||
const rotateYTransforms = Array.from({ length: maxItems }, (_, index) => {
|
||||
const range = computed(() => [
|
||||
-(index + 1) * trackItemOffset.value,
|
||||
-index * trackItemOffset.value,
|
||||
-(index - 1) * trackItemOffset.value,
|
||||
])
|
||||
const outputRange = [90, 0, -90]
|
||||
return useTransform(motionX, range, outputRange, { clamp: false })
|
||||
})
|
||||
-(index - 1) * trackItemOffset.value
|
||||
]);
|
||||
const outputRange = [90, 0, -90];
|
||||
return useTransform(motionX, range, outputRange, { clamp: false });
|
||||
});
|
||||
|
||||
const getRotateY = (index: number) => {
|
||||
return rotateYTransforms[index] || rotateYTransforms[0]
|
||||
}
|
||||
return rotateYTransforms[index] || rotateYTransforms[0];
|
||||
};
|
||||
|
||||
const setCurrentIndex = (index: number) => {
|
||||
currentIndex.value = index
|
||||
}
|
||||
currentIndex.value = index;
|
||||
};
|
||||
|
||||
const handleAnimationComplete = () => {
|
||||
if (props.loop && currentIndex.value === carouselItems.value.length - 1) {
|
||||
isResetting.value = true
|
||||
motionX.set(0)
|
||||
currentIndex.value = 0
|
||||
isResetting.value = true;
|
||||
motionX.set(0);
|
||||
currentIndex.value = 0;
|
||||
setTimeout(() => {
|
||||
isResetting.value = false
|
||||
}, 50)
|
||||
isResetting.value = false;
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface DragInfo {
|
||||
offset: { x: number; y: number }
|
||||
velocity: { x: number; y: number }
|
||||
offset: { x: number; y: number };
|
||||
velocity: { x: number; y: number };
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: Event, info: DragInfo) => {
|
||||
const offset = info.offset.x
|
||||
const velocity = info.velocity.x
|
||||
const offset = info.offset.x;
|
||||
const velocity = info.velocity.x;
|
||||
|
||||
if (offset < -DRAG_BUFFER || velocity < -VELOCITY_THRESHOLD) {
|
||||
if (props.loop && currentIndex.value === props.items.length - 1) {
|
||||
currentIndex.value = currentIndex.value + 1
|
||||
currentIndex.value = currentIndex.value + 1;
|
||||
} else {
|
||||
currentIndex.value = Math.min(currentIndex.value + 1, carouselItems.value.length - 1)
|
||||
currentIndex.value = Math.min(currentIndex.value + 1, carouselItems.value.length - 1);
|
||||
}
|
||||
} else if (offset > DRAG_BUFFER || velocity > VELOCITY_THRESHOLD) {
|
||||
if (props.loop && currentIndex.value === 0) {
|
||||
currentIndex.value = props.items.length - 1
|
||||
currentIndex.value = props.items.length - 1;
|
||||
} else {
|
||||
currentIndex.value = Math.max(currentIndex.value - 1, 0)
|
||||
currentIndex.value = Math.max(currentIndex.value - 1, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startAutoplay = () => {
|
||||
if (props.autoplay && (!props.pauseOnHover || !isHovered.value)) {
|
||||
autoplayTimer = window.setInterval(() => {
|
||||
currentIndex.value = (() => {
|
||||
const prev = currentIndex.value
|
||||
const prev = currentIndex.value;
|
||||
if (prev === props.items.length - 1 && props.loop) {
|
||||
return prev + 1
|
||||
return prev + 1;
|
||||
}
|
||||
if (prev === carouselItems.value.length - 1) {
|
||||
return props.loop ? 0 : prev
|
||||
return props.loop ? 0 : prev;
|
||||
}
|
||||
return prev + 1
|
||||
})()
|
||||
}, props.autoplayDelay)
|
||||
return prev + 1;
|
||||
})();
|
||||
}, props.autoplayDelay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stopAutoplay = () => {
|
||||
if (autoplayTimer) {
|
||||
clearInterval(autoplayTimer)
|
||||
autoplayTimer = null
|
||||
clearInterval(autoplayTimer);
|
||||
autoplayTimer = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHovered.value = true
|
||||
isHovered.value = true;
|
||||
if (props.pauseOnHover) {
|
||||
stopAutoplay()
|
||||
stopAutoplay();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
isHovered.value = false;
|
||||
if (props.pauseOnHover) {
|
||||
startAutoplay()
|
||||
startAutoplay();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
[() => props.autoplay, () => props.autoplayDelay, isHovered, () => props.loop, () => props.items.length, () => carouselItems.value.length, () => props.pauseOnHover],
|
||||
[
|
||||
() => props.autoplay,
|
||||
() => props.autoplayDelay,
|
||||
isHovered,
|
||||
() => props.loop,
|
||||
() => props.items.length,
|
||||
() => carouselItems.value.length,
|
||||
() => props.pauseOnHover
|
||||
],
|
||||
() => {
|
||||
stopAutoplay()
|
||||
startAutoplay()
|
||||
stopAutoplay();
|
||||
startAutoplay();
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.pauseOnHover && containerRef.value) {
|
||||
containerRef.value.addEventListener('mouseenter', handleMouseEnter)
|
||||
containerRef.value.addEventListener('mouseleave', handleMouseLeave)
|
||||
containerRef.value.addEventListener('mouseenter', handleMouseEnter);
|
||||
containerRef.value.addEventListener('mouseleave', handleMouseLeave);
|
||||
}
|
||||
startAutoplay()
|
||||
})
|
||||
startAutoplay();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('mouseenter', handleMouseEnter)
|
||||
containerRef.value.removeEventListener('mouseleave', handleMouseLeave)
|
||||
containerRef.value.removeEventListener('mouseenter', handleMouseEnter);
|
||||
containerRef.value.removeEventListener('mouseleave', handleMouseLeave);
|
||||
}
|
||||
stopAutoplay()
|
||||
})
|
||||
</script>
|
||||
stopAutoplay();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from 'ogl'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { Camera, Mesh, Plane, Program, Renderer, Texture, Transform } from 'ogl';
|
||||
|
||||
interface CircularGalleryProps {
|
||||
items?: { image: string; text: string }[]
|
||||
bend?: number
|
||||
textColor?: string
|
||||
borderRadius?: number
|
||||
font?: string
|
||||
scrollSpeed?: number
|
||||
scrollEase?: number
|
||||
items?: { image: string; text: string }[];
|
||||
bend?: number;
|
||||
textColor?: string;
|
||||
borderRadius?: number;
|
||||
font?: string;
|
||||
scrollSpeed?: number;
|
||||
scrollEase?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CircularGalleryProps>(), {
|
||||
@@ -23,38 +23,38 @@ const props = withDefaults(defineProps<CircularGalleryProps>(), {
|
||||
font: 'bold 30px Figtree',
|
||||
scrollSpeed: 2,
|
||||
scrollEase: 0.05
|
||||
})
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
let app: App | null = null
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
let app: App | null = null;
|
||||
|
||||
type GL = Renderer['gl']
|
||||
type GL = Renderer['gl'];
|
||||
|
||||
function debounce<T extends (...args: unknown[]) => void>(func: T, wait: number) {
|
||||
let timeout: number
|
||||
let timeout: number;
|
||||
return function (this: unknown, ...args: Parameters<T>) {
|
||||
window.clearTimeout(timeout)
|
||||
timeout = window.setTimeout(() => func.apply(this, args), wait)
|
||||
}
|
||||
window.clearTimeout(timeout);
|
||||
timeout = window.setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
function lerp(p1: number, p2: number, t: number): number {
|
||||
return p1 + (p2 - p1) * t
|
||||
return p1 + (p2 - p1) * t;
|
||||
}
|
||||
|
||||
function autoBind(instance: Record<string, unknown>): void {
|
||||
const proto = Object.getPrototypeOf(instance)
|
||||
Object.getOwnPropertyNames(proto).forEach((key) => {
|
||||
const proto = Object.getPrototypeOf(instance);
|
||||
Object.getOwnPropertyNames(proto).forEach(key => {
|
||||
if (key !== 'constructor' && typeof instance[key] === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
instance[key] = (instance[key] as any).bind(instance)
|
||||
instance[key] = (instance[key] as any).bind(instance);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function getFontSize(font: string): number {
|
||||
const match = font.match(/(\d+)px/)
|
||||
return match ? parseInt(match[1], 10) : 30
|
||||
const match = font.match(/(\d+)px/);
|
||||
return match ? parseInt(match[1], 10) : 30;
|
||||
}
|
||||
|
||||
function createTextTexture(
|
||||
@@ -63,64 +63,64 @@ function createTextTexture(
|
||||
font: string = 'bold 30px monospace',
|
||||
color: string = 'black'
|
||||
): { texture: Texture; width: number; height: number } {
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('Could not get 2d context')
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Could not get 2d context');
|
||||
|
||||
context.font = font
|
||||
const metrics = context.measureText(text)
|
||||
const textWidth = Math.ceil(metrics.width)
|
||||
const fontSize = getFontSize(font)
|
||||
const textHeight = Math.ceil(fontSize * 1.2)
|
||||
context.font = font;
|
||||
const metrics = context.measureText(text);
|
||||
const textWidth = Math.ceil(metrics.width);
|
||||
const fontSize = getFontSize(font);
|
||||
const textHeight = Math.ceil(fontSize * 1.2);
|
||||
|
||||
canvas.width = textWidth + 20
|
||||
canvas.height = textHeight + 20
|
||||
canvas.width = textWidth + 20;
|
||||
canvas.height = textHeight + 20;
|
||||
|
||||
context.font = font
|
||||
context.fillStyle = color
|
||||
context.textBaseline = 'middle'
|
||||
context.textAlign = 'center'
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.fillText(text, canvas.width / 2, canvas.height / 2)
|
||||
context.font = font;
|
||||
context.fillStyle = color;
|
||||
context.textBaseline = 'middle';
|
||||
context.textAlign = 'center';
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillText(text, canvas.width / 2, canvas.height / 2);
|
||||
|
||||
const texture = new Texture(gl, { generateMipmaps: false })
|
||||
texture.image = canvas
|
||||
return { texture, width: canvas.width, height: canvas.height }
|
||||
const texture = new Texture(gl, { generateMipmaps: false });
|
||||
texture.image = canvas;
|
||||
return { texture, width: canvas.width, height: canvas.height };
|
||||
}
|
||||
|
||||
interface TitleProps {
|
||||
gl: GL
|
||||
plane: Mesh
|
||||
renderer: Renderer
|
||||
text: string
|
||||
textColor?: string
|
||||
font?: string
|
||||
gl: GL;
|
||||
plane: Mesh;
|
||||
renderer: Renderer;
|
||||
text: string;
|
||||
textColor?: string;
|
||||
font?: string;
|
||||
}
|
||||
|
||||
class Title {
|
||||
gl: GL
|
||||
plane: Mesh
|
||||
renderer: Renderer
|
||||
text: string
|
||||
textColor: string
|
||||
font: string
|
||||
mesh!: Mesh
|
||||
gl: GL;
|
||||
plane: Mesh;
|
||||
renderer: Renderer;
|
||||
text: string;
|
||||
textColor: string;
|
||||
font: string;
|
||||
mesh!: Mesh;
|
||||
|
||||
constructor({ gl, plane, renderer, text, textColor = '#545050', font = '30px sans-serif' }: TitleProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
autoBind(this as any)
|
||||
this.gl = gl
|
||||
this.plane = plane
|
||||
this.renderer = renderer
|
||||
this.text = text
|
||||
this.textColor = textColor
|
||||
this.font = font
|
||||
this.createMesh()
|
||||
autoBind(this as any);
|
||||
this.gl = gl;
|
||||
this.plane = plane;
|
||||
this.renderer = renderer;
|
||||
this.text = text;
|
||||
this.textColor = textColor;
|
||||
this.font = font;
|
||||
this.createMesh();
|
||||
}
|
||||
|
||||
createMesh() {
|
||||
const { texture, width, height } = createTextTexture(this.gl, this.text, this.font, this.textColor)
|
||||
const geometry = new Plane(this.gl)
|
||||
const { texture, width, height } = createTextTexture(this.gl, this.text, this.font, this.textColor);
|
||||
const geometry = new Plane(this.gl);
|
||||
const program = new Program(this.gl, {
|
||||
vertex: `
|
||||
attribute vec3 position;
|
||||
@@ -144,72 +144,72 @@ class Title {
|
||||
}
|
||||
`,
|
||||
uniforms: { tMap: { value: texture } },
|
||||
transparent: true,
|
||||
})
|
||||
this.mesh = new Mesh(this.gl, { geometry, program })
|
||||
const aspect = width / height
|
||||
const textHeightScaled = this.plane.scale.y * 0.15
|
||||
const textWidthScaled = textHeightScaled * aspect
|
||||
this.mesh.scale.set(textWidthScaled, textHeightScaled, 1)
|
||||
this.mesh.position.y = -this.plane.scale.y * 0.5 - textHeightScaled * 0.5 - 0.05
|
||||
this.mesh.setParent(this.plane)
|
||||
transparent: true
|
||||
});
|
||||
this.mesh = new Mesh(this.gl, { geometry, program });
|
||||
const aspect = width / height;
|
||||
const textHeightScaled = this.plane.scale.y * 0.15;
|
||||
const textWidthScaled = textHeightScaled * aspect;
|
||||
this.mesh.scale.set(textWidthScaled, textHeightScaled, 1);
|
||||
this.mesh.position.y = -this.plane.scale.y * 0.5 - textHeightScaled * 0.5 - 0.05;
|
||||
this.mesh.setParent(this.plane);
|
||||
}
|
||||
}
|
||||
|
||||
interface ScreenSize {
|
||||
width: number
|
||||
height: number
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface Viewport {
|
||||
width: number
|
||||
height: number
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface MediaProps {
|
||||
geometry: Plane
|
||||
gl: GL
|
||||
image: string
|
||||
index: number
|
||||
length: number
|
||||
renderer: Renderer
|
||||
scene: Transform
|
||||
screen: ScreenSize
|
||||
text: string
|
||||
viewport: Viewport
|
||||
bend: number
|
||||
textColor: string
|
||||
borderRadius?: number
|
||||
font?: string
|
||||
geometry: Plane;
|
||||
gl: GL;
|
||||
image: string;
|
||||
index: number;
|
||||
length: number;
|
||||
renderer: Renderer;
|
||||
scene: Transform;
|
||||
screen: ScreenSize;
|
||||
text: string;
|
||||
viewport: Viewport;
|
||||
bend: number;
|
||||
textColor: string;
|
||||
borderRadius?: number;
|
||||
font?: string;
|
||||
}
|
||||
|
||||
class Media {
|
||||
extra: number = 0
|
||||
geometry: Plane
|
||||
gl: GL
|
||||
image: string
|
||||
index: number
|
||||
length: number
|
||||
renderer: Renderer
|
||||
scene: Transform
|
||||
screen: ScreenSize
|
||||
text: string
|
||||
viewport: Viewport
|
||||
bend: number
|
||||
textColor: string
|
||||
borderRadius: number
|
||||
font?: string
|
||||
program!: Program
|
||||
plane!: Mesh
|
||||
title!: Title
|
||||
scale!: number
|
||||
padding!: number
|
||||
width!: number
|
||||
widthTotal!: number
|
||||
x!: number
|
||||
speed: number = 0
|
||||
isBefore: boolean = false
|
||||
isAfter: boolean = false
|
||||
extra: number = 0;
|
||||
geometry: Plane;
|
||||
gl: GL;
|
||||
image: string;
|
||||
index: number;
|
||||
length: number;
|
||||
renderer: Renderer;
|
||||
scene: Transform;
|
||||
screen: ScreenSize;
|
||||
text: string;
|
||||
viewport: Viewport;
|
||||
bend: number;
|
||||
textColor: string;
|
||||
borderRadius: number;
|
||||
font?: string;
|
||||
program!: Program;
|
||||
plane!: Mesh;
|
||||
title!: Title;
|
||||
scale!: number;
|
||||
padding!: number;
|
||||
width!: number;
|
||||
widthTotal!: number;
|
||||
x!: number;
|
||||
speed: number = 0;
|
||||
isBefore: boolean = false;
|
||||
isAfter: boolean = false;
|
||||
|
||||
constructor({
|
||||
geometry,
|
||||
@@ -225,30 +225,30 @@ class Media {
|
||||
bend,
|
||||
textColor,
|
||||
borderRadius = 0,
|
||||
font,
|
||||
font
|
||||
}: MediaProps) {
|
||||
this.geometry = geometry
|
||||
this.gl = gl
|
||||
this.image = image
|
||||
this.index = index
|
||||
this.length = length
|
||||
this.renderer = renderer
|
||||
this.scene = scene
|
||||
this.screen = screen
|
||||
this.text = text
|
||||
this.viewport = viewport
|
||||
this.bend = bend
|
||||
this.textColor = textColor
|
||||
this.borderRadius = borderRadius
|
||||
this.font = font
|
||||
this.createShader()
|
||||
this.createMesh()
|
||||
this.createTitle()
|
||||
this.onResize()
|
||||
this.geometry = geometry;
|
||||
this.gl = gl;
|
||||
this.image = image;
|
||||
this.index = index;
|
||||
this.length = length;
|
||||
this.renderer = renderer;
|
||||
this.scene = scene;
|
||||
this.screen = screen;
|
||||
this.text = text;
|
||||
this.viewport = viewport;
|
||||
this.bend = bend;
|
||||
this.textColor = textColor;
|
||||
this.borderRadius = borderRadius;
|
||||
this.font = font;
|
||||
this.createShader();
|
||||
this.createMesh();
|
||||
this.createTitle();
|
||||
this.onResize();
|
||||
}
|
||||
|
||||
createShader() {
|
||||
const texture = new Texture(this.gl, { generateMipmaps: false })
|
||||
const texture = new Texture(this.gl, { generateMipmaps: false });
|
||||
this.program = new Program(this.gl, {
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
@@ -306,25 +306,25 @@ class Media {
|
||||
uImageSizes: { value: [0, 0] },
|
||||
uSpeed: { value: 0 },
|
||||
uTime: { value: 100 * Math.random() },
|
||||
uBorderRadius: { value: this.borderRadius },
|
||||
uBorderRadius: { value: this.borderRadius }
|
||||
},
|
||||
transparent: true,
|
||||
})
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = this.image
|
||||
transparent: true
|
||||
});
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = this.image;
|
||||
img.onload = () => {
|
||||
texture.image = img
|
||||
this.program.uniforms.uImageSizes.value = [img.naturalWidth, img.naturalHeight]
|
||||
}
|
||||
texture.image = img;
|
||||
this.program.uniforms.uImageSizes.value = [img.naturalWidth, img.naturalHeight];
|
||||
};
|
||||
}
|
||||
|
||||
createMesh() {
|
||||
this.plane = new Mesh(this.gl, {
|
||||
geometry: this.geometry,
|
||||
program: this.program,
|
||||
})
|
||||
this.plane.setParent(this.scene)
|
||||
program: this.program
|
||||
});
|
||||
this.plane.setParent(this.scene);
|
||||
}
|
||||
|
||||
createTitle() {
|
||||
@@ -334,111 +334,111 @@ class Media {
|
||||
renderer: this.renderer,
|
||||
text: this.text,
|
||||
textColor: this.textColor,
|
||||
font: this.font,
|
||||
})
|
||||
font: this.font
|
||||
});
|
||||
}
|
||||
|
||||
update(scroll: { current: number; last: number }, direction: 'right' | 'left') {
|
||||
this.plane.position.x = this.x - scroll.current - this.extra
|
||||
this.plane.position.x = this.x - scroll.current - this.extra;
|
||||
|
||||
const x = this.plane.position.x
|
||||
const H = this.viewport.width / 2
|
||||
const x = this.plane.position.x;
|
||||
const H = this.viewport.width / 2;
|
||||
|
||||
if (this.bend === 0) {
|
||||
this.plane.position.y = 0
|
||||
this.plane.rotation.z = 0
|
||||
this.plane.position.y = 0;
|
||||
this.plane.rotation.z = 0;
|
||||
} else {
|
||||
const B_abs = Math.abs(this.bend)
|
||||
const R = (H * H + B_abs * B_abs) / (2 * B_abs)
|
||||
const effectiveX = Math.min(Math.abs(x), H)
|
||||
const B_abs = Math.abs(this.bend);
|
||||
const R = (H * H + B_abs * B_abs) / (2 * B_abs);
|
||||
const effectiveX = Math.min(Math.abs(x), H);
|
||||
|
||||
const arc = R - Math.sqrt(R * R - effectiveX * effectiveX)
|
||||
const arc = R - Math.sqrt(R * R - effectiveX * effectiveX);
|
||||
if (this.bend > 0) {
|
||||
this.plane.position.y = -arc
|
||||
this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R)
|
||||
this.plane.position.y = -arc;
|
||||
this.plane.rotation.z = -Math.sign(x) * Math.asin(effectiveX / R);
|
||||
} else {
|
||||
this.plane.position.y = arc
|
||||
this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R)
|
||||
this.plane.position.y = arc;
|
||||
this.plane.rotation.z = Math.sign(x) * Math.asin(effectiveX / R);
|
||||
}
|
||||
}
|
||||
|
||||
this.speed = scroll.current - scroll.last
|
||||
this.program.uniforms.uTime.value += 0.04
|
||||
this.program.uniforms.uSpeed.value = this.speed
|
||||
this.speed = scroll.current - scroll.last;
|
||||
this.program.uniforms.uTime.value += 0.04;
|
||||
this.program.uniforms.uSpeed.value = this.speed;
|
||||
|
||||
const planeOffset = this.plane.scale.x / 2
|
||||
const viewportOffset = this.viewport.width / 2
|
||||
this.isBefore = this.plane.position.x + planeOffset < -viewportOffset
|
||||
this.isAfter = this.plane.position.x - planeOffset > viewportOffset
|
||||
const planeOffset = this.plane.scale.x / 2;
|
||||
const viewportOffset = this.viewport.width / 2;
|
||||
this.isBefore = this.plane.position.x + planeOffset < -viewportOffset;
|
||||
this.isAfter = this.plane.position.x - planeOffset > viewportOffset;
|
||||
if (direction === 'right' && this.isBefore) {
|
||||
this.extra -= this.widthTotal
|
||||
this.isBefore = this.isAfter = false
|
||||
this.extra -= this.widthTotal;
|
||||
this.isBefore = this.isAfter = false;
|
||||
}
|
||||
if (direction === 'left' && this.isAfter) {
|
||||
this.extra += this.widthTotal
|
||||
this.isBefore = this.isAfter = false
|
||||
this.extra += this.widthTotal;
|
||||
this.isBefore = this.isAfter = false;
|
||||
}
|
||||
}
|
||||
|
||||
onResize({ screen, viewport }: { screen?: ScreenSize; viewport?: Viewport } = {}) {
|
||||
if (screen) this.screen = screen
|
||||
if (screen) this.screen = screen;
|
||||
if (viewport) {
|
||||
this.viewport = viewport
|
||||
this.viewport = viewport;
|
||||
if (this.plane.program.uniforms.uViewportSizes) {
|
||||
this.plane.program.uniforms.uViewportSizes.value = [this.viewport.width, this.viewport.height]
|
||||
this.plane.program.uniforms.uViewportSizes.value = [this.viewport.width, this.viewport.height];
|
||||
}
|
||||
}
|
||||
this.scale = this.screen.height / 1500
|
||||
this.plane.scale.y = (this.viewport.height * (900 * this.scale)) / this.screen.height
|
||||
this.plane.scale.x = (this.viewport.width * (700 * this.scale)) / this.screen.width
|
||||
this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y]
|
||||
this.padding = 2
|
||||
this.width = this.plane.scale.x + this.padding
|
||||
this.widthTotal = this.width * this.length
|
||||
this.x = this.width * this.index
|
||||
this.scale = this.screen.height / 1500;
|
||||
this.plane.scale.y = (this.viewport.height * (900 * this.scale)) / this.screen.height;
|
||||
this.plane.scale.x = (this.viewport.width * (700 * this.scale)) / this.screen.width;
|
||||
this.plane.program.uniforms.uPlaneSizes.value = [this.plane.scale.x, this.plane.scale.y];
|
||||
this.padding = 2;
|
||||
this.width = this.plane.scale.x + this.padding;
|
||||
this.widthTotal = this.width * this.length;
|
||||
this.x = this.width * this.index;
|
||||
}
|
||||
}
|
||||
|
||||
interface AppConfig {
|
||||
items?: { image: string; text: string }[]
|
||||
bend?: number
|
||||
textColor?: string
|
||||
borderRadius?: number
|
||||
font?: string
|
||||
scrollSpeed?: number
|
||||
scrollEase?: number
|
||||
items?: { image: string; text: string }[];
|
||||
bend?: number;
|
||||
textColor?: string;
|
||||
borderRadius?: number;
|
||||
font?: string;
|
||||
scrollSpeed?: number;
|
||||
scrollEase?: number;
|
||||
}
|
||||
|
||||
class App {
|
||||
container: HTMLElement
|
||||
scrollSpeed: number
|
||||
container: HTMLElement;
|
||||
scrollSpeed: number;
|
||||
scroll: {
|
||||
ease: number
|
||||
current: number
|
||||
target: number
|
||||
last: number
|
||||
position?: number
|
||||
}
|
||||
onCheckDebounce: (...args: unknown[]) => void
|
||||
renderer!: Renderer
|
||||
gl!: GL
|
||||
camera!: Camera
|
||||
scene!: Transform
|
||||
planeGeometry!: Plane
|
||||
medias: Media[] = []
|
||||
mediasImages: { image: string; text: string }[] = []
|
||||
screen!: { width: number; height: number }
|
||||
viewport!: { width: number; height: number }
|
||||
raf: number = 0
|
||||
ease: number;
|
||||
current: number;
|
||||
target: number;
|
||||
last: number;
|
||||
position?: number;
|
||||
};
|
||||
onCheckDebounce: (...args: unknown[]) => void;
|
||||
renderer!: Renderer;
|
||||
gl!: GL;
|
||||
camera!: Camera;
|
||||
scene!: Transform;
|
||||
planeGeometry!: Plane;
|
||||
medias: Media[] = [];
|
||||
mediasImages: { image: string; text: string }[] = [];
|
||||
screen!: { width: number; height: number };
|
||||
viewport!: { width: number; height: number };
|
||||
raf: number = 0;
|
||||
|
||||
boundOnResize!: () => void
|
||||
boundOnWheel!: (e: Event) => void
|
||||
boundOnTouchDown!: (e: MouseEvent | TouchEvent) => void
|
||||
boundOnTouchMove!: (e: MouseEvent | TouchEvent) => void
|
||||
boundOnTouchUp!: () => void
|
||||
boundOnResize!: () => void;
|
||||
boundOnWheel!: (e: Event) => void;
|
||||
boundOnTouchDown!: (e: MouseEvent | TouchEvent) => void;
|
||||
boundOnTouchMove!: (e: MouseEvent | TouchEvent) => void;
|
||||
boundOnTouchUp!: () => void;
|
||||
|
||||
isDown: boolean = false
|
||||
start: number = 0
|
||||
isDown: boolean = false;
|
||||
start: number = 0;
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
@@ -449,46 +449,46 @@ class App {
|
||||
borderRadius = 0,
|
||||
font = 'bold 30px Figtree',
|
||||
scrollSpeed = 2,
|
||||
scrollEase = 0.05,
|
||||
scrollEase = 0.05
|
||||
}: AppConfig
|
||||
) {
|
||||
document.documentElement.classList.remove('no-js')
|
||||
this.container = container
|
||||
this.scrollSpeed = scrollSpeed
|
||||
this.scroll = { ease: scrollEase, current: 0, target: 0, last: 0 }
|
||||
this.onCheckDebounce = debounce(this.onCheck.bind(this), 200)
|
||||
this.createRenderer()
|
||||
this.createCamera()
|
||||
this.createScene()
|
||||
this.onResize()
|
||||
this.createGeometry()
|
||||
this.createMedias(items, bend, textColor, borderRadius, font)
|
||||
this.update()
|
||||
this.addEventListeners()
|
||||
document.documentElement.classList.remove('no-js');
|
||||
this.container = container;
|
||||
this.scrollSpeed = scrollSpeed;
|
||||
this.scroll = { ease: scrollEase, current: 0, target: 0, last: 0 };
|
||||
this.onCheckDebounce = debounce(this.onCheck.bind(this), 200);
|
||||
this.createRenderer();
|
||||
this.createCamera();
|
||||
this.createScene();
|
||||
this.onResize();
|
||||
this.createGeometry();
|
||||
this.createMedias(items, bend, textColor, borderRadius, font);
|
||||
this.update();
|
||||
this.addEventListeners();
|
||||
}
|
||||
|
||||
createRenderer() {
|
||||
this.renderer = new Renderer({ alpha: true })
|
||||
this.gl = this.renderer.gl
|
||||
this.gl.clearColor(0, 0, 0, 0)
|
||||
this.container.appendChild(this.renderer.gl.canvas as HTMLCanvasElement)
|
||||
this.renderer = new Renderer({ alpha: true });
|
||||
this.gl = this.renderer.gl;
|
||||
this.gl.clearColor(0, 0, 0, 0);
|
||||
this.container.appendChild(this.renderer.gl.canvas as HTMLCanvasElement);
|
||||
}
|
||||
|
||||
createCamera() {
|
||||
this.camera = new Camera(this.gl)
|
||||
this.camera.fov = 45
|
||||
this.camera.position.z = 20
|
||||
this.camera = new Camera(this.gl);
|
||||
this.camera.fov = 45;
|
||||
this.camera.position.z = 20;
|
||||
}
|
||||
|
||||
createScene() {
|
||||
this.scene = new Transform()
|
||||
this.scene = new Transform();
|
||||
}
|
||||
|
||||
createGeometry() {
|
||||
this.planeGeometry = new Plane(this.gl, {
|
||||
heightSegments: 50,
|
||||
widthSegments: 100,
|
||||
})
|
||||
widthSegments: 100
|
||||
});
|
||||
}
|
||||
|
||||
createMedias(
|
||||
@@ -501,55 +501,55 @@ class App {
|
||||
const defaultItems = [
|
||||
{
|
||||
image: `https://picsum.photos/seed/1/800/600?grayscale`,
|
||||
text: 'Bridge',
|
||||
text: 'Bridge'
|
||||
},
|
||||
{
|
||||
image: `https://picsum.photos/seed/2/800/600?grayscale`,
|
||||
text: 'Desk Setup',
|
||||
text: 'Desk Setup'
|
||||
},
|
||||
{
|
||||
image: `https://picsum.photos/seed/3/800/600?grayscale`,
|
||||
text: 'Waterfall',
|
||||
text: 'Waterfall'
|
||||
},
|
||||
{
|
||||
image: `https://picsum.photos/seed/4/800/600?grayscale`,
|
||||
text: 'Strawberries',
|
||||
text: 'Strawberries'
|
||||
},
|
||||
{
|
||||
image: `https://picsum.photos/seed/5/800/600?grayscale`,
|
||||
text: 'Deep Diving',
|
||||
text: 'Deep Diving'
|
||||
},
|
||||
{
|
||||
image: `https://picsum.photos/seed/16/800/600?grayscale`,
|
||||
text: 'Train Track',
|
||||
text: 'Train Track'
|
||||
},
|
||||
{
|
||||
image: `https://picsum.photos/seed/17/800/600?grayscale`,
|
||||
text: 'Santorini',
|
||||
text: 'Santorini'
|
||||
},
|
||||
{
|
||||
image: `https://picsum.photos/seed/8/800/600?grayscale`,
|
||||
text: 'Blurry Lights',
|
||||
text: 'Blurry Lights'
|
||||
},
|
||||
{
|
||||
image: `https://picsum.photos/seed/9/800/600?grayscale`,
|
||||
text: 'New York',
|
||||
text: 'New York'
|
||||
},
|
||||
{
|
||||
image: `https://picsum.photos/seed/10/800/600?grayscale`,
|
||||
text: 'Good Boy',
|
||||
text: 'Good Boy'
|
||||
},
|
||||
{
|
||||
image: `https://picsum.photos/seed/21/800/600?grayscale`,
|
||||
text: 'Coastline',
|
||||
text: 'Coastline'
|
||||
},
|
||||
{
|
||||
image: `https://picsum.photos/seed/12/800/600?grayscale`,
|
||||
text: 'Palm Trees',
|
||||
},
|
||||
]
|
||||
const galleryItems = items && items.length ? items : defaultItems
|
||||
this.mediasImages = galleryItems.concat(galleryItems)
|
||||
text: 'Palm Trees'
|
||||
}
|
||||
];
|
||||
const galleryItems = items && items.length ? items : defaultItems;
|
||||
this.mediasImages = galleryItems.concat(galleryItems);
|
||||
this.medias = this.mediasImages.map((data, index) => {
|
||||
return new Media({
|
||||
geometry: this.planeGeometry,
|
||||
@@ -565,114 +565,114 @@ class App {
|
||||
bend,
|
||||
textColor,
|
||||
borderRadius,
|
||||
font,
|
||||
})
|
||||
})
|
||||
font
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onTouchDown(e: MouseEvent | TouchEvent) {
|
||||
this.isDown = true
|
||||
this.scroll.position = this.scroll.current
|
||||
this.start = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
this.isDown = true;
|
||||
this.scroll.position = this.scroll.current;
|
||||
this.start = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
}
|
||||
|
||||
onTouchMove(e: MouseEvent | TouchEvent) {
|
||||
if (!this.isDown) return
|
||||
const x = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
const distance = (this.start - x) * (this.scrollSpeed * 0.025)
|
||||
this.scroll.target = (this.scroll.position ?? 0) + distance
|
||||
if (!this.isDown) return;
|
||||
const x = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const distance = (this.start - x) * (this.scrollSpeed * 0.025);
|
||||
this.scroll.target = (this.scroll.position ?? 0) + distance;
|
||||
}
|
||||
|
||||
onTouchUp() {
|
||||
this.isDown = false
|
||||
this.onCheck()
|
||||
this.isDown = false;
|
||||
this.onCheck();
|
||||
}
|
||||
|
||||
onWheel(e: Event) {
|
||||
const wheelEvent = e as WheelEvent
|
||||
const wheelEvent = e as WheelEvent;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const delta = wheelEvent.deltaY || (wheelEvent as any).wheelDelta || (wheelEvent as any).detail
|
||||
this.scroll.target += delta > 0 ? this.scrollSpeed : -this.scrollSpeed
|
||||
this.onCheckDebounce()
|
||||
const delta = wheelEvent.deltaY || (wheelEvent as any).wheelDelta || (wheelEvent as any).detail;
|
||||
this.scroll.target += delta > 0 ? this.scrollSpeed : -this.scrollSpeed;
|
||||
this.onCheckDebounce();
|
||||
}
|
||||
|
||||
onCheck() {
|
||||
if (!this.medias || !this.medias[0]) return
|
||||
const width = this.medias[0].width
|
||||
const itemIndex = Math.round(Math.abs(this.scroll.target) / width)
|
||||
const item = width * itemIndex
|
||||
this.scroll.target = this.scroll.target < 0 ? -item : item
|
||||
if (!this.medias || !this.medias[0]) return;
|
||||
const width = this.medias[0].width;
|
||||
const itemIndex = Math.round(Math.abs(this.scroll.target) / width);
|
||||
const item = width * itemIndex;
|
||||
this.scroll.target = this.scroll.target < 0 ? -item : item;
|
||||
}
|
||||
|
||||
onResize() {
|
||||
this.screen = {
|
||||
width: this.container.clientWidth,
|
||||
height: this.container.clientHeight,
|
||||
}
|
||||
this.renderer.setSize(this.screen.width, this.screen.height)
|
||||
height: this.container.clientHeight
|
||||
};
|
||||
this.renderer.setSize(this.screen.width, this.screen.height);
|
||||
this.camera.perspective({
|
||||
aspect: this.screen.width / this.screen.height,
|
||||
})
|
||||
const fov = (this.camera.fov * Math.PI) / 180
|
||||
const height = 2 * Math.tan(fov / 2) * this.camera.position.z
|
||||
const width = height * this.camera.aspect
|
||||
this.viewport = { width, height }
|
||||
aspect: this.screen.width / this.screen.height
|
||||
});
|
||||
const fov = (this.camera.fov * Math.PI) / 180;
|
||||
const height = 2 * Math.tan(fov / 2) * this.camera.position.z;
|
||||
const width = height * this.camera.aspect;
|
||||
this.viewport = { width, height };
|
||||
if (this.medias) {
|
||||
this.medias.forEach((media) => media.onResize({ screen: this.screen, viewport: this.viewport }))
|
||||
this.medias.forEach(media => media.onResize({ screen: this.screen, viewport: this.viewport }));
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease)
|
||||
const direction = this.scroll.current > this.scroll.last ? 'right' : 'left'
|
||||
this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease);
|
||||
const direction = this.scroll.current > this.scroll.last ? 'right' : 'left';
|
||||
if (this.medias) {
|
||||
this.medias.forEach((media) => media.update(this.scroll, direction))
|
||||
this.medias.forEach(media => media.update(this.scroll, direction));
|
||||
}
|
||||
this.renderer.render({ scene: this.scene, camera: this.camera })
|
||||
this.scroll.last = this.scroll.current
|
||||
this.raf = window.requestAnimationFrame(this.update.bind(this))
|
||||
this.renderer.render({ scene: this.scene, camera: this.camera });
|
||||
this.scroll.last = this.scroll.current;
|
||||
this.raf = window.requestAnimationFrame(this.update.bind(this));
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
this.boundOnResize = this.onResize.bind(this)
|
||||
this.boundOnWheel = this.onWheel.bind(this)
|
||||
this.boundOnTouchDown = this.onTouchDown.bind(this)
|
||||
this.boundOnTouchMove = this.onTouchMove.bind(this)
|
||||
this.boundOnTouchUp = this.onTouchUp.bind(this)
|
||||
|
||||
window.addEventListener('resize', this.boundOnResize)
|
||||
|
||||
this.container.addEventListener('wheel', this.boundOnWheel)
|
||||
this.container.addEventListener('mousedown', this.boundOnTouchDown)
|
||||
this.container.addEventListener('touchstart', this.boundOnTouchDown)
|
||||
|
||||
window.addEventListener('mousemove', this.boundOnTouchMove)
|
||||
window.addEventListener('mouseup', this.boundOnTouchUp)
|
||||
window.addEventListener('touchmove', this.boundOnTouchMove)
|
||||
window.addEventListener('touchend', this.boundOnTouchUp)
|
||||
this.boundOnResize = this.onResize.bind(this);
|
||||
this.boundOnWheel = this.onWheel.bind(this);
|
||||
this.boundOnTouchDown = this.onTouchDown.bind(this);
|
||||
this.boundOnTouchMove = this.onTouchMove.bind(this);
|
||||
this.boundOnTouchUp = this.onTouchUp.bind(this);
|
||||
|
||||
window.addEventListener('resize', this.boundOnResize);
|
||||
|
||||
this.container.addEventListener('wheel', this.boundOnWheel);
|
||||
this.container.addEventListener('mousedown', this.boundOnTouchDown);
|
||||
this.container.addEventListener('touchstart', this.boundOnTouchDown);
|
||||
|
||||
window.addEventListener('mousemove', this.boundOnTouchMove);
|
||||
window.addEventListener('mouseup', this.boundOnTouchUp);
|
||||
window.addEventListener('touchmove', this.boundOnTouchMove);
|
||||
window.addEventListener('touchend', this.boundOnTouchUp);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.cancelAnimationFrame(this.raf)
|
||||
|
||||
window.removeEventListener('resize', this.boundOnResize)
|
||||
window.removeEventListener('mousemove', this.boundOnTouchMove)
|
||||
window.removeEventListener('mouseup', this.boundOnTouchUp)
|
||||
window.removeEventListener('touchmove', this.boundOnTouchMove)
|
||||
window.removeEventListener('touchend', this.boundOnTouchUp)
|
||||
|
||||
this.container.removeEventListener('wheel', this.boundOnWheel)
|
||||
this.container.removeEventListener('mousedown', this.boundOnTouchDown)
|
||||
this.container.removeEventListener('touchstart', this.boundOnTouchDown)
|
||||
|
||||
window.cancelAnimationFrame(this.raf);
|
||||
|
||||
window.removeEventListener('resize', this.boundOnResize);
|
||||
window.removeEventListener('mousemove', this.boundOnTouchMove);
|
||||
window.removeEventListener('mouseup', this.boundOnTouchUp);
|
||||
window.removeEventListener('touchmove', this.boundOnTouchMove);
|
||||
window.removeEventListener('touchend', this.boundOnTouchUp);
|
||||
|
||||
this.container.removeEventListener('wheel', this.boundOnWheel);
|
||||
this.container.removeEventListener('mousedown', this.boundOnTouchDown);
|
||||
this.container.removeEventListener('touchstart', this.boundOnTouchDown);
|
||||
|
||||
if (this.renderer && this.renderer.gl && this.renderer.gl.canvas.parentNode) {
|
||||
this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas as HTMLCanvasElement)
|
||||
this.renderer.gl.canvas.parentNode.removeChild(this.renderer.gl.canvas as HTMLCanvasElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!containerRef.value) return
|
||||
if (!containerRef.value) return;
|
||||
|
||||
app = new App(containerRef.value, {
|
||||
items: props.items,
|
||||
@@ -681,16 +681,16 @@ onMounted(() => {
|
||||
borderRadius: props.borderRadius,
|
||||
font: props.font,
|
||||
scrollSpeed: props.scrollSpeed,
|
||||
scrollEase: props.scrollEase,
|
||||
})
|
||||
})
|
||||
scrollEase: props.scrollEase
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (app) {
|
||||
app.destroy()
|
||||
app = null
|
||||
app.destroy();
|
||||
app = null;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
@@ -700,16 +700,16 @@ watch(
|
||||
borderRadius: props.borderRadius,
|
||||
font: props.font,
|
||||
scrollSpeed: props.scrollSpeed,
|
||||
scrollEase: props.scrollEase,
|
||||
scrollEase: props.scrollEase
|
||||
}),
|
||||
(newProps) => {
|
||||
newProps => {
|
||||
if (app) {
|
||||
app.destroy()
|
||||
app.destroy();
|
||||
}
|
||||
if (containerRef.value) {
|
||||
app = new App(containerRef.value, newProps)
|
||||
app = new App(containerRef.value, newProps);
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,161 +1,167 @@
|
||||
<template>
|
||||
<div ref="svgRef" class="relative" :style="{ width: `${width}px`, height: `${height}px` }">
|
||||
<svg viewBox="-60 -75 720 900" preserveAspectRatio="xMidYMid slice"
|
||||
class="relative w-full h-full block [will-change:transform]">
|
||||
<svg
|
||||
viewBox="-60 -75 720 900"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="relative w-full h-full block [will-change:transform]"
|
||||
>
|
||||
<filter id="imgFilter">
|
||||
<feTurbulence type="turbulence" baseFrequency="0.015" numOctaves="5" seed="4" stitchTiles="stitch" x="0%" y="0%"
|
||||
width="100%" height="100%" result="turbulence1" />
|
||||
<feDisplacementMap ref="displacementMapRef" in="SourceGraphic" in2="turbulence1" scale="0" xChannelSelector="R"
|
||||
yChannelSelector="B" x="0%" y="0%" width="100%" height="100%" result="displacementMap3" />
|
||||
<feTurbulence
|
||||
type="turbulence"
|
||||
baseFrequency="0.015"
|
||||
numOctaves="5"
|
||||
seed="4"
|
||||
stitchTiles="stitch"
|
||||
x="0%"
|
||||
y="0%"
|
||||
width="100%"
|
||||
height="100%"
|
||||
result="turbulence1"
|
||||
/>
|
||||
|
||||
<feDisplacementMap
|
||||
ref="displacementMapRef"
|
||||
in="SourceGraphic"
|
||||
in2="turbulence1"
|
||||
scale="0"
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="B"
|
||||
x="0%"
|
||||
y="0%"
|
||||
width="100%"
|
||||
height="100%"
|
||||
result="displacementMap3"
|
||||
/>
|
||||
</filter>
|
||||
|
||||
<g>
|
||||
<image :href="image" x="0" y="0" width="600" height="750" filter="url(#imgFilter)"
|
||||
preserveAspectRatio="xMidYMid slice" />
|
||||
<image
|
||||
:href="image"
|
||||
x="0"
|
||||
y="0"
|
||||
width="600"
|
||||
height="750"
|
||||
filter="url(#imgFilter)"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
class="absolute bottom-[1.2em] left-[1em] tracking-[-0.5px] font-black text-[2rem] leading-[1.5em] first-line:text-[4rem]">
|
||||
class="absolute bottom-[1.2em] left-[1em] tracking-[-0.5px] font-black text-[2rem] leading-[1.5em] first-line:text-[4rem]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
interface Props {
|
||||
width?: number
|
||||
height?: number
|
||||
image?: string
|
||||
width?: number;
|
||||
height?: number;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
width: 300,
|
||||
height: 400,
|
||||
image: 'https://picsum.photos/300/400?grayscale'
|
||||
})
|
||||
});
|
||||
|
||||
const svgRef = ref<HTMLDivElement | null>(null)
|
||||
const displacementMapRef = ref<SVGFEDisplacementMapElement | null>(null)
|
||||
const svgRef = ref<HTMLDivElement | null>(null);
|
||||
const displacementMapRef = ref<SVGFEDisplacementMapElement | null>(null);
|
||||
|
||||
let cursor = {
|
||||
x: typeof window !== 'undefined' ? window.innerWidth / 2 : 0,
|
||||
y: typeof window !== 'undefined' ? window.innerHeight / 2 : 0
|
||||
}
|
||||
};
|
||||
|
||||
let cachedCursor = { ...cursor }
|
||||
let cachedCursor = { ...cursor };
|
||||
|
||||
let winsize = {
|
||||
width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
||||
height: typeof window !== 'undefined' ? window.innerHeight : 0
|
||||
}
|
||||
};
|
||||
|
||||
let animationFrameId: number | null = null
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
const lerp = (a: number, b: number, n: number): number =>
|
||||
(1 - n) * a + n * b
|
||||
const lerp = (a: number, b: number, n: number): number => (1 - n) * a + n * b;
|
||||
|
||||
const map = (
|
||||
x: number,
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
d: number
|
||||
): number => ((x - a) * (d - c)) / (b - a) + c
|
||||
const map = (x: number, a: number, b: number, c: number, d: number): number => ((x - a) * (d - c)) / (b - a) + c;
|
||||
|
||||
const distance = (x1: number, x2: number, y1: number, y2: number): number =>
|
||||
Math.hypot(x1 - x2, y1 - y2)
|
||||
const distance = (x1: number, x2: number, y1: number, y2: number): number => Math.hypot(x1 - x2, y1 - y2);
|
||||
|
||||
const handleResize = (): void => {
|
||||
winsize = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleMouseMove = (ev: MouseEvent): void => {
|
||||
cursor = { x: ev.clientX, y: ev.clientY }
|
||||
}
|
||||
cursor = { x: ev.clientX, y: ev.clientY };
|
||||
};
|
||||
|
||||
const imgValues = {
|
||||
imgTransforms: { x: 0, y: 0, rz: 0 },
|
||||
displacementScale: 0
|
||||
}
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
let targetX = lerp(
|
||||
imgValues.imgTransforms.x,
|
||||
map(cursor.x, 0, winsize.width, -120, 120),
|
||||
0.1
|
||||
)
|
||||
let targetY = lerp(
|
||||
imgValues.imgTransforms.y,
|
||||
map(cursor.y, 0, winsize.height, -120, 120),
|
||||
0.1
|
||||
)
|
||||
const targetRz = lerp(
|
||||
imgValues.imgTransforms.rz,
|
||||
map(cursor.x, 0, winsize.width, -10, 10),
|
||||
0.1
|
||||
)
|
||||
let targetX = lerp(imgValues.imgTransforms.x, map(cursor.x, 0, winsize.width, -120, 120), 0.1);
|
||||
let targetY = lerp(imgValues.imgTransforms.y, map(cursor.y, 0, winsize.height, -120, 120), 0.1);
|
||||
const targetRz = lerp(imgValues.imgTransforms.rz, map(cursor.x, 0, winsize.width, -10, 10), 0.1);
|
||||
|
||||
const bound = 50
|
||||
if (targetX > bound) targetX = bound + (targetX - bound) * 0.2
|
||||
if (targetX < -bound) targetX = -bound + (targetX + bound) * 0.2
|
||||
if (targetY > bound) targetY = bound + (targetY - bound) * 0.2
|
||||
if (targetY < -bound) targetY = -bound + (targetY + bound) * 0.2
|
||||
const bound = 50;
|
||||
if (targetX > bound) targetX = bound + (targetX - bound) * 0.2;
|
||||
if (targetX < -bound) targetX = -bound + (targetX + bound) * 0.2;
|
||||
if (targetY > bound) targetY = bound + (targetY - bound) * 0.2;
|
||||
if (targetY < -bound) targetY = -bound + (targetY + bound) * 0.2;
|
||||
|
||||
imgValues.imgTransforms.x = targetX
|
||||
imgValues.imgTransforms.y = targetY
|
||||
imgValues.imgTransforms.rz = targetRz
|
||||
imgValues.imgTransforms.x = targetX;
|
||||
imgValues.imgTransforms.y = targetY;
|
||||
imgValues.imgTransforms.rz = targetRz;
|
||||
|
||||
if (svgRef.value) {
|
||||
gsap.set(svgRef.value, {
|
||||
x: imgValues.imgTransforms.x,
|
||||
y: imgValues.imgTransforms.y,
|
||||
rotateZ: imgValues.imgTransforms.rz
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const cursorTravelledDistance = distance(
|
||||
cachedCursor.x,
|
||||
cursor.x,
|
||||
cachedCursor.y,
|
||||
cursor.y
|
||||
)
|
||||
imgValues.displacementScale = lerp(
|
||||
imgValues.displacementScale,
|
||||
map(cursorTravelledDistance, 0, 200, 0, 400),
|
||||
0.06
|
||||
)
|
||||
const cursorTravelledDistance = distance(cachedCursor.x, cursor.x, cachedCursor.y, cursor.y);
|
||||
imgValues.displacementScale = lerp(imgValues.displacementScale, map(cursorTravelledDistance, 0, 200, 0, 400), 0.06);
|
||||
|
||||
if (displacementMapRef.value) {
|
||||
gsap.set(displacementMapRef.value, {
|
||||
attr: { scale: imgValues.displacementScale }
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
cachedCursor = { ...cursor }
|
||||
cachedCursor = { ...cursor };
|
||||
|
||||
animationFrameId = requestAnimationFrame(render)
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
render()
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
render();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue'
|
||||
import { useMotionValue, useSpring, useTransform, type SpringOptions } from 'motion-v'
|
||||
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue';
|
||||
import { useMotionValue, useSpring, useTransform, type SpringOptions } from 'motion-v';
|
||||
|
||||
export type DockItemData = {
|
||||
icon: unknown
|
||||
label: unknown
|
||||
onClick: () => void
|
||||
className?: string
|
||||
}
|
||||
icon: unknown;
|
||||
label: unknown;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type DockProps = {
|
||||
items: DockItemData[]
|
||||
className?: string
|
||||
distance?: number
|
||||
panelHeight?: number
|
||||
baseItemSize?: number
|
||||
dockHeight?: number
|
||||
magnification?: number
|
||||
spring?: SpringOptions
|
||||
}
|
||||
items: DockItemData[];
|
||||
className?: string;
|
||||
distance?: number;
|
||||
panelHeight?: number;
|
||||
baseItemSize?: number;
|
||||
dockHeight?: number;
|
||||
magnification?: number;
|
||||
spring?: SpringOptions;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<DockProps>(), {
|
||||
className: '',
|
||||
@@ -28,52 +28,64 @@ const props = withDefaults(defineProps<DockProps>(), {
|
||||
dockHeight: 256,
|
||||
magnification: 70,
|
||||
spring: () => ({ mass: 0.1, stiffness: 150, damping: 12 })
|
||||
})
|
||||
});
|
||||
|
||||
const mouseX = useMotionValue(Infinity)
|
||||
const isHovered = useMotionValue(0)
|
||||
const currentHeight = ref(props.panelHeight)
|
||||
const mouseX = useMotionValue(Infinity);
|
||||
const isHovered = useMotionValue(0);
|
||||
const currentHeight = ref(props.panelHeight);
|
||||
|
||||
const maxHeight = computed(() =>
|
||||
Math.max(props.dockHeight, props.magnification + props.magnification / 2 + 4)
|
||||
)
|
||||
const maxHeight = computed(() => Math.max(props.dockHeight, props.magnification + props.magnification / 2 + 4));
|
||||
|
||||
const heightRow = useTransform(isHovered, [0, 1], [props.panelHeight, maxHeight.value])
|
||||
const height = useSpring(heightRow, props.spring)
|
||||
const heightRow = useTransform(isHovered, [0, 1], [props.panelHeight, maxHeight.value]);
|
||||
const height = useSpring(heightRow, props.spring);
|
||||
|
||||
let unsubscribeHeight: (() => void) | null = null
|
||||
let unsubscribeHeight: (() => void) | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribeHeight = height.on('change', (latest: number) => {
|
||||
currentHeight.value = latest
|
||||
})
|
||||
})
|
||||
currentHeight.value = latest;
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeHeight) {
|
||||
unsubscribeHeight()
|
||||
unsubscribeHeight();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
isHovered.set(1)
|
||||
mouseX.set(event.pageX)
|
||||
}
|
||||
isHovered.set(1);
|
||||
mouseX.set(event.pageX);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.set(0)
|
||||
mouseX.set(Infinity)
|
||||
}
|
||||
isHovered.set(0);
|
||||
mouseX.set(Infinity);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ height: currentHeight + 'px', scrollbarWidth: 'none' }" class="mx-2 flex max-w-full items-center">
|
||||
<div @mousemove="handleMouseMove" @mouseleave="handleMouseLeave"
|
||||
<div
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseleave="handleMouseLeave"
|
||||
:class="`${props.className} absolute bottom-2 left-1/2 transform -translate-x-1/2 flex items-end w-fit gap-4 rounded-2xl border-neutral-700 border-2 pb-2 px-4`"
|
||||
:style="{ height: props.panelHeight + 'px' }" role="toolbar" aria-="Application dock">
|
||||
<DockItem v-for="(item, index) in props.items" :key="index" :onClick="item.onClick" :className="item.className"
|
||||
:mouseX="mouseX" :spring="props.spring" :distance="props.distance" :magnification="props.magnification"
|
||||
:baseItemSize="props.baseItemSize" :item="item" />
|
||||
:style="{ height: props.panelHeight + 'px' }"
|
||||
role="toolbar"
|
||||
aria-="Application dock"
|
||||
>
|
||||
<DockItem
|
||||
v-for="(item, index) in props.items"
|
||||
:key="index"
|
||||
:onClick="item.onClick"
|
||||
:className="item.className"
|
||||
:mouseX="mouseX"
|
||||
:spring="props.spring"
|
||||
:distance="props.distance"
|
||||
:magnification="props.magnification"
|
||||
:baseItemSize="props.baseItemSize"
|
||||
:item="item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -88,7 +100,7 @@ const DockItem = defineComponent({
|
||||
},
|
||||
onClick: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
default: () => {}
|
||||
},
|
||||
mouseX: {
|
||||
type: Object as () => ReturnType<typeof useMotionValue<number>>,
|
||||
@@ -116,43 +128,43 @@ const DockItem = defineComponent({
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const itemRef = ref<HTMLDivElement>()
|
||||
const isHovered = useMotionValue(0)
|
||||
const currentSize = ref(props.baseItemSize)
|
||||
const itemRef = ref<HTMLDivElement>();
|
||||
const isHovered = useMotionValue(0);
|
||||
const currentSize = ref(props.baseItemSize);
|
||||
|
||||
const mouseDistance = useTransform(props.mouseX, (val: number) => {
|
||||
const rect = itemRef.value?.getBoundingClientRect() ?? {
|
||||
x: 0,
|
||||
width: props.baseItemSize,
|
||||
}
|
||||
return val - rect.x - props.baseItemSize / 2
|
||||
})
|
||||
width: props.baseItemSize
|
||||
};
|
||||
return val - rect.x - props.baseItemSize / 2;
|
||||
});
|
||||
|
||||
const targetSize = useTransform(
|
||||
mouseDistance,
|
||||
[-props.distance, 0, props.distance],
|
||||
[props.baseItemSize, props.magnification, props.baseItemSize]
|
||||
)
|
||||
const size = useSpring(targetSize, props.spring)
|
||||
);
|
||||
const size = useSpring(targetSize, props.spring);
|
||||
|
||||
let unsubscribeSize: (() => void) | null = null
|
||||
let unsubscribeSize: (() => void) | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribeSize = size.on('change', (latest: number) => {
|
||||
currentSize.value = latest
|
||||
})
|
||||
})
|
||||
currentSize.value = latest;
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeSize) {
|
||||
unsubscribeSize()
|
||||
unsubscribeSize();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handleHoverStart = () => isHovered.set(1)
|
||||
const handleHoverEnd = () => isHovered.set(0)
|
||||
const handleFocus = () => isHovered.set(1)
|
||||
const handleBlur = () => isHovered.set(0)
|
||||
const handleHoverStart = () => isHovered.set(1);
|
||||
const handleHoverEnd = () => isHovered.set(0);
|
||||
const handleFocus = () => isHovered.set(1);
|
||||
const handleBlur = () => isHovered.set(0);
|
||||
|
||||
return {
|
||||
itemRef,
|
||||
@@ -163,33 +175,37 @@ const DockItem = defineComponent({
|
||||
handleHoverEnd,
|
||||
handleFocus,
|
||||
handleBlur
|
||||
}
|
||||
};
|
||||
},
|
||||
render() {
|
||||
const icon = typeof this.item.icon === 'function' ? this.item.icon() : this.item.icon
|
||||
const label = typeof this.item.label === 'function' ? this.item.label() : this.item.label
|
||||
const icon = typeof this.item.icon === 'function' ? this.item.icon() : this.item.icon;
|
||||
const label = typeof this.item.label === 'function' ? this.item.label() : this.item.label;
|
||||
|
||||
return h('div', {
|
||||
ref: 'itemRef',
|
||||
style: {
|
||||
width: this.currentSize + 'px',
|
||||
height: this.currentSize + 'px',
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
ref: 'itemRef',
|
||||
style: {
|
||||
width: this.currentSize + 'px',
|
||||
height: this.currentSize + 'px'
|
||||
},
|
||||
onMouseenter: this.handleHoverStart,
|
||||
onMouseleave: this.handleHoverEnd,
|
||||
onFocus: this.handleFocus,
|
||||
onBlur: this.handleBlur,
|
||||
onClick: this.onClick,
|
||||
class: `relative cursor-pointer inline-flex items-center justify-center rounded-full bg-[#111] border-neutral-700 border-2 shadow-md ${this.className}`,
|
||||
tabindex: 0,
|
||||
role: 'button',
|
||||
'aria-haspopup': 'true'
|
||||
},
|
||||
onMouseenter: this.handleHoverStart,
|
||||
onMouseleave: this.handleHoverEnd,
|
||||
onFocus: this.handleFocus,
|
||||
onBlur: this.handleBlur,
|
||||
onClick: this.onClick,
|
||||
class: `relative cursor-pointer inline-flex items-center justify-center rounded-full bg-[#111] border-neutral-700 border-2 shadow-md ${this.className}`,
|
||||
tabindex: 0,
|
||||
role: 'button',
|
||||
'aria-haspopup': 'true'
|
||||
}, [
|
||||
h(DockIcon, {}, () => [icon]),
|
||||
h(DockLabel, { isHovered: this.isHovered }, () => [typeof label === 'string' ? label : label])
|
||||
])
|
||||
[
|
||||
h(DockIcon, {}, () => [icon]),
|
||||
h(DockLabel, { isHovered: this.isHovered }, () => [typeof label === 'string' ? label : label])
|
||||
]
|
||||
);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const DockLabel = defineComponent({
|
||||
name: 'DockLabel',
|
||||
@@ -204,38 +220,42 @@ const DockLabel = defineComponent({
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const isVisible = ref(false)
|
||||
const isVisible = ref(false);
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribe = props.isHovered.on('change', (latest: number) => {
|
||||
isVisible.value = latest === 1
|
||||
})
|
||||
})
|
||||
isVisible.value = latest === 1;
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
unsubscribe();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
isVisible
|
||||
}
|
||||
};
|
||||
},
|
||||
render() {
|
||||
return h('div', {
|
||||
class: `${this.className} absolute -top-8 left-1/2 w-fit whitespace-pre rounded-md border border-neutral-700 bg-[#111] px-2 py-0.5 text-xs text-white transition-all duration-200`,
|
||||
role: 'tooltip',
|
||||
style: {
|
||||
transform: 'translateX(-50%)',
|
||||
opacity: this.isVisible ? 1 : 0,
|
||||
visibility: this.isVisible ? 'visible' : 'hidden'
|
||||
}
|
||||
}, this.$slots.default?.())
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: `${this.className} absolute -top-8 left-1/2 w-fit whitespace-pre rounded-md border border-neutral-700 bg-[#111] px-2 py-0.5 text-xs text-white transition-all duration-200`,
|
||||
role: 'tooltip',
|
||||
style: {
|
||||
transform: 'translateX(-50%)',
|
||||
opacity: this.isVisible ? 1 : 0,
|
||||
visibility: this.isVisible ? 'visible' : 'hidden'
|
||||
}
|
||||
},
|
||||
this.$slots.default?.()
|
||||
);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const DockIcon = defineComponent({
|
||||
name: 'DockIcon',
|
||||
@@ -246,16 +266,20 @@ const DockIcon = defineComponent({
|
||||
}
|
||||
},
|
||||
render() {
|
||||
return h('div', {
|
||||
class: `flex items-center justify-center ${this.className}`
|
||||
}, this.$slots.default?.())
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: `flex items-center justify-center ${this.className}`
|
||||
},
|
||||
this.$slots.default?.()
|
||||
);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Dock',
|
||||
components: {
|
||||
DockItem
|
||||
}
|
||||
})
|
||||
</script>
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,67 +1,90 @@
|
||||
<template>
|
||||
<div :class="`flex flex-col items-center justify-center gap-4 w-48 ${className}`">
|
||||
<div class="flex w-full touch-none select-none items-center justify-center gap-4" :style="{
|
||||
scale: scale,
|
||||
opacity: sliderOpacity
|
||||
}" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" @touchstart="handleTouchStart"
|
||||
@touchend="handleTouchEnd">
|
||||
<div ref="leftIconRef" :style="{
|
||||
transform: `translateX(${leftIconTranslateX}px) scale(${leftIconScale})`,
|
||||
}" class="transition-transform duration-200 ease-out">
|
||||
<div
|
||||
class="flex w-full touch-none select-none items-center justify-center gap-4"
|
||||
:style="{
|
||||
scale: scale,
|
||||
opacity: sliderOpacity
|
||||
}"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchend="handleTouchEnd"
|
||||
>
|
||||
<div
|
||||
ref="leftIconRef"
|
||||
:style="{
|
||||
transform: `translateX(${leftIconTranslateX}px) scale(${leftIconScale})`
|
||||
}"
|
||||
class="transition-transform duration-200 ease-out"
|
||||
>
|
||||
<slot name="left-icon">
|
||||
<component :is="leftIcon" v-if="leftIcon && typeof leftIcon === 'object'" />
|
||||
|
||||
<span v-else-if="leftIcon">{{ leftIcon }}</span>
|
||||
|
||||
<span v-else>-</span>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div ref="sliderRef"
|
||||
<div
|
||||
ref="sliderRef"
|
||||
class="relative flex w-full max-w-xs flex-grow cursor-grab touch-none select-none items-center py-4"
|
||||
@pointermove="handlePointerMove" @pointerdown="handlePointerDown" @pointerup="handlePointerUp">
|
||||
<div :style="{
|
||||
transform: `scaleX(${sliderScaleX}) scaleY(${sliderScaleY})`,
|
||||
transformOrigin: transformOrigin,
|
||||
height: `${sliderHeight}px`,
|
||||
marginTop: `${sliderMarginTop}px`,
|
||||
marginBottom: `${sliderMarginBottom}px`,
|
||||
}" class="flex flex-grow">
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointerup="handlePointerUp"
|
||||
>
|
||||
<div
|
||||
:style="{
|
||||
transform: `scaleX(${sliderScaleX}) scaleY(${sliderScaleY})`,
|
||||
transformOrigin: transformOrigin,
|
||||
height: `${sliderHeight}px`,
|
||||
marginTop: `${sliderMarginTop}px`,
|
||||
marginBottom: `${sliderMarginBottom}px`
|
||||
}"
|
||||
class="flex flex-grow"
|
||||
>
|
||||
<div class="relative h-full flex-grow overflow-hidden rounded-full bg-gray-400">
|
||||
<div class="absolute h-full bg-[#27FF64] rounded-full" :style="{ width: `${rangePercentage}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="rightIconRef" :style="{
|
||||
transform: `translateX(${rightIconTranslateX}px) scale(${rightIconScale})`,
|
||||
}" class="transition-transform duration-200 ease-out">
|
||||
<div
|
||||
ref="rightIconRef"
|
||||
:style="{
|
||||
transform: `translateX(${rightIconTranslateX}px) scale(${rightIconScale})`
|
||||
}"
|
||||
class="transition-transform duration-200 ease-out"
|
||||
>
|
||||
<slot name="right-icon">
|
||||
<component :is="rightIcon" v-if="rightIcon && typeof rightIcon === 'object'" />
|
||||
|
||||
<span v-else-if="rightIcon">{{ rightIcon }}</span>
|
||||
|
||||
<span v-else>+</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="absolute text-gray-400 transform -translate-y-6 font-medium tracking-wide">
|
||||
{{ Math.round(value) }}
|
||||
</p>
|
||||
<p class="absolute text-gray-400 transform -translate-y-6 font-medium tracking-wide">{{ Math.round(value) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, type Component } from 'vue'
|
||||
import { ref, computed, watch, onMounted, type Component } from 'vue';
|
||||
|
||||
const MAX_OVERFLOW = 50
|
||||
const MAX_OVERFLOW = 50;
|
||||
|
||||
interface Props {
|
||||
defaultValue?: number
|
||||
startingValue?: number
|
||||
maxValue?: number
|
||||
className?: string
|
||||
isStepped?: boolean
|
||||
stepSize?: number
|
||||
leftIcon?: Component | string
|
||||
rightIcon?: Component | string
|
||||
defaultValue?: number;
|
||||
startingValue?: number;
|
||||
maxValue?: number;
|
||||
className?: string;
|
||||
isStepped?: boolean;
|
||||
stepSize?: number;
|
||||
leftIcon?: Component | string;
|
||||
rightIcon?: Component | string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -73,256 +96,263 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
stepSize: 1,
|
||||
leftIcon: '-',
|
||||
rightIcon: '+'
|
||||
})
|
||||
});
|
||||
|
||||
const sliderRef = ref<HTMLDivElement>()
|
||||
const leftIconRef = ref<HTMLDivElement>()
|
||||
const rightIconRef = ref<HTMLDivElement>()
|
||||
const sliderRef = ref<HTMLDivElement>();
|
||||
const leftIconRef = ref<HTMLDivElement>();
|
||||
const rightIconRef = ref<HTMLDivElement>();
|
||||
|
||||
const value = ref(props.defaultValue)
|
||||
const region = ref<'left' | 'middle' | 'right'>('middle')
|
||||
const clientX = ref(0)
|
||||
const overflow = ref(0)
|
||||
const scale = ref(1)
|
||||
const leftIconScale = ref(1)
|
||||
const rightIconScale = ref(1)
|
||||
const value = ref(props.defaultValue);
|
||||
const region = ref<'left' | 'middle' | 'right'>('middle');
|
||||
const clientX = ref(0);
|
||||
const overflow = ref(0);
|
||||
const scale = ref(1);
|
||||
const leftIconScale = ref(1);
|
||||
const rightIconScale = ref(1);
|
||||
|
||||
let scaleAnimation: number | null = null
|
||||
let overflowAnimation: number | null = null
|
||||
let scaleAnimation: number | null = null;
|
||||
let overflowAnimation: number | null = null;
|
||||
|
||||
watch(() => props.defaultValue, (newValue) => {
|
||||
value.value = newValue
|
||||
})
|
||||
|
||||
watch(clientX, (latest) => {
|
||||
if (sliderRef.value) {
|
||||
const { left, right } = sliderRef.value.getBoundingClientRect()
|
||||
let newValue: number
|
||||
if (latest < left) {
|
||||
region.value = 'left'
|
||||
newValue = left - latest
|
||||
} else if (latest > right) {
|
||||
region.value = 'right'
|
||||
newValue = latest - right
|
||||
} else {
|
||||
region.value = 'middle'
|
||||
newValue = 0
|
||||
}
|
||||
overflow.value = decay(newValue, MAX_OVERFLOW)
|
||||
watch(
|
||||
() => props.defaultValue,
|
||||
newValue => {
|
||||
value.value = newValue;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
watch(clientX, latest => {
|
||||
if (sliderRef.value) {
|
||||
const { left, right } = sliderRef.value.getBoundingClientRect();
|
||||
let newValue: number;
|
||||
if (latest < left) {
|
||||
region.value = 'left';
|
||||
newValue = left - latest;
|
||||
} else if (latest > right) {
|
||||
region.value = 'right';
|
||||
newValue = latest - right;
|
||||
} else {
|
||||
region.value = 'middle';
|
||||
newValue = 0;
|
||||
}
|
||||
overflow.value = decay(newValue, MAX_OVERFLOW);
|
||||
}
|
||||
});
|
||||
|
||||
const rangePercentage = computed(() => {
|
||||
const totalRange = props.maxValue - props.startingValue
|
||||
if (totalRange === 0) return 0
|
||||
return ((value.value - props.startingValue) / totalRange) * 100
|
||||
})
|
||||
const totalRange = props.maxValue - props.startingValue;
|
||||
if (totalRange === 0) return 0;
|
||||
return ((value.value - props.startingValue) / totalRange) * 100;
|
||||
});
|
||||
|
||||
const sliderScaleX = computed(() => {
|
||||
if (!sliderRef.value) return 1
|
||||
const { width } = sliderRef.value.getBoundingClientRect()
|
||||
return 1 + overflow.value / width
|
||||
})
|
||||
if (!sliderRef.value) return 1;
|
||||
const { width } = sliderRef.value.getBoundingClientRect();
|
||||
return 1 + overflow.value / width;
|
||||
});
|
||||
|
||||
const sliderScaleY = computed(() => {
|
||||
const t = overflow.value / MAX_OVERFLOW
|
||||
return 1 + t * (0.8 - 1)
|
||||
})
|
||||
const t = overflow.value / MAX_OVERFLOW;
|
||||
return 1 + t * (0.8 - 1);
|
||||
});
|
||||
|
||||
const transformOrigin = computed(() => {
|
||||
if (!sliderRef.value) return 'center'
|
||||
const { left, width } = sliderRef.value.getBoundingClientRect()
|
||||
return clientX.value < left + width / 2 ? 'right' : 'left'
|
||||
})
|
||||
if (!sliderRef.value) return 'center';
|
||||
const { left, width } = sliderRef.value.getBoundingClientRect();
|
||||
return clientX.value < left + width / 2 ? 'right' : 'left';
|
||||
});
|
||||
|
||||
const sliderHeight = computed(() => {
|
||||
const t = (scale.value - 1) / (1.2 - 1)
|
||||
return 6 + t * (12 - 6)
|
||||
})
|
||||
const t = (scale.value - 1) / (1.2 - 1);
|
||||
return 6 + t * (12 - 6);
|
||||
});
|
||||
|
||||
const sliderMarginTop = computed(() => {
|
||||
const t = (scale.value - 1) / (1.2 - 1)
|
||||
return 0 + t * (-3 - 0)
|
||||
})
|
||||
const t = (scale.value - 1) / (1.2 - 1);
|
||||
return 0 + t * (-3 - 0);
|
||||
});
|
||||
|
||||
const sliderMarginBottom = computed(() => {
|
||||
const t = (scale.value - 1) / (1.2 - 1)
|
||||
return 0 + t * (-3 - 0)
|
||||
})
|
||||
const t = (scale.value - 1) / (1.2 - 1);
|
||||
return 0 + t * (-3 - 0);
|
||||
});
|
||||
|
||||
const sliderOpacity = computed(() => {
|
||||
const t = (scale.value - 1) / (1.2 - 1)
|
||||
return 0.7 + t * (1 - 0.7)
|
||||
})
|
||||
const t = (scale.value - 1) / (1.2 - 1);
|
||||
return 0.7 + t * (1 - 0.7);
|
||||
});
|
||||
|
||||
const leftIconTranslateX = computed(() => {
|
||||
return region.value === 'left' ? -overflow.value / scale.value : 0
|
||||
})
|
||||
return region.value === 'left' ? -overflow.value / scale.value : 0;
|
||||
});
|
||||
|
||||
const rightIconTranslateX = computed(() => {
|
||||
return region.value === 'right' ? overflow.value / scale.value : 0
|
||||
})
|
||||
return region.value === 'right' ? overflow.value / scale.value : 0;
|
||||
});
|
||||
|
||||
const decay = (inputValue: number, max: number): number => {
|
||||
if (max === 0) return 0
|
||||
const entry = inputValue / max
|
||||
const sigmoid = 2 * (1 / (1 + Math.exp(-entry)) - 0.5)
|
||||
return sigmoid * max
|
||||
}
|
||||
if (max === 0) return 0;
|
||||
const entry = inputValue / max;
|
||||
const sigmoid = 2 * (1 / (1 + Math.exp(-entry)) - 0.5);
|
||||
return sigmoid * max;
|
||||
};
|
||||
|
||||
const animate = (target: { value: number }, to: number, options: { type?: string; bounce?: number; duration?: number } = {}) => {
|
||||
const { type = 'tween', bounce = 0, duration = 0.3 } = options
|
||||
const animate = (
|
||||
target: { value: number },
|
||||
to: number,
|
||||
options: { type?: string; bounce?: number; duration?: number } = {}
|
||||
) => {
|
||||
const { type = 'tween', bounce = 0, duration = 0.3 } = options;
|
||||
|
||||
if (type === 'spring') {
|
||||
return animateSpring(target, to, bounce, duration)
|
||||
return animateSpring(target, to, bounce, duration);
|
||||
} else {
|
||||
return animateValue(target, to, duration)
|
||||
return animateValue(target, to, duration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const animateValue = (target: { value: number }, to: number, duration = 300) => {
|
||||
const start = target.value
|
||||
const diff = to - start
|
||||
const startTime = performance.now()
|
||||
const start = target.value;
|
||||
const diff = to - start;
|
||||
const startTime = performance.now();
|
||||
|
||||
const animateFrame = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const easeOut = 1 - Math.pow(1 - progress, 3)
|
||||
target.value = start + diff * easeOut
|
||||
const easeOut = 1 - Math.pow(1 - progress, 3);
|
||||
target.value = start + diff * easeOut;
|
||||
|
||||
if (progress < 1) {
|
||||
return requestAnimationFrame(animateFrame)
|
||||
return requestAnimationFrame(animateFrame);
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return requestAnimationFrame(animateFrame)
|
||||
}
|
||||
return requestAnimationFrame(animateFrame);
|
||||
};
|
||||
|
||||
const animateSpring = (target: { value: number }, to: number, bounce = 0.5, duration = 600) => {
|
||||
const start = target.value
|
||||
const startTime = performance.now()
|
||||
const start = target.value;
|
||||
const startTime = performance.now();
|
||||
|
||||
const mass = 1
|
||||
const stiffness = 170
|
||||
const damping = 26 * (1 - bounce)
|
||||
const mass = 1;
|
||||
const stiffness = 170;
|
||||
const damping = 26 * (1 - bounce);
|
||||
|
||||
const dampingRatio = damping / (2 * Math.sqrt(mass * stiffness))
|
||||
const angularFreq = Math.sqrt(stiffness / mass)
|
||||
const dampedFreq = angularFreq * Math.sqrt(1 - dampingRatio * dampingRatio)
|
||||
const dampingRatio = damping / (2 * Math.sqrt(mass * stiffness));
|
||||
const angularFreq = Math.sqrt(stiffness / mass);
|
||||
const dampedFreq = angularFreq * Math.sqrt(1 - dampingRatio * dampingRatio);
|
||||
|
||||
const animateFrame = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const t = elapsed / 1000
|
||||
const elapsed = currentTime - startTime;
|
||||
const t = elapsed / 1000;
|
||||
|
||||
let displacement: number
|
||||
let displacement: number;
|
||||
|
||||
if (dampingRatio < 1) {
|
||||
const envelope = Math.exp(-dampingRatio * angularFreq * t)
|
||||
const cos = Math.cos(dampedFreq * t)
|
||||
const sin = Math.sin(dampedFreq * t)
|
||||
const envelope = Math.exp(-dampingRatio * angularFreq * t);
|
||||
const cos = Math.cos(dampedFreq * t);
|
||||
const sin = Math.sin(dampedFreq * t);
|
||||
|
||||
displacement = envelope * (cos + (dampingRatio * angularFreq / dampedFreq) * sin)
|
||||
displacement = envelope * (cos + ((dampingRatio * angularFreq) / dampedFreq) * sin);
|
||||
} else {
|
||||
displacement = Math.exp(-angularFreq * t)
|
||||
displacement = Math.exp(-angularFreq * t);
|
||||
}
|
||||
|
||||
const currentValue = to + (start - to) * displacement
|
||||
target.value = currentValue
|
||||
const currentValue = to + (start - to) * displacement;
|
||||
target.value = currentValue;
|
||||
|
||||
const velocity = Math.abs(currentValue - to)
|
||||
const isSettled = velocity < 0.01 && elapsed > 100
|
||||
const velocity = Math.abs(currentValue - to);
|
||||
const isSettled = velocity < 0.01 && elapsed > 100;
|
||||
|
||||
if (!isSettled && elapsed < duration * 3) {
|
||||
return requestAnimationFrame(animateFrame)
|
||||
return requestAnimationFrame(animateFrame);
|
||||
} else {
|
||||
target.value = to
|
||||
return null
|
||||
target.value = to;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return requestAnimationFrame(animateFrame)
|
||||
}
|
||||
return requestAnimationFrame(animateFrame);
|
||||
};
|
||||
|
||||
const animateIconScale = (target: { value: number }, isActive: boolean) => {
|
||||
if (isActive) {
|
||||
animate(target, 1.4, { duration: 125 })
|
||||
animate(target, 1.4, { duration: 125 });
|
||||
setTimeout(() => {
|
||||
animate(target, 1, { duration: 125 })
|
||||
}, 125)
|
||||
animate(target, 1, { duration: 125 });
|
||||
}, 125);
|
||||
} else {
|
||||
animate(target, 1, { duration: 250 })
|
||||
animate(target, 1, { duration: 250 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(region, (newRegion, oldRegion) => {
|
||||
if (newRegion === 'left' && oldRegion !== 'left') {
|
||||
animateIconScale(leftIconScale, true)
|
||||
animateIconScale(leftIconScale, true);
|
||||
} else if (newRegion === 'right' && oldRegion !== 'right') {
|
||||
animateIconScale(rightIconScale, true)
|
||||
animateIconScale(rightIconScale, true);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (e.buttons > 0 && sliderRef.value) {
|
||||
const { left, width } = sliderRef.value.getBoundingClientRect()
|
||||
const { left, width } = sliderRef.value.getBoundingClientRect();
|
||||
|
||||
let newValue = props.startingValue + ((e.clientX - left) / width) * (props.maxValue - props.startingValue)
|
||||
let newValue = props.startingValue + ((e.clientX - left) / width) * (props.maxValue - props.startingValue);
|
||||
|
||||
if (props.isStepped) {
|
||||
newValue = Math.round(newValue / props.stepSize) * props.stepSize
|
||||
newValue = Math.round(newValue / props.stepSize) * props.stepSize;
|
||||
}
|
||||
|
||||
newValue = Math.min(Math.max(newValue, props.startingValue), props.maxValue)
|
||||
value.value = newValue
|
||||
newValue = Math.min(Math.max(newValue, props.startingValue), props.maxValue);
|
||||
value.value = newValue;
|
||||
|
||||
clientX.value = e.clientX
|
||||
clientX.value = e.clientX;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
handlePointerMove(e)
|
||||
; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}
|
||||
handlePointerMove(e);
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (overflowAnimation) {
|
||||
cancelAnimationFrame(overflowAnimation)
|
||||
cancelAnimationFrame(overflowAnimation);
|
||||
}
|
||||
overflowAnimation = animate(overflow, 0, { type: 'spring', bounce: 0.4, duration: 500 })
|
||||
}
|
||||
overflowAnimation = animate(overflow, 0, { type: 'spring', bounce: 0.4, duration: 500 });
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (scaleAnimation) {
|
||||
cancelAnimationFrame(scaleAnimation)
|
||||
cancelAnimationFrame(scaleAnimation);
|
||||
}
|
||||
scaleAnimation = animate(scale, 1.2, { duration: 200 })
|
||||
}
|
||||
scaleAnimation = animate(scale, 1.2, { duration: 200 });
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (scaleAnimation) {
|
||||
cancelAnimationFrame(scaleAnimation)
|
||||
cancelAnimationFrame(scaleAnimation);
|
||||
}
|
||||
scaleAnimation = animate(scale, 1, { duration: 200 })
|
||||
}
|
||||
scaleAnimation = animate(scale, 1, { duration: 200 });
|
||||
};
|
||||
|
||||
const handleTouchStart = () => {
|
||||
if (scaleAnimation) {
|
||||
cancelAnimationFrame(scaleAnimation)
|
||||
cancelAnimationFrame(scaleAnimation);
|
||||
}
|
||||
scaleAnimation = animate(scale, 1.2, { duration: 200 })
|
||||
}
|
||||
scaleAnimation = animate(scale, 1.2, { duration: 200 });
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (scaleAnimation) {
|
||||
cancelAnimationFrame(scaleAnimation)
|
||||
cancelAnimationFrame(scaleAnimation);
|
||||
}
|
||||
scaleAnimation = animate(scale, 1, { duration: 200 })
|
||||
}
|
||||
scaleAnimation = animate(scale, 1, { duration: 200 });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
value.value = props.defaultValue
|
||||
})
|
||||
value.value = props.defaultValue;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden">
|
||||
<nav class="flex flex-col h-full m-0 p-0">
|
||||
<div v-for="(item, idx) in items" :key="idx"
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="flex-1 relative overflow-hidden text-center shadow-[0_-1px_0_0_#fff]"
|
||||
:ref="(el) => setItemRef(el as HTMLDivElement, idx)">
|
||||
<a class="flex items-center justify-center h-full relative cursor-pointer uppercase no-underline font-semibold text-white text-[4vh] hover:text-[#060010] focus:text-white focus-visible:text-[#060010]"
|
||||
:href="item.link" @mouseenter="(ev) => handleMouseEnter(ev, idx)"
|
||||
@mouseleave="(ev) => handleMouseLeave(ev, idx)">
|
||||
:ref="el => setItemRef(el as HTMLDivElement, idx)"
|
||||
>
|
||||
<a
|
||||
class="flex items-center justify-center h-full relative cursor-pointer uppercase no-underline font-semibold text-white text-[4vh] hover:text-[#060010] focus:text-white focus-visible:text-[#060010]"
|
||||
:href="item.link"
|
||||
@mouseenter="ev => handleMouseEnter(ev, idx)"
|
||||
@mouseleave="ev => handleMouseLeave(ev, idx)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
<div class="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none bg-white translate-y-[101%]"
|
||||
:ref="(el) => marqueeRefs[idx] = el as HTMLDivElement">
|
||||
<div class="h-full w-[200%] flex" :ref="(el) => marqueeInnerRefs[idx] = el as HTMLDivElement">
|
||||
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none bg-white translate-y-[101%]"
|
||||
:ref="el => (marqueeRefs[idx] = el as HTMLDivElement)"
|
||||
>
|
||||
<div class="h-full w-[200%] flex" :ref="el => (marqueeInnerRefs[idx] = el as HTMLDivElement)">
|
||||
<div class="flex items-center relative h-full w-[200%] will-change-transform animate-marquee">
|
||||
<template v-for="i in 4" :key="`${idx}-${i}`">
|
||||
<span class="text-[#060010] uppercase font-normal text-[4vh] leading-[1.2] p-[1vh_1vw_0]">
|
||||
{{ item.text }}
|
||||
</span>
|
||||
<div class="w-[200px] h-[7vh] my-[2em] mx-[2vw] p-[1em_0] rounded-[50px] bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${item.image})` }" />
|
||||
|
||||
<div
|
||||
class="w-[200px] h-[7vh] my-[2em] mx-[2vw] p-[1em_0] rounded-[50px] bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${item.image})` }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,89 +41,72 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
import { ref } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
interface MenuItemProps {
|
||||
link: string
|
||||
text: string
|
||||
image: string
|
||||
link: string;
|
||||
text: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items?: MenuItemProps[]
|
||||
items?: MenuItemProps[];
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
items: () => []
|
||||
})
|
||||
});
|
||||
|
||||
const itemRefs = ref<(HTMLDivElement | null)[]>([])
|
||||
const marqueeRefs = ref<(HTMLDivElement | null)[]>([])
|
||||
const marqueeInnerRefs = ref<(HTMLDivElement | null)[]>([])
|
||||
const itemRefs = ref<(HTMLDivElement | null)[]>([]);
|
||||
const marqueeRefs = ref<(HTMLDivElement | null)[]>([]);
|
||||
const marqueeInnerRefs = ref<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
const animationDefaults = { duration: 0.6, ease: 'expo' }
|
||||
const animationDefaults = { duration: 0.6, ease: 'expo' };
|
||||
|
||||
const setItemRef = (el: HTMLDivElement | null, idx: number) => {
|
||||
if (el) {
|
||||
itemRefs.value[idx] = el
|
||||
itemRefs.value[idx] = el;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const findClosestEdge = (
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
width: number,
|
||||
height: number
|
||||
): 'top' | 'bottom' => {
|
||||
const topEdgeDist = Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY, 2)
|
||||
const bottomEdgeDist =
|
||||
Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY - height, 2)
|
||||
return topEdgeDist < bottomEdgeDist ? 'top' : 'bottom'
|
||||
}
|
||||
const findClosestEdge = (mouseX: number, mouseY: number, width: number, height: number): 'top' | 'bottom' => {
|
||||
const topEdgeDist = Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY, 2);
|
||||
const bottomEdgeDist = Math.pow(mouseX - width / 2, 2) + Math.pow(mouseY - height, 2);
|
||||
return topEdgeDist < bottomEdgeDist ? 'top' : 'bottom';
|
||||
};
|
||||
|
||||
const handleMouseEnter = (ev: MouseEvent, idx: number) => {
|
||||
const itemRef = itemRefs.value[idx]
|
||||
const marqueeRef = marqueeRefs.value[idx]
|
||||
const marqueeInnerRef = marqueeInnerRefs.value[idx]
|
||||
const itemRef = itemRefs.value[idx];
|
||||
const marqueeRef = marqueeRefs.value[idx];
|
||||
const marqueeInnerRef = marqueeInnerRefs.value[idx];
|
||||
|
||||
if (!itemRef || !marqueeRef || !marqueeInnerRef) return
|
||||
if (!itemRef || !marqueeRef || !marqueeInnerRef) return;
|
||||
|
||||
const rect = itemRef.getBoundingClientRect()
|
||||
const edge = findClosestEdge(
|
||||
ev.clientX - rect.left,
|
||||
ev.clientY - rect.top,
|
||||
rect.width,
|
||||
rect.height
|
||||
)
|
||||
const rect = itemRef.getBoundingClientRect();
|
||||
const edge = findClosestEdge(ev.clientX - rect.left, ev.clientY - rect.top, rect.width, rect.height);
|
||||
|
||||
const tl = gsap.timeline({ defaults: animationDefaults })
|
||||
const tl = gsap.timeline({ defaults: animationDefaults });
|
||||
tl.set(marqueeRef, { y: edge === 'top' ? '-101%' : '101%' })
|
||||
.set(marqueeInnerRef, { y: edge === 'top' ? '101%' : '-101%' })
|
||||
.to([marqueeRef, marqueeInnerRef], { y: '0%' })
|
||||
}
|
||||
.to([marqueeRef, marqueeInnerRef], { y: '0%' });
|
||||
};
|
||||
|
||||
const handleMouseLeave = (ev: MouseEvent, idx: number) => {
|
||||
const itemRef = itemRefs.value[idx]
|
||||
const marqueeRef = marqueeRefs.value[idx]
|
||||
const marqueeInnerRef = marqueeInnerRefs.value[idx]
|
||||
const itemRef = itemRefs.value[idx];
|
||||
const marqueeRef = marqueeRefs.value[idx];
|
||||
const marqueeInnerRef = marqueeInnerRefs.value[idx];
|
||||
|
||||
if (!itemRef || !marqueeRef || !marqueeInnerRef) return
|
||||
if (!itemRef || !marqueeRef || !marqueeInnerRef) return;
|
||||
|
||||
const rect = itemRef.getBoundingClientRect()
|
||||
const edge = findClosestEdge(
|
||||
ev.clientX - rect.left,
|
||||
ev.clientY - rect.top,
|
||||
rect.width,
|
||||
rect.height
|
||||
)
|
||||
const rect = itemRef.getBoundingClientRect();
|
||||
const edge = findClosestEdge(ev.clientX - rect.left, ev.clientY - rect.top, rect.width, rect.height);
|
||||
|
||||
const tl = gsap.timeline({ defaults: animationDefaults })
|
||||
tl.to(marqueeRef, { y: edge === 'top' ? '-101%' : '101%' }).to(
|
||||
marqueeInnerRef,
|
||||
{ y: edge === 'top' ? '101%' : '-101%' }
|
||||
)
|
||||
}
|
||||
const tl = gsap.timeline({ defaults: animationDefaults });
|
||||
tl.to(marqueeRef, { y: edge === 'top' ? '-101%' : '101%' }).to(marqueeInnerRef, {
|
||||
y: edge === 'top' ? '101%' : '-101%'
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
<template>
|
||||
<div ref="containerRef" :class="[
|
||||
'w-full h-full overflow-hidden relative z-2',
|
||||
className
|
||||
]" v-bind="$attrs">
|
||||
<div ref="containerRef" :class="['w-full h-full overflow-hidden relative z-2', className]" v-bind="$attrs">
|
||||
<canvas ref="canvasRef" class="block w-full h-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Renderer,
|
||||
Camera,
|
||||
Transform,
|
||||
Plane,
|
||||
Program,
|
||||
Mesh,
|
||||
Texture,
|
||||
type OGLRenderingContext,
|
||||
} from "ogl";
|
||||
import { Renderer, Camera, Transform, Plane, Program, Mesh, Texture, type OGLRenderingContext } from 'ogl';
|
||||
|
||||
type GL = OGLRenderingContext;
|
||||
type OGLProgram = Program;
|
||||
@@ -177,18 +165,13 @@ function AutoBind(self: any, { include, exclude }: AutoBindOptions = {}) {
|
||||
for (const key of Reflect.ownKeys(currentObject)) {
|
||||
properties.add([currentObject, key]);
|
||||
}
|
||||
} while (
|
||||
(currentObject = Reflect.getPrototypeOf(currentObject)) &&
|
||||
currentObject !== Object.prototype
|
||||
);
|
||||
} while ((currentObject = Reflect.getPrototypeOf(currentObject)) && currentObject !== Object.prototype);
|
||||
return properties;
|
||||
};
|
||||
|
||||
const filter = (key: string | symbol) => {
|
||||
const match = (pattern: string | RegExp) =>
|
||||
typeof pattern === "string"
|
||||
? key === pattern
|
||||
: (pattern as RegExp).test(key.toString());
|
||||
typeof pattern === 'string' ? key === pattern : (pattern as RegExp).test(key.toString());
|
||||
|
||||
if (include) return include.some(match);
|
||||
if (exclude) return !exclude.some(match);
|
||||
@@ -196,9 +179,9 @@ function AutoBind(self: any, { include, exclude }: AutoBindOptions = {}) {
|
||||
};
|
||||
|
||||
for (const [object, key] of getAllProperties(self.constructor.prototype)) {
|
||||
if (key === "constructor" || !filter(key)) continue;
|
||||
if (key === 'constructor' || !filter(key)) continue;
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(object, key);
|
||||
if (descriptor && typeof descriptor.value === "function" && typeof key === "string") {
|
||||
if (descriptor && typeof descriptor.value === 'function' && typeof key === 'string') {
|
||||
self[key] = self[key].bind(self);
|
||||
}
|
||||
}
|
||||
@@ -209,14 +192,7 @@ function lerp(p1: number, p2: number, t: number): number {
|
||||
return p1 + (p2 - p1) * t;
|
||||
}
|
||||
|
||||
function map(
|
||||
num: number,
|
||||
min1: number,
|
||||
max1: number,
|
||||
min2: number,
|
||||
max2: number,
|
||||
round = false
|
||||
): number {
|
||||
function map(num: number, min1: number, max1: number, min2: number, max2: number, round = false): number {
|
||||
const num1 = (num - min1) / (max1 - min1);
|
||||
const num2 = num1 * (max2 - min2) + min2;
|
||||
return round ? Math.round(num2) : num2;
|
||||
@@ -254,7 +230,7 @@ class Media {
|
||||
index,
|
||||
planeWidth,
|
||||
planeHeight,
|
||||
distortion,
|
||||
distortion
|
||||
}: MediaParams) {
|
||||
this.gl = gl;
|
||||
this.geometry = geometry;
|
||||
@@ -290,54 +266,40 @@ class Media {
|
||||
distortionAxis: { value: [1, 1, 0] },
|
||||
uDistortion: { value: this.distortion },
|
||||
uViewportSize: { value: [this.viewport.width, this.viewport.height] },
|
||||
uTime: { value: 0 },
|
||||
uTime: { value: 0 }
|
||||
},
|
||||
cullFace: false,
|
||||
cullFace: false
|
||||
});
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = this.image;
|
||||
img.onload = () => {
|
||||
texture.image = img;
|
||||
this.program.uniforms.uImageSize.value = [
|
||||
img.naturalWidth,
|
||||
img.naturalHeight,
|
||||
];
|
||||
this.program.uniforms.uImageSize.value = [img.naturalWidth, img.naturalHeight];
|
||||
};
|
||||
}
|
||||
|
||||
createMesh() {
|
||||
this.plane = new Mesh(this.gl, {
|
||||
geometry: this.geometry,
|
||||
program: this.program,
|
||||
program: this.program
|
||||
});
|
||||
this.plane.setParent(this.scene);
|
||||
}
|
||||
|
||||
setScale() {
|
||||
this.plane.scale.x =
|
||||
(this.viewport.width * this.planeWidth) / this.screen.width;
|
||||
this.plane.scale.y =
|
||||
(this.viewport.height * this.planeHeight) / this.screen.height;
|
||||
this.plane.scale.x = (this.viewport.width * this.planeWidth) / this.screen.width;
|
||||
this.plane.scale.y = (this.viewport.height * this.planeHeight) / this.screen.height;
|
||||
this.plane.position.x = 0;
|
||||
this.program.uniforms.uPlaneSize.value = [
|
||||
this.plane.scale.x,
|
||||
this.plane.scale.y,
|
||||
];
|
||||
this.program.uniforms.uPlaneSize.value = [this.plane.scale.x, this.plane.scale.y];
|
||||
}
|
||||
|
||||
onResize({
|
||||
screen,
|
||||
viewport,
|
||||
}: { screen?: ScreenSize; viewport?: ViewportSize } = {}) {
|
||||
onResize({ screen, viewport }: { screen?: ScreenSize; viewport?: ViewportSize } = {}) {
|
||||
if (screen) this.screen = screen;
|
||||
if (viewport) {
|
||||
this.viewport = viewport;
|
||||
this.program.uniforms.uViewportSize.value = [
|
||||
viewport.width,
|
||||
viewport.height,
|
||||
];
|
||||
this.program.uniforms.uViewportSize.value = [viewport.width, viewport.height];
|
||||
}
|
||||
this.setScale();
|
||||
|
||||
@@ -349,13 +311,7 @@ class Media {
|
||||
|
||||
update(scroll: ScrollState) {
|
||||
this.plane.position.y = this.y - scroll.current - this.extra;
|
||||
const position = map(
|
||||
this.plane.position.y,
|
||||
-this.viewport.height,
|
||||
this.viewport.height,
|
||||
5,
|
||||
15
|
||||
);
|
||||
const position = map(this.plane.position.y, -this.viewport.height, this.viewport.height, 5, 15);
|
||||
|
||||
this.program.uniforms.uPosition.value = position;
|
||||
this.program.uniforms.uTime.value += 0.04;
|
||||
@@ -406,7 +362,7 @@ class Canvas {
|
||||
distortion,
|
||||
scrollEase,
|
||||
cameraFov,
|
||||
cameraZ,
|
||||
cameraZ
|
||||
}: CanvasParams) {
|
||||
this.container = container;
|
||||
this.canvas = canvas;
|
||||
@@ -418,7 +374,7 @@ class Canvas {
|
||||
ease: scrollEase,
|
||||
current: 0,
|
||||
target: 0,
|
||||
last: 0,
|
||||
last: 0
|
||||
};
|
||||
this.cameraFov = cameraFov;
|
||||
this.cameraZ = cameraZ;
|
||||
@@ -441,7 +397,7 @@ class Canvas {
|
||||
canvas: this.canvas,
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
dpr: Math.min(window.devicePixelRatio, 2),
|
||||
dpr: Math.min(window.devicePixelRatio, 2)
|
||||
});
|
||||
this.gl = this.renderer.gl;
|
||||
}
|
||||
@@ -459,7 +415,7 @@ class Canvas {
|
||||
createGeometry() {
|
||||
this.planeGeometry = new Plane(this.gl, {
|
||||
heightSegments: 1,
|
||||
widthSegments: 100,
|
||||
widthSegments: 100
|
||||
});
|
||||
}
|
||||
|
||||
@@ -477,7 +433,7 @@ class Canvas {
|
||||
index,
|
||||
planeWidth: this.planeWidth,
|
||||
planeHeight: this.planeHeight,
|
||||
distortion: this.distortion,
|
||||
distortion: this.distortion
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -493,14 +449,14 @@ class Canvas {
|
||||
|
||||
createPreloader() {
|
||||
this.loaded = 0;
|
||||
this.items.forEach((src) => {
|
||||
this.items.forEach(src => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.src = src;
|
||||
image.onload = () => {
|
||||
if (++this.loaded === this.items.length) {
|
||||
document.documentElement.classList.remove("loading");
|
||||
document.documentElement.classList.add("loaded");
|
||||
document.documentElement.classList.remove('loading');
|
||||
document.documentElement.classList.add('loaded');
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -512,7 +468,7 @@ class Canvas {
|
||||
this.renderer.setSize(this.screen.width, this.screen.height);
|
||||
|
||||
this.camera.perspective({
|
||||
aspect: this.gl.canvas.width / this.gl.canvas.height,
|
||||
aspect: this.gl.canvas.width / this.gl.canvas.height
|
||||
});
|
||||
|
||||
const fov = (this.camera.fov * Math.PI) / 180;
|
||||
@@ -520,9 +476,7 @@ class Canvas {
|
||||
const width = height * this.camera.aspect;
|
||||
this.viewport = { width, height };
|
||||
|
||||
this.medias?.forEach((media) =>
|
||||
media.onResize({ screen: this.screen, viewport: this.viewport })
|
||||
);
|
||||
this.medias?.forEach(media => media.onResize({ screen: this.screen, viewport: this.viewport }));
|
||||
}
|
||||
|
||||
onTouchDown(e: MouseEvent | TouchEvent) {
|
||||
@@ -547,37 +501,33 @@ class Canvas {
|
||||
}
|
||||
|
||||
update() {
|
||||
this.scroll.current = lerp(
|
||||
this.scroll.current,
|
||||
this.scroll.target,
|
||||
this.scroll.ease
|
||||
);
|
||||
this.medias?.forEach((media) => media.update(this.scroll));
|
||||
this.scroll.current = lerp(this.scroll.current, this.scroll.target, this.scroll.ease);
|
||||
this.medias?.forEach(media => media.update(this.scroll));
|
||||
this.renderer.render({ scene: this.scene, camera: this.camera });
|
||||
this.scroll.last = this.scroll.current;
|
||||
requestAnimationFrame(this.update);
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
window.addEventListener("resize", this.onResize);
|
||||
window.addEventListener("wheel", this.onWheel);
|
||||
window.addEventListener("mousedown", this.onTouchDown);
|
||||
window.addEventListener("mousemove", this.onTouchMove);
|
||||
window.addEventListener("mouseup", this.onTouchUp);
|
||||
window.addEventListener("touchstart", this.onTouchDown as EventListener);
|
||||
window.addEventListener("touchmove", this.onTouchMove as EventListener);
|
||||
window.addEventListener("touchend", this.onTouchUp as EventListener);
|
||||
window.addEventListener('resize', this.onResize);
|
||||
window.addEventListener('wheel', this.onWheel);
|
||||
window.addEventListener('mousedown', this.onTouchDown);
|
||||
window.addEventListener('mousemove', this.onTouchMove);
|
||||
window.addEventListener('mouseup', this.onTouchUp);
|
||||
window.addEventListener('touchstart', this.onTouchDown as EventListener);
|
||||
window.addEventListener('touchmove', this.onTouchMove as EventListener);
|
||||
window.addEventListener('touchend', this.onTouchUp as EventListener);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener("resize", this.onResize);
|
||||
window.removeEventListener("wheel", this.onWheel);
|
||||
window.removeEventListener("mousedown", this.onTouchDown);
|
||||
window.removeEventListener("mousemove", this.onTouchMove);
|
||||
window.removeEventListener("mouseup", this.onTouchUp);
|
||||
window.removeEventListener("touchstart", this.onTouchDown as EventListener);
|
||||
window.removeEventListener("touchmove", this.onTouchMove as EventListener);
|
||||
window.removeEventListener("touchend", this.onTouchUp as EventListener);
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
window.removeEventListener('wheel', this.onWheel);
|
||||
window.removeEventListener('mousedown', this.onTouchDown);
|
||||
window.removeEventListener('mousemove', this.onTouchMove);
|
||||
window.removeEventListener('mouseup', this.onTouchUp);
|
||||
window.removeEventListener('touchstart', this.onTouchDown as EventListener);
|
||||
window.removeEventListener('touchmove', this.onTouchMove as EventListener);
|
||||
window.removeEventListener('touchend', this.onTouchUp as EventListener);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,7 +546,7 @@ export { Canvas, Media };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<FlyingPostersProps>(), {
|
||||
items: () => [],
|
||||
@@ -606,15 +556,15 @@ const props = withDefaults(defineProps<FlyingPostersProps>(), {
|
||||
scrollEase: 0.01,
|
||||
cameraFov: 45,
|
||||
cameraZ: 20,
|
||||
className: '',
|
||||
})
|
||||
className: ''
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const instanceRef = ref<Canvas | null>(null)
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const canvasRef = ref<HTMLCanvasElement>();
|
||||
const instanceRef = ref<Canvas | null>(null);
|
||||
|
||||
const initCanvas = () => {
|
||||
if (!containerRef.value || !canvasRef.value) return
|
||||
if (!containerRef.value || !canvasRef.value) return;
|
||||
|
||||
instanceRef.value = new Canvas({
|
||||
container: containerRef.value,
|
||||
@@ -625,27 +575,27 @@ const initCanvas = () => {
|
||||
distortion: props.distortion,
|
||||
scrollEase: props.scrollEase,
|
||||
cameraFov: props.cameraFov,
|
||||
cameraZ: props.cameraZ,
|
||||
})
|
||||
}
|
||||
cameraZ: props.cameraZ
|
||||
});
|
||||
};
|
||||
|
||||
const destroyCanvas = () => {
|
||||
if (instanceRef.value) {
|
||||
instanceRef.value.destroy()
|
||||
instanceRef.value = null
|
||||
instanceRef.value.destroy();
|
||||
instanceRef.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
if (instanceRef.value) {
|
||||
instanceRef.value.onWheel(e)
|
||||
instanceRef.value.onWheel(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [
|
||||
@@ -655,32 +605,32 @@ watch(
|
||||
props.distortion,
|
||||
props.scrollEase,
|
||||
props.cameraFov,
|
||||
props.cameraZ,
|
||||
props.cameraZ
|
||||
],
|
||||
() => {
|
||||
destroyCanvas()
|
||||
initCanvas()
|
||||
destroyCanvas();
|
||||
initCanvas();
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas()
|
||||
initCanvas();
|
||||
|
||||
if (canvasRef.value) {
|
||||
const canvasEl = canvasRef.value
|
||||
canvasEl.addEventListener('wheel', handleWheel, { passive: false })
|
||||
canvasEl.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
const canvasEl = canvasRef.value;
|
||||
canvasEl.addEventListener('wheel', handleWheel, { passive: false });
|
||||
canvasEl.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyCanvas()
|
||||
destroyCanvas();
|
||||
|
||||
if (canvasRef.value) {
|
||||
const canvasEl = canvasRef.value
|
||||
canvasEl.removeEventListener('wheel', handleWheel)
|
||||
canvasEl.removeEventListener('touchmove', handleTouchMove)
|
||||
const canvasEl = canvasRef.value;
|
||||
canvasEl.removeEventListener('wheel', handleWheel);
|
||||
canvasEl.removeEventListener('touchmove', handleTouchMove);
|
||||
}
|
||||
})
|
||||
</script>
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'grid gap-[5em] grid-cols-2 md:grid-cols-3 mx-auto py-[3em] overflow-visible',
|
||||
className
|
||||
]"
|
||||
>
|
||||
<div :class="['grid gap-[5em] grid-cols-2 md:grid-cols-3 mx-auto py-[3em] overflow-visible', className]">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
@@ -29,15 +24,14 @@
|
||||
boxShadow: '0 0 0 0.1em hsla(0, 0%, 100%, 0.3) inset'
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="m-auto w-[1.5em] h-[1.5em] flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="m-auto w-[1.5em] h-[1.5em] flex items-center justify-center" aria-hidden="true">
|
||||
<i :class="item.icon" class="text-xl"></i>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="absolute top-full left-0 right-0 text-center whitespace-nowrap leading-[2] text-base opacity-0 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.83,0,0.17,1)] translate-y-0 group-hover:opacity-100 group-hover:[transform:translateY(20%)]">
|
||||
<span
|
||||
class="absolute top-full left-0 right-0 text-center whitespace-nowrap leading-[2] text-base opacity-0 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.83,0,0.17,1)] translate-y-0 group-hover:opacity-100 group-hover:[transform:translateY(20%)]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
@@ -46,21 +40,21 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
interface GlassIconsItem {
|
||||
icon: string
|
||||
color: string
|
||||
label: string
|
||||
customClass?: string
|
||||
icon: string;
|
||||
color: string;
|
||||
label: string;
|
||||
customClass?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: GlassIconsItem[]
|
||||
className?: string
|
||||
items: GlassIconsItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
items: () => [],
|
||||
className: ''
|
||||
})
|
||||
});
|
||||
|
||||
const gradientMapping: Record<string, string> = {
|
||||
blue: 'linear-gradient(hsl(223, 90%, 50%), hsl(208, 90%, 50%))',
|
||||
@@ -69,12 +63,12 @@ const gradientMapping: Record<string, string> = {
|
||||
indigo: 'linear-gradient(hsl(253, 90%, 50%), hsl(238, 90%, 50%))',
|
||||
orange: 'linear-gradient(hsl(43, 90%, 50%), hsl(28, 90%, 50%))',
|
||||
green: 'linear-gradient(hsl(123, 90%, 40%), hsl(108, 90%, 40%))'
|
||||
}
|
||||
};
|
||||
|
||||
const getBackgroundStyle = (color: string): Record<string, string> => {
|
||||
if (gradientMapping[color]) {
|
||||
return { background: gradientMapping[color] }
|
||||
return { background: gradientMapping[color] };
|
||||
}
|
||||
return { background: color }
|
||||
}
|
||||
return { background: color };
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative" ref="containerRef">
|
||||
<nav
|
||||
class="flex relative"
|
||||
:style="{ transform: 'translate3d(0,0,0.01px)' }"
|
||||
>
|
||||
<nav class="flex relative" :style="{ transform: 'translate3d(0,0,0.01px)' }">
|
||||
<ul
|
||||
ref="navRef"
|
||||
class="flex gap-8 list-none p-0 px-4 m-0 relative z-[3]"
|
||||
:style="{
|
||||
color: 'white',
|
||||
textShadow: '0 1px 1px hsl(205deg 30% 10% / 0.2)',
|
||||
textShadow: '0 1px 1px hsl(205deg 30% 10% / 0.2)'
|
||||
}"
|
||||
>
|
||||
<li
|
||||
@@ -23,8 +20,8 @@
|
||||
>
|
||||
<a
|
||||
:href="item.href || undefined"
|
||||
@click="(e) => handleClick(e, index)"
|
||||
@keydown="(e) => handleKeyDown(e, index)"
|
||||
@click="e => handleClick(e, index)"
|
||||
@keydown="e => handleKeyDown(e, index)"
|
||||
class="outline-none py-[0.6em] px-[1em] inline-block"
|
||||
>
|
||||
{{ item.label }}
|
||||
@@ -32,29 +29,31 @@
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<span class="effect filter" ref="filterRef" />
|
||||
|
||||
<span class="effect text" ref="textRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
interface GooeyNavItem {
|
||||
label: string
|
||||
href: string | null
|
||||
label: string;
|
||||
href: string | null;
|
||||
}
|
||||
|
||||
interface GooeyNavProps {
|
||||
items: GooeyNavItem[]
|
||||
animationTime?: number
|
||||
particleCount?: number
|
||||
particleDistances?: [number, number]
|
||||
particleR?: number
|
||||
timeVariance?: number
|
||||
colors?: number[]
|
||||
initialActiveIndex?: number
|
||||
items: GooeyNavItem[];
|
||||
animationTime?: number;
|
||||
particleCount?: number;
|
||||
particleDistances?: [number, number];
|
||||
particleR?: number;
|
||||
timeVariance?: number;
|
||||
colors?: number[];
|
||||
initialActiveIndex?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<GooeyNavProps>(), {
|
||||
@@ -64,172 +63,181 @@ const props = withDefaults(defineProps<GooeyNavProps>(), {
|
||||
particleR: 100,
|
||||
timeVariance: 300,
|
||||
colors: () => [1, 2, 3, 1, 2, 3, 1, 4],
|
||||
initialActiveIndex: 0,
|
||||
})
|
||||
initialActiveIndex: 0
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const navRef = ref<HTMLUListElement>()
|
||||
const filterRef = ref<HTMLSpanElement>()
|
||||
const textRef = ref<HTMLSpanElement>()
|
||||
const activeIndex = ref<number>(props.initialActiveIndex)
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const navRef = ref<HTMLUListElement>();
|
||||
const filterRef = ref<HTMLSpanElement>();
|
||||
const textRef = ref<HTMLSpanElement>();
|
||||
const activeIndex = ref<number>(props.initialActiveIndex);
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const noise = (n = 1) => n / 2 - Math.random() * n
|
||||
const noise = (n = 1) => n / 2 - Math.random() * n;
|
||||
|
||||
const getXY = (
|
||||
distance: number,
|
||||
pointIndex: number,
|
||||
totalPoints: number
|
||||
): [number, number] => {
|
||||
const angle =
|
||||
((360 + noise(8)) / totalPoints) * pointIndex * (Math.PI / 180)
|
||||
return [distance * Math.cos(angle), distance * Math.sin(angle)]
|
||||
}
|
||||
const getXY = (distance: number, pointIndex: number, totalPoints: number): [number, number] => {
|
||||
const angle = ((360 + noise(8)) / totalPoints) * pointIndex * (Math.PI / 180);
|
||||
return [distance * Math.cos(angle), distance * Math.sin(angle)];
|
||||
};
|
||||
|
||||
const createParticle = (
|
||||
i: number,
|
||||
t: number,
|
||||
d: [number, number],
|
||||
r: number
|
||||
) => {
|
||||
const rotate = noise(r / 10)
|
||||
const createParticle = (i: number, t: number, d: [number, number], r: number) => {
|
||||
const rotate = noise(r / 10);
|
||||
return {
|
||||
start: getXY(d[0], props.particleCount - i, props.particleCount),
|
||||
end: getXY(d[1] + noise(7), props.particleCount - i, props.particleCount),
|
||||
time: t,
|
||||
scale: 1 + noise(0.2),
|
||||
color: props.colors[Math.floor(Math.random() * props.colors.length)],
|
||||
rotate: rotate > 0 ? (rotate + r / 20) * 10 : (rotate - r / 20) * 10,
|
||||
}
|
||||
}
|
||||
rotate: rotate > 0 ? (rotate + r / 20) * 10 : (rotate - r / 20) * 10
|
||||
};
|
||||
};
|
||||
|
||||
const makeParticles = (element: HTMLElement) => {
|
||||
const d: [number, number] = props.particleDistances
|
||||
const r = props.particleR
|
||||
const bubbleTime = props.animationTime * 2 + props.timeVariance
|
||||
element.style.setProperty('--time', `${bubbleTime}ms`)
|
||||
const d: [number, number] = props.particleDistances;
|
||||
const r = props.particleR;
|
||||
const bubbleTime = props.animationTime * 2 + props.timeVariance;
|
||||
element.style.setProperty('--time', `${bubbleTime}ms`);
|
||||
for (let i = 0; i < props.particleCount; i++) {
|
||||
const t = props.animationTime * 2 + noise(props.timeVariance * 2)
|
||||
const p = createParticle(i, t, d, r)
|
||||
element.classList.remove('active')
|
||||
const t = props.animationTime * 2 + noise(props.timeVariance * 2);
|
||||
const p = createParticle(i, t, d, r);
|
||||
element.classList.remove('active');
|
||||
setTimeout(() => {
|
||||
const particle = document.createElement('span')
|
||||
const point = document.createElement('span')
|
||||
particle.classList.add('particle')
|
||||
particle.style.setProperty('--start-x', `${p.start[0]}px`)
|
||||
particle.style.setProperty('--start-y', `${p.start[1]}px`)
|
||||
particle.style.setProperty('--end-x', `${p.end[0]}px`)
|
||||
particle.style.setProperty('--end-y', `${p.end[1]}px`)
|
||||
particle.style.setProperty('--time', `${p.time}ms`)
|
||||
particle.style.setProperty('--scale', `${p.scale}`)
|
||||
particle.style.setProperty('--color', `var(--color-${p.color}, white)`)
|
||||
particle.style.setProperty('--rotate', `${p.rotate}deg`)
|
||||
point.classList.add('point')
|
||||
particle.appendChild(point)
|
||||
element.appendChild(particle)
|
||||
const particle = document.createElement('span');
|
||||
const point = document.createElement('span');
|
||||
particle.classList.add('particle');
|
||||
particle.style.setProperty('--start-x', `${p.start[0]}px`);
|
||||
particle.style.setProperty('--start-y', `${p.start[1]}px`);
|
||||
particle.style.setProperty('--end-x', `${p.end[0]}px`);
|
||||
particle.style.setProperty('--end-y', `${p.end[1]}px`);
|
||||
particle.style.setProperty('--time', `${p.time}ms`);
|
||||
particle.style.setProperty('--scale', `${p.scale}`);
|
||||
particle.style.setProperty('--color', `var(--color-${p.color}, white)`);
|
||||
particle.style.setProperty('--rotate', `${p.rotate}deg`);
|
||||
point.classList.add('point');
|
||||
particle.appendChild(point);
|
||||
element.appendChild(particle);
|
||||
requestAnimationFrame(() => {
|
||||
element.classList.add('active')
|
||||
})
|
||||
element.classList.add('active');
|
||||
});
|
||||
setTimeout(() => {
|
||||
try {
|
||||
element.removeChild(particle)
|
||||
element.removeChild(particle);
|
||||
} catch {}
|
||||
}, t)
|
||||
}, 30)
|
||||
}, t);
|
||||
}, 30);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateEffectPosition = (element: HTMLElement) => {
|
||||
if (!containerRef.value || !filterRef.value || !textRef.value) return
|
||||
const containerRect = containerRef.value.getBoundingClientRect()
|
||||
const pos = element.getBoundingClientRect()
|
||||
if (!containerRef.value || !filterRef.value || !textRef.value) return;
|
||||
const containerRect = containerRef.value.getBoundingClientRect();
|
||||
const pos = element.getBoundingClientRect();
|
||||
const styles = {
|
||||
left: `${pos.x - containerRect.x}px`,
|
||||
top: `${pos.y - containerRect.y}px`,
|
||||
width: `${pos.width}px`,
|
||||
height: `${pos.height}px`,
|
||||
}
|
||||
Object.assign(filterRef.value.style, styles)
|
||||
Object.assign(textRef.value.style, styles)
|
||||
textRef.value.innerText = element.innerText
|
||||
}
|
||||
height: `${pos.height}px`
|
||||
};
|
||||
Object.assign(filterRef.value.style, styles);
|
||||
Object.assign(textRef.value.style, styles);
|
||||
textRef.value.innerText = element.innerText;
|
||||
};
|
||||
|
||||
const handleClick = (e: Event, index: number) => {
|
||||
const liEl = (e.currentTarget as HTMLElement).parentElement as HTMLElement
|
||||
if (activeIndex.value === index) return
|
||||
activeIndex.value = index
|
||||
updateEffectPosition(liEl)
|
||||
const liEl = (e.currentTarget as HTMLElement).parentElement as HTMLElement;
|
||||
if (activeIndex.value === index) return;
|
||||
activeIndex.value = index;
|
||||
updateEffectPosition(liEl);
|
||||
if (filterRef.value) {
|
||||
const particles = filterRef.value.querySelectorAll('.particle')
|
||||
particles.forEach((p) => filterRef.value!.removeChild(p))
|
||||
const particles = filterRef.value.querySelectorAll('.particle');
|
||||
particles.forEach(p => filterRef.value!.removeChild(p));
|
||||
}
|
||||
if (textRef.value) {
|
||||
textRef.value.classList.remove('active')
|
||||
void textRef.value.offsetWidth
|
||||
textRef.value.classList.add('active')
|
||||
textRef.value.classList.remove('active');
|
||||
void textRef.value.offsetWidth;
|
||||
textRef.value.classList.add('active');
|
||||
}
|
||||
if (filterRef.value) {
|
||||
makeParticles(filterRef.value)
|
||||
makeParticles(filterRef.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent, index: number) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
const liEl = (e.currentTarget as HTMLElement).parentElement
|
||||
e.preventDefault();
|
||||
const liEl = (e.currentTarget as HTMLElement).parentElement;
|
||||
if (liEl) {
|
||||
handleClick(
|
||||
{
|
||||
currentTarget: liEl,
|
||||
currentTarget: liEl
|
||||
} as unknown as Event,
|
||||
index
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(activeIndex, () => {
|
||||
if (!navRef.value || !containerRef.value) return
|
||||
const activeLi = navRef.value.querySelectorAll('li')[
|
||||
activeIndex.value
|
||||
] as HTMLElement
|
||||
if (!navRef.value || !containerRef.value) return;
|
||||
const activeLi = navRef.value.querySelectorAll('li')[activeIndex.value] as HTMLElement;
|
||||
if (activeLi) {
|
||||
updateEffectPosition(activeLi)
|
||||
textRef.value?.classList.add('active')
|
||||
updateEffectPosition(activeLi);
|
||||
textRef.value?.classList.add('active');
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!navRef.value || !containerRef.value) return
|
||||
const activeLi = navRef.value.querySelectorAll('li')[
|
||||
activeIndex.value
|
||||
] as HTMLElement
|
||||
if (!navRef.value || !containerRef.value) return;
|
||||
const activeLi = navRef.value.querySelectorAll('li')[activeIndex.value] as HTMLElement;
|
||||
if (activeLi) {
|
||||
updateEffectPosition(activeLi)
|
||||
textRef.value?.classList.add('active')
|
||||
updateEffectPosition(activeLi);
|
||||
textRef.value?.classList.add('active');
|
||||
}
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
const currentActiveLi = navRef.value?.querySelectorAll('li')[
|
||||
activeIndex.value
|
||||
] as HTMLElement
|
||||
const currentActiveLi = navRef.value?.querySelectorAll('li')[activeIndex.value] as HTMLElement;
|
||||
if (currentActiveLi) {
|
||||
updateEffectPosition(currentActiveLi)
|
||||
updateEffectPosition(currentActiveLi);
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
})
|
||||
});
|
||||
resizeObserver.observe(containerRef.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--linear-ease: linear(0, 0.068, 0.19 2.7%, 0.804 8.1%, 1.037, 1.199 13.2%, 1.245, 1.27 15.8%, 1.274, 1.272 17.4%, 1.249 19.1%, 0.996 28%, 0.949, 0.928 33.3%, 0.926, 0.933 36.8%, 1.001 45.6%, 1.013, 1.019 50.8%, 1.018 54.4%, 1 63.1%, 0.995 68%, 1.001 85%, 1);
|
||||
--linear-ease: linear(
|
||||
0,
|
||||
0.068,
|
||||
0.19 2.7%,
|
||||
0.804 8.1%,
|
||||
1.037,
|
||||
1.199 13.2%,
|
||||
1.245,
|
||||
1.27 15.8%,
|
||||
1.274,
|
||||
1.272 17.4%,
|
||||
1.249 19.1%,
|
||||
0.996 28%,
|
||||
0.949,
|
||||
0.928 33.3%,
|
||||
0.926,
|
||||
0.933 36.8%,
|
||||
1.001 45.6%,
|
||||
1.013,
|
||||
1.019 50.8%,
|
||||
1.018 54.4%,
|
||||
1 63.1%,
|
||||
0.995 68%,
|
||||
1.001 85%,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
.effect {
|
||||
@@ -256,7 +264,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.effect.filter::before {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -75px;
|
||||
z-index: -2;
|
||||
@@ -264,7 +272,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.effect.filter::after {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: white;
|
||||
@@ -368,7 +376,7 @@ li.active::after {
|
||||
}
|
||||
|
||||
li::after {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 8px;
|
||||
@@ -378,4 +386,4 @@ li::after {
|
||||
transition: all 0.3s ease;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="infinite-scroll-wrapper relative flex items-center justify-center w-full overflow-hidden"
|
||||
ref="wrapperRef" :style="{
|
||||
<div
|
||||
class="infinite-scroll-wrapper relative flex items-center justify-center w-full overflow-hidden"
|
||||
ref="wrapperRef"
|
||||
:style="{
|
||||
maxHeight: maxHeight,
|
||||
overscrollBehavior: 'none'
|
||||
}">
|
||||
<div class="infinite-scroll-container flex flex-col px-4 cursor-grab" ref="containerRef" :style="{
|
||||
transform: getTiltTransform(),
|
||||
width: width,
|
||||
overscrollBehavior: 'contain',
|
||||
transformOrigin: 'center center',
|
||||
transformStyle: 'preserve-3d'
|
||||
}">
|
||||
<div v-for="(item, index) in items" :key="index"
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="infinite-scroll-container flex flex-col px-4 cursor-grab"
|
||||
ref="containerRef"
|
||||
:style="{
|
||||
transform: getTiltTransform(),
|
||||
width: width,
|
||||
overscrollBehavior: 'contain',
|
||||
transformOrigin: 'center center',
|
||||
transformStyle: 'preserve-3d'
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="infinite-scroll-item rounded-2xl flex items-center justify-center p-4 text-xl font-semibold text-center border-2 border-white select-none box-border relative"
|
||||
:style="{
|
||||
height: itemMinHeight + 'px',
|
||||
marginTop: negativeMargin
|
||||
}">
|
||||
}"
|
||||
>
|
||||
<component :is="item.content" v-if="typeof item.content === 'object'" />
|
||||
|
||||
<template v-else>{{ item.content }}</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,28 +38,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
import { Observer } from 'gsap/Observer'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
import { Observer } from 'gsap/Observer';
|
||||
|
||||
gsap.registerPlugin(Observer)
|
||||
gsap.registerPlugin(Observer);
|
||||
|
||||
interface InfiniteScrollItem {
|
||||
content: string | object
|
||||
content: string | object;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
width?: string
|
||||
maxHeight?: string
|
||||
negativeMargin?: string
|
||||
items?: InfiniteScrollItem[]
|
||||
itemMinHeight?: number
|
||||
isTilted?: boolean
|
||||
tiltDirection?: 'left' | 'right'
|
||||
autoplay?: boolean
|
||||
autoplaySpeed?: number
|
||||
autoplayDirection?: 'down' | 'up'
|
||||
pauseOnHover?: boolean
|
||||
width?: string;
|
||||
maxHeight?: string;
|
||||
negativeMargin?: string;
|
||||
items?: InfiniteScrollItem[];
|
||||
itemMinHeight?: number;
|
||||
isTilted?: boolean;
|
||||
tiltDirection?: 'left' | 'right';
|
||||
autoplay?: boolean;
|
||||
autoplaySpeed?: number;
|
||||
autoplayDirection?: 'down' | 'up';
|
||||
pauseOnHover?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -63,57 +74,57 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
autoplaySpeed: 0.5,
|
||||
autoplayDirection: 'down',
|
||||
pauseOnHover: false
|
||||
})
|
||||
});
|
||||
|
||||
const wrapperRef = ref<HTMLDivElement | null>(null)
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
let observer: Observer | null = null
|
||||
let rafId: number | null = null
|
||||
let velocity = 0
|
||||
let stopTicker: (() => void) | null = null
|
||||
let startTicker: (() => void) | null = null
|
||||
const wrapperRef = ref<HTMLDivElement | null>(null);
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
let observer: Observer | null = null;
|
||||
let rafId: number | null = null;
|
||||
let velocity = 0;
|
||||
let stopTicker: (() => void) | null = null;
|
||||
let startTicker: (() => void) | null = null;
|
||||
|
||||
const getTiltTransform = (): string => {
|
||||
if (!props.isTilted) return 'none'
|
||||
if (!props.isTilted) return 'none';
|
||||
return props.tiltDirection === 'left'
|
||||
? 'rotateX(20deg) rotateZ(-20deg) skewX(20deg)'
|
||||
: 'rotateX(20deg) rotateZ(20deg) skewX(-20deg)'
|
||||
}
|
||||
: 'rotateX(20deg) rotateZ(20deg) skewX(-20deg)';
|
||||
};
|
||||
|
||||
const initializeScroll = () => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
if (props.items.length === 0) return
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
if (props.items.length === 0) return;
|
||||
|
||||
const divItems = gsap.utils.toArray<HTMLDivElement>(container.children)
|
||||
if (!divItems.length) return
|
||||
const divItems = gsap.utils.toArray<HTMLDivElement>(container.children);
|
||||
if (!divItems.length) return;
|
||||
|
||||
const firstItem = divItems[0]
|
||||
const itemStyle = getComputedStyle(firstItem)
|
||||
const itemHeight = firstItem.offsetHeight
|
||||
const itemMarginTop = parseFloat(itemStyle.marginTop) || 0
|
||||
const totalItemHeight = itemHeight + itemMarginTop
|
||||
const totalHeight = itemHeight * props.items.length + itemMarginTop * (props.items.length - 1)
|
||||
const firstItem = divItems[0];
|
||||
const itemStyle = getComputedStyle(firstItem);
|
||||
const itemHeight = firstItem.offsetHeight;
|
||||
const itemMarginTop = parseFloat(itemStyle.marginTop) || 0;
|
||||
const totalItemHeight = itemHeight + itemMarginTop;
|
||||
const totalHeight = itemHeight * props.items.length + itemMarginTop * (props.items.length - 1);
|
||||
|
||||
const wrapFn = gsap.utils.wrap(-totalHeight, totalHeight)
|
||||
const wrapFn = gsap.utils.wrap(-totalHeight, totalHeight);
|
||||
|
||||
divItems.forEach((child, i) => {
|
||||
const y = i * totalItemHeight
|
||||
gsap.set(child, { y })
|
||||
})
|
||||
const y = i * totalItemHeight;
|
||||
gsap.set(child, { y });
|
||||
});
|
||||
|
||||
observer = Observer.create({
|
||||
target: container,
|
||||
type: 'wheel,touch,pointer',
|
||||
preventDefault: true,
|
||||
onPress: ({ target }) => {
|
||||
; (target as HTMLElement).style.cursor = 'grabbing'
|
||||
(target as HTMLElement).style.cursor = 'grabbing';
|
||||
},
|
||||
onRelease: ({ target }) => {
|
||||
; (target as HTMLElement).style.cursor = 'grab'
|
||||
(target as HTMLElement).style.cursor = 'grab';
|
||||
if (Math.abs(velocity) > 0.1) {
|
||||
const momentum = velocity * 0.8
|
||||
divItems.forEach((child) => {
|
||||
const momentum = velocity * 0.8;
|
||||
divItems.forEach(child => {
|
||||
gsap.to(child, {
|
||||
duration: 1.5,
|
||||
ease: 'power2.out',
|
||||
@@ -121,18 +132,18 @@ const initializeScroll = () => {
|
||||
modifiers: {
|
||||
y: gsap.utils.unitize(wrapFn)
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
velocity = 0
|
||||
velocity = 0;
|
||||
},
|
||||
onChange: ({ deltaY, isDragging, event }) => {
|
||||
const d = event.type === 'wheel' ? -deltaY : deltaY
|
||||
const distance = isDragging ? d * 5 : d * 1.5
|
||||
const d = event.type === 'wheel' ? -deltaY : deltaY;
|
||||
const distance = isDragging ? d * 5 : d * 1.5;
|
||||
|
||||
velocity = distance * 0.5
|
||||
velocity = distance * 0.5;
|
||||
|
||||
divItems.forEach((child) => {
|
||||
divItems.forEach(child => {
|
||||
gsap.to(child, {
|
||||
duration: isDragging ? 0.3 : 1.2,
|
||||
ease: isDragging ? 'power1.out' : 'power3.out',
|
||||
@@ -140,70 +151,70 @@ const initializeScroll = () => {
|
||||
modifiers: {
|
||||
y: gsap.utils.unitize(wrapFn)
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (props.autoplay) {
|
||||
const directionFactor = props.autoplayDirection === 'down' ? 1 : -1
|
||||
const speedPerFrame = props.autoplaySpeed * directionFactor
|
||||
const directionFactor = props.autoplayDirection === 'down' ? 1 : -1;
|
||||
const speedPerFrame = props.autoplaySpeed * directionFactor;
|
||||
|
||||
const tick = () => {
|
||||
divItems.forEach((child) => {
|
||||
divItems.forEach(child => {
|
||||
gsap.set(child, {
|
||||
y: `+=${speedPerFrame}`,
|
||||
modifiers: {
|
||||
y: gsap.utils.unitize(wrapFn)
|
||||
}
|
||||
})
|
||||
})
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
});
|
||||
});
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(tick)
|
||||
rafId = requestAnimationFrame(tick);
|
||||
|
||||
if (props.pauseOnHover) {
|
||||
stopTicker = () => rafId && cancelAnimationFrame(rafId)
|
||||
stopTicker = () => rafId && cancelAnimationFrame(rafId);
|
||||
startTicker = () => {
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
container.addEventListener('mouseenter', stopTicker)
|
||||
container.addEventListener('mouseleave', startTicker)
|
||||
container.addEventListener('mouseenter', stopTicker);
|
||||
container.addEventListener('mouseleave', startTicker);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (observer) {
|
||||
observer.kill()
|
||||
observer = null
|
||||
observer.kill();
|
||||
observer = null;
|
||||
}
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
|
||||
velocity = 0
|
||||
velocity = 0;
|
||||
|
||||
const container = containerRef.value
|
||||
const container = containerRef.value;
|
||||
if (container && props.pauseOnHover && stopTicker && startTicker) {
|
||||
container.removeEventListener('mouseenter', stopTicker)
|
||||
container.removeEventListener('mouseleave', startTicker)
|
||||
container.removeEventListener('mouseenter', stopTicker);
|
||||
container.removeEventListener('mouseleave', startTicker);
|
||||
}
|
||||
|
||||
stopTicker = null
|
||||
startTicker = null
|
||||
}
|
||||
stopTicker = null;
|
||||
startTicker = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeScroll()
|
||||
})
|
||||
initializeScroll();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
cleanup();
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
@@ -217,18 +228,18 @@ watch(
|
||||
() => props.negativeMargin
|
||||
],
|
||||
() => {
|
||||
cleanup()
|
||||
cleanup();
|
||||
setTimeout(() => {
|
||||
initializeScroll()
|
||||
}, 0)
|
||||
initializeScroll();
|
||||
}, 0);
|
||||
}
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.infinite-scroll-wrapper::before,
|
||||
.infinite-scroll-wrapper::after {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: linear-gradient(var(--dir, to bottom), #060010, transparent);
|
||||
height: 25%;
|
||||
|
||||
@@ -1,40 +1,49 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative w-full h-full">
|
||||
<div v-for="item in grid" :key="item.id" :data-key="item.id" class="absolute box-content"
|
||||
:style="{ willChange: 'transform, width, height, opacity' }" @click="openUrl(item.url)"
|
||||
@mouseenter="(e) => handleMouseEnter(item.id, e.currentTarget as HTMLElement)"
|
||||
@mouseleave="(e) => handleMouseLeave(item.id, e.currentTarget as HTMLElement)">
|
||||
<div
|
||||
v-for="item in grid"
|
||||
:key="item.id"
|
||||
:data-key="item.id"
|
||||
class="absolute box-content"
|
||||
:style="{ willChange: 'transform, width, height, opacity' }"
|
||||
@click="openUrl(item.url)"
|
||||
@mouseenter="e => handleMouseEnter(item.id, e.currentTarget as HTMLElement)"
|
||||
@mouseleave="e => handleMouseLeave(item.id, e.currentTarget as HTMLElement)"
|
||||
>
|
||||
<div
|
||||
class="relative w-full h-full bg-cover bg-center rounded-[10px] shadow-[0px_10px_50px_-10px_rgba(0,0,0,0.2)] uppercase text-[10px] leading-[10px]"
|
||||
:style="{ backgroundImage: `url(${item.img})` }">
|
||||
<div v-if="colorShiftOnHover"
|
||||
class="color-overlay absolute inset-0 rounded-[10px] bg-gradient-to-tr from-pink-500/50 to-sky-500/50 opacity-0 pointer-events-none" />
|
||||
:style="{ backgroundImage: `url(${item.img})` }"
|
||||
>
|
||||
<div
|
||||
v-if="colorShiftOnHover"
|
||||
class="color-overlay absolute inset-0 rounded-[10px] bg-gradient-to-tr from-pink-500/50 to-sky-500/50 opacity-0 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watchEffect, nextTick } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
import { ref, computed, onMounted, onUnmounted, watchEffect, nextTick } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
interface Item {
|
||||
id: string
|
||||
img: string
|
||||
url: string
|
||||
height: number
|
||||
id: string;
|
||||
img: string;
|
||||
url: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface MasonryProps {
|
||||
items: Item[]
|
||||
ease?: string
|
||||
duration?: number
|
||||
stagger?: number
|
||||
animateFrom?: 'bottom' | 'top' | 'left' | 'right' | 'center' | 'random'
|
||||
scaleOnHover?: boolean
|
||||
hoverScale?: number
|
||||
blurToFocus?: boolean
|
||||
colorShiftOnHover?: boolean
|
||||
items: Item[];
|
||||
ease?: string;
|
||||
duration?: number;
|
||||
stagger?: number;
|
||||
animateFrom?: 'bottom' | 'top' | 'left' | 'right' | 'center' | 'random';
|
||||
scaleOnHover?: boolean;
|
||||
hoverScale?: number;
|
||||
blurToFocus?: boolean;
|
||||
colorShiftOnHover?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<MasonryProps>(), {
|
||||
@@ -46,134 +55,129 @@ const props = withDefaults(defineProps<MasonryProps>(), {
|
||||
hoverScale: 0.95,
|
||||
blurToFocus: true,
|
||||
colorShiftOnHover: false
|
||||
})
|
||||
});
|
||||
|
||||
const useMedia = (queries: string[], values: number[], defaultValue: number) => {
|
||||
const get = () => values[queries.findIndex((q) => matchMedia(q).matches)] ?? defaultValue
|
||||
const value = ref<number>(get())
|
||||
const get = () => values[queries.findIndex(q => matchMedia(q).matches)] ?? defaultValue;
|
||||
const value = ref<number>(get());
|
||||
|
||||
onMounted(() => {
|
||||
const handler = () => value.value = get()
|
||||
queries.forEach((q) => matchMedia(q).addEventListener('change', handler))
|
||||
const handler = () => (value.value = get());
|
||||
queries.forEach(q => matchMedia(q).addEventListener('change', handler));
|
||||
|
||||
onUnmounted(() => {
|
||||
queries.forEach((q) => matchMedia(q).removeEventListener('change', handler))
|
||||
})
|
||||
})
|
||||
queries.forEach(q => matchMedia(q).removeEventListener('change', handler));
|
||||
});
|
||||
});
|
||||
|
||||
return value
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const useMeasure = () => {
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const size = ref({ width: 0, height: 0 })
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const size = ref({ width: 0, height: 0 });
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (!containerRef.value) return
|
||||
if (!containerRef.value) return;
|
||||
|
||||
resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect
|
||||
size.value = { width, height }
|
||||
})
|
||||
const { width, height } = entry.contentRect;
|
||||
size.value = { width, height };
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.value)
|
||||
})
|
||||
resizeObserver.observe(containerRef.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return [containerRef, size] as const
|
||||
}
|
||||
return [containerRef, size] as const;
|
||||
};
|
||||
|
||||
const preloadImages = async (urls: string[]): Promise<void> => {
|
||||
await Promise.all(
|
||||
urls.map(
|
||||
(src) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const img = new Image()
|
||||
img.src = src
|
||||
img.onload = img.onerror = () => resolve()
|
||||
src =>
|
||||
new Promise<void>(resolve => {
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
img.onload = img.onerror = () => resolve();
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMedia(
|
||||
[
|
||||
'(min-width:1500px)',
|
||||
'(min-width:1000px)',
|
||||
'(min-width:600px)',
|
||||
'(min-width:400px)'
|
||||
],
|
||||
['(min-width:1500px)', '(min-width:1000px)', '(min-width:600px)', '(min-width:400px)'],
|
||||
[5, 4, 3, 2],
|
||||
1
|
||||
)
|
||||
);
|
||||
|
||||
const [containerRef, size] = useMeasure()
|
||||
const imagesReady = ref(false)
|
||||
const hasMounted = ref(false)
|
||||
const [containerRef, size] = useMeasure();
|
||||
const imagesReady = ref(false);
|
||||
const hasMounted = ref(false);
|
||||
|
||||
const grid = computed(() => {
|
||||
if (!size.value.width) return []
|
||||
const colHeights = new Array(columns.value).fill(0)
|
||||
const gap = 16
|
||||
const totalGaps = (columns.value - 1) * gap
|
||||
const columnWidth = (size.value.width - totalGaps) / columns.value
|
||||
if (!size.value.width) return [];
|
||||
const colHeights = new Array(columns.value).fill(0);
|
||||
const gap = 16;
|
||||
const totalGaps = (columns.value - 1) * gap;
|
||||
const columnWidth = (size.value.width - totalGaps) / columns.value;
|
||||
|
||||
return props.items.map((child) => {
|
||||
const col = colHeights.indexOf(Math.min(...colHeights))
|
||||
const x = col * (columnWidth + gap)
|
||||
const height = child.height / 2
|
||||
const y = colHeights[col]
|
||||
return props.items.map(child => {
|
||||
const col = colHeights.indexOf(Math.min(...colHeights));
|
||||
const x = col * (columnWidth + gap);
|
||||
const height = child.height / 2;
|
||||
const y = colHeights[col];
|
||||
|
||||
colHeights[col] += height + gap
|
||||
return { ...child, x, y, w: columnWidth, h: height }
|
||||
})
|
||||
})
|
||||
colHeights[col] += height + gap;
|
||||
return { ...child, x, y, w: columnWidth, h: height };
|
||||
});
|
||||
});
|
||||
|
||||
const openUrl = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener')
|
||||
}
|
||||
window.open(url, '_blank', 'noopener');
|
||||
};
|
||||
|
||||
interface GridItem extends Item {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
const getInitialPosition = (item: GridItem) => {
|
||||
const containerRect = containerRef.value?.getBoundingClientRect()
|
||||
if (!containerRect) return { x: item.x, y: item.y }
|
||||
const containerRect = containerRef.value?.getBoundingClientRect();
|
||||
if (!containerRect) return { x: item.x, y: item.y };
|
||||
|
||||
let direction = props.animateFrom
|
||||
let direction = props.animateFrom;
|
||||
if (props.animateFrom === 'random') {
|
||||
const dirs = ['top', 'bottom', 'left', 'right']
|
||||
direction = dirs[Math.floor(Math.random() * dirs.length)] as typeof props.animateFrom
|
||||
const dirs = ['top', 'bottom', 'left', 'right'];
|
||||
direction = dirs[Math.floor(Math.random() * dirs.length)] as typeof props.animateFrom;
|
||||
}
|
||||
|
||||
switch (direction) {
|
||||
case 'top':
|
||||
return { x: item.x, y: -200 }
|
||||
return { x: item.x, y: -200 };
|
||||
case 'bottom':
|
||||
return { x: item.x, y: window.innerHeight + 200 }
|
||||
return { x: item.x, y: window.innerHeight + 200 };
|
||||
case 'left':
|
||||
return { x: -200, y: item.y }
|
||||
return { x: -200, y: item.y };
|
||||
case 'right':
|
||||
return { x: window.innerWidth + 200, y: item.y }
|
||||
return { x: window.innerWidth + 200, y: item.y };
|
||||
case 'center':
|
||||
return {
|
||||
x: containerRect.width / 2 - item.w / 2,
|
||||
y: containerRect.height / 2 - item.h / 2
|
||||
}
|
||||
};
|
||||
default:
|
||||
return { x: item.x, y: item.y + 100 }
|
||||
return { x: item.x, y: item.y + 100 };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = (id: string, element: HTMLElement) => {
|
||||
if (props.scaleOnHover) {
|
||||
@@ -181,13 +185,13 @@ const handleMouseEnter = (id: string, element: HTMLElement) => {
|
||||
scale: props.hoverScale,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out'
|
||||
})
|
||||
});
|
||||
}
|
||||
if (props.colorShiftOnHover) {
|
||||
const overlay = element.querySelector('.color-overlay') as HTMLElement
|
||||
if (overlay) gsap.to(overlay, { opacity: 0.3, duration: 0.3 })
|
||||
const overlay = element.querySelector('.color-overlay') as HTMLElement;
|
||||
if (overlay) gsap.to(overlay, { opacity: 0.3, duration: 0.3 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (id: string, element: HTMLElement) => {
|
||||
if (props.scaleOnHover) {
|
||||
@@ -195,37 +199,37 @@ const handleMouseLeave = (id: string, element: HTMLElement) => {
|
||||
scale: 1,
|
||||
duration: 0.3,
|
||||
ease: 'power2.out'
|
||||
})
|
||||
});
|
||||
}
|
||||
if (props.colorShiftOnHover) {
|
||||
const overlay = element.querySelector('.color-overlay') as HTMLElement
|
||||
if (overlay) gsap.to(overlay, { opacity: 0, duration: 0.3 })
|
||||
const overlay = element.querySelector('.color-overlay') as HTMLElement;
|
||||
if (overlay) gsap.to(overlay, { opacity: 0, duration: 0.3 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
preloadImages(props.items.map((i) => i.img)).then(() => {
|
||||
imagesReady.value = true
|
||||
})
|
||||
})
|
||||
preloadImages(props.items.map(i => i.img)).then(() => {
|
||||
imagesReady.value = true;
|
||||
});
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (!imagesReady.value) return
|
||||
if (!imagesReady.value) return;
|
||||
|
||||
const currentGrid = grid.value
|
||||
void props.items.length
|
||||
void columns.value
|
||||
void size.value.width
|
||||
const currentGrid = grid.value;
|
||||
void props.items.length;
|
||||
void columns.value;
|
||||
void size.value.width;
|
||||
|
||||
if (!currentGrid.length) return
|
||||
if (!currentGrid.length) return;
|
||||
|
||||
nextTick(() => {
|
||||
currentGrid.forEach((item, index) => {
|
||||
const selector = `[data-key="${item.id}"]`
|
||||
const animProps = { x: item.x, y: item.y, width: item.w, height: item.h }
|
||||
const selector = `[data-key="${item.id}"]`;
|
||||
const animProps = { x: item.x, y: item.y, width: item.w, height: item.h };
|
||||
|
||||
if (!hasMounted.value) {
|
||||
const start = getInitialPosition(item)
|
||||
const start = getInitialPosition(item);
|
||||
gsap.fromTo(
|
||||
selector,
|
||||
{
|
||||
@@ -244,18 +248,18 @@ watchEffect(() => {
|
||||
ease: 'power3.out',
|
||||
delay: index * props.stagger
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
gsap.to(selector, {
|
||||
...animProps,
|
||||
duration: props.duration,
|
||||
ease: props.ease,
|
||||
overwrite: 'auto'
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
hasMounted.value = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
hasMounted.value = true;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
<template>
|
||||
<div ref="containerRef" :class="[
|
||||
'h-[400px] w-[300px] relative overflow-hidden grid place-items-center aspect-[4/5] border border-[#27272a] rounded-[25px] isolate transition-colors duration-200 ease-[cubic-bezier(0.5,1,0.89,1)] select-none',
|
||||
className
|
||||
]" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @focus="finalNoFocus ? undefined : onFocus"
|
||||
@blur="finalNoFocus ? undefined : onBlur" :tabindex="finalNoFocus ? -1 : 0">
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="[
|
||||
'h-[400px] w-[300px] relative overflow-hidden grid place-items-center aspect-[4/5] border border-[#27272a] rounded-[25px] isolate transition-colors duration-200 ease-[cubic-bezier(0.5,1,0.89,1)] select-none',
|
||||
className
|
||||
]"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
@focus="finalNoFocus ? undefined : onFocus"
|
||||
@blur="finalNoFocus ? undefined : onBlur"
|
||||
:tabindex="finalNoFocus ? -1 : 0"
|
||||
>
|
||||
<canvas class="w-full h-full block" ref="canvasRef" />
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue';
|
||||
|
||||
class Pixel {
|
||||
width: number
|
||||
height: number
|
||||
ctx: CanvasRenderingContext2D
|
||||
x: number
|
||||
y: number
|
||||
color: string
|
||||
speed: number
|
||||
size: number
|
||||
sizeStep: number
|
||||
minSize: number
|
||||
maxSizeInteger: number
|
||||
maxSize: number
|
||||
delay: number
|
||||
counter: number
|
||||
counterStep: number
|
||||
isIdle: boolean
|
||||
isReverse: boolean
|
||||
isShimmer: boolean
|
||||
width: number;
|
||||
height: number;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
speed: number;
|
||||
size: number;
|
||||
sizeStep: number;
|
||||
minSize: number;
|
||||
maxSizeInteger: number;
|
||||
maxSize: number;
|
||||
delay: number;
|
||||
counter: number;
|
||||
counterStep: number;
|
||||
isIdle: boolean;
|
||||
isReverse: boolean;
|
||||
isShimmer: boolean;
|
||||
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -41,95 +49,90 @@ class Pixel {
|
||||
speed: number,
|
||||
delay: number
|
||||
) {
|
||||
this.width = canvas.width
|
||||
this.height = canvas.height
|
||||
this.ctx = context
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.color = color
|
||||
this.speed = this.getRandomValue(0.1, 0.9) * speed
|
||||
this.size = 0
|
||||
this.sizeStep = Math.random() * 0.4
|
||||
this.minSize = 0.5
|
||||
this.maxSizeInteger = 2
|
||||
this.maxSize = this.getRandomValue(this.minSize, this.maxSizeInteger)
|
||||
this.delay = delay
|
||||
this.counter = 0
|
||||
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01
|
||||
this.isIdle = false
|
||||
this.isReverse = false
|
||||
this.isShimmer = false
|
||||
this.width = canvas.width;
|
||||
this.height = canvas.height;
|
||||
this.ctx = context;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.color = color;
|
||||
this.speed = this.getRandomValue(0.1, 0.9) * speed;
|
||||
this.size = 0;
|
||||
this.sizeStep = Math.random() * 0.4;
|
||||
this.minSize = 0.5;
|
||||
this.maxSizeInteger = 2;
|
||||
this.maxSize = this.getRandomValue(this.minSize, this.maxSizeInteger);
|
||||
this.delay = delay;
|
||||
this.counter = 0;
|
||||
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01;
|
||||
this.isIdle = false;
|
||||
this.isReverse = false;
|
||||
this.isShimmer = false;
|
||||
}
|
||||
|
||||
getRandomValue(min: number, max: number) {
|
||||
return Math.random() * (max - min) + min
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
draw() {
|
||||
const centerOffset = this.maxSizeInteger * 0.5 - this.size * 0.5
|
||||
this.ctx.fillStyle = this.color
|
||||
this.ctx.fillRect(
|
||||
this.x + centerOffset,
|
||||
this.y + centerOffset,
|
||||
this.size,
|
||||
this.size
|
||||
)
|
||||
const centerOffset = this.maxSizeInteger * 0.5 - this.size * 0.5;
|
||||
this.ctx.fillStyle = this.color;
|
||||
this.ctx.fillRect(this.x + centerOffset, this.y + centerOffset, this.size, this.size);
|
||||
}
|
||||
|
||||
appear() {
|
||||
this.isIdle = false
|
||||
this.isIdle = false;
|
||||
if (this.counter <= this.delay) {
|
||||
this.counter += this.counterStep
|
||||
return
|
||||
this.counter += this.counterStep;
|
||||
return;
|
||||
}
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isShimmer = true
|
||||
this.isShimmer = true;
|
||||
}
|
||||
if (this.isShimmer) {
|
||||
this.shimmer()
|
||||
this.shimmer();
|
||||
} else {
|
||||
this.size += this.sizeStep
|
||||
this.size += this.sizeStep;
|
||||
}
|
||||
this.draw()
|
||||
this.draw();
|
||||
}
|
||||
|
||||
disappear() {
|
||||
this.isShimmer = false
|
||||
this.counter = 0
|
||||
this.isShimmer = false;
|
||||
this.counter = 0;
|
||||
if (this.size <= 0) {
|
||||
this.isIdle = true
|
||||
return
|
||||
this.isIdle = true;
|
||||
return;
|
||||
} else {
|
||||
this.size -= 0.1
|
||||
this.size -= 0.1;
|
||||
}
|
||||
this.draw()
|
||||
this.draw();
|
||||
}
|
||||
|
||||
shimmer() {
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isReverse = true
|
||||
this.isReverse = true;
|
||||
} else if (this.size <= this.minSize) {
|
||||
this.isReverse = false
|
||||
this.isReverse = false;
|
||||
}
|
||||
if (this.isReverse) {
|
||||
this.size -= this.speed
|
||||
this.size -= this.speed;
|
||||
} else {
|
||||
this.size += this.speed
|
||||
this.size += this.speed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getEffectiveSpeed(value: number, reducedMotion: boolean) {
|
||||
const min = 0
|
||||
const max = 100
|
||||
const throttle = 0.001
|
||||
const min = 0;
|
||||
const max = 100;
|
||||
const throttle = 0.001;
|
||||
|
||||
if (value <= min || reducedMotion) {
|
||||
return min
|
||||
return min;
|
||||
} else if (value >= max) {
|
||||
return max * throttle
|
||||
return max * throttle;
|
||||
} else {
|
||||
return value * throttle
|
||||
return value * throttle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,177 +142,166 @@ const VARIANTS = {
|
||||
gap: 5,
|
||||
speed: 35,
|
||||
colors: '#f8fafc,#f1f5f9,#cbd5e1',
|
||||
noFocus: false,
|
||||
noFocus: false
|
||||
},
|
||||
blue: {
|
||||
activeColor: '#e0f2fe',
|
||||
gap: 10,
|
||||
speed: 25,
|
||||
colors: '#e0f2fe,#7dd3fc,#0ea5e9',
|
||||
noFocus: false,
|
||||
noFocus: false
|
||||
},
|
||||
yellow: {
|
||||
activeColor: '#fef08a',
|
||||
gap: 3,
|
||||
speed: 20,
|
||||
colors: '#fef08a,#fde047,#eab308',
|
||||
noFocus: false,
|
||||
noFocus: false
|
||||
},
|
||||
pink: {
|
||||
activeColor: '#fecdd3',
|
||||
gap: 6,
|
||||
speed: 80,
|
||||
colors: '#fecdd3,#fda4af,#e11d48',
|
||||
noFocus: true,
|
||||
},
|
||||
}
|
||||
noFocus: true
|
||||
}
|
||||
};
|
||||
|
||||
interface PixelCardProps {
|
||||
variant?: 'default' | 'blue' | 'yellow' | 'pink'
|
||||
gap?: number
|
||||
speed?: number
|
||||
colors?: string
|
||||
noFocus?: boolean
|
||||
className?: string
|
||||
variant?: 'default' | 'blue' | 'yellow' | 'pink';
|
||||
gap?: number;
|
||||
speed?: number;
|
||||
colors?: string;
|
||||
noFocus?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface VariantConfig {
|
||||
activeColor: string | null
|
||||
gap: number
|
||||
speed: number
|
||||
colors: string
|
||||
noFocus: boolean
|
||||
activeColor: string | null;
|
||||
gap: number;
|
||||
speed: number;
|
||||
colors: string;
|
||||
noFocus: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PixelCardProps>(), {
|
||||
variant: 'default',
|
||||
className: '',
|
||||
})
|
||||
className: ''
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const pixelsRef = ref<Pixel[]>([])
|
||||
const animationRef = ref<number | null>(null)
|
||||
const timePreviousRef = ref(performance.now())
|
||||
const reducedMotion = ref(
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
)
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const canvasRef = ref<HTMLCanvasElement>();
|
||||
const pixelsRef = ref<Pixel[]>([]);
|
||||
const animationRef = ref<number | null>(null);
|
||||
const timePreviousRef = ref(performance.now());
|
||||
const reducedMotion = ref(window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
|
||||
const variantCfg = computed((): VariantConfig => VARIANTS[props.variant] || VARIANTS.default)
|
||||
const finalGap = computed(() => props.gap ?? variantCfg.value.gap)
|
||||
const finalSpeed = computed(() => props.speed ?? variantCfg.value.speed)
|
||||
const finalColors = computed(() => props.colors ?? variantCfg.value.colors)
|
||||
const finalNoFocus = computed(() => props.noFocus ?? variantCfg.value.noFocus)
|
||||
const variantCfg = computed((): VariantConfig => VARIANTS[props.variant] || VARIANTS.default);
|
||||
const finalGap = computed(() => props.gap ?? variantCfg.value.gap);
|
||||
const finalSpeed = computed(() => props.speed ?? variantCfg.value.speed);
|
||||
const finalColors = computed(() => props.colors ?? variantCfg.value.colors);
|
||||
const finalNoFocus = computed(() => props.noFocus ?? variantCfg.value.noFocus);
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const initPixels = () => {
|
||||
if (!containerRef.value || !canvasRef.value) return
|
||||
if (!containerRef.value || !canvasRef.value) return;
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
const width = Math.floor(rect.width)
|
||||
const height = Math.floor(rect.height)
|
||||
const ctx = canvasRef.value.getContext('2d')
|
||||
const rect = containerRef.value.getBoundingClientRect();
|
||||
const width = Math.floor(rect.width);
|
||||
const height = Math.floor(rect.height);
|
||||
const ctx = canvasRef.value.getContext('2d');
|
||||
|
||||
canvasRef.value.width = width
|
||||
canvasRef.value.height = height
|
||||
canvasRef.value.style.width = `${width}px`
|
||||
canvasRef.value.style.height = `${height}px`
|
||||
canvasRef.value.width = width;
|
||||
canvasRef.value.height = height;
|
||||
canvasRef.value.style.width = `${width}px`;
|
||||
canvasRef.value.style.height = `${height}px`;
|
||||
|
||||
const colorsArray = finalColors.value.split(',')
|
||||
const pxs = []
|
||||
const colorsArray = finalColors.value.split(',');
|
||||
const pxs = [];
|
||||
for (let x = 0; x < width; x += parseInt(finalGap.value.toString(), 10)) {
|
||||
for (let y = 0; y < height; y += parseInt(finalGap.value.toString(), 10)) {
|
||||
const color =
|
||||
colorsArray[Math.floor(Math.random() * colorsArray.length)]
|
||||
const color = colorsArray[Math.floor(Math.random() * colorsArray.length)];
|
||||
|
||||
const dx = x - width / 2
|
||||
const dy = y - height / 2
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
const delay = reducedMotion.value ? 0 : distance
|
||||
if (!ctx) return
|
||||
const dx = x - width / 2;
|
||||
const dy = y - height / 2;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const delay = reducedMotion.value ? 0 : distance;
|
||||
if (!ctx) return;
|
||||
pxs.push(
|
||||
new Pixel(
|
||||
canvasRef.value,
|
||||
ctx,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
getEffectiveSpeed(finalSpeed.value, reducedMotion.value),
|
||||
delay
|
||||
)
|
||||
)
|
||||
new Pixel(canvasRef.value, ctx, x, y, color, getEffectiveSpeed(finalSpeed.value, reducedMotion.value), delay)
|
||||
);
|
||||
}
|
||||
}
|
||||
pixelsRef.value = pxs
|
||||
}
|
||||
pixelsRef.value = pxs;
|
||||
};
|
||||
|
||||
const doAnimate = (fnName: keyof Pixel) => {
|
||||
animationRef.value = requestAnimationFrame(() => doAnimate(fnName))
|
||||
const timeNow = performance.now()
|
||||
const timePassed = timeNow - timePreviousRef.value
|
||||
const timeInterval = 1000 / 60
|
||||
animationRef.value = requestAnimationFrame(() => doAnimate(fnName));
|
||||
const timeNow = performance.now();
|
||||
const timePassed = timeNow - timePreviousRef.value;
|
||||
const timeInterval = 1000 / 60;
|
||||
|
||||
if (timePassed < timeInterval) return
|
||||
timePreviousRef.value = timeNow - (timePassed % timeInterval)
|
||||
if (timePassed < timeInterval) return;
|
||||
timePreviousRef.value = timeNow - (timePassed % timeInterval);
|
||||
|
||||
const ctx = canvasRef.value?.getContext('2d')
|
||||
if (!ctx || !canvasRef.value) return
|
||||
const ctx = canvasRef.value?.getContext('2d');
|
||||
if (!ctx || !canvasRef.value) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
|
||||
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
|
||||
|
||||
let allIdle = true
|
||||
let allIdle = true;
|
||||
for (let i = 0; i < pixelsRef.value.length; i++) {
|
||||
const pixel = pixelsRef.value[i]
|
||||
const pixel = pixelsRef.value[i];
|
||||
// @ts-expect-error - Dynamic method call on Pixel class
|
||||
pixel[fnName]()
|
||||
pixel[fnName]();
|
||||
if (!pixel.isIdle) {
|
||||
allIdle = false
|
||||
allIdle = false;
|
||||
}
|
||||
}
|
||||
if (allIdle && animationRef.value) {
|
||||
cancelAnimationFrame(animationRef.value)
|
||||
cancelAnimationFrame(animationRef.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimation = (name: keyof Pixel) => {
|
||||
if (animationRef.value !== null) {
|
||||
cancelAnimationFrame(animationRef.value)
|
||||
cancelAnimationFrame(animationRef.value);
|
||||
}
|
||||
animationRef.value = requestAnimationFrame(() => doAnimate(name))
|
||||
}
|
||||
animationRef.value = requestAnimationFrame(() => doAnimate(name));
|
||||
};
|
||||
|
||||
const onMouseEnter = () => handleAnimation('appear')
|
||||
const onMouseLeave = () => handleAnimation('disappear')
|
||||
const onMouseEnter = () => handleAnimation('appear');
|
||||
const onMouseLeave = () => handleAnimation('disappear');
|
||||
const onFocus = (e: FocusEvent) => {
|
||||
if ((e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) return
|
||||
handleAnimation('appear')
|
||||
}
|
||||
if ((e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) return;
|
||||
handleAnimation('appear');
|
||||
};
|
||||
const onBlur = (e: FocusEvent) => {
|
||||
if ((e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) return
|
||||
handleAnimation('disappear')
|
||||
}
|
||||
if ((e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) return;
|
||||
handleAnimation('disappear');
|
||||
};
|
||||
|
||||
watch([finalGap, finalSpeed, finalColors, finalNoFocus], () => {
|
||||
initPixels()
|
||||
})
|
||||
initPixels();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
initPixels()
|
||||
initPixels();
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
initPixels()
|
||||
})
|
||||
initPixels();
|
||||
});
|
||||
if (containerRef.value) {
|
||||
resizeObserver.observe(containerRef.value)
|
||||
resizeObserver.observe(containerRef.value);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
if (animationRef.value !== null) {
|
||||
cancelAnimationFrame(animationRef.value)
|
||||
cancelAnimationFrame(animationRef.value);
|
||||
}
|
||||
})
|
||||
</script>
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3,30 +3,52 @@
|
||||
<section ref="cardRef" class="pc-card">
|
||||
<div class="pc-inside">
|
||||
<div class="pc-shine" />
|
||||
|
||||
<div class="pc-glare" />
|
||||
|
||||
<div class="pc-content pc-avatar-content">
|
||||
<img class="avatar" :src="avatarUrl" :alt="`${name || 'User'} avatar`" loading="lazy"
|
||||
@error="handleAvatarError" />
|
||||
<img
|
||||
class="avatar"
|
||||
:src="avatarUrl"
|
||||
:alt="`${name || 'User'} avatar`"
|
||||
loading="lazy"
|
||||
@error="handleAvatarError"
|
||||
/>
|
||||
|
||||
<div v-if="showUserInfo" class="pc-user-info">
|
||||
<div class="pc-user-details">
|
||||
<div class="pc-mini-avatar">
|
||||
<img :src="miniAvatarUrl || avatarUrl" :alt="`${name || 'User'} mini avatar`" loading="lazy"
|
||||
@error="handleMiniAvatarError" />
|
||||
<img
|
||||
:src="miniAvatarUrl || avatarUrl"
|
||||
:alt="`${name || 'User'} mini avatar`"
|
||||
loading="lazy"
|
||||
@error="handleMiniAvatarError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pc-user-text">
|
||||
<div class="pc-handle">@{{ handle }}</div>
|
||||
|
||||
<div class="pc-status">{{ status }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="pc-contact-btn" @click="handleContactClick" style="pointer-events: auto" type="button"
|
||||
:aria-label="`Contact ${name || 'user'}`">
|
||||
|
||||
<button
|
||||
class="pc-contact-btn"
|
||||
@click="handleContactClick"
|
||||
style="pointer-events: auto"
|
||||
type="button"
|
||||
:aria-label="`Contact ${name || 'user'}`"
|
||||
>
|
||||
{{ contactText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pc-content">
|
||||
<div class="pc-details">
|
||||
<h3>{{ name }}</h3>
|
||||
|
||||
<p>{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,24 +58,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
interface Props {
|
||||
avatarUrl?: string
|
||||
iconUrl?: string
|
||||
grainUrl?: string
|
||||
behindGradient?: string
|
||||
innerGradient?: string
|
||||
showBehindGradient?: boolean
|
||||
className?: string
|
||||
enableTilt?: boolean
|
||||
miniAvatarUrl?: string
|
||||
name?: string
|
||||
title?: string
|
||||
handle?: string
|
||||
status?: string
|
||||
contactText?: string
|
||||
showUserInfo?: boolean
|
||||
avatarUrl?: string;
|
||||
iconUrl?: string;
|
||||
grainUrl?: string;
|
||||
behindGradient?: string;
|
||||
innerGradient?: string;
|
||||
showBehindGradient?: boolean;
|
||||
className?: string;
|
||||
enableTilt?: boolean;
|
||||
miniAvatarUrl?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
handle?: string;
|
||||
status?: string;
|
||||
contactText?: string;
|
||||
showUserInfo?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -72,60 +94,47 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
status: 'Online',
|
||||
contactText: 'Contact',
|
||||
showUserInfo: true
|
||||
})
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
contactClick: []
|
||||
}>()
|
||||
contactClick: [];
|
||||
}>();
|
||||
|
||||
const wrapRef = ref<HTMLDivElement>()
|
||||
const cardRef = ref<HTMLElement>()
|
||||
const wrapRef = ref<HTMLDivElement>();
|
||||
const cardRef = ref<HTMLElement>();
|
||||
|
||||
const DEFAULT_BEHIND_GRADIENT = "radial-gradient(farthest-side circle at var(--pointer-x) var(--pointer-y),hsla(266,100%,90%,var(--card-opacity)) 4%,hsla(266,50%,80%,calc(var(--card-opacity)*0.75)) 10%,hsla(266,25%,70%,calc(var(--card-opacity)*0.5)) 50%,hsla(266,0%,60%,0) 100%),radial-gradient(35% 52% at 55% 20%,#00ffaac4 0%,#073aff00 100%),radial-gradient(100% 100% at 50% 50%,#00c1ffff 1%,#073aff00 76%),conic-gradient(from 124deg at 50% 50%,#c137ffff 0%,#07c6ffff 40%,#07c6ffff 60%,#c137ffff 100%)"
|
||||
const DEFAULT_BEHIND_GRADIENT =
|
||||
'radial-gradient(farthest-side circle at var(--pointer-x) var(--pointer-y),hsla(266,100%,90%,var(--card-opacity)) 4%,hsla(266,50%,80%,calc(var(--card-opacity)*0.75)) 10%,hsla(266,25%,70%,calc(var(--card-opacity)*0.5)) 50%,hsla(266,0%,60%,0) 100%),radial-gradient(35% 52% at 55% 20%,#00ffaac4 0%,#073aff00 100%),radial-gradient(100% 100% at 50% 50%,#00c1ffff 1%,#073aff00 76%),conic-gradient(from 124deg at 50% 50%,#c137ffff 0%,#07c6ffff 40%,#07c6ffff 60%,#c137ffff 100%)';
|
||||
|
||||
const DEFAULT_INNER_GRADIENT = "linear-gradient(145deg,#60496e8c 0%,#71C4FF44 100%)"
|
||||
const DEFAULT_INNER_GRADIENT = 'linear-gradient(145deg,#60496e8c 0%,#71C4FF44 100%)';
|
||||
|
||||
const ANIMATION_CONFIG = {
|
||||
SMOOTH_DURATION: 600,
|
||||
INITIAL_DURATION: 1500,
|
||||
INITIAL_X_OFFSET: 70,
|
||||
INITIAL_Y_OFFSET: 60,
|
||||
} as const
|
||||
INITIAL_Y_OFFSET: 60
|
||||
} as const;
|
||||
|
||||
const clamp = (value: number, min = 0, max = 100): number =>
|
||||
Math.min(Math.max(value, min), max)
|
||||
const clamp = (value: number, min = 0, max = 100): number => Math.min(Math.max(value, min), max);
|
||||
|
||||
const round = (value: number, precision = 3): number =>
|
||||
parseFloat(value.toFixed(precision))
|
||||
const round = (value: number, precision = 3): number => parseFloat(value.toFixed(precision));
|
||||
|
||||
const adjust = (
|
||||
value: number,
|
||||
fromMin: number,
|
||||
fromMax: number,
|
||||
toMin: number,
|
||||
toMax: number
|
||||
): number =>
|
||||
round(toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin))
|
||||
const adjust = (value: number, fromMin: number, fromMax: number, toMin: number, toMax: number): number =>
|
||||
round(toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin));
|
||||
|
||||
const easeInOutCubic = (x: number): number =>
|
||||
x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2
|
||||
const easeInOutCubic = (x: number): number => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
|
||||
|
||||
let rafId: number | null = null
|
||||
let rafId: number | null = null;
|
||||
|
||||
const updateCardTransform = (
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
card: HTMLElement,
|
||||
wrap: HTMLElement
|
||||
) => {
|
||||
const width = card.clientWidth
|
||||
const height = card.clientHeight
|
||||
const updateCardTransform = (offsetX: number, offsetY: number, card: HTMLElement, wrap: HTMLElement) => {
|
||||
const width = card.clientWidth;
|
||||
const height = card.clientHeight;
|
||||
|
||||
const percentX = clamp((100 / width) * offsetX)
|
||||
const percentY = clamp((100 / height) * offsetY)
|
||||
const percentX = clamp((100 / width) * offsetX);
|
||||
const percentY = clamp((100 / height) * offsetY);
|
||||
|
||||
const centerX = percentX - 50
|
||||
const centerY = percentY - 50
|
||||
const centerX = percentX - 50;
|
||||
const centerY = percentY - 50;
|
||||
|
||||
const properties = {
|
||||
'--pointer-x': `${percentX}%`,
|
||||
@@ -136,13 +145,13 @@ const updateCardTransform = (
|
||||
'--pointer-from-top': `${percentY / 100}`,
|
||||
'--pointer-from-left': `${percentX / 100}`,
|
||||
'--rotate-x': `${round(-(centerX / 5))}deg`,
|
||||
'--rotate-y': `${round(centerY / 4)}deg`,
|
||||
}
|
||||
'--rotate-y': `${round(centerY / 4)}deg`
|
||||
};
|
||||
|
||||
Object.entries(properties).forEach(([property, value]) => {
|
||||
wrap.style.setProperty(property, value)
|
||||
})
|
||||
}
|
||||
wrap.style.setProperty(property, value);
|
||||
});
|
||||
};
|
||||
|
||||
const createSmoothAnimation = (
|
||||
duration: number,
|
||||
@@ -151,138 +160,119 @@ const createSmoothAnimation = (
|
||||
card: HTMLElement,
|
||||
wrap: HTMLElement
|
||||
) => {
|
||||
const startTime = performance.now()
|
||||
const targetX = wrap.clientWidth / 2
|
||||
const targetY = wrap.clientHeight / 2
|
||||
const startTime = performance.now();
|
||||
const targetX = wrap.clientWidth / 2;
|
||||
const targetY = wrap.clientHeight / 2;
|
||||
|
||||
const animationLoop = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = clamp(elapsed / duration)
|
||||
const easedProgress = easeInOutCubic(progress)
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = clamp(elapsed / duration);
|
||||
const easedProgress = easeInOutCubic(progress);
|
||||
|
||||
const currentX = adjust(easedProgress, 0, 1, startX, targetX)
|
||||
const currentY = adjust(easedProgress, 0, 1, startY, targetY)
|
||||
const currentX = adjust(easedProgress, 0, 1, startX, targetX);
|
||||
const currentY = adjust(easedProgress, 0, 1, startY, targetY);
|
||||
|
||||
updateCardTransform(currentX, currentY, card, wrap)
|
||||
updateCardTransform(currentX, currentY, card, wrap);
|
||||
|
||||
if (progress < 1) {
|
||||
rafId = requestAnimationFrame(animationLoop)
|
||||
rafId = requestAnimationFrame(animationLoop);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(animationLoop)
|
||||
}
|
||||
rafId = requestAnimationFrame(animationLoop);
|
||||
};
|
||||
|
||||
const cancelAnimation = () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const card = cardRef.value
|
||||
const wrap = wrapRef.value
|
||||
const card = cardRef.value;
|
||||
const wrap = wrapRef.value;
|
||||
|
||||
if (!card || !wrap || !props.enableTilt) return
|
||||
if (!card || !wrap || !props.enableTilt) return;
|
||||
|
||||
const rect = card.getBoundingClientRect()
|
||||
updateCardTransform(
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
card,
|
||||
wrap
|
||||
)
|
||||
}
|
||||
const rect = card.getBoundingClientRect();
|
||||
updateCardTransform(event.clientX - rect.left, event.clientY - rect.top, card, wrap);
|
||||
};
|
||||
|
||||
const handlePointerEnter = () => {
|
||||
const card = cardRef.value
|
||||
const wrap = wrapRef.value
|
||||
const card = cardRef.value;
|
||||
const wrap = wrapRef.value;
|
||||
|
||||
if (!card || !wrap || !props.enableTilt) return
|
||||
if (!card || !wrap || !props.enableTilt) return;
|
||||
|
||||
cancelAnimation()
|
||||
wrap.classList.add('active')
|
||||
card.classList.add('active')
|
||||
}
|
||||
cancelAnimation();
|
||||
wrap.classList.add('active');
|
||||
card.classList.add('active');
|
||||
};
|
||||
|
||||
const handlePointerLeave = (event: PointerEvent) => {
|
||||
const card = cardRef.value
|
||||
const wrap = wrapRef.value
|
||||
const card = cardRef.value;
|
||||
const wrap = wrapRef.value;
|
||||
|
||||
if (!card || !wrap || !props.enableTilt) return
|
||||
if (!card || !wrap || !props.enableTilt) return;
|
||||
|
||||
createSmoothAnimation(
|
||||
ANIMATION_CONFIG.SMOOTH_DURATION,
|
||||
event.offsetX,
|
||||
event.offsetY,
|
||||
card,
|
||||
wrap
|
||||
)
|
||||
wrap.classList.remove('active')
|
||||
card.classList.remove('active')
|
||||
}
|
||||
createSmoothAnimation(ANIMATION_CONFIG.SMOOTH_DURATION, event.offsetX, event.offsetY, card, wrap);
|
||||
wrap.classList.remove('active');
|
||||
card.classList.remove('active');
|
||||
};
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
'--icon': props.iconUrl ? `url(${props.iconUrl})` : 'none',
|
||||
'--grain': props.grainUrl ? `url(${props.grainUrl})` : 'none',
|
||||
'--behind-gradient': props.showBehindGradient
|
||||
? (props.behindGradient ?? DEFAULT_BEHIND_GRADIENT)
|
||||
: 'none',
|
||||
'--inner-gradient': props.innerGradient ?? DEFAULT_INNER_GRADIENT,
|
||||
}))
|
||||
'--behind-gradient': props.showBehindGradient ? (props.behindGradient ?? DEFAULT_BEHIND_GRADIENT) : 'none',
|
||||
'--inner-gradient': props.innerGradient ?? DEFAULT_INNER_GRADIENT
|
||||
}));
|
||||
|
||||
const handleContactClick = () => {
|
||||
emit('contactClick')
|
||||
}
|
||||
emit('contactClick');
|
||||
};
|
||||
|
||||
const handleAvatarError = (event: Event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}
|
||||
const target = event.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
};
|
||||
|
||||
const handleMiniAvatarError = (event: Event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.style.opacity = '0.5'
|
||||
target.src = props.avatarUrl
|
||||
}
|
||||
const target = event.target as HTMLImageElement;
|
||||
target.style.opacity = '0.5';
|
||||
target.src = props.avatarUrl;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.enableTilt) return
|
||||
if (!props.enableTilt) return;
|
||||
|
||||
const card = cardRef.value
|
||||
const wrap = wrapRef.value
|
||||
const card = cardRef.value;
|
||||
const wrap = wrapRef.value;
|
||||
|
||||
if (!card || !wrap) return
|
||||
if (!card || !wrap) return;
|
||||
|
||||
card.addEventListener('pointerenter', handlePointerEnter)
|
||||
card.addEventListener('pointermove', handlePointerMove)
|
||||
card.addEventListener('pointerleave', handlePointerLeave)
|
||||
card.addEventListener('pointerenter', handlePointerEnter);
|
||||
card.addEventListener('pointermove', handlePointerMove);
|
||||
card.addEventListener('pointerleave', handlePointerLeave);
|
||||
|
||||
const initialX = wrap.clientWidth - ANIMATION_CONFIG.INITIAL_X_OFFSET
|
||||
const initialY = ANIMATION_CONFIG.INITIAL_Y_OFFSET
|
||||
const initialX = wrap.clientWidth - ANIMATION_CONFIG.INITIAL_X_OFFSET;
|
||||
const initialY = ANIMATION_CONFIG.INITIAL_Y_OFFSET;
|
||||
|
||||
updateCardTransform(initialX, initialY, card, wrap)
|
||||
createSmoothAnimation(
|
||||
ANIMATION_CONFIG.INITIAL_DURATION,
|
||||
initialX,
|
||||
initialY,
|
||||
card,
|
||||
wrap
|
||||
)
|
||||
})
|
||||
updateCardTransform(initialX, initialY, card, wrap);
|
||||
createSmoothAnimation(ANIMATION_CONFIG.INITIAL_DURATION, initialX, initialY, card, wrap);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
const card = cardRef.value
|
||||
const card = cardRef.value;
|
||||
|
||||
if (card) {
|
||||
card.removeEventListener('pointerenter', handlePointerEnter)
|
||||
card.removeEventListener('pointermove', handlePointerMove)
|
||||
card.removeEventListener('pointerleave', handlePointerLeave)
|
||||
card.removeEventListener('pointerenter', handlePointerEnter);
|
||||
card.removeEventListener('pointermove', handlePointerMove);
|
||||
card.removeEventListener('pointerleave', handlePointerLeave);
|
||||
}
|
||||
|
||||
cancelAnimation()
|
||||
})
|
||||
cancelAnimation();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -357,12 +347,27 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
background-blend-mode: color-dodge, normal, normal, normal;
|
||||
animation: glow-bg 12s linear infinite;
|
||||
box-shadow: rgba(0, 0, 0, 0.8) calc((var(--pointer-from-left) * 10px) - 3px) calc((var(--pointer-from-top) * 20px) - 6px) 20px -5px;
|
||||
box-shadow: rgba(0, 0, 0, 0.8) calc((var(--pointer-from-left) * 10px) - 3px)
|
||||
calc((var(--pointer-from-top) * 20px) - 6px) 20px -5px;
|
||||
transition: transform 1s ease;
|
||||
transform: translate3d(0, 0, 0.1px) rotateX(0deg) rotateY(0deg);
|
||||
background-size: 100% 100%;
|
||||
background-position: 0 0, 0 0, 50% 50%, 0 0;
|
||||
background-image: radial-gradient(farthest-side circle at var(--pointer-x) var(--pointer-y), hsla(266, 100%, 90%, var(--card-opacity)) 4%, hsla(266, 50%, 80%, calc(var(--card-opacity) * 0.75)) 10%, hsla(266, 25%, 70%, calc(var(--card-opacity) * 0.5)) 50%, hsla(266, 0%, 60%, 0) 100%), radial-gradient(35% 52% at 55% 20%, #00ffaac4 0%, #073aff00 100%), radial-gradient(100% 100% at 50% 50%, #00c1ffff 1%, #073aff00 76%), conic-gradient(from 124deg at 50% 50%, #c137ffff 0%, #07c6ffff 40%, #07c6ffff 60%, #c137ffff 100%);
|
||||
background-position:
|
||||
0 0,
|
||||
0 0,
|
||||
50% 50%,
|
||||
0 0;
|
||||
background-image:
|
||||
radial-gradient(
|
||||
farthest-side circle at var(--pointer-x) var(--pointer-y),
|
||||
hsla(266, 100%, 90%, var(--card-opacity)) 4%,
|
||||
hsla(266, 50%, 80%, calc(var(--card-opacity) * 0.75)) 10%,
|
||||
hsla(266, 25%, 70%, calc(var(--card-opacity) * 0.5)) 50%,
|
||||
hsla(266, 0%, 60%, 0) 100%
|
||||
),
|
||||
radial-gradient(35% 52% at 55% 20%, #00ffaac4 0%, #073aff00 100%),
|
||||
radial-gradient(100% 100% at 50% 50%, #00c1ffff 1%, #073aff00 76%),
|
||||
conic-gradient(from 124deg at 50% 50%, #c137ffff 0%, #07c6ffff 40%, #07c6ffff 60%, #c137ffff 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -415,10 +420,41 @@ onUnmounted(() => {
|
||||
background: transparent;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-image: repeating-linear-gradient(0deg, var(--sunpillar-clr-1) calc(var(--space) * 1), var(--sunpillar-clr-2) calc(var(--space) * 2), var(--sunpillar-clr-3) calc(var(--space) * 3), var(--sunpillar-clr-4) calc(var(--space) * 4), var(--sunpillar-clr-5) calc(var(--space) * 5), var(--sunpillar-clr-6) calc(var(--space) * 6), var(--sunpillar-clr-1) calc(var(--space) * 7)), repeating-linear-gradient(var(--angle), #0e152e 0%, hsl(180, 10%, 60%) 3.8%, hsl(180, 29%, 66%) 4.5%, hsl(180, 10%, 60%) 5.2%, #0e152e 10%, #0e152e 12%), radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsla(0, 0%, 0%, 0.1) 12%, hsla(0, 0%, 0%, 0.15) 20%, hsla(0, 0%, 0%, 0.25) 120%);
|
||||
background-position: 0 var(--background-y), var(--background-x) var(--background-y), center;
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
var(--sunpillar-clr-1) calc(var(--space) * 1),
|
||||
var(--sunpillar-clr-2) calc(var(--space) * 2),
|
||||
var(--sunpillar-clr-3) calc(var(--space) * 3),
|
||||
var(--sunpillar-clr-4) calc(var(--space) * 4),
|
||||
var(--sunpillar-clr-5) calc(var(--space) * 5),
|
||||
var(--sunpillar-clr-6) calc(var(--space) * 6),
|
||||
var(--sunpillar-clr-1) calc(var(--space) * 7)
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
var(--angle),
|
||||
#0e152e 0%,
|
||||
hsl(180, 10%, 60%) 3.8%,
|
||||
hsl(180, 29%, 66%) 4.5%,
|
||||
hsl(180, 10%, 60%) 5.2%,
|
||||
#0e152e 10%,
|
||||
#0e152e 12%
|
||||
),
|
||||
radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
||||
hsla(0, 0%, 0%, 0.1) 12%,
|
||||
hsla(0, 0%, 0%, 0.15) 20%,
|
||||
hsla(0, 0%, 0%, 0.25) 120%
|
||||
);
|
||||
background-position:
|
||||
0 var(--background-y),
|
||||
var(--background-x) var(--background-y),
|
||||
center;
|
||||
background-blend-mode: color, hard-light;
|
||||
background-size: 500% 500%, 300% 300%, 200% 200%;
|
||||
background-size:
|
||||
500% 500%,
|
||||
300% 300%,
|
||||
200% 200%;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
@@ -445,19 +481,68 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.pc-shine::before {
|
||||
background-image: linear-gradient(45deg, var(--sunpillar-4), var(--sunpillar-5), var(--sunpillar-6), var(--sunpillar-1), var(--sunpillar-2), var(--sunpillar-3)), radial-gradient(circle at var(--pointer-x) var(--pointer-y), hsl(0, 0%, 70%) 0%, hsla(0, 0%, 30%, 0.2) 90%), var(--grain);
|
||||
background-size: 250% 250%, 100% 100%, 220px 220px;
|
||||
background-position: var(--pointer-x) var(--pointer-y), center, calc(var(--pointer-x) * 0.01) calc(var(--pointer-y) * 0.01);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
45deg,
|
||||
var(--sunpillar-4),
|
||||
var(--sunpillar-5),
|
||||
var(--sunpillar-6),
|
||||
var(--sunpillar-1),
|
||||
var(--sunpillar-2),
|
||||
var(--sunpillar-3)
|
||||
),
|
||||
radial-gradient(circle at var(--pointer-x) var(--pointer-y), hsl(0, 0%, 70%) 0%, hsla(0, 0%, 30%, 0.2) 90%),
|
||||
var(--grain);
|
||||
background-size:
|
||||
250% 250%,
|
||||
100% 100%,
|
||||
220px 220px;
|
||||
background-position:
|
||||
var(--pointer-x) var(--pointer-y),
|
||||
center,
|
||||
calc(var(--pointer-x) * 0.01) calc(var(--pointer-y) * 0.01);
|
||||
background-blend-mode: color-dodge;
|
||||
filter: brightness(calc(2 - var(--pointer-from-center))) contrast(calc(var(--pointer-from-center) + 2)) saturate(calc(0.5 + var(--pointer-from-center)));
|
||||
filter: brightness(calc(2 - var(--pointer-from-center))) contrast(calc(var(--pointer-from-center) + 2))
|
||||
saturate(calc(0.5 + var(--pointer-from-center)));
|
||||
mix-blend-mode: luminosity;
|
||||
}
|
||||
|
||||
.pc-shine::after {
|
||||
content: '';
|
||||
background-image: repeating-linear-gradient(0deg, var(--sunpillar-clr-1) calc(5% * 1), var(--sunpillar-clr-2) calc(5% * 2), var(--sunpillar-clr-3) calc(5% * 3), var(--sunpillar-clr-4) calc(5% * 4), var(--sunpillar-clr-5) calc(5% * 5), var(--sunpillar-clr-6) calc(5% * 6), var(--sunpillar-clr-1) calc(5% * 7)), repeating-linear-gradient(-45deg, #0e152e 0%, hsl(180, 10%, 60%) 3.8%, hsl(180, 29%, 66%) 4.5%, hsl(180, 10%, 60%) 5.2%, #0e152e 10%, #0e152e 12%), radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsla(0, 0%, 0%, 0.1) 12%, hsla(0, 0%, 0%, 0.15) 20%, hsla(0, 0%, 0%, 0.25) 120%);
|
||||
background-position: 0 var(--background-y), calc(var(--background-x) * 0.4) calc(var(--background-y) * 0.5), center;
|
||||
background-size: 200% 300%, 700% 700%, 100% 100%;
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
var(--sunpillar-clr-1) calc(5% * 1),
|
||||
var(--sunpillar-clr-2) calc(5% * 2),
|
||||
var(--sunpillar-clr-3) calc(5% * 3),
|
||||
var(--sunpillar-clr-4) calc(5% * 4),
|
||||
var(--sunpillar-clr-5) calc(5% * 5),
|
||||
var(--sunpillar-clr-6) calc(5% * 6),
|
||||
var(--sunpillar-clr-1) calc(5% * 7)
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
#0e152e 0%,
|
||||
hsl(180, 10%, 60%) 3.8%,
|
||||
hsl(180, 29%, 66%) 4.5%,
|
||||
hsl(180, 10%, 60%) 5.2%,
|
||||
#0e152e 10%,
|
||||
#0e152e 12%
|
||||
),
|
||||
radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
||||
hsla(0, 0%, 0%, 0.1) 12%,
|
||||
hsla(0, 0%, 0%, 0.15) 20%,
|
||||
hsla(0, 0%, 0%, 0.25) 120%
|
||||
);
|
||||
background-position:
|
||||
0 var(--background-y),
|
||||
calc(var(--background-x) * 0.4) calc(var(--background-y) * 0.5),
|
||||
center;
|
||||
background-size:
|
||||
200% 300%,
|
||||
700% 700%,
|
||||
100% 100%;
|
||||
mix-blend-mode: difference;
|
||||
filter: brightness(0.8) contrast(1.5);
|
||||
}
|
||||
@@ -465,7 +550,11 @@ onUnmounted(() => {
|
||||
.pc-glare {
|
||||
transform: translate3d(0, 0, 1.1px);
|
||||
overflow: hidden;
|
||||
background-image: radial-gradient(farthest-corner circle at var(--pointer-x) var(--pointer-y), hsl(248, 25%, 80%) 12%, hsla(207, 40%, 30%, 0.8) 90%);
|
||||
background-image: radial-gradient(
|
||||
farthest-corner circle at var(--pointer-x) var(--pointer-y),
|
||||
hsl(248, 25%, 80%) 12%,
|
||||
hsla(207, 40%, 30%, 0.8) 90%
|
||||
);
|
||||
mix-blend-mode: overlay;
|
||||
filter: brightness(0.8) contrast(1.2);
|
||||
z-index: 4;
|
||||
@@ -486,16 +575,18 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.pc-avatar-content::before {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
backdrop-filter: blur(30px);
|
||||
mask: linear-gradient(to bottom,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0) 60%,
|
||||
rgba(0, 0, 0, 1) 90%,
|
||||
rgba(0, 0, 0, 1) 100%);
|
||||
mask: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0) 60%,
|
||||
rgba(0, 0, 0, 1) 90%,
|
||||
rgba(0, 0, 0, 1) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -582,7 +673,11 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
transform: translate3d(calc(var(--pointer-from-left) * -6px + 3px), calc(var(--pointer-from-top) * -6px + 3px), 0.1px) !important;
|
||||
transform: translate3d(
|
||||
calc(var(--pointer-from-left) * -6px + 3px),
|
||||
calc(var(--pointer-from-top) * -6px + 3px),
|
||||
0.1px
|
||||
) !important;
|
||||
z-index: 5;
|
||||
mix-blend-mode: luminosity;
|
||||
}
|
||||
@@ -634,11 +729,17 @@ onUnmounted(() => {
|
||||
|
||||
@keyframes holo-bg {
|
||||
0% {
|
||||
background-position: 0 var(--background-y), 0 0, center;
|
||||
background-position:
|
||||
0 var(--background-y),
|
||||
0 0,
|
||||
center;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0 var(--background-y), 90% 90%, center;
|
||||
background-position:
|
||||
0 var(--background-y),
|
||||
90% 90%,
|
||||
center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,4 +884,4 @@ onUnmounted(() => {
|
||||
border-radius: 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,59 +1,67 @@
|
||||
<template>
|
||||
<div ref="divRef" @mousemove="handleMouseMove" @focus="handleFocus" @blur="handleBlur" @mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave" :class="[
|
||||
'relative rounded-3xl border overflow-hidden p-8',
|
||||
className
|
||||
]">
|
||||
<div class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 ease-in-out" :style="{
|
||||
opacity,
|
||||
background: `radial-gradient(circle at ${position.x}px ${position.y}px, ${spotlightColor}, transparent 80%)`,
|
||||
}" />
|
||||
<div
|
||||
ref="divRef"
|
||||
@mousemove="handleMouseMove"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
:class="['relative rounded-3xl border overflow-hidden p-8', className]"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 ease-in-out"
|
||||
:style="{
|
||||
opacity,
|
||||
background: `radial-gradient(circle at ${position.x}px ${position.y}px, ${spotlightColor}, transparent 80%)`
|
||||
}"
|
||||
/>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface SpotlightCardProps {
|
||||
className?: string
|
||||
spotlightColor?: string
|
||||
className?: string;
|
||||
spotlightColor?: string;
|
||||
}
|
||||
|
||||
const { className = '', spotlightColor = 'rgba(255, 255, 255, 0.25)' } = defineProps<SpotlightCardProps>()
|
||||
const { className = '', spotlightColor = 'rgba(255, 255, 255, 0.25)' } = defineProps<SpotlightCardProps>();
|
||||
|
||||
const divRef = ref<HTMLDivElement>()
|
||||
const isFocused = ref<boolean>(false)
|
||||
const position = ref<Position>({ x: 0, y: 0 })
|
||||
const opacity = ref<number>(0)
|
||||
const divRef = ref<HTMLDivElement>();
|
||||
const isFocused = ref<boolean>(false);
|
||||
const position = ref<Position>({ x: 0, y: 0 });
|
||||
const opacity = ref<number>(0);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!divRef.value || isFocused.value) return
|
||||
if (!divRef.value || isFocused.value) return;
|
||||
|
||||
const rect = divRef.value.getBoundingClientRect()
|
||||
position.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
}
|
||||
const rect = divRef.value.getBoundingClientRect();
|
||||
position.value = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
isFocused.value = true
|
||||
opacity.value = 0.6
|
||||
}
|
||||
isFocused.value = true;
|
||||
opacity.value = 0.6;
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocused.value = false
|
||||
opacity.value = 0
|
||||
}
|
||||
isFocused.value = false;
|
||||
opacity.value = 0;
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
opacity.value = 0.6
|
||||
}
|
||||
opacity.value = 0.6;
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
opacity.value = 0
|
||||
}
|
||||
</script>
|
||||
opacity.value = 0;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,65 +1,87 @@
|
||||
<template>
|
||||
<figure ref="cardRef" class="relative w-full h-full [perspective:800px] flex flex-col items-center justify-center"
|
||||
<figure
|
||||
ref="cardRef"
|
||||
class="relative w-full h-full [perspective:800px] flex flex-col items-center justify-center"
|
||||
:style="{
|
||||
height: containerHeight,
|
||||
width: containerWidth,
|
||||
}" @mousemove="handleMouse" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
|
||||
width: containerWidth
|
||||
}"
|
||||
@mousemove="handleMouse"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<div v-if="showMobileWarning" class="absolute top-4 text-center text-sm block sm:hidden">
|
||||
This effect is not optimized for mobile. Check on desktop.
|
||||
</div>
|
||||
|
||||
<Motion tag="div" class="relative [transform-style:preserve-3d]" :style="{
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
}" :animate="{
|
||||
<Motion
|
||||
tag="div"
|
||||
class="relative [transform-style:preserve-3d]"
|
||||
:style="{
|
||||
width: imageWidth,
|
||||
height: imageHeight
|
||||
}"
|
||||
:animate="{
|
||||
rotateX: rotateXValue,
|
||||
rotateY: rotateYValue,
|
||||
scale: scaleValue,
|
||||
}" :transition="springTransition">
|
||||
<img :src="imageSrc" :alt="altText"
|
||||
scale: scaleValue
|
||||
}"
|
||||
:transition="springTransition"
|
||||
>
|
||||
<img
|
||||
:src="imageSrc"
|
||||
:alt="altText"
|
||||
class="absolute top-0 left-0 object-cover rounded-[15px] will-change-transform [transform:translateZ(0)]"
|
||||
:style="{
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
}" />
|
||||
height: imageHeight
|
||||
}"
|
||||
/>
|
||||
|
||||
<Motion v-if="displayOverlayContent && overlayContent" tag="div"
|
||||
class="absolute top-0 left-0 z-[2] will-change-transform [transform:translateZ(30px)]">
|
||||
<Motion
|
||||
v-if="displayOverlayContent && overlayContent"
|
||||
tag="div"
|
||||
class="absolute top-0 left-0 z-[2] will-change-transform [transform:translateZ(30px)]"
|
||||
>
|
||||
<slot name="overlay" />
|
||||
</Motion>
|
||||
</Motion>
|
||||
|
||||
<Motion v-if="showTooltip && captionText" tag="figcaption"
|
||||
<Motion
|
||||
v-if="showTooltip && captionText"
|
||||
tag="figcaption"
|
||||
class="pointer-events-none absolute left-0 top-0 rounded-[4px] bg-white px-[10px] py-[4px] text-[10px] text-[#2d2d2d] opacity-0 z-[3] hidden sm:block"
|
||||
:animate="{
|
||||
x: xValue,
|
||||
y: yValue,
|
||||
opacity: opacityValue,
|
||||
rotate: rotateFigcaptionValue,
|
||||
}" :transition="tooltipTransition">
|
||||
rotate: rotateFigcaptionValue
|
||||
}"
|
||||
:transition="tooltipTransition"
|
||||
>
|
||||
{{ captionText }}
|
||||
</Motion>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Motion } from 'motion-v'
|
||||
import { ref, computed } from 'vue';
|
||||
import { Motion } from 'motion-v';
|
||||
|
||||
interface TiltedCardProps {
|
||||
imageSrc: string
|
||||
altText?: string
|
||||
captionText?: string
|
||||
containerHeight?: string
|
||||
containerWidth?: string
|
||||
imageHeight?: string
|
||||
imageWidth?: string
|
||||
scaleOnHover?: number
|
||||
rotateAmplitude?: number
|
||||
showMobileWarning?: boolean
|
||||
showTooltip?: boolean
|
||||
overlayContent?: boolean
|
||||
displayOverlayContent?: boolean
|
||||
imageSrc: string;
|
||||
altText?: string;
|
||||
captionText?: string;
|
||||
containerHeight?: string;
|
||||
containerWidth?: string;
|
||||
imageHeight?: string;
|
||||
imageWidth?: string;
|
||||
scaleOnHover?: number;
|
||||
rotateAmplitude?: number;
|
||||
showMobileWarning?: boolean;
|
||||
showTooltip?: boolean;
|
||||
overlayContent?: boolean;
|
||||
displayOverlayContent?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TiltedCardProps>(), {
|
||||
@@ -74,64 +96,64 @@ const props = withDefaults(defineProps<TiltedCardProps>(), {
|
||||
showMobileWarning: true,
|
||||
showTooltip: true,
|
||||
overlayContent: false,
|
||||
displayOverlayContent: false,
|
||||
})
|
||||
displayOverlayContent: false
|
||||
});
|
||||
|
||||
const cardRef = ref<HTMLElement | null>(null)
|
||||
const xValue = ref(0)
|
||||
const yValue = ref(0)
|
||||
const rotateXValue = ref(0)
|
||||
const rotateYValue = ref(0)
|
||||
const scaleValue = ref(1)
|
||||
const opacityValue = ref(0)
|
||||
const rotateFigcaptionValue = ref(0)
|
||||
const lastY = ref(0)
|
||||
const cardRef = ref<HTMLElement | null>(null);
|
||||
const xValue = ref(0);
|
||||
const yValue = ref(0);
|
||||
const rotateXValue = ref(0);
|
||||
const rotateYValue = ref(0);
|
||||
const scaleValue = ref(1);
|
||||
const opacityValue = ref(0);
|
||||
const rotateFigcaptionValue = ref(0);
|
||||
const lastY = ref(0);
|
||||
|
||||
const springTransition = computed(() => ({
|
||||
type: 'spring' as const,
|
||||
damping: 30,
|
||||
stiffness: 100,
|
||||
mass: 2,
|
||||
}))
|
||||
mass: 2
|
||||
}));
|
||||
|
||||
const tooltipTransition = computed(() => ({
|
||||
type: 'spring' as const,
|
||||
damping: 30,
|
||||
stiffness: 350,
|
||||
mass: 1,
|
||||
}))
|
||||
mass: 1
|
||||
}));
|
||||
|
||||
function handleMouse(e: MouseEvent) {
|
||||
if (!cardRef.value) return
|
||||
if (!cardRef.value) return;
|
||||
|
||||
const rect = cardRef.value.getBoundingClientRect()
|
||||
const offsetX = e.clientX - rect.left - rect.width / 2
|
||||
const offsetY = e.clientY - rect.top - rect.height / 2
|
||||
const rect = cardRef.value.getBoundingClientRect();
|
||||
const offsetX = e.clientX - rect.left - rect.width / 2;
|
||||
const offsetY = e.clientY - rect.top - rect.height / 2;
|
||||
|
||||
const rotationX = (offsetY / (rect.height / 2)) * -props.rotateAmplitude
|
||||
const rotationY = (offsetX / (rect.width / 2)) * props.rotateAmplitude
|
||||
const rotationX = (offsetY / (rect.height / 2)) * -props.rotateAmplitude;
|
||||
const rotationY = (offsetX / (rect.width / 2)) * props.rotateAmplitude;
|
||||
|
||||
rotateXValue.value = rotationX
|
||||
rotateYValue.value = rotationY
|
||||
rotateXValue.value = rotationX;
|
||||
rotateYValue.value = rotationY;
|
||||
|
||||
xValue.value = e.clientX - rect.left
|
||||
yValue.value = e.clientY - rect.top
|
||||
xValue.value = e.clientX - rect.left;
|
||||
yValue.value = e.clientY - rect.top;
|
||||
|
||||
const velocityY = offsetY - lastY.value
|
||||
rotateFigcaptionValue.value = -velocityY * 0.6
|
||||
lastY.value = offsetY
|
||||
const velocityY = offsetY - lastY.value;
|
||||
rotateFigcaptionValue.value = -velocityY * 0.6;
|
||||
lastY.value = offsetY;
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
scaleValue.value = props.scaleOnHover
|
||||
opacityValue.value = 1
|
||||
scaleValue.value = props.scaleOnHover;
|
||||
opacityValue.value = 1;
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
opacityValue.value = 0
|
||||
scaleValue.value = 1
|
||||
rotateXValue.value = 0
|
||||
rotateYValue.value = 0
|
||||
rotateFigcaptionValue.value = 0
|
||||
opacityValue.value = 0;
|
||||
scaleValue.value = 1;
|
||||
rotateXValue.value = 0;
|
||||
rotateYValue.value = 0;
|
||||
rotateFigcaptionValue.value = 0;
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<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
|
||||
: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 }}
|
||||
{{ char === ' ' ? '\u00A0' : char }}
|
||||
</span>
|
||||
</span>
|
||||
</h2>
|
||||
@@ -43,7 +45,7 @@ let scrollTriggerInstance: ScrollTrigger | null = null;
|
||||
|
||||
const splitText = computed(() => {
|
||||
const text = typeof props.children === 'string' ? props.children : '';
|
||||
return text.split("");
|
||||
return text.split('');
|
||||
});
|
||||
|
||||
const initializeAnimation = () => {
|
||||
@@ -51,9 +53,7 @@ const initializeAnimation = () => {
|
||||
if (!el) return;
|
||||
|
||||
const scroller =
|
||||
props.scrollContainerRef && props.scrollContainerRef.current
|
||||
? props.scrollContainerRef.current
|
||||
: window;
|
||||
props.scrollContainerRef && props.scrollContainerRef.current ? props.scrollContainerRef.current : window;
|
||||
|
||||
const charElements = el.querySelectorAll('.char');
|
||||
|
||||
@@ -117,4 +117,4 @@ watch(
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
</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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { motion } from "motion-v";
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { motion } from 'motion-v';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
interface TrueFocusProps {
|
||||
sentence?: string;
|
||||
@@ -13,16 +13,16 @@ interface TrueFocusProps {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TrueFocusProps>(), {
|
||||
sentence: "True Focus",
|
||||
sentence: 'True Focus',
|
||||
manualMode: false,
|
||||
blurAmount: 5,
|
||||
borderColor: "green",
|
||||
glowColor: "rgba(0, 255, 0, 0.6)",
|
||||
borderColor: 'green',
|
||||
glowColor: 'rgba(0, 255, 0, 0.6)',
|
||||
animationDuration: 0.5,
|
||||
pauseBetweenAnimations: 1,
|
||||
pauseBetweenAnimations: 1
|
||||
});
|
||||
|
||||
const words = computed(() => props.sentence.split(" "));
|
||||
const words = computed(() => props.sentence.split(' '));
|
||||
const currentIndex = ref(0);
|
||||
const lastActiveIndex = ref<number | null>(null);
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
@@ -32,12 +32,7 @@ const focusRect = ref({ x: 0, y: 0, width: 0, height: 0 });
|
||||
let interval: number | null = null;
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.manualMode,
|
||||
() => props.animationDuration,
|
||||
() => props.pauseBetweenAnimations,
|
||||
words,
|
||||
],
|
||||
[() => props.manualMode, () => props.animationDuration, () => props.pauseBetweenAnimations, words],
|
||||
() => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
@@ -49,11 +44,11 @@ watch(
|
||||
() => {
|
||||
currentIndex.value = (currentIndex.value + 1) % words.value.length;
|
||||
},
|
||||
(props.animationDuration + props.pauseBetweenAnimations) * 1000,
|
||||
(props.animationDuration + props.pauseBetweenAnimations) * 1000
|
||||
);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -71,10 +66,10 @@ watch(
|
||||
x: activeRect.left - parentRect.left,
|
||||
y: activeRect.top - parentRect.top,
|
||||
width: activeRect.width,
|
||||
height: activeRect.height,
|
||||
height: activeRect.height
|
||||
};
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleMouseEnter = (index: number) => {
|
||||
@@ -107,7 +102,7 @@ onMounted(async () => {
|
||||
x: activeRect.left - parentRect.left,
|
||||
y: activeRect.top - parentRect.top,
|
||||
width: activeRect.width,
|
||||
height: activeRect.height,
|
||||
height: activeRect.height
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -121,36 +116,55 @@ onUnmounted(() => {
|
||||
|
||||
<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="{
|
||||
<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">
|
||||
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="{
|
||||
<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,
|
||||
}">
|
||||
'--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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
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