Files
Evandro Fonseca Junior 204ebd5fa5 fix(navegacao): foco em âncora de outra página, vitrine acessível e 3D mais leve
Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
2026-10-14 19:14:48 -03:00

168 lines
6.2 KiB
TypeScript

"use client";
import { type RefObject, useEffect } from "react";
import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
import { focusFragment, onFragmentClick } from "./fragment-nav";
/*
* Rolagem da home. Duas coisas, nenhuma delas com biblioteca de animação:
*
* 1. As passagens entre cenas são CSS (globals.css, `animation-timeline: view()`): o conteúdo de cada seção
* (`.ds-section-shell`) chega com fade e uma subida curta enquanto a seção entra na janela, e o fundo da
* seção fica sempre inteiro (seções do mesmo tom não piscam em cinza). Nada de JavaScript por quadro, e onde
* o navegador não tem o recurso o conteúdo simplesmente já está lá. Qualquer filho direto de <main> é uma
* cena; nada aqui depende de uma seção específica existir.
* 2. Este hook cuida só do que o CSS não faz: no mouse, o Lenis suaviza a roda (com o relógio ligado apenas
* enquanto algo se move, nunca com a página parada), e os links internos (#id) "avançam o vídeo" até o
* destino em vez de saltar (o clique vem de fragment-nav). No toque a rolagem nativa corre em outra thread:
* nem o Lenis é baixado. Com movimento reduzido não há Lenis nem animação, mas o clique segue tratado aqui: o
* salto é instantâneo, em todos os cliques (o link do Next só rolava no primeiro, com a mesma âncora).
*
* As únicas rolagens "presas" são as do CSS: o hero (sticky) e a vitrine de projetos (sticky).
*/
/** Âncoras mais distantes que isto (em alturas de tela) são um salto direto. */
export const JUMP_SCREENS = 3;
/** Nenhuma âncora anima por mais que isto. */
export const MAX_ANCHOR_SECONDS = 0.8;
/** Depois deste tempo parada, a rolagem suave solta o relógio (requestAnimationFrame). */
export const IDLE_SECONDS = 1;
const vh = () => window.innerHeight;
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms que as passagens aplicam). */
function pageTop(el: HTMLElement) {
let top = 0;
for (
let node: HTMLElement | null = el;
node;
node = node.offsetParent as HTMLElement | null
)
top += node.offsetTop;
return top;
}
/** Duração (s) da rolagem até uma âncora a `distance` px; `null` = salto direto. */
export function anchorDuration(distance: number, viewport: number) {
const screens = Math.abs(distance) / Math.max(1, viewport);
if (screens > JUMP_SCREENS) return null;
return Math.min(MAX_ANCHOR_SECONDS, 0.3 + screens * 0.2);
}
const easeInOutCubic = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
/**
* Posição da rolagem (px) para mostrar `target`: o alvo que é a cena inteira para no topo dela; um alvo
* interno desconta o cabeçalho fixo, sem passar do topo da cena.
*/
export function anchorTop(target: HTMLElement, scenes: readonly HTMLElement[]) {
const scene = scenes.find((candidate) => candidate.contains(target));
const sceneTop = scene ? pageTop(scene) : 0;
if (scene === target) return sceneTop;
const offset =
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
return Math.max(sceneTop, pageTop(target) - offset);
}
type SmoothScroll = {
scrollTo: (top: number, duration: number | null) => void;
destroy: () => void;
};
/**
* Lenis com o relógio ligado só enquanto a rolagem se move; parado por IDLE_SECONDS, o requestAnimationFrame
* para. Devolve `null` (sem criar nada) se a página já saiu de cena enquanto o pacote carregava.
*/
async function startSmoothScroll(
isAlive: () => boolean,
): Promise<SmoothScroll | null> {
const { default: Lenis } = await import("lenis");
if (!isAlive()) return null;
const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 });
let frame = 0;
let lastBusy = 0;
const tick = (time: number) => {
lenis.raf(time);
if (lenis.isScrolling) lastBusy = time;
frame =
time - lastBusy > IDLE_SECONDS * 1000 ? 0 : requestAnimationFrame(tick);
};
const wake = () => {
lastBusy = performance.now();
if (!frame) frame = requestAnimationFrame(tick);
};
window.addEventListener("wheel", wake, { passive: true });
window.addEventListener("scroll", wake, { passive: true });
wake();
return {
scrollTo(top, duration) {
wake();
lenis.scrollTo(top, {
immediate: duration === null,
duration: duration ?? 0,
easing: easeInOutCubic,
});
},
destroy() {
window.removeEventListener("wheel", wake);
window.removeEventListener("scroll", wake);
cancelAnimationFrame(frame);
frame = 0;
lenis.destroy();
},
};
}
export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
useEffect(() => {
const main = mainRef.current;
if (!main) return;
const reducedMotion = prefersReducedMotion();
let cancelled = false;
let smooth: SmoothScroll | null = null;
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote); com movimento reduzido,
// também não há suavização
if (!reducedMotion && !isCoarsePointer())
startSmoothScroll(() => !cancelled).then((scroll) => {
smooth = scroll;
});
const scrollToTop = (top: number) => {
if (reducedMotion) {
// "instant" vale mesmo com `scroll-behavior: smooth` no CSS
window.scrollTo({ top, behavior: "instant" });
return;
}
const duration = anchorDuration(top - window.scrollY, vh());
if (smooth) smooth.scrollTo(top, duration);
else
window.scrollTo({
top,
behavior: duration === null ? "instant" : "smooth",
});
};
// botões e links internos: em vez do salto, a página "avança o vídeo" (de pouco) até o destino. O clique
// chega já interpretado por fragment-nav (o único ouvinte de clique das âncoras).
const stopClicks = onFragmentClick((hit, event) => {
event.preventDefault();
if (location.hash !== hit.hash) history.pushState(null, "", hit.hash);
scrollToTop(
anchorTop(hit.target, Array.from(main.children) as HTMLElement[]),
);
// o foco vai junto: a cena já está no fluxo, visível e pronta para recebê-lo
focusFragment(hit.target);
});
return () => {
cancelled = true;
stopClicks();
smooth?.destroy();
};
}, [mainRef]);
}