"use client"; import { type RefObject, useEffect } from "react"; import { isCoarsePointer, prefersReducedMotion } from "@/lib/device"; /* * Rolagem como um vídeo: o GSAP liga cada passagem de uma cena para a seguinte à posição da rolagem (rolar * para cima volta o "vídeo") e, no mouse, o Lenis suaviza a roda. * * Uma única passagem para a home inteira, só com opacidade e um deslocamento vertical curto: * - a cena que entra chega com fade e sobe ENTER_OFFSET px até o lugar dela; * - a cena que sai some com fade e termina SEMPRE em opacidade 0 (nenhuma cena anterior fica "fantasma" atrás * das seguintes); * - nada de recorte (clip-path), escala ou rotação, e nenhuma cena é escondida com visibility: o conteúdo * nasce visível no HTML do servidor e a opacidade só muda por rolagem (Ctrl+F e leitores de tela enxergam * tudo, e sem JavaScript tudo aparece). * As cenas ficam no fluxo normal da página, uma depois da outra. A única rolagem "presa" é a do CSS: o hero * (sticky) e a vitrine de projetos (sticky); este arquivo não prende nada. * * Cada cena tem uma entrada e uma saída, cada uma com o próprio gatilho, e a saída só começa depois que a * entrada da mesma cena terminou (cenas curtas não brigam pela mesma opacidade). Qualquer filho direto de *
é uma cena; nada aqui depende de uma seção específica existir. */ /** Deslocamento vertical, em px, com que a cena chega (entre 24 e 40). */ export const ENTER_OFFSET = 32; /** A passagem corre enquanto o topo da cena que entra sobe de 1 altura de tela até esta fração dela. */ export const PASSAGE_END = 0.2; /** Âncoras mais distantes que isto (em alturas de tela) são um salto direto. */ export const JUMP_SCREENS = 3; /** Nenhuma âncora anima por mais que isto. */ export const MAX_ANCHOR_SECONDS = 0.8; /** Depois deste tempo parada, a rolagem suave solta o relógio do GSAP. */ export const IDLE_SECONDS = 1; const vh = () => window.innerHeight; /** Topo do elemento na página pelo layout (offsetTop ignora os transforms das passagens, 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 cena chega: fade e subida curta. Ao terminar, o estilo inline some (sem transform sobrando na cena). */ export function enterScene(tl: gsap.core.Timeline, scene: HTMLElement) { tl.fromTo( scene, { opacity: 0, y: ENTER_OFFSET }, { opacity: 1, y: 0, ease: "power2.out", clearProps: "opacity,transform", immediateRender: false, }, 0, ); } /** A cena sai: fade até 0. É o último estado dela até a rolagem voltar. */ export function exitScene(tl: gsap.core.Timeline, scene: HTMLElement) { tl.fromTo( scene, { opacity: 1 }, { opacity: 0, ease: "power2.in", immediateRender: false }, 0, ); } /** Duração (s) da rolagem até uma âncora a `distance` px; `null` = salto direto. */ export function anchorDuration(distance: number, viewport: number) { const screens = Math.abs(distance) / Math.max(1, viewport); if (screens > JUMP_SCREENS) return null; return Math.min(MAX_ANCHOR_SECONDS, 0.3 + screens * 0.2); } 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 (prefersReducedMotion()) return; let cancelled = false; let cleanup = () => {}; (async () => { // no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote) const smooth = !isCoarsePointer(); const [{ gsap }, { ScrollTrigger }, lenisModule] = await Promise.all([ import("gsap"), import("gsap/ScrollTrigger"), smooth ? import("lenis") : Promise.resolve(null), ]); if (cancelled) return; gsap.registerPlugin(ScrollTrigger); // Lenis: o relógio do GSAP só roda enquanto a rolagem anima; parada por IDLE_SECONDS, ele é solto const lenis = lenisModule ? new lenisModule.default({ lerp: 0.075, wheelMultiplier: 0.85 }) : null; let awake = false; let lastBusy = 0; const tick = (time: number) => { lenis?.raf(time * 1000); if (lenis?.isScrolling) lastBusy = time; else if (time - lastBusy > IDLE_SECONDS) sleep(); }; function sleep() { if (!awake) return; awake = false; gsap.ticker.remove(tick); } const wake = () => { if (!lenis) return; lastBusy = gsap.ticker.time; if (awake) return; awake = true; gsap.ticker.add(tick); }; if (lenis) { lenis.on("scroll", ScrollTrigger.update); gsap.ticker.lagSmoothing(0); wake(); window.addEventListener("wheel", wake, { passive: true }); window.addEventListener("scroll", wake, { passive: true }); } const scenes = Array.from(main.children) as HTMLElement[]; const context = gsap.context(() => { scenes.forEach((scene, index) => { const following = scenes[index + 1]; const trigger = ( start: () => number, end: () => number, extra: ScrollTrigger.Vars = {}, ) => ({ trigger: scene, start, end, scrub: true, invalidateOnRefresh: true, ...extra, }); const timeline = (vars: ScrollTrigger.Vars) => gsap.timeline({ defaults: { ease: "none", duration: 1 }, scrollTrigger: vars, }); if (index > 0) enterScene( timeline( trigger( () => pageTop(scene) - vh(), () => pageTop(scene) - vh() * PASSAGE_END, ), ), scene, ); if (following) exitScene( timeline( trigger( // só depois de a própria entrada terminar, mesmo numa cena mais curta que a tela () => Math.max( pageTop(scene) - vh() * PASSAGE_END, pageTop(following) - vh(), ), () => pageTop(following) - vh() * PASSAGE_END, ), ), scene, ); }); }, main); // a página mudou de altura (fontes, imagens, seções que abrem e fecham): o GSAP remede as passagens let refreshTimer: ReturnType | undefined; const resizeObserver = new ResizeObserver(() => { clearTimeout(refreshTimer); refreshTimer = setTimeout(() => ScrollTrigger.refresh(), 150); }); resizeObserver.observe(main); const scrollToTop = (top: number) => { const distance = top - window.scrollY; const duration = anchorDuration(distance, vh()); if (lenis) { wake(); lenis.scrollTo(top, { immediate: duration === null, duration: duration ?? 0, easing: easeInOutCubic, }); } else { window.scrollTo({ top, behavior: duration === null ? "instant" : "smooth", }); } }; // botões e links internos: em vez do salto, a página "avança o vídeo" (de pouco) 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(); // o alvo é a cena inteira: para no topo dela; alvo interno: desconta o cabeçalho fixo, sem passar do topo da cena const destination = target; const scene = scenes.find((candidate) => candidate.contains(destination), ); const offset = Number.parseFloat( getComputedStyle(document.documentElement).scrollPaddingTop, ) || 0; const sceneTop = scene ? pageTop(scene) : 0; const top = scene === destination ? sceneTop : Math.max(sceneTop, pageTop(destination) - offset); if (location.hash !== url.hash) history.pushState(null, "", url.hash); scrollToTop(top); // o foco vai junto: a cena já está no fluxo, visível e pronta para recebê-lo if (!destination.hasAttribute("tabindex")) destination.tabIndex = -1; destination.focus({ preventScroll: true }); }; document.addEventListener("click", onClick, true); cleanup = () => { document.removeEventListener("click", onClick, true); window.removeEventListener("wheel", wake); window.removeEventListener("scroll", wake); clearTimeout(refreshTimer); resizeObserver.disconnect(); context.revert(); sleep(); lenis?.destroy(); }; })(); return () => { cancelled = true; cleanup(); }; }, [mainRef]); }