"use client"; import { type RefObject, useEffect } from "react"; import { isCoarsePointer, prefersReducedMotion } from "@/lib/device"; import { focusFragment, onFragmentClick } from "./fragment-nav"; /* * Rolagem da home. Duas coisas, nenhuma delas com biblioteca de animação: * * 1. As passagens entre cenas são CSS (globals.css, `animation-timeline: view()`): o conteúdo de cada seção * (`.ds-section-shell`) chega com fade e uma subida curta enquanto a seção entra na janela, e o fundo da * seção fica sempre inteiro (seções do mesmo tom não piscam em cinza). Nada de JavaScript por quadro, e onde * o navegador não tem o recurso o conteúdo simplesmente já está lá. Qualquer filho direto de
é uma * cena; nada aqui depende de uma seção específica existir. * 2. Este hook cuida só do que o CSS não faz: no mouse, o Lenis suaviza a roda (com o relógio ligado apenas * enquanto algo se move, nunca com a página parada), e os links internos (#id) "avançam o vídeo" até o * destino em vez de saltar (o clique vem de fragment-nav). No toque a rolagem nativa corre em outra thread: * nem o Lenis é baixado. * * As únicas rolagens "presas" são as do CSS: o hero (sticky) e a vitrine de projetos (sticky). */ /** Â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 (requestAnimationFrame). */ export const IDLE_SECONDS = 1; const vh = () => window.innerHeight; /** Topo do elemento na página pelo layout (offsetTop ignora os transforms que as passagens aplicam). */ 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; } /** 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; /** * Posição da rolagem (px) para mostrar `target`: o alvo que é a cena inteira para no topo dela; um alvo * interno desconta o cabeçalho fixo, sem passar do topo da cena. */ export function anchorTop(target: HTMLElement, scenes: readonly HTMLElement[]) { const scene = scenes.find((candidate) => candidate.contains(target)); const sceneTop = scene ? pageTop(scene) : 0; if (scene === target) return sceneTop; const offset = Number.parseFloat( getComputedStyle(document.documentElement).scrollPaddingTop, ) || 0; return Math.max(sceneTop, pageTop(target) - offset); } type SmoothScroll = { scrollTo: (top: number, duration: number | null) => void; destroy: () => void; }; /** * Lenis com o relógio ligado só enquanto a rolagem se move; parado por IDLE_SECONDS, o requestAnimationFrame * para. Devolve `null` (sem criar nada) se a página já saiu de cena enquanto o pacote carregava. */ async function startSmoothScroll( isAlive: () => boolean, ): Promise { const { default: Lenis } = await import("lenis"); if (!isAlive()) return null; const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 }); let frame = 0; let lastBusy = 0; const tick = (time: number) => { lenis.raf(time); if (lenis.isScrolling) lastBusy = time; frame = time - lastBusy > IDLE_SECONDS * 1000 ? 0 : requestAnimationFrame(tick); }; const wake = () => { lastBusy = performance.now(); if (!frame) frame = requestAnimationFrame(tick); }; window.addEventListener("wheel", wake, { passive: true }); window.addEventListener("scroll", wake, { passive: true }); wake(); return { scrollTo(top, duration) { wake(); lenis.scrollTo(top, { immediate: duration === null, duration: duration ?? 0, easing: easeInOutCubic, }); }, destroy() { window.removeEventListener("wheel", wake); window.removeEventListener("scroll", wake); cancelAnimationFrame(frame); frame = 0; lenis.destroy(); }, }; } export function useCinematicScroll(mainRef: RefObject) { useEffect(() => { const main = mainRef.current; if (!main) return; if (prefersReducedMotion()) return; let cancelled = false; let smooth: SmoothScroll | null = null; // no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote) if (!isCoarsePointer()) startSmoothScroll(() => !cancelled).then((scroll) => { smooth = scroll; }); const scrollToTop = (top: number) => { const duration = anchorDuration(top - window.scrollY, vh()); if (smooth) smooth.scrollTo(top, duration); 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. O clique // chega já interpretado por fragment-nav (o único ouvinte de clique das âncoras). const stopClicks = onFragmentClick((hit, event) => { event.preventDefault(); if (location.hash !== hit.hash) history.pushState(null, "", hit.hash); scrollToTop( anchorTop(hit.target, Array.from(main.children) as HTMLElement[]), ); // o foco vai junto: a cena já está no fluxo, visível e pronta para recebê-lo focusFragment(hit.target); }); return () => { cancelled = true; stopClicks(); smooth?.destroy(); }; }, [mainRef]); }