"use client"; import { type RefObject, useEffect } from "react"; type Transition = ( tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement, touch: boolean, ) => 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"). * * Quem segura as seções paradas durante uma passagem: * - a que SAI fica com position: sticky (topo = altura da tela − altura da seção): ela para quando o fim * dela chega ao fim da tela. Quem mantém isso é o compositor do navegador, na mesma batida da rolagem; * - a que ENTRA só fica parada no topo no desktop (deslocamento pelo GSAP). Com a roda do mouse, o Lenis * rola e desloca no mesmo quadro. No toque, a rolagem nativa corre em outra thread e o deslocamento * chegava um quadro atrasado: em rolagens rápidas a seção tremia. Por isso, no toque, ela sobe com o * dedo e a passagem usa só efeitos que não disputam a posição (recorte, transparência, giro, escala). * * Regras que mantêm tudo estável: * - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1); * - nenhum ancestral de um recebe scale (o R3F mede o canvas com getBoundingClientRect); * - cada cena só fica visível da passagem em que entra até a passagem em que sai (com folga), para não * aparecer atrás de transparências e para o navegador não pintar o que está coberto. * Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh). */ const vh = () => window.innerHeight; // 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`; /** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */ 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; } // a seção que entra fica parada no topo (só desktop, ver o comentário acima) 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 → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema (tl, prev, next, touch) => { tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0); if (touch) { tl.fromTo( next, { opacity: 0.3, clipPath: "inset(0% 5% 0% 5% round 28px 28px 0px 0px)", }, { opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" }, 0, ); return; } holdNext(tl, next); const band = (open: number) => () => `inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`; tl.fromTo( next, { opacity: 0, clipPath: band(0.36) }, { opacity: 1, clipPath: band(1), duration: 0.65 }, 0.35, ); }, // projetos → modalidades: uma íris se abre enquanto a anterior avança contra a câmera (tl, prev, next, touch) => { tl.fromTo( prev, { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, { scale: 1.18, opacity: 0.2 }, 0, ); // no toque a seção sobe com o dedo: a íris nasce do topo dela, como uma bolha subindo const center = () => (touch ? "at 50% 0px" : `at 50% ${vh() / 2}px`); const radius = () => touch ? Math.hypot(window.innerWidth / 2, vh()) : Math.hypot(window.innerWidth / 2, vh() / 2); if (!touch) holdNext(tl, next); // power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem tl.fromTo( next, { clipPath: () => `circle(0px ${center()})` }, { clipPath: () => `circle(${radius()}px ${center()})`, ease: "power1.in", }, 0, ); }, // modalidades → IA: corte lateral, uma tela empurra a outra (tl, prev, next, touch) => { if (!touch) holdNext(tl, next); tl.fromTo( prev, { xPercent: 0, opacity: 1 }, { xPercent: -35, opacity: 0.3 }, 0, ).fromTo(next, { xPercent: 100 }, { xPercent: 0, ease: "power2.out" }, 0); }, // IA → qualidade: a seção sobe como um cartão sobre a anterior, que escurece. // Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o // desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha // uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter). (tl, prev, next, touch) => { tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0); // no desktop ela sobe um pouco mais rápido que a rolagem; no toque, só com o dedo if (!touch) tl.fromTo( next, { y: () => vh() * 0.35 }, { y: 0, ease: "power2.out" }, 0, ); }, // qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform) (tl, prev, next, touch) => { if (!touch) holdNext(tl, next); tl.fromTo( prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 20 }, 0, ).fromTo( next, { xPercent: -100, rotation: -7, transformOrigin: "0% 0%" }, { xPercent: 0, rotation: 0, ease: "power2.out" }, 0, ); }, // sobre → contato: o contato cresce do centro, como um zoom de câmera (tl, prev, next, touch) => { if (!touch) holdNext(tl, next); tl.fromTo( prev, { scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) }, { scale: 0.92, opacity: 0.2 }, 0, ) .fromTo( next, { scale: touch ? 0.8 : 0.55, clipPath: "inset(0% round 56px)", transformOrigin: () => (touch ? "50% 0%" : `50% ${vh() / 2}px`), }, { scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" }, 0, ) .fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0); }, ]; const easeInOutCubic = (t: number) => t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; 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); // toque: rolagem nativa em outra thread, sem trava de posição pelo JavaScript const touch = window.matchMedia( "(hover: none) and (pointer: coarse)", ).matches; const scenes = Array.from(main.children) as HTMLElement[]; // Topo de cada cena somando as alturas das anteriores (as cenas são contíguas): com sticky, o // offsetTop de uma cena presa passa a incluir o deslocamento dela. const sceneTops = () => { let top = pageTop(main); return scenes.map((scene) => { const current = top; top += scene.offsetHeight; return current; }); }; const stick = () => { for (const scene of scenes) scene.style.top = `${Math.min(0, vh() - scene.offsetHeight)}px`; }; const context = gsap.context(() => { scenes.forEach((scene, index) => { // quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte gsap.set(scene, { position: "sticky", zIndex: index === 0 ? 30 : index + 1, }); }); stick(); 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: () => sceneTops()[index + 1] - vh(), end: () => sceneTops()[index + 1], scrub: true, invalidateOnRefresh: true, // passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente onLeave: () => gsap.set(next, { clipPath: "none" }), // diagrama de qualidade: desenha uma vez quando a seção começa a entrar onEnter: () => next.classList.add("is-drawing"), }, }); transition(tl, prev, next, touch); }); }, main); // Visibilidade pela posição da rolagem: cada cena só existe na tela da passagem em que entra até a // passagem em que sai. Folga para o quadro de atraso entre a rolagem nativa e este cálculo: um quarto // de tela depois da saída e, no toque, meia tela antes da entrada (lá a cena sobe com o dedo; no // desktop ela já nasce presa no topo e só pode aparecer quando a passagem começa). let frame = 0; const syncVisibility = () => { frame = 0; const y = window.scrollY; const tops = sceneTops(); scenes.forEach((scene, index) => { const from = index === 0 ? -Infinity : tops[index] - vh() * (touch ? 1.5 : 1); const to = index === scenes.length - 1 ? Infinity : tops[index + 1] + vh() * 0.25; const visibility = y >= from && y <= to ? "" : "hidden"; if (scene.style.visibility !== visibility) scene.style.visibility = visibility; }); }; const queueSync = () => { if (!frame) frame = requestAnimationFrame(syncVisibility); }; const onRefresh = () => { stick(); queueSync(); }; syncVisibility(); ScrollTrigger.addEventListener("refresh", onRefresh); window.addEventListener("scroll", queueSync, { passive: true }); // a página mudou de altura (fontes, imagens, "Experiência" aberta ou fechada): o GSAP remede as passagens let refreshTimer: ReturnType | undefined; const resizeObserver = new ResizeObserver(() => { clearTimeout(refreshTimer); refreshTimer = setTimeout(() => ScrollTrigger.refresh(), 150); }); resizeObserver.observe(main); // botões e links internos: em vez do salto, a página "avança o vídeo" até o destino const onClick = (event: MouseEvent) => { if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) return; const link = event.target instanceof Element ? event.target.closest("a[href]") : null; if ( !(link instanceof HTMLAnchorElement) || (link.target && link.target !== "_self") ) return; const url = new URL(link.href); if ( url.origin !== location.origin || url.pathname !== location.pathname || !url.hash ) return; let target: HTMLElement | null = null; try { target = document.getElementById( decodeURIComponent(url.hash.slice(1)), ); } catch {} if (!target) return; event.preventDefault(); // nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo; // alvos internos descontam o cabeçalho fixo (posição relativa à cena, que não muda se ela estiver presa) const index = scenes.findIndex((candidate) => candidate.contains(target), ); const offset = Number.parseFloat( getComputedStyle(document.documentElement).scrollPaddingTop, ) || 0; const sceneTop = index < 0 ? 0 : sceneTops()[index]; const top = index <= 0 ? 0 : Math.max( sceneTop, scenes[index] === target ? 0 : sceneTop + pageTop(target) - pageTop(scenes[index]) - offset, ); if (location.hash !== url.hash) history.pushState(null, "", url.hash); lenis.scrollTo(top, { duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000), easing: easeInOutCubic, // o foco vai depois: antes de a cena entrar ela está invisível e não aceita foco onComplete: () => { if (!target.hasAttribute("tabindex")) target.tabIndex = -1; target.focus({ preventScroll: true }); }, }); }; document.addEventListener("click", onClick, true); cleanup = () => { document.removeEventListener("click", onClick, true); ScrollTrigger.removeEventListener("refresh", onRefresh); window.removeEventListener("scroll", queueSync); cancelAnimationFrame(frame); clearTimeout(refreshTimer); resizeObserver.disconnect(); context.revert(); for (const scene of scenes) { scene.style.visibility = ""; scene.style.top = ""; } gsap.ticker.remove(tick); lenis.destroy(); }; })(); return () => { cancelled = true; cleanup(); }; }, [mainRef]); }