Files
portifolio-evandrodevbr/app/components/cinematic/fragment-nav.ts
T
Evandro Fonseca Junior 6ac87824f3 perf(movimento): sem tempo morto, vídeos regravados e 3D sem React Three Fiber
Legenda do mergulho até Projetos cobrir a cena; transição de página sem blur e com fundo escuro; foco no conteúdo ao trocar de página; listas com marcador e dois pesos de título; vídeos da SEV e da SC Plus regravados sem quadros brancos; scrub a 12-15 fps (-30%); vitrine com uma parada de Tab e setas, contador no celular e CSS sob demanda; âncoras num módulo só (fragment-nav); FramePulse com setInterval; CSS morto removido.
2026-10-14 17:01:37 -03:00

124 lines
5.0 KiB
TypeScript

/*
* Âncoras da home (links #id para a mesma página), num lugar só.
*
* - Um único ouvinte de clique no documento (capturando, antes de qualquer menu remover o link ativado). Quem quer
* reagir a um clique num link de âncora se inscreve com `onFragmentClick`: a rolagem suave (useCinematicScroll) e a
* chegada por fragmento (useHomeFragmentNavigation) recebem o mesmo clique já interpretado, sem cada uma ler o
* evento de novo.
* - Um único dono de `history.scrollRestoration`: o script que roda antes da hidratação (`FRAGMENT_NAV_BOOT_SCRIPT`,
* que app/layout.tsx põe no <head>) e `setScrollRestoration`, que os ganchos usam. Nenhum outro código escreve nele.
*
* Sem "use client": o layout (servidor) importa só a constante do script; o resto roda no navegador.
*/
/** 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 e não baixa arquivo; `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) || link.hasAttribute("download"))
return null;
return !link.target || link.target === "_self" ? link : null;
}
/** O `#id` de um link para esta mesma página (mesmo caminho e mesma busca); `null` se leva a outro lugar 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 &&
url.search === location.search;
return same && url.hash ? url.hash : null;
}
/** O elemento que um `#id` aponta (aceita percent-encoding); `null` se não existe ou o hash é malformado. */
export function elementForHash(hash: string) {
if (!hash) return null;
try {
return document.getElementById(decodeURIComponent(hash.slice(1)));
} catch {
return null;
}
}
export type FragmentHit = { target: HTMLElement; hash: string };
/** 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): FragmentHit | null {
const link = sameTabLink(event);
const hash = link && hashOnThisPage(link);
const target = hash ? elementForHash(hash) : null;
return hash && target ? { target, hash } : null;
}
export type FragmentClickHandler = (
hit: FragmentHit,
event: MouseEvent,
) => void;
const handlers = new Set<FragmentClickHandler>();
/** O único ouvinte: um clique simples num link de âncora chega a cada inscrito, na ordem em que se inscreveram. */
function onDocumentClick(event: MouseEvent) {
if (!isPlainPrimaryClick(event)) return;
const hit = samePageHashTarget(event);
if (!hit) return;
for (const handler of [...handlers]) handler(hit, event);
}
/**
* Inscreve `handler` nos cliques em links de âncora da mesma página. O ouvinte do documento existe só enquanto houver
* inscritos. Devolve a função que cancela a inscrição.
*/
export function onFragmentClick(handler: FragmentClickHandler) {
if (handlers.size === 0)
document.addEventListener("click", onDocumentClick, true);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (handlers.size === 0)
document.removeEventListener("click", onDocumentClick, true);
};
}
/** Dá o foco ao destino de uma âncora (um alvo sem tabindex ganha -1), sem rolar a página. */
export function focusFragment(target: HTMLElement) {
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
target.focus({ preventScroll: true });
}
/** Única escrita em `history.scrollRestoration` fora do script de abertura: "manual" na chegada, "auto" depois. */
export function setScrollRestoration(mode: "manual" | "auto") {
window.history.scrollRestoration = mode;
}
/**
* Roda no <head>, antes da hidratação e da restauração nativa de rolagem: numa chegada nova à home a rolagem fica
* "manual" (a home posiciona a página depois de montar o layout; num recarregamento o fragmento antigo é tirado da
* URL), e ao sair da página volta a "auto". Voltar e avançar no histórico seguem com a restauração do navegador.
*/
export const FRAGMENT_NAV_BOOT_SCRIPT = `
if (location.pathname === "/") {
const navigation = performance.getEntriesByType("navigation")[0];
if (navigation?.type !== "back_forward") {
history.scrollRestoration = "manual";
if (navigation?.type === "reload" && location.hash) {
history.replaceState(history.state, "", location.pathname + location.search);
}
addEventListener("pagehide", () => {
history.scrollRestoration = "auto";
}, { once: true });
}
}`;