Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
65 lines
2.1 KiB
TypeScript
65 lines
2.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
/** Telas dos projetos (vitrine e capas): nenhum efeito pode ficar por cima delas. */
|
|
const PROJECT_SCREENS = ".ds-reel, .ds-frame, .ds-bframe, .ds-phone";
|
|
|
|
/**
|
|
* Avisa `onChange(true)` enquanto qualquer tela de projeto está à vista. Usa IntersectionObserver (nada mede a
|
|
* página a cada quadro de rolagem) e um MutationObserver só para pegar as telas que a página cria ou remove
|
|
* depois (a vitrine fixa troca a lista ao hidratar). Devolve a função que desfaz tudo.
|
|
*/
|
|
function watchProjectScreens(onChange: (over: boolean) => void) {
|
|
if (typeof IntersectionObserver === "undefined") return () => {};
|
|
const watched = new Set<Element>();
|
|
const inView = new Set<Element>();
|
|
const observer = new IntersectionObserver((entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting && entry.boundingClientRect.height > 0)
|
|
inView.add(entry.target);
|
|
else inView.delete(entry.target);
|
|
}
|
|
onChange(inView.size > 0);
|
|
});
|
|
|
|
const scan = () => {
|
|
for (const screen of document.querySelectorAll(PROJECT_SCREENS)) {
|
|
if (watched.has(screen)) continue;
|
|
watched.add(screen);
|
|
observer.observe(screen);
|
|
}
|
|
for (const screen of watched) {
|
|
if (screen.isConnected) continue;
|
|
observer.unobserve(screen);
|
|
watched.delete(screen);
|
|
inView.delete(screen);
|
|
}
|
|
onChange(inView.size > 0);
|
|
};
|
|
scan();
|
|
const mutations = new MutationObserver(scan);
|
|
mutations.observe(document.body, { childList: true, subtree: true });
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
mutations.disconnect();
|
|
};
|
|
}
|
|
|
|
/** Grão de filme sobre a página. Some enquanto qualquer tela de projeto está à vista (data-over-projects). */
|
|
export default function FilmGrain() {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const grain = ref.current;
|
|
if (!grain) return;
|
|
return watchProjectScreens((over) => {
|
|
if (over) grain.dataset.overProjects = "";
|
|
else delete grain.dataset.overProjects;
|
|
});
|
|
}, []);
|
|
|
|
return <div ref={ref} aria-hidden="true" className="film-grain" />;
|
|
}
|