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

125 lines
5.1 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 (mesma origem e mesmo caminho); `null` se leva a outro lugar ou não tem
* âncora. A busca (`?tipo=`) não conta: a página é a mesma e o link do cabeçalho (`/#contact`) não a repete.
*/
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;
}
/** 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 });
}
}`;