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.
168 lines
6.2 KiB
TypeScript
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]);
|
|
}
|