328 lines
8.1 KiB
JavaScript
328 lines
8.1 KiB
JavaScript
import {
|
|
useCallback,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useNavigate } from "react-router";
|
|
import { gsap } from "gsap";
|
|
import { ProductTransitionContext } from "../transitions/ProductTransitionContext";
|
|
import "./ProductTransition.css";
|
|
|
|
const supportsProductTransition = () => {
|
|
if (typeof window === "undefined") return false;
|
|
|
|
return !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
};
|
|
|
|
const rectToObject = (rect) => ({
|
|
top: rect.top,
|
|
left: rect.left,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
});
|
|
|
|
const isPlainNavigationClick = (event) =>
|
|
event.button === 0 &&
|
|
!event.metaKey &&
|
|
!event.altKey &&
|
|
!event.ctrlKey &&
|
|
!event.shiftKey;
|
|
|
|
const getStageRect = (sourceRect) => {
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
const sourceRatio = sourceRect.height / sourceRect.width || 1;
|
|
const stageWidth = Math.min(
|
|
Math.max(sourceRect.width * 1.16, 260),
|
|
viewportWidth * 0.56,
|
|
620
|
|
);
|
|
const stageHeight = Math.min(stageWidth * sourceRatio, viewportHeight * 0.72);
|
|
const normalizedWidth = stageHeight / sourceRatio;
|
|
|
|
return {
|
|
left: (viewportWidth - normalizedWidth) / 2,
|
|
top: (viewportHeight - stageHeight) / 2,
|
|
width: normalizedWidth,
|
|
height: stageHeight,
|
|
};
|
|
};
|
|
|
|
const getTransformForRect = (rect, sourceRect) => ({
|
|
x: rect.left,
|
|
y: rect.top,
|
|
scaleX: rect.width / sourceRect.width,
|
|
scaleY: rect.height / sourceRect.height,
|
|
});
|
|
|
|
export function ProductTransitionProvider({ children }) {
|
|
const navigate = useNavigate();
|
|
const overlayRef = useRef(null);
|
|
const imageRef = useRef(null);
|
|
const washRef = useRef(null);
|
|
const timelineRef = useRef(null);
|
|
const [transition, setTransition] = useState(null);
|
|
|
|
const clearTransition = useCallback(() => {
|
|
timelineRef.current?.kill();
|
|
timelineRef.current = null;
|
|
document.body.classList.remove("product-transition-active");
|
|
setTransition(null);
|
|
}, []);
|
|
|
|
const startProductTransition = useCallback(
|
|
(event, perfume) => {
|
|
if (!perfume?.slug || !isPlainNavigationClick(event)) {
|
|
return false;
|
|
}
|
|
|
|
if (!supportsProductTransition()) {
|
|
return false;
|
|
}
|
|
|
|
const card = event.currentTarget;
|
|
const image = card.querySelector("[data-product-transition-source]");
|
|
|
|
if (!image) {
|
|
return false;
|
|
}
|
|
|
|
const sourceRect = image.getBoundingClientRect();
|
|
|
|
if (sourceRect.width <= 0 || sourceRect.height <= 0) {
|
|
return false;
|
|
}
|
|
|
|
event.preventDefault();
|
|
timelineRef.current?.kill();
|
|
document.body.classList.add("product-transition-active");
|
|
|
|
setTransition({
|
|
id: `${perfume.slug}-${Date.now()}`,
|
|
slug: perfume.slug,
|
|
to: `/duft/${perfume.slug}`,
|
|
image: image.currentSrc || image.src || perfume.image,
|
|
alt: perfume.name,
|
|
sourceRect: rectToObject(sourceRect),
|
|
phase: "leaving",
|
|
});
|
|
|
|
return true;
|
|
},
|
|
[]
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
if (transition?.phase !== "leaving") {
|
|
return undefined;
|
|
}
|
|
|
|
const overlay = overlayRef.current;
|
|
const image = imageRef.current;
|
|
const wash = washRef.current;
|
|
|
|
if (!overlay || !image || !wash) {
|
|
return undefined;
|
|
}
|
|
|
|
const routeContent = document.querySelector("[data-route-content]");
|
|
const sourceRect = transition.sourceRect;
|
|
const stageRect = getStageRect(sourceRect);
|
|
let completed = false;
|
|
|
|
gsap.set(overlay, { autoAlpha: 1, pointerEvents: "auto" });
|
|
gsap.set(wash, { autoAlpha: 0 });
|
|
gsap.set(image, {
|
|
x: sourceRect.left,
|
|
y: sourceRect.top,
|
|
width: sourceRect.width,
|
|
height: sourceRect.height,
|
|
scaleX: 1,
|
|
scaleY: 1,
|
|
autoAlpha: 1,
|
|
transformOrigin: "0 0",
|
|
force3D: true,
|
|
});
|
|
|
|
timelineRef.current = gsap.timeline({
|
|
defaults: { ease: "power4.inOut" },
|
|
onComplete: () => {
|
|
completed = true;
|
|
|
|
navigate(transition.to, {
|
|
state: {
|
|
productTransition: true,
|
|
transitionId: transition.id,
|
|
},
|
|
});
|
|
|
|
setTransition((current) =>
|
|
current?.id === transition.id
|
|
? { ...current, phase: "entering" }
|
|
: current
|
|
);
|
|
},
|
|
});
|
|
|
|
timelineRef.current
|
|
.to(wash, { autoAlpha: 1, duration: 0.42, ease: "power2.out" }, 0)
|
|
.to(
|
|
routeContent,
|
|
{
|
|
autoAlpha: 0.16,
|
|
filter: "blur(10px)",
|
|
scale: 0.985,
|
|
duration: 0.62,
|
|
ease: "power3.out",
|
|
},
|
|
0
|
|
)
|
|
.to(
|
|
image,
|
|
{
|
|
...getTransformForRect(stageRect, sourceRect),
|
|
duration: 0.78,
|
|
},
|
|
0.03
|
|
);
|
|
|
|
return () => {
|
|
if (!completed) {
|
|
timelineRef.current?.kill();
|
|
}
|
|
};
|
|
}, [navigate, transition]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (transition?.phase !== "entering") {
|
|
return undefined;
|
|
}
|
|
|
|
const overlay = overlayRef.current;
|
|
const image = imageRef.current;
|
|
const wash = washRef.current;
|
|
|
|
if (!overlay || !image || !wash) {
|
|
return undefined;
|
|
}
|
|
|
|
let frame = 0;
|
|
let cancelled = false;
|
|
let completed = false;
|
|
const sourceRect = transition.sourceRect;
|
|
|
|
const runEnterAnimation = () => {
|
|
if (cancelled) return;
|
|
|
|
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
|
|
|
const target = document.querySelector(
|
|
`[data-product-transition-target="${transition.slug}"]`
|
|
);
|
|
|
|
if (!target && frame < 16) {
|
|
frame += 1;
|
|
window.requestAnimationFrame(runEnterAnimation);
|
|
return;
|
|
}
|
|
|
|
const routeContent = document.querySelector("[data-route-content]");
|
|
gsap.set(routeContent, {
|
|
autoAlpha: 1,
|
|
filter: "none",
|
|
scale: 1,
|
|
clearProps: "opacity,visibility,filter,transform",
|
|
});
|
|
|
|
if (!target) {
|
|
gsap.to(overlay, {
|
|
autoAlpha: 0,
|
|
duration: 0.3,
|
|
ease: "power2.out",
|
|
onComplete: clearTransition,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const targetRect = rectToObject(target.getBoundingClientRect());
|
|
const revealItems = gsap.utils.toArray("[data-product-transition-reveal]");
|
|
|
|
gsap.set(target, { autoAlpha: 0 });
|
|
gsap.set(revealItems, { y: 28, autoAlpha: 0, force3D: true });
|
|
|
|
timelineRef.current?.kill();
|
|
timelineRef.current = gsap.timeline({
|
|
defaults: { ease: "power4.out" },
|
|
onComplete: () => {
|
|
completed = true;
|
|
clearTransition();
|
|
},
|
|
});
|
|
|
|
timelineRef.current
|
|
.to(image, {
|
|
...getTransformForRect(targetRect, sourceRect),
|
|
duration: 0.72,
|
|
})
|
|
.set(target, { autoAlpha: 1 }, ">-0.08")
|
|
.to(image, { autoAlpha: 0, duration: 0.16, ease: "power2.out" }, "<")
|
|
.to(wash, { autoAlpha: 0, duration: 0.5, ease: "power2.out" }, "<")
|
|
.to(
|
|
revealItems,
|
|
{
|
|
y: 0,
|
|
autoAlpha: 1,
|
|
duration: 0.82,
|
|
stagger: 0.07,
|
|
ease: "power4.out",
|
|
clearProps: "transform,opacity,visibility",
|
|
},
|
|
">-0.05"
|
|
);
|
|
};
|
|
|
|
window.requestAnimationFrame(runEnterAnimation);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (!completed) {
|
|
timelineRef.current?.kill();
|
|
}
|
|
};
|
|
}, [clearTransition, transition]);
|
|
|
|
const value = useMemo(
|
|
() => ({
|
|
activeSlug: transition?.slug || null,
|
|
phase: transition?.phase || "idle",
|
|
startProductTransition,
|
|
}),
|
|
[startProductTransition, transition]
|
|
);
|
|
|
|
return (
|
|
<ProductTransitionContext.Provider value={value}>
|
|
{children}
|
|
|
|
{transition && (
|
|
<div
|
|
className={`product-transition product-transition--${transition.phase}`}
|
|
ref={overlayRef}
|
|
aria-hidden="true"
|
|
>
|
|
<div className="product-transition__wash" ref={washRef} />
|
|
<img
|
|
className="product-transition__image"
|
|
src={transition.image}
|
|
alt={transition.alt}
|
|
ref={imageRef}
|
|
decoding="async"
|
|
/>
|
|
</div>
|
|
)}
|
|
</ProductTransitionContext.Provider>
|
|
);
|
|
}
|