"use client"; import { useEffect } from "react"; /** * ReelDirector — motor de animação "vídeo-ao-rolar" para a página inteira. * * Contrato (declarativo, via atributos data-*): * [data-reel="left|right|top|bottom|zoom|fade"] * elemento entra em scrub contínuo: rolar p/ baixo avança, p/ cima REBOBINA. * [data-reel-stagger="left|right|top|zoom|fade|bottom"] * container: os FILHOS DIRETOS entram em cascata no mesmo trilho. * data-reel-start="top 88%" data-reel-end="top 26%" (opcional: janela do scrub) * data-reel-distance="64" (opcional: px de deslocamento) * * Nunca one-shot — tudo ligado ao progresso do scroll, como o reel do hero. * prefers-reduced-motion: nada é inicializado; conteúdo fica no fluxo natural. * * Anti-hidratação: elementos inicializados vivem num WeakSet (nunca um * atributo data-reel-init no DOM) e o primeiro scan roda depois que a * hidratação assentou (double-rAF + debounce) — MutationObserver dispara em * microtask e pegaria nós ainda em hidratação, produzindo mismatch de * atributos no overlay do Next. */ type Direction = "left" | "right" | "top" | "bottom" | "zoom" | "fade"; const DISTANCE = 64; function fromVars(dir: Direction, dist: number) { const base = { opacity: 0, x: 0, y: 0, scale: 1, filter: "blur(0px)" }; if (dir === "left") return { ...base, x: -dist }; if (dir === "right") return { ...base, x: dist }; if (dir === "top") return { ...base, y: -dist * 0.75 }; if (dir === "bottom") return { ...base, y: dist }; if (dir === "zoom") return { ...base, scale: 1.28, filter: "blur(6px)" }; return { ...base, scale: 1.02 }; } const TO_VARS = { opacity: 1, x: 0, y: 0, scale: 1, filter: "blur(0px)", ease: "none", }; export default function ReelDirector() { useEffect(() => { let cancelled = false; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return undefined; let teardown = () => {}; let raf1 = 0; let raf2 = 0; let timer = 0; const boot = async () => { const [{ gsap }, { ScrollTrigger }] = await Promise.all([ import("gsap"), import("gsap/ScrollTrigger"), ]); if (cancelled) return; gsap.registerPlugin(ScrollTrigger); const done = new WeakSet(); const ctx = gsap.context(() => { const scan = () => { if (cancelled) return; for (const el of document.querySelectorAll( "[data-reel]", )) { if (done.has(el)) continue; done.add(el); const dir = (el.dataset.reel || "bottom") as Direction; const dist = Number(el.dataset.reelDistance || DISTANCE); gsap.fromTo(el, fromVars(dir, dist), { ...TO_VARS, scrollTrigger: { trigger: el, start: el.dataset.reelStart || "top 88%", end: el.dataset.reelEnd || "top 26%", scrub: 0.6, }, }); } for (const container of document.querySelectorAll( "[data-reel-stagger]", )) { if (done.has(container)) continue; done.add(container); const dir = (container.dataset.reelStagger || "bottom") as Direction; const dist = Number(container.dataset.reelDistance || DISTANCE); const children = Array.from(container.children) as HTMLElement[]; if (children.length === 0) continue; gsap.fromTo(children, fromVars(dir, dist), { ...TO_VARS, stagger: { each: 0.09 }, scrollTrigger: { trigger: container, start: container.dataset.reelStart || "top 85%", end: container.dataset.reelEnd || "top 25%", scrub: 0.6, }, }); } }; // debounce: nós de seções dinâmicas (next/dynamic) chegam depois let pending = 0; const scheduleScan = () => { window.clearTimeout(pending); pending = window.setTimeout(scan, 120); }; // espera a hidratação assentar antes de tocar o DOM raf1 = window.requestAnimationFrame(() => { raf2 = window.requestAnimationFrame(() => { timer = window.setTimeout(scan, 250); }); }); const observer = new MutationObserver(scheduleScan); observer.observe(document.body, { childList: true, subtree: true }); teardown = () => { window.clearTimeout(pending); observer.disconnect(); ctx.revert(); }; }); }; void boot(); return () => { cancelled = true; window.cancelAnimationFrame(raf1); window.cancelAnimationFrame(raf2); window.clearTimeout(timer); teardown(); }; }, []); return null; }