Merge branch 'main' into feat/counter

This commit is contained in:
David
2025-07-24 15:42:58 +03:00
committed by GitHub
9 changed files with 1051 additions and 2 deletions

View File

@@ -0,0 +1,317 @@
<template>
<div class="relative h-[500px] w-full overflow-hidden">
<div class="absolute top-0 left-0 h-full w-12 z-10 bg-gradient-to-l from-transparent to-[#0b0b0b]" />
<div class="absolute top-0 right-0 h-full w-12 z-10 bg-gradient-to-r from-transparent to-[#0b0b0b]" />
<div class="flex h-full items-center justify-center [perspective:1000px] [transform-style:preserve-3d]">
<Motion
tag="div"
class="flex min-h-[200px] items-center justify-center w-full cursor-grab select-none will-change-transform [transform-style:preserve-3d] active:cursor-grabbing"
:style="trackStyle"
:animate="animateProps"
:transition="springTransition"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@mousedown="handleMouseDown"
>
<div
v-for="(url, i) in displayImages"
:key="`gallery-${i}`"
:style="getItemStyle(i)"
class="absolute flex items-center justify-center px-[8%] [backface-visibility:hidden] will-change-transform pointer-events-none"
>
<img
:src="url"
alt="gallery"
loading="lazy"
decoding="async"
class="pointer-events-auto h-[120px] w-[300px] rounded-[15px] border-[3px] border-white object-cover transition-transform duration-300 ease-in-out will-change-transform hover:scale-105"
/>
</div>
</Motion>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
import { Motion } from 'motion-v';
interface RollingGalleryProps {
autoplay?: boolean;
pauseOnHover?: boolean;
images?: string[];
}
const props = withDefaults(defineProps<RollingGalleryProps>(), {
autoplay: false,
pauseOnHover: false,
images: () => []
});
const DEFAULT_IMAGES = shallowRef([
"https://images.unsplash.com/photo-1528181304800-259b08848526?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
"https://images.unsplash.com/photo-1506665531195-3566af2b4dfa?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
"https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?q=80&w=3456&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
"https://images.unsplash.com/photo-1495103033382-fe343886b671?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
"https://images.unsplash.com/photo-1506781961370-37a89d6b3095?q=80&w=3264&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
"https://images.unsplash.com/photo-1599576838688-8a6c11263108?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
"https://images.unsplash.com/photo-1494094892896-7f14a4433b7a?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
"https://plus.unsplash.com/premium_photo-1664910706524-e783eed89e71?q=80&w=3869&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
"https://images.unsplash.com/photo-1503788311183-fa3bf9c4bc32?q=80&w=3870&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
"https://images.unsplash.com/photo-1585970480901-90d6bb2a48b5?q=80&w=3774&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
])
const isScreenSizeSm = ref(false);
const rotateYValue = ref(0);
const autoplayInterval = ref<number | null>(null);
const autoplayTimeout = ref<number | null>(null);
const isDragging = ref(false);
const isHovered = ref(false);
const dragStartX = ref(0);
const dragStartRotation = ref(0);
const displayImages = computed(() => {
const sourceImages = props.images.length > 0 ? props.images : DEFAULT_IMAGES.value;
const maxImages = REFERENCE_FACE_COUNT_SPACING;
if (sourceImages.length >= maxImages) {
return sourceImages;
}
const repeatedImages = [];
const repetitions = Math.ceil(maxImages / sourceImages.length);
for (let i = 0; i < repetitions; i++) {
repeatedImages.push(...sourceImages);
}
return repeatedImages.slice(0, maxImages);
});
const cylinderWidth = computed(() => (isScreenSizeSm.value ? 1100 : 1800));
const faceWidth = computed(() => {
return (cylinderWidth.value / REFERENCE_FACE_COUNT_SIZING) * 1.5;
});
const radius = computed(() => cylinderWidth.value / (2 * Math.PI));
const DRAG_FACTOR = Object.freeze(0.15);
const MOMENTUM_FACTOR = Object.freeze(0.05);
const AUTOPLAY_INTERVAL = Object.freeze(2000);
const DRAG_RESTART_DELAY = Object.freeze(1500);
const HOVER_RESTART_DELAY = Object.freeze(100);
const HOVER_DEBOUNCE_DELAY = Object.freeze(50);
const REFERENCE_FACE_COUNT_SPACING = Object.freeze(10);
const REFERENCE_FACE_COUNT_SIZING = Object.freeze(10);
const trackStyle = computed(() => ({
width: `${cylinderWidth.value}px`,
transformStyle: 'preserve-3d' as const
}));
const animateProps = computed(() => ({
rotateY: rotateYValue.value
}));
const springTransition = computed(() => {
if (isDragging.value) {
return { duration: 0 };
} else {
return {
duration: 0.8,
ease: 'easeOut' as const
};
}
});
const styleCache = new Map<string, { width: string; transform: string }>();
const getItemStyle = (index: number) => {
const cacheKey = `${index}-${faceWidth.value}-${radius.value}`;
if (styleCache.has(cacheKey)) {
return styleCache.get(cacheKey)!;
}
const style = {
width: `${faceWidth.value}px`,
transform: `rotateY(${index * (360 / REFERENCE_FACE_COUNT_SPACING)}deg) translateZ(${radius.value}px)`
};
if (styleCache.size > 50) {
styleCache.clear();
}
styleCache.set(cacheKey, style);
return style;
};
let resizeTimeout: number | null = null;
let hoverTimeout: number | null = null;
function checkScreenSize() {
isScreenSizeSm.value = window.innerWidth <= 640;
}
function throttledResize() {
if (resizeTimeout) return;
resizeTimeout = setTimeout(() => {
checkScreenSize();
resizeTimeout = null;
}, 100);
}
function handleMouseDown(event: MouseEvent) {
isDragging.value = true;
dragStartX.value = event.clientX;
dragStartRotation.value = rotateYValue.value;
stopAutoplay();
document.addEventListener('mousemove', handleMouseMove, { passive: true });
document.addEventListener('mouseup', handleMouseUp, { passive: true });
event.preventDefault();
}
function handleMouseMove(event: MouseEvent) {
if (!isDragging.value) return;
const deltaX = event.clientX - dragStartX.value;
const rotationDelta = deltaX * DRAG_FACTOR;
rotateYValue.value = dragStartRotation.value + rotationDelta;
}
function handleMouseUp(event: MouseEvent) {
if (!isDragging.value) return;
isDragging.value = false;
const deltaX = event.clientX - dragStartX.value;
const velocity = deltaX * MOMENTUM_FACTOR;
rotateYValue.value += velocity;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
stopAutoplay();
if (props.autoplay) {
if (props.pauseOnHover && isHovered.value) {
return;
} else {
autoplayTimeout.value = setTimeout(() => {
if (!isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
startAutoplay();
}
}, DRAG_RESTART_DELAY);
}
}
}
function startAutoplay() {
if (!props.autoplay || isDragging.value || (props.pauseOnHover && isHovered.value)) return;
stopAutoplay();
autoplayInterval.value = setInterval(() => {
if (!isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
rotateYValue.value -= 360 / REFERENCE_FACE_COUNT_SPACING;
}
}, AUTOPLAY_INTERVAL);
}
function stopAutoplay() {
if (autoplayInterval.value) {
clearInterval(autoplayInterval.value);
autoplayInterval.value = null;
}
if (autoplayTimeout.value) {
clearTimeout(autoplayTimeout.value);
autoplayTimeout.value = null;
}
}
function handleMouseEnter() {
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
hoverTimeout = setTimeout(() => {
isHovered.value = true;
if (props.autoplay && props.pauseOnHover && !isDragging.value) {
stopAutoplay();
}
}, HOVER_DEBOUNCE_DELAY);
}
function handleMouseLeave() {
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
hoverTimeout = setTimeout(() => {
isHovered.value = false;
if (props.autoplay && props.pauseOnHover && !isDragging.value) {
stopAutoplay();
autoplayTimeout.value = setTimeout(() => {
if (props.autoplay && !isDragging.value && !isHovered.value) {
startAutoplay();
}
}, HOVER_RESTART_DELAY);
}
}, HOVER_DEBOUNCE_DELAY);
}
onMounted(() => {
checkScreenSize();
window.addEventListener('resize', throttledResize, { passive: true });
if (props.autoplay) {
startAutoplay();
}
});
onUnmounted(() => {
window.removeEventListener('resize', throttledResize);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
stopAutoplay();
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
});
watch(
() => props.autoplay,
newVal => {
stopAutoplay();
if (newVal && !isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
autoplayTimeout.value = setTimeout(() => {
if (!isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
startAutoplay();
}
}, HOVER_RESTART_DELAY);
}
}
);
watch(
() => props.pauseOnHover,
() => {
if (props.autoplay) {
stopAutoplay();
if (!isDragging.value && (!props.pauseOnHover || !isHovered.value)) {
startAutoplay();
}
}
}
);
</script>

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
import Lenis from 'lenis';
import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
interface CardTransform {
translateY: number;
scale: number;
rotation: number;
blur: number;
}
interface ScrollStackProps {
className?: string;
itemDistance?: number;
itemScale?: number;
itemStackDistance?: number;
stackPosition?: string;
scaleEndPosition?: string;
baseScale?: number;
scaleDuration?: number;
rotationAmount?: number;
blurAmount?: number;
onStackComplete?: () => void;
}
const props = withDefaults(defineProps<ScrollStackProps>(), {
className: '',
itemDistance: 100,
itemScale: 0.03,
itemStackDistance: 30,
stackPosition: '20%',
scaleEndPosition: '10%',
baseScale: 0.85,
scaleDuration: 0.5,
rotationAmount: 0,
blurAmount: 0
});
const scrollerRef = useTemplateRef('scrollerRef');
const stackCompletedRef = ref(false);
const animationFrameRef = ref<number | null>(null);
const lenisRef = ref<Lenis | null>(null);
const cardsRef = ref<HTMLElement[]>([]);
const lastTransformsRef = ref(new Map<number, CardTransform>());
const isUpdatingRef = ref(false);
const calculateProgress = (scrollTop: number, start: number, end: number) => {
if (scrollTop < start) return 0;
if (scrollTop > end) return 1;
return (scrollTop - start) / (end - start);
};
const parsePercentage = (value: string | number, containerHeight: number) => {
if (typeof value === 'string' && value.includes('%')) {
return (parseFloat(value) / 100) * containerHeight;
}
return parseFloat(value as string);
};
const updateCardTransforms = () => {
const scroller = scrollerRef.value;
if (!scroller || !cardsRef.value.length || isUpdatingRef.value) return;
isUpdatingRef.value = true;
const scrollTop = scroller.scrollTop;
const containerHeight = scroller.clientHeight;
const stackPositionPx = parsePercentage(props.stackPosition, containerHeight);
const scaleEndPositionPx = parsePercentage(props.scaleEndPosition, containerHeight);
const endElement = scroller.querySelector('.scroll-stack-end') as HTMLElement;
const endElementTop = endElement ? endElement.offsetTop : 0;
cardsRef.value.forEach((card, i) => {
if (!card) return;
const cardTop = card.offsetTop;
const triggerStart = cardTop - stackPositionPx - props.itemStackDistance * i;
const triggerEnd = cardTop - scaleEndPositionPx;
const pinStart = cardTop - stackPositionPx - props.itemStackDistance * i;
const pinEnd = endElementTop - containerHeight / 2;
const scaleProgress = calculateProgress(scrollTop, triggerStart, triggerEnd);
const targetScale = props.baseScale + i * props.itemScale;
const scale = 1 - scaleProgress * (1 - targetScale);
const rotation = props.rotationAmount ? i * props.rotationAmount * scaleProgress : 0;
let blur = 0;
if (props.blurAmount) {
let topCardIndex = 0;
for (let j = 0; j < cardsRef.value.length; j++) {
const jCardTop = cardsRef.value[j].offsetTop;
const jTriggerStart = jCardTop - stackPositionPx - props.itemStackDistance * j;
if (scrollTop >= jTriggerStart) {
topCardIndex = j;
}
}
if (i < topCardIndex) {
const depthInStack = topCardIndex - i;
blur = Math.max(0, depthInStack * props.blurAmount);
}
}
let translateY = 0;
const isPinned = scrollTop >= pinStart && scrollTop <= pinEnd;
if (isPinned) {
translateY = scrollTop - cardTop + stackPositionPx + props.itemStackDistance * i;
} else if (scrollTop > pinEnd) {
translateY = pinEnd - cardTop + stackPositionPx + props.itemStackDistance * i;
}
const newTransform = {
translateY: Math.round(translateY * 100) / 100,
scale: Math.round(scale * 1000) / 1000,
rotation: Math.round(rotation * 100) / 100,
blur: Math.round(blur * 100) / 100
};
const lastTransform = lastTransformsRef.value.get(i);
const hasChanged =
!lastTransform ||
Math.abs(lastTransform.translateY - newTransform.translateY) > 0.1 ||
Math.abs(lastTransform.scale - newTransform.scale) > 0.001 ||
Math.abs(lastTransform.rotation - newTransform.rotation) > 0.1 ||
Math.abs(lastTransform.blur - newTransform.blur) > 0.1;
if (hasChanged) {
const transform = `translate3d(0, ${newTransform.translateY}px, 0) scale(${newTransform.scale}) rotate(${newTransform.rotation}deg)`;
const filter = newTransform.blur > 0 ? `blur(${newTransform.blur}px)` : '';
card.style.transform = transform;
card.style.filter = filter;
lastTransformsRef.value.set(i, newTransform);
}
if (i === cardsRef.value.length - 1) {
const isInView = scrollTop >= pinStart && scrollTop <= pinEnd;
if (isInView && !stackCompletedRef.value) {
stackCompletedRef.value = true;
props.onStackComplete?.();
} else if (!isInView && stackCompletedRef.value) {
stackCompletedRef.value = false;
}
}
});
isUpdatingRef.value = false;
};
const handleScroll = () => {
updateCardTransforms();
};
const setupLenis = () => {
const scroller = scrollerRef.value;
if (!scroller) return;
const lenis = new Lenis({
wrapper: scroller,
content: scroller.querySelector('.scroll-stack-inner') as HTMLElement,
duration: 1.2,
easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
touchMultiplier: 2,
infinite: false,
gestureOrientation: 'vertical',
wheelMultiplier: 1,
lerp: 0.1,
syncTouch: true,
syncTouchLerp: 0.075
});
lenis.on('scroll', handleScroll);
const raf = (time: number) => {
lenis.raf(time);
animationFrameRef.value = requestAnimationFrame(raf);
};
animationFrameRef.value = requestAnimationFrame(raf);
lenisRef.value = lenis;
return lenis;
};
let cleanup: (() => void) | null = null;
const setup = () => {
const scroller = scrollerRef.value;
if (!scroller) return;
const cards = Array.from(scroller.querySelectorAll('.scroll-stack-card')) as HTMLElement[];
cardsRef.value = cards;
const transformsCache = lastTransformsRef.value;
cards.forEach((card, i) => {
if (i < cards.length - 1) {
card.style.marginBottom = `${props.itemDistance}px`;
}
card.style.willChange = 'transform, filter';
card.style.transformOrigin = 'top center';
card.style.backfaceVisibility = 'hidden';
card.style.transform = 'translateZ(0)';
card.style.webkitTransform = 'translateZ(0)';
card.style.perspective = '1000px';
card.style.webkitPerspective = '1000px';
});
setupLenis();
updateCardTransforms();
cleanup = () => {
if (animationFrameRef.value) {
cancelAnimationFrame(animationFrameRef.value);
}
if (lenisRef.value) {
lenisRef.value.destroy();
}
stackCompletedRef.value = false;
cardsRef.value = [];
transformsCache.clear();
isUpdatingRef.value = false;
};
};
onMounted(async () => {
await nextTick();
setup();
});
onBeforeUnmount(() => {
cleanup?.();
});
watch(
() => props,
() => {
cleanup?.();
setup();
},
{ deep: true }
);
</script>
<script lang="ts">
export const ScrollStackItem = defineComponent({
name: 'ScrollStackItem',
props: {
itemClassName: {
type: String,
default: ''
}
},
setup(props, { slots }) {
return () =>
h(
'div',
{
class:
`scroll-stack-card relative w-full h-80 my-8 p-12 rounded-[40px] shadow-[0_0_30px_rgba(0,0,0,0.1)] box-border origin-top will-change-transform ${props.itemClassName}`.trim(),
style: {
backfaceVisibility: 'hidden',
transformStyle: 'preserve-3d'
}
},
slots.default?.()
);
}
});
</script>
<template>
<div
ref="scrollerRef"
:class="['relative w-full h-full overflow-y-auto overflow-x-visible', className]"
:style="{
overscrollBehavior: 'contain',
WebkitOverflowScrolling: 'touch',
scrollBehavior: 'smooth',
WebkitTransform: 'translateZ(0)',
transform: 'translateZ(0)',
willChange: 'scroll-position'
}"
>
<div class="px-20 pt-[20vh] pb-[50rem] min-h-screen scroll-stack-inner">
<slot />
<!-- Spacer so the last pin can release cleanly -->
<div class="w-full h-px scroll-stack-end" />
</div>
</div>
</template>