Files
portifolio-evandrodevbr/app/components/story/ReelDirector.tsx
T

150 lines
4.9 KiB
TypeScript

"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<Element>();
const ctx = gsap.context(() => {
const scan = () => {
if (cancelled) return;
for (const el of document.querySelectorAll<HTMLElement>(
"[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<HTMLElement>(
"[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;
}