diff --git a/public/img/bg/room.png b/public/img/bg/room.png new file mode 100644 index 0000000..f881ac4 Binary files /dev/null and b/public/img/bg/room.png differ diff --git a/src/components/pages/Collection/Collection.tsx b/src/components/pages/Collection/Collection.tsx index 5e5b164..4636102 100644 --- a/src/components/pages/Collection/Collection.tsx +++ b/src/components/pages/Collection/Collection.tsx @@ -1,5 +1,10 @@ import Section from "../../ui/Section"; -import { ANIMATION_DURATION, SwipeState } from "./helpers"; +import { + AFTER_TINDER_SCROLL_THRESHOLD, + ANIMATION_DURATION, + IS_TINDER_SCROLL_THRESHOLD, + SwipeState, +} from "./helpers"; import CursorButtonWrapper from "../../ui/CursorButtonWrapper"; import { useScroll, @@ -14,6 +19,7 @@ import { useCollectionScrollCardMotion } from "./useCollectionScrollCardMotion"; import AppartamentDescription from "./AppartamentDescription"; import PlanDescription from "./PlanDescription"; import { COLLECTION_DATA, getInitialCollectionSlotIndices } from "./data"; +import useDiscreteScroll from "@/hooks/useDiscreteScroll"; const DECK_LENGTH = COLLECTION_DATA.length; @@ -34,17 +40,33 @@ export default function Collection() { // Состояния const isAnimating = useRef(false); const [isTinder, setIsTinder] = useState(false); + const [afterTinder, setAfterTinder] = useState(false); // Отслеживание скролла const ref = useRef(null); + const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"], }); - useMotionValueEvent(scrollYProgress, "change", (latest) => { - setIsTinder(latest >= 0.8); + const discreteScrollProgress = useDiscreteScroll(scrollYProgress, [ + 0, + IS_TINDER_SCROLL_THRESHOLD, + AFTER_TINDER_SCROLL_THRESHOLD, + 1, + ]); + + useMotionValueEvent(discreteScrollProgress, "change", (latest) => { + setIsTinder( + latest >= IS_TINDER_SCROLL_THRESHOLD && + latest < AFTER_TINDER_SCROLL_THRESHOLD + ); + setAfterTinder(latest >= AFTER_TINDER_SCROLL_THRESHOLD); }); + // Motion-значения в `vw` для трёх карточек коллекции при скролле. + const scrollCards = useCollectionScrollCardMotion(discreteScrollProgress); + // по сути, порядок изображений, которые сдивгается влево или вправо при листании, // в результате чего обновялются отображаемые фото. // Нужен для сохранения порядка изображения после выхода из состояния тиндера. @@ -90,9 +112,6 @@ export default function Collection() { return () => clearTimeout(timer); }, [swipe]); - // Motion-значения в `vw` для трёх карточек коллекции при скролле. - const scrollCards = useCollectionScrollCardMotion(scrollYProgress); - const handleSliderClick = (direction: "right" | "left") => { if (!isTinder || isAnimating.current || DECK_LENGTH === 0) return; const nextOrder = @@ -137,16 +156,17 @@ export default function Collection() { } return ( -
+

Коллекция авторских резиденций

-
-
+
+
+ {/* Описание квартиры */} - {isTinder && activeItem && ( + {isTinder && activeItem && !afterTinder && ( + {/* План квартиры */} - {isTinder && activeItem && ( + {isTinder && activeItem && !afterTinder && ( - {isTinder ? ( - <> - {[0, 1, 2, 3].map((index) => ( - + {isTinder || afterTinder ? ( + <> + {[0, 1, 2, 3].map((index) => ( + + ))} + + ) : ( + <> + - ))} - - ) : ( - <> - - - - - )} + + + + )} - {isTinder && ( - handleSliderClick("left")} - onLeftClick={() => handleSliderClick("right")} - > -
- - )} + {isTinder && !afterTinder && ( + handleSliderClick("left")} + onLeftClick={() => handleSliderClick("right")} + > +
+ + )} +
+
); diff --git a/src/components/pages/Collection/TinderSlotCard.tsx b/src/components/pages/Collection/TinderSlotCard.tsx index 0edc067..c0c16a0 100644 --- a/src/components/pages/Collection/TinderSlotCard.tsx +++ b/src/components/pages/Collection/TinderSlotCard.tsx @@ -30,23 +30,34 @@ const CARDS_STATE = { zIndex: 2, }, under: { - width: "14.931vw", - height: "19.583vw", + width: "16.806vw", + height: "21.667vw", left: "40.708vw", zIndex: 1, }, } as const; +const AFTER_TINDER_CARDS_STATE = { + middle: { + width: "16.806vw", + height: "21.667vw", + left: "40.708vw", + zIndex: 3, + }, +} as const; + const Z_INDEX_DELAY_MS = (ANIMATION_DURATION * 1000) / 2; export default function TinderSlotCard({ indexInDeck, swipe, + afterTinder, src, className, }: { indexInDeck: number; swipe: SwipeState; + afterTinder: boolean; src: string; className?: string; }) { @@ -59,15 +70,30 @@ export default function TinderSlotCard({ // Переход из состояния const baseGeo = cardPosition(indexInDeck, { phase: "idle" }); // в состояние - const geo = cardPosition(indexInDeck, swipe); + const isTopCard = indexInDeck === 1; + const geo = + afterTinder && isTopCard + ? AFTER_TINDER_CARDS_STATE.middle + : cardPosition(indexInDeck, swipe); + const opacity = afterTinder && !isTopCard ? 0 : 1; const swipeDir = swipe.phase === "animating" ? swipe.dir : undefined; + const [isAfterTinderTransition, setIsAfterTinderTransition] = + useState(afterTinder); /** В idle z-index из геометрии слота — без кадра с «чужим» delayedZ */ const [delayedZ, setDelayedZ] = useState(() => baseGeo.zIndex); const zForMotion = swipe.phase === "idle" ? geo.zIndex : delayedZ; const zHalfTimerRef = useRef(undefined); + useLayoutEffect(() => { + const timer = window.setTimeout(() => { + setIsAfterTinderTransition(afterTinder); + }, afterTinder ? 0 : ANIMATION_DURATION * 1000); + + return () => clearTimeout(timer); + }, [afterTinder]); + useLayoutEffect(() => { if (swipe.phase !== "animating") return; @@ -95,8 +121,10 @@ export default function TinderSlotCard({ }; }, [swipe.phase, swipeDir, indexInDeck, geo.zIndex, baseGeo.zIndex]); + const shouldAnimateCard = + swipe.phase === "animating" || afterTinder || isAfterTinderTransition; const transition = - swipe.phase === "animating" + shouldAnimateCard ? { duration: ANIMATION_DURATION, ease: "easeInOut" as const, @@ -111,13 +139,16 @@ export default function TinderSlotCard({ width: geo.width, height: geo.height, left: geo.left, + opacity, zIndex: zForMotion, }} transition={transition} + style={{ pointerEvents: afterTinder && !isTopCard ? "none" : undefined }} className={clsx( "absolute top-1/2", swipe.phase === "animating" && "will-change-[transform,width,height,left]", + indexInDeck === 1 && "shadow-md", className )} > diff --git a/src/components/pages/Collection/helpers.ts b/src/components/pages/Collection/helpers.ts index f3451e5..92534c6 100644 --- a/src/components/pages/Collection/helpers.ts +++ b/src/components/pages/Collection/helpers.ts @@ -1,5 +1,11 @@ export const ANIMATION_DURATION = 0.5; +// Расстояние до перехода от трех отдельных карточек до тиндера +export const IS_TINDER_SCROLL_THRESHOLD = 0.15; + +// Расстояние до перехода от тиндера до одной карточки +export const AFTER_TINDER_SCROLL_THRESHOLD = 0.4; + export type SwipeState = | { phase: "idle" } | { phase: "animating"; dir: "left" | "right" }; \ No newline at end of file diff --git a/src/components/pages/Collection/useCollectionScrollCardMotion.ts b/src/components/pages/Collection/useCollectionScrollCardMotion.ts index 6f01a82..a2a8c9f 100644 --- a/src/components/pages/Collection/useCollectionScrollCardMotion.ts +++ b/src/components/pages/Collection/useCollectionScrollCardMotion.ts @@ -1,7 +1,8 @@ import { type MotionValue, useTransform } from "framer-motion"; +import { IS_TINDER_SCROLL_THRESHOLD } from "./helpers"; /** Диапазон прогресса скролла секции (как в useScroll offset) */ -const SCROLL_INPUT: [number, number] = [0, 0.8]; +const SCROLL_INPUT: [number, number] = [0, IS_TINDER_SCROLL_THRESHOLD]; /** Тройки [начало, конец] для left / width / height по каждой из трёх карточек вне тиндера */ export const COLLECTION_SCROLL_CARD_SPECS: Array<{ diff --git a/src/components/ui/CursorButtonWrapper.tsx b/src/components/ui/CursorButtonWrapper.tsx index 44965c9..67c8fe7 100644 --- a/src/components/ui/CursorButtonWrapper.tsx +++ b/src/components/ui/CursorButtonWrapper.tsx @@ -40,10 +40,13 @@ export default function CursorButtonWrapper({ const targetRef = wrapperRef ?? localWrapperRef; - const setHovered = useCallback((value: boolean) => { - setIsHovered(value); - onHoverChange?.(value); - }, [onHoverChange]); + const setHovered = useCallback( + (value: boolean) => { + setIsHovered(value); + onHoverChange?.(value); + }, + [onHoverChange] + ); const handleMouseEnter = () => { setHovered(true); @@ -121,6 +124,7 @@ export default function CursorButtonWrapper({ className={clsx("relative hover:cursor-none", className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} + onMouseMove={() => !isHovered && handleMouseEnter()} onClick={handleClick} > diff --git a/src/hooks/useDiscreteScroll.tsx b/src/hooks/useDiscreteScroll.tsx new file mode 100644 index 0000000..aade5eb --- /dev/null +++ b/src/hooks/useDiscreteScroll.tsx @@ -0,0 +1,71 @@ +import { + MotionValue, + useMotionValue, + useMotionValueEvent, + useSpring, +} from "framer-motion"; + +/** + * Хук, который преобразует непрерывный прогресс скролла в дискретное + * значение индекса сегмента, а затем плавно сглаживает его с помощью useSpring. + * * @param scrollProgress MotionValue от 0 до 1 (например, scrollYProgress). + * @param breakpoints Массив чисел от 0 до 1, определяющих границы сегментов. + * @returns MotionValue сглаженное с помощью useSpring, представляющее текущее значение, соответствующие сегментам breakpoints. + * @example + * // Здесь segmentProgress будет принимать одно из значений [0, 0.33, 0.66, 1] + * const segmentProgress = useDiscreteScroll( + scrollYProgress, + [0, 0.33, 0.66, 1] + ); + + + return ( + + + + ) + * + */ +function useDiscreteScroll( + scrollProgress: MotionValue, + breakpoints: number[] +): MotionValue { + const discreteIndex = useMotionValue(0); + const smoothedIndex = useSpring(discreteIndex, { + stiffness: 160, + damping: 25, + restDelta: 0.001, + }); + + useMotionValueEvent(scrollProgress, "change", (latestScroll) => { + let newIndex = 0; + + for (let i = 1; i < breakpoints.length; i++) { + const start = breakpoints[i - 1]; + const end = breakpoints[i]; + + if (latestScroll >= start && latestScroll < end) { + newIndex = i - 1; + break; + } + if (i === breakpoints.length - 1 && latestScroll >= end) { + newIndex = breakpoints.length - 1; + break; + } + } + + if (newIndex !== discreteIndex.get()) { + discreteIndex.set(breakpoints[newIndex]); + } + }); + + return smoothedIndex; +} + +export default useDiscreteScroll;