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.
207 lines
7.4 KiB
TypeScript
207 lines
7.4 KiB
TypeScript
"use client";
|
|
|
|
import { type RefObject, useEffect } from "react";
|
|
import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
|
|
|
|
/*
|
|
* 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. No toque a rolagem nativa corre em outra thread: nem o Lenis é baixado.
|
|
*
|
|
* 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;
|
|
|
|
/** Clique do botão principal, sem tecla modificadora, que nenhum outro código já tratou. */
|
|
export function isPlainPrimaryClick(event: MouseEvent) {
|
|
return !(
|
|
event.defaultPrevented ||
|
|
event.button !== 0 ||
|
|
event.metaKey ||
|
|
event.ctrlKey ||
|
|
event.shiftKey ||
|
|
event.altKey
|
|
);
|
|
}
|
|
|
|
/** O link (<a>) que o clique atingiu, se abre na mesma aba; `null` para qualquer outro clique. */
|
|
function sameTabLink(event: MouseEvent) {
|
|
const link =
|
|
event.target instanceof Element ? event.target.closest("a[href]") : null;
|
|
if (!(link instanceof HTMLAnchorElement)) return null;
|
|
return !link.target || link.target === "_self" ? link : null;
|
|
}
|
|
|
|
/** O `#id` de um link para esta mesma página; `null` se ele leva a outra página ou não tem âncora. */
|
|
function hashOnThisPage(link: HTMLAnchorElement) {
|
|
const url = new URL(link.href);
|
|
const same =
|
|
url.origin === location.origin && url.pathname === location.pathname;
|
|
return same && url.hash ? url.hash : null;
|
|
}
|
|
|
|
function elementForHash(hash: string) {
|
|
try {
|
|
return document.getElementById(decodeURIComponent(hash.slice(1)));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Link para outro ponto da MESMA página (`#id`): o elemento de destino e o hash; `null` para qualquer outro clique. */
|
|
export function samePageHashTarget(event: MouseEvent) {
|
|
const link = sameTabLink(event);
|
|
const hash = link && hashOnThisPage(link);
|
|
const target = hash ? elementForHash(hash) : null;
|
|
return hash && target ? { target, hash } : null;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
if (prefersReducedMotion()) return;
|
|
let cancelled = false;
|
|
let smooth: SmoothScroll | null = null;
|
|
|
|
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote)
|
|
if (!isCoarsePointer())
|
|
startSmoothScroll(() => !cancelled).then((scroll) => {
|
|
smooth = scroll;
|
|
});
|
|
|
|
const scrollToTop = (top: number) => {
|
|
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
|
|
const onClick = (event: MouseEvent) => {
|
|
if (!isPlainPrimaryClick(event)) return;
|
|
const hit = samePageHashTarget(event);
|
|
if (!hit) return;
|
|
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
|
|
if (!hit.target.hasAttribute("tabindex")) hit.target.tabIndex = -1;
|
|
hit.target.focus({ preventScroll: true });
|
|
};
|
|
document.addEventListener("click", onClick, true);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
document.removeEventListener("click", onClick, true);
|
|
smooth?.destroy();
|
|
};
|
|
}, [mainRef]);
|
|
}
|