"use client"; import { type RefObject, useEffect } from "react"; type Transition = (tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement) => void; /* * Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição * da rolagem (rolar para cima volta o "vídeo"). * Truque de todas as transições: durante a última altura de tela entre duas seções, a que sai é empurrada * para baixo na mesma medida da rolagem (parece parada) e a que entra pode fazer o mesmo. Assim nada * "rola"; só a transição acontece. Sem pin e sem mudar o layout: links internos e medições seguem iguais. * Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh). */ const vh = () => window.innerHeight; /** Topo da seção na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */ export function pageTop(el: HTMLElement) { let top = 0; for (let node: HTMLElement | null = el; node; node = node.offsetParent as HTMLElement | null) top += node.offsetTop; return top; } // origem no meio da parte visível da seção que sai (a última altura de tela dela) const visibleCenter = (el: HTMLElement) => () => `50% ${el.offsetHeight - vh() / 2}px`; // a seção que sai fica parada na tela enquanto a próxima entra const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) => tl.fromTo(prev, { y: 0 }, { y: vh }, 0); // a seção que entra também fica parada no topo: a transição acontece "no lugar" const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) => tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0); // uma linguagem diferente por passagem, na ordem das seções da home const TRANSITIONS: Transition[] = [ // hero → notebook: o mergulho termina no escuro e o notebook assenta vindo de perto (tl, prev, next) => { holdPrev(tl, prev); holdNext(tl, next); // o hero some antes de o notebook aparecer: sem texto sobre texto (no celular o hero não esmaece antes) tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0).fromTo( next.querySelector(".notebook-story-sticky") ?? next, { scale: 1.22, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.65 }, 0.35, ); }, // notebook → projetos: a seção sobe como um cartão e o notebook recua para o fundo (tl, prev, next) => { holdPrev(tl, prev); tl.fromTo( prev, { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, { scale: 0.86, opacity: 0.15 }, 0, ).fromTo( next, { clipPath: "inset(0% 5% 0% 5% round 40px 40px 0px 0px)" }, { clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" }, 0, ); }, // projetos → modalidades: uma íris abre do centro enquanto a anterior avança contra a câmera (tl, prev, next) => { holdPrev(tl, prev); holdNext(tl, next); tl.fromTo( prev, { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, { scale: 1.18, opacity: 0.2 }, 0, ) .fromTo( next, { clipPath: () => `circle(0px at 50% ${vh() / 2}px)` }, { clipPath: () => `circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px at 50% ${vh() / 2}px)`, duration: 0.96, }, 0, ) .set(next, { clipPath: "none" }, 1); }, // modalidades → IA: corte lateral, uma tela empurra a outra (tl, prev, next) => { holdPrev(tl, prev); holdNext(tl, next); tl.fromTo(prev, { xPercent: 0, opacity: 1 }, { xPercent: -35, opacity: 0.3 }, 0).fromTo( next, { xPercent: 100 }, { xPercent: 0 }, 0, ); }, // IA → qualidade: a próxima tomba para a frente, em perspectiva (tl, prev, next) => { holdPrev(tl, prev); tl.fromTo( prev, { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, { scale: 0.9, opacity: 0.25 }, 0, ).fromTo( next, { rotationX: 28, scale: 0.88, transformPerspective: 1600, transformOrigin: "50% 0%" }, { rotationX: 0, scale: 1 }, 0, ); }, // qualidade → sobre: cortina diagonal (tl, prev, next) => { holdPrev(tl, prev); holdNext(tl, next); tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 8 }, 0) .fromTo( next, { clipPath: () => `polygon(0% 0px, 0% 0px, -30% ${vh()}px, -30% ${vh()}px)` }, { clipPath: () => `polygon(0% 0px, 130% 0px, 100% ${vh()}px, -30% ${vh()}px)`, duration: 0.96, }, 0, ) .set(next, { clipPath: "none" }, 1); }, // sobre → contato: o contato cresce do centro, como um zoom de câmera (tl, prev, next) => { holdPrev(tl, prev); holdNext(tl, next); tl.fromTo( prev, { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, { scale: 0.92, opacity: 0.2 }, 0, ).fromTo( next, { scale: 0.55, clipPath: "inset(0% round 56px)", transformOrigin: () => `50% ${vh() / 2}px`, }, { scale: 1, clipPath: "inset(0% round 0px)" }, 0, ).fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0); }, ]; export function useCinematicScroll(mainRef: RefObject) { useEffect(() => { const main = mainRef.current; if (!main) return; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; let cancelled = false; let cleanup = () => {}; (async () => { const [{ gsap }, { ScrollTrigger }, { default: Lenis }] = await Promise.all([ import("gsap"), import("gsap/ScrollTrigger"), import("lenis"), ]); if (cancelled) return; gsap.registerPlugin(ScrollTrigger); const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 }); lenis.on("scroll", ScrollTrigger.update); const tick = (time: number) => lenis.raf(time * 1000); gsap.ticker.add(tick); gsap.ticker.lagSmoothing(0); const scenes = Array.from(main.children) as HTMLElement[]; const context = gsap.context(() => { scenes.forEach((scene, index) => { // quem entra fica por cima; o hero, que sai por dissolução, fica por cima do notebook gsap.set(scene, { position: "relative", zIndex: index === 0 ? 30 : index + 1 }); }); TRANSITIONS.forEach((transition, index) => { const prev = scenes[index]; const next = scenes[index + 1]; if (!prev || !next) return; const tl = gsap.timeline({ defaults: { ease: "none", duration: 1 }, scrollTrigger: { trigger: next, start: () => pageTop(next) - vh(), end: () => pageTop(next), scrub: true, invalidateOnRefresh: true, }, }); transition(tl, prev, next); }); }, main); cleanup = () => { context.revert(); gsap.ticker.remove(tick); lenis.destroy(); }; })(); return () => { cancelled = true; cleanup(); }; }, [mainRef]); }