Files
portifolio-evandrodevbr/app/components/showcase/ShowcaseProvider.tsx
T
Evandro Fonseca Junior c9a0b80656 perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
2026-10-14 03:32:23 -03:00

258 lines
8.4 KiB
TypeScript

"use client";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { SHOWCASE, type ShowcaseConfig } from "@/lib/project-showcase";
import { ProjectLightbox } from "./ProjectLightbox";
import { pickActiveCard } from "./showcase-math";
import "./showcase.css";
export interface ShowcaseItem {
config: ShowcaseConfig;
project: PortfolioProjectView;
}
interface ShowcaseApi {
register(id: string, element: HTMLElement): () => void;
setHover(id: string, hovered: boolean): void;
open(id: string): void;
/** Pausa (ou retoma) os vídeos que tocam sozinhos nos cards: quem não quer movimento na página tem como desligar. */
toggleAutoplay(): void;
items: ShowcaseItem[];
}
interface ActiveState {
activeId: string | null;
userPaused: boolean;
}
const ApiContext = createContext<ShowcaseApi | null>(null);
const ActiveContext = createContext<ActiveState>({
activeId: null,
userPaused: false,
});
/** Só aparece como card com vídeo o projeto que existe na lista e tem vídeo gravado. */
export function buildShowcaseItems(
projects: PortfolioProjectView[],
): ShowcaseItem[] {
const items: ShowcaseItem[] = [];
for (const config of SHOWCASE) {
const project = projects.find((candidate) => candidate.id === config.id);
if (project) items.push({ config, project });
}
return items;
}
/** Itens da vitrine para quem está dentro do provedor (a vitrine animada usa a mesma lista dos cards). */
export function useShowcaseItems() {
return useContext(ApiContext)?.items ?? [];
}
/** Abre a janela ampliada de um projeto (nada acontece fora do provedor). */
export function useShowcaseOpen() {
const api = useContext(ApiContext);
return useCallback((id: string) => api?.open(id), [api]);
}
/**
* Um vídeo por vez nos cards: toca o card sob o mouse (ou com foco); sem mouse sobre nenhum, o mais perto do
* centro da tela. Também guarda qual card está ampliado e mostra a janela ampliada.
*/
export function ShowcaseProvider({
projects,
children,
}: {
projects: PortfolioProjectView[];
children: ReactNode;
}) {
const reducedMotion = useReducedMotion();
const items = useMemo(() => buildShowcaseItems(projects), [projects]);
const [activeId, setActiveId] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [userPaused, setUserPaused] = useState(false);
const cards = useRef(
new Map<string, { el: HTMLElement; visible: boolean }>(),
);
const hovered = useRef<string | null>(null);
const paused = useRef(false);
const queued = useRef(false);
const frame = useRef(0);
const observer = useRef<IntersectionObserver | null>(null);
const evaluate = useCallback(() => {
queued.current = false;
if (paused.current || document.hidden) {
setActiveId(null);
return;
}
const middle = window.innerHeight / 2;
const candidates = [...cards.current].map(([id, { el, visible }]) => {
// só quem está à vista entra na disputa: os outros nem têm o layout lido a cada quadro de rolagem
if (!visible) return { id, visible, distance: Number.POSITIVE_INFINITY };
const rect = el.getBoundingClientRect();
return {
id,
visible,
distance: Math.abs(rect.top + rect.height / 2 - middle),
};
});
setActiveId(pickActiveCard(candidates, hovered.current));
}, []);
// sem cards registrados (a home mostra só a vitrine animada) não há o que decidir a cada quadro de rolagem;
// `force` vale para o que acabou de mudar a lista (um card saiu), que ainda precisa ser reavaliado
const schedule = useCallback(
(force = false) => {
if (queued.current || (!force && cards.current.size === 0)) return;
queued.current = true;
frame.current = requestAnimationFrame(evaluate);
},
[evaluate],
);
// com a janela ampliada aberta, ou com preferência por menos movimento, nenhum card toca
useEffect(() => {
paused.current = reducedMotion || openId !== null || userPaused;
schedule(true);
}, [schedule, openId, reducedMotion, userPaused]);
useEffect(() => {
const onChange = () => schedule();
window.addEventListener("scroll", onChange, { passive: true });
window.addEventListener("resize", onChange);
document.addEventListener("visibilitychange", onChange);
return () => {
window.removeEventListener("scroll", onChange);
window.removeEventListener("resize", onChange);
document.removeEventListener("visibilitychange", onChange);
cancelAnimationFrame(frame.current);
queued.current = false;
observer.current?.disconnect();
observer.current = null;
};
}, [schedule]);
const api = useMemo<ShowcaseApi>(
() => ({
items,
register(id, element) {
if (!observer.current && typeof IntersectionObserver !== "undefined") {
observer.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
for (const card of cards.current.values()) {
// um card só vale como "à vista" com pelo menos um terço dele na tela
if (card.el === entry.target)
card.visible = entry.intersectionRatio >= 0.33;
}
}
schedule();
},
{ threshold: [0, 0.33, 0.66, 1] },
);
}
cards.current.set(id, { el: element, visible: false });
observer.current?.observe(element);
return () => {
observer.current?.unobserve(element);
cards.current.delete(id);
if (hovered.current === id) hovered.current = null;
schedule(true);
};
},
setHover(id, isHovered) {
if (isHovered) hovered.current = id;
else if (hovered.current === id) hovered.current = null;
schedule();
},
open(id) {
setOpenId(id);
},
toggleAutoplay() {
setUserPaused((paused) => !paused);
},
}),
[items, schedule],
);
const activeState = useMemo(
() => ({ activeId, userPaused }),
[activeId, userPaused],
);
const openIndex = openId
? items.findIndex((item) => item.config.id === openId)
: -1;
return (
<ApiContext.Provider value={api}>
<ActiveContext.Provider value={activeState}>
{children}
{openIndex >= 0 && (
<ProjectLightbox
items={items}
index={openIndex}
onClose={() => setOpenId(null)}
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
/>
)}
</ActiveContext.Provider>
</ApiContext.Provider>
);
}
/** Liga um card à vitrine: devolve a ref da moldura, se ele é o card que toca agora e como ampliá-lo. */
export function useShowcaseCard(id: string) {
const api = useContext(ApiContext);
const { activeId, userPaused } = useContext(ActiveContext);
const frameRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = frameRef.current;
if (!api || !element) return;
const unregister = api.register(id, element);
const card = element.closest<HTMLElement>(".ds-card") ?? element;
const enter = (event: PointerEvent) => {
// no toque não existe "passar o mouse": só o mouse e a caneta mandam na vitrine
if (event.pointerType !== "touch") api.setHover(id, true);
};
const leave = () => api.setHover(id, false);
const focusIn = () => api.setHover(id, true);
const focusOut = (event: FocusEvent) => {
if (!card.contains(event.relatedTarget as Node | null))
api.setHover(id, false);
};
card.addEventListener("pointerenter", enter);
card.addEventListener("pointerleave", leave);
card.addEventListener("focusin", focusIn);
card.addEventListener("focusout", focusOut);
return () => {
unregister();
card.removeEventListener("pointerenter", enter);
card.removeEventListener("pointerleave", leave);
card.removeEventListener("focusin", focusIn);
card.removeEventListener("focusout", focusOut);
};
}, [api, id]);
return {
frameRef,
active: activeId === id,
userPaused,
toggleAutoplay: useCallback(() => api?.toggleAutoplay(), [api]),
open: useCallback(() => api?.open(id), [api, id]),
};
}