diff --git a/app/components/story/PrintReel.tsx b/app/components/story/PrintReel.tsx index 1be896b..16b2e6b 100644 --- a/app/components/story/PrintReel.tsx +++ b/app/components/story/PrintReel.tsx @@ -1,14 +1,15 @@ "use client"; -import { DrawLine } from "../animations/DrawLine"; -import HorizontalScroll from "../cinematic/HorizontalScroll"; -import { Section } from "../Section"; +import { useEffect, useRef, useState } from "react"; /** - * Palco extra estilo product-page: filmstrip horizontal PINNADO com prints - * reais capturados dos deployments em produção (public/assets/prints). - * O scroll vertical empurra a faixa na horizontal (scrub), cada card entra - * com rotateY/scale ligados ao mesmo trilho — sensação de carrossel de vídeo. + * Palco 2 do estilo product-page: filmstrip horizontal dirigido pelo scroll. + * Arquitetura: runway 280vh + palco `position: sticky` (mesma técnica provada + * no reel do hero — sem GSAP pin, sem wrapper de flex). O scrub move a faixa + * da direita para a esquerda; quando a faixa termina, o sticky solta e o + * scroll continua para baixo normalmente. Role para cima = rebobina. + * + * Sem JS / reduced motion: grid estático (fallback), como no ScrollStory. */ const PRINTS = [ @@ -42,68 +43,248 @@ const PRINTS = [ }, ]; +const CARD_W = "min(80vw, 430px)"; + export default function PrintReel() { - return ( - + ); + } + + // ---- Modo reel: runway + sticky ---- + return ( + + ); } diff --git a/app/components/story/ReelDirector.tsx b/app/components/story/ReelDirector.tsx index 0798fb4..0eaa11a 100644 --- a/app/components/story/ReelDirector.tsx +++ b/app/components/story/ReelDirector.tsx @@ -8,13 +8,19 @@ import { useEffect, useRef } from "react"; * 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|bottom|zoom|fade"] + * [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"; @@ -49,6 +55,9 @@ export default function ReelDirector() { return undefined; let teardown = () => {}; + let raf1 = 0; + let raf2 = 0; + let timer = 0; const boot = async () => { const [{ gsap }, { ScrollTrigger }] = await Promise.all([ @@ -58,12 +67,16 @@ export default function ReelDirector() { if (!alive.current) return; gsap.registerPlugin(ScrollTrigger); + const done = new WeakSet(); + const ctx = gsap.context(() => { const scan = () => { + if (!alive.current) return; for (const el of document.querySelectorAll( - "[data-reel]:not([data-reel-init])", + "[data-reel]", )) { - el.dataset.reelInit = "1"; + 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), { @@ -77,9 +90,10 @@ export default function ReelDirector() { }); } for (const container of document.querySelectorAll( - "[data-reel-stagger]:not([data-reel-init])", + "[data-reel-stagger]", )) { - container.dataset.reelInit = "1"; + 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[]; @@ -101,12 +115,25 @@ export default function ReelDirector() { } }; - scan(); - // seções chegam via next/dynamic depois do boot — observa nós novos - const observer = new MutationObserver(() => scan()); + // 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(); }; @@ -116,6 +143,9 @@ export default function ReelDirector() { void boot(); return () => { alive.current = false; + window.cancelAnimationFrame(raf1); + window.cancelAnimationFrame(raf2); + window.clearTimeout(timer); teardown(); }; }, []);