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.
This commit is contained in:
1 parent
f8545cd442
commit
6ac87824f3
68 files changed
+5735
-3300
No files matched your search
@@ -0,0 +1,141 @@
|
||||
import { startFramePulse } from "./FramePulse";
|
||||
|
||||
const invalidate = jest.fn();
|
||||
let stops: Array<() => void> = [];
|
||||
|
||||
/** Liga a batida e guarda o desligamento para o afterEach não deixar ouvinte para trás. */
|
||||
function start(options?: Parameters<typeof startFramePulse>[1]) {
|
||||
const stop = startFramePulse(invalidate, options);
|
||||
stops.push(stop);
|
||||
return stop;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
invalidate.mockClear();
|
||||
stops = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const stop of stops) stop();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const advance = (ms: number) => jest.advanceTimersByTime(ms);
|
||||
|
||||
describe("startFramePulse", () => {
|
||||
it("asks for frames at about 30 per second, not 60", () => {
|
||||
start();
|
||||
advance(1000);
|
||||
expect(invalidate.mock.calls.length).toBeGreaterThan(20);
|
||||
expect(invalidate.mock.calls.length).toBeLessThanOrEqual(34);
|
||||
});
|
||||
|
||||
it("asks for the first frame right away", () => {
|
||||
start();
|
||||
expect(invalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is a single setInterval, never a second animation-frame loop", () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
start();
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
advance(1000);
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
frames.mockRestore();
|
||||
});
|
||||
|
||||
it("does nothing once it is stopped (the hero is off screen)", () => {
|
||||
const stop = start();
|
||||
advance(200);
|
||||
const running = invalidate.mock.calls.length;
|
||||
expect(running).toBeGreaterThan(0);
|
||||
|
||||
stop();
|
||||
advance(1000);
|
||||
expect(invalidate.mock.calls.length).toBe(running);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("removes its listeners when it stops", () => {
|
||||
const stop = start();
|
||||
stop();
|
||||
invalidate.mockClear();
|
||||
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
advance(500);
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("can be stopped twice", () => {
|
||||
const stop = start();
|
||||
stop();
|
||||
expect(() => stop()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("startFramePulse: stops when nobody is looking", () => {
|
||||
it("stops drawing, and has no timer left, after 5 s without a cursor or scroll", () => {
|
||||
start();
|
||||
advance(4000);
|
||||
const drawn = invalidate.mock.calls.length;
|
||||
expect(drawn).toBeGreaterThan(80); // ainda batendo, ~30 por segundo
|
||||
|
||||
advance(1500); // passou dos 5 s
|
||||
const stopped = invalidate.mock.calls.length;
|
||||
// parou perto dos 5 s: no máximo 5 s de batidas a ~30 por segundo
|
||||
expect(stopped).toBeLessThanOrEqual(5 * 32);
|
||||
|
||||
advance(3000);
|
||||
expect(invalidate.mock.calls.length).toBe(stopped);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([["pointermove"], ["scroll"], ["wheel"], ["keydown"]])(
|
||||
"wakes up again with %s and keeps drawing",
|
||||
(type) => {
|
||||
start();
|
||||
advance(7000);
|
||||
const stopped = invalidate.mock.calls.length;
|
||||
|
||||
window.dispatchEvent(new Event(type));
|
||||
advance(500);
|
||||
|
||||
expect(invalidate.mock.calls.length).toBeGreaterThan(stopped + 8);
|
||||
},
|
||||
);
|
||||
|
||||
it("wakes up once, not once per gesture (one timer, however many events arrive)", () => {
|
||||
start();
|
||||
for (let i = 0; i < 20; i++) window.dispatchEvent(new Event("pointermove"));
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("each gesture buys another 5 s: it does not stop while there is movement", () => {
|
||||
start();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
advance(3000);
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
}
|
||||
const before = invalidate.mock.calls.length;
|
||||
|
||||
advance(1000);
|
||||
|
||||
expect(invalidate.mock.calls.length).toBeGreaterThan(before + 20);
|
||||
});
|
||||
|
||||
it("lets the idle time and the rate be configured", () => {
|
||||
start({ idleMs: 1000 });
|
||||
advance(1500);
|
||||
const stopped = invalidate.mock.calls.length;
|
||||
advance(2000);
|
||||
expect(invalidate.mock.calls.length).toBe(stopped);
|
||||
|
||||
invalidate.mockClear();
|
||||
start({ fps: 10 });
|
||||
advance(1000);
|
||||
expect(invalidate.mock.calls.length).toBeLessThanOrEqual(11);
|
||||
expect(invalidate.mock.calls.length).toBeGreaterThanOrEqual(9);
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import FramePulse from "./FramePulse";
|
||||
|
||||
const mockInvalidate = jest.fn();
|
||||
|
||||
jest.mock("@react-three/fiber", () => ({
|
||||
useThree: (select: (state: { invalidate: () => void }) => unknown) =>
|
||||
select({ invalidate: mockInvalidate }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockInvalidate.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const advance = (ms: number) =>
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
});
|
||||
|
||||
describe("FramePulse", () => {
|
||||
it("asks for frames at about 30 per second while active, not 60", () => {
|
||||
render(<FramePulse active />);
|
||||
advance(1000);
|
||||
expect(mockInvalidate.mock.calls.length).toBeGreaterThan(20);
|
||||
expect(mockInvalidate.mock.calls.length).toBeLessThanOrEqual(34);
|
||||
});
|
||||
|
||||
it("draws nothing at all when it is not active (off screen)", () => {
|
||||
const { rerender } = render(<FramePulse active={false} />);
|
||||
advance(1000);
|
||||
expect(mockInvalidate).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<FramePulse active />);
|
||||
advance(200);
|
||||
const running = mockInvalidate.mock.calls.length;
|
||||
expect(running).toBeGreaterThan(0);
|
||||
|
||||
rerender(<FramePulse active={false} />);
|
||||
advance(1000);
|
||||
expect(mockInvalidate.mock.calls.length).toBe(running);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FramePulse: stops when nobody is looking", () => {
|
||||
it("stops drawing, and stops asking for animation frames, after 5 s without a cursor or scroll", () => {
|
||||
render(<FramePulse active />);
|
||||
advance(4000);
|
||||
const drawn = mockInvalidate.mock.calls.length;
|
||||
expect(drawn).toBeGreaterThan(80); // ainda batendo, ~30 por segundo
|
||||
|
||||
advance(1500); // passou dos 5 s
|
||||
const stopped = mockInvalidate.mock.calls.length;
|
||||
// parou perto dos 5 s: no máximo 5 s de batidas a ~30 por segundo
|
||||
expect(stopped).toBeLessThanOrEqual(5 * 32);
|
||||
|
||||
advance(3000);
|
||||
expect(mockInvalidate.mock.calls.length).toBe(stopped);
|
||||
// sem laço nenhum: nenhum quadro de animação pendente
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([["pointermove"], ["scroll"], ["wheel"], ["keydown"]])(
|
||||
"wakes up again with %s and keeps drawing",
|
||||
(type) => {
|
||||
render(<FramePulse active />);
|
||||
advance(7000);
|
||||
const stopped = mockInvalidate.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(type));
|
||||
});
|
||||
advance(500);
|
||||
|
||||
expect(mockInvalidate.mock.calls.length).toBeGreaterThan(stopped + 8);
|
||||
},
|
||||
);
|
||||
|
||||
it("each gesture buys another 5 s: it does not stop while there is movement", () => {
|
||||
render(<FramePulse active />);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
advance(3000);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
}
|
||||
const before = mockInvalidate.mock.calls.length;
|
||||
|
||||
advance(1000);
|
||||
|
||||
expect(mockInvalidate.mock.calls.length).toBeGreaterThan(before + 20);
|
||||
});
|
||||
|
||||
it("lets the idle time be configured", () => {
|
||||
render(<FramePulse active idleMs={1000} />);
|
||||
advance(1500);
|
||||
const stopped = mockInvalidate.mock.calls.length;
|
||||
|
||||
advance(2000);
|
||||
|
||||
expect(mockInvalidate.mock.calls.length).toBe(stopped);
|
||||
});
|
||||
|
||||
it("removes its listeners when it unmounts or goes inactive", () => {
|
||||
const { rerender, unmount } = render(<FramePulse active />);
|
||||
rerender(<FramePulse active={false} />);
|
||||
mockInvalidate.mockClear();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
advance(500);
|
||||
expect(mockInvalidate).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
advance(500);
|
||||
expect(mockInvalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Gestos que mostram alguém olhando para a página: qualquer um deles religa a batida. */
|
||||
const ACTIVITY = [
|
||||
"pointermove",
|
||||
"pointerdown",
|
||||
"scroll",
|
||||
"wheel",
|
||||
"touchmove",
|
||||
"keydown",
|
||||
] as const;
|
||||
|
||||
export type FramePulseOptions = {
|
||||
/** Quadros por segundo pedidos (padrão 30). */
|
||||
fps?: number;
|
||||
/** Tempo (ms) sem cursor, rolagem nem teclado depois do qual a batida para de vez (padrão 5000). */
|
||||
idleMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Batida das cenas WebGL em demanda: pede um quadro (`invalidate`) a no máximo `fps` por segundo, de um único
|
||||
* `setInterval` (fundo ambiente, sem gastar 60 quadros por segundo e sem um segundo laço de requestAnimationFrame ao
|
||||
* lado do da cena). Depois de `idleMs` sem cursor, rolagem nem teclado, a batida para de vez (nenhum intervalo, nenhum
|
||||
* quadro) e o último quadro fica na tela; qualquer movimento a religa. Quem precisa de mais (entrada, rolagem, cursor)
|
||||
* pede o próximo quadro por conta própria, dentro do quadro. Devolve a função que desliga tudo.
|
||||
*/
|
||||
export function startFramePulse(
|
||||
invalidate: () => void,
|
||||
{ fps = 30, idleMs = 5000 }: FramePulseOptions = {},
|
||||
) {
|
||||
const period = Math.round(1000 / fps);
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let lastActivity = performance.now();
|
||||
|
||||
const stopTimer = () => {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
};
|
||||
const beat = () => {
|
||||
// parada por inatividade: sem intervalo até o próximo movimento
|
||||
if (performance.now() - lastActivity > idleMs) stopTimer();
|
||||
else invalidate();
|
||||
};
|
||||
const wake = () => {
|
||||
lastActivity = performance.now();
|
||||
if (timer === undefined) timer = setInterval(beat, period);
|
||||
};
|
||||
|
||||
for (const type of ACTIVITY)
|
||||
window.addEventListener(type, wake, { passive: true });
|
||||
wake();
|
||||
invalidate();
|
||||
return () => {
|
||||
for (const type of ACTIVITY) window.removeEventListener(type, wake);
|
||||
stopTimer();
|
||||
};
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/** Gestos que mostram alguém olhando para a página: qualquer um deles religa a batida. */
|
||||
const ACTIVITY = [
|
||||
"pointermove",
|
||||
"pointerdown",
|
||||
"scroll",
|
||||
"wheel",
|
||||
"touchmove",
|
||||
"keydown",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Batida das cenas WebGL em `frameloop="demand"`: enquanto `active`, pede um quadro a no máximo `fps` por
|
||||
* segundo (fundo ambiente, sem gastar 60 quadros por segundo); com `active` falso, nenhum quadro é desenhado.
|
||||
* Depois de `idleMs` sem cursor, rolagem nem teclado, a batida para de vez (nenhum quadro, nenhum
|
||||
* requestAnimationFrame) e o último quadro fica na tela; qualquer movimento a religa.
|
||||
* Quem precisa de mais (entrada, rolagem, cursor) pede o próximo quadro com `invalidate()` dentro do useFrame.
|
||||
*/
|
||||
export default function FramePulse({
|
||||
active,
|
||||
fps = 30,
|
||||
idleMs = 5000,
|
||||
}: {
|
||||
active: boolean;
|
||||
fps?: number;
|
||||
idleMs?: number;
|
||||
}) {
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const gap = 1000 / fps - 2;
|
||||
let id = 0;
|
||||
let last = 0;
|
||||
let lastActivity = performance.now();
|
||||
const beat = (now: number) => {
|
||||
// parada por inatividade: sem laço até o próximo movimento
|
||||
if (now - lastActivity > idleMs) {
|
||||
id = 0;
|
||||
return;
|
||||
}
|
||||
id = requestAnimationFrame(beat);
|
||||
if (now - last < gap) return;
|
||||
last = now;
|
||||
invalidate();
|
||||
};
|
||||
const wake = () => {
|
||||
lastActivity = performance.now();
|
||||
if (!id) id = requestAnimationFrame(beat);
|
||||
};
|
||||
for (const type of ACTIVITY)
|
||||
window.addEventListener(type, wake, { passive: true });
|
||||
id = requestAnimationFrame(beat);
|
||||
return () => {
|
||||
for (const type of ACTIVITY) window.removeEventListener(type, wake);
|
||||
cancelAnimationFrame(id);
|
||||
};
|
||||
}, [active, fps, idleMs, invalidate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -388,7 +388,7 @@ describe("HeroField: falhas voltam ao hero simples", () => {
|
||||
});
|
||||
|
||||
describe("HeroField: rolagem e visibilidade", () => {
|
||||
function place(top: number, height = 3000) {
|
||||
function place(top: number, height = 2760) {
|
||||
section().getBoundingClientRect = () =>
|
||||
({ top, height, bottom: top + height }) as DOMRect;
|
||||
}
|
||||
@@ -443,7 +443,7 @@ describe("HeroField: rolagem e visibilidade", () => {
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
|
||||
// trilho de 1400 px (3000 de hero - 2 telas de 800)
|
||||
// trilho de 1400 px (2760 de hero - 1,7 tela de 800)
|
||||
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
|
||||
0.5,
|
||||
);
|
||||
@@ -571,3 +571,29 @@ describe("HeroField: navegador sem matchMedia", () => {
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroField: desmontagem em pontos que faltavam", () => {
|
||||
it("path: unmounted after the geometry was prepared, before the Canvas -> the scene is never mounted", async () => {
|
||||
const view = render(<Hero />);
|
||||
mockPrepare.mockImplementationOnce(() => {
|
||||
act(() => view.unmount());
|
||||
});
|
||||
|
||||
await advance(1200);
|
||||
await finishLoading();
|
||||
|
||||
expect(mockPrepare).toHaveBeenCalledTimes(1);
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: no dive on this screen (the dive query does not match) -> unmounting is quiet and the hero stays plain", async () => {
|
||||
mockMedia([]);
|
||||
const view = render(<Hero />);
|
||||
await finishLoading();
|
||||
const hero = section();
|
||||
|
||||
expect(() => view.unmount()).not.toThrow();
|
||||
|
||||
expect(hero).not.toHaveAttribute("data-dive");
|
||||
});
|
||||
});
|
||||
@@ -56,7 +56,7 @@ const section = () => document.querySelector(".hero") as HTMLElement;
|
||||
/** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */
|
||||
function scrollWritesProgress() {
|
||||
section().getBoundingClientRect = () =>
|
||||
({ top: -700, height: 3000, bottom: 2300 }) as DOMRect;
|
||||
({ top: -700, height: 2760, bottom: 2300 }) as DOMRect;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(40);
|
||||
@@ -192,10 +192,10 @@ describe("HeroField (ambient background)", () => {
|
||||
const hero = container.querySelector(".hero") as HTMLElement;
|
||||
expect(hero).toHaveAttribute("data-dive");
|
||||
await idle();
|
||||
// 3000 px de hero: 800 de palco, 800 da tela de Projetos subindo e 1400 de mergulho
|
||||
// 2760 px de hero (altura = 1,7 tela de cauda + o mergulho): 800 de palco, 560 de Projetos subindo e 1400 de mergulho
|
||||
let top = 0;
|
||||
hero.getBoundingClientRect = () =>
|
||||
({ top, height: 3000, bottom: top + 3000 }) as DOMRect;
|
||||
({ top, height: 2760, bottom: top + 2760 }) as DOMRect;
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
|
||||
@@ -13,6 +13,11 @@ import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
||||
/** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */
|
||||
export const DIVE_QUERY =
|
||||
"(min-height: 700px) and (prefers-reduced-motion: no-preference)";
|
||||
/**
|
||||
* Telas de hero além do mergulho (globals.css: height = 170svh + --hero-dive): 100 do palco preso e 70 em que Projetos
|
||||
* sobe sobre ele (margin-top de -70svh). O mergulho (progresso 0 a 1) é o resto da altura do hero.
|
||||
*/
|
||||
export const HERO_TAIL_SCREENS = 1.7;
|
||||
/** Tempo livre (ms) que um intervalo ocioso precisa ter para a cena ser montada sem engasgar a página. */
|
||||
const IDLE_SLACK_MS = 20;
|
||||
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
|
||||
@@ -112,9 +117,9 @@ function trackDive(
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const rect = section.getBoundingClientRect();
|
||||
// o palco fica preso por (altura - 1 tela); a última tela dessa rolagem é de Projetos subindo sobre ele
|
||||
// o palco fica preso por (altura - 1 tela); o fim dessa rolagem é de Projetos subindo sobre ele
|
||||
const span = Math.max(
|
||||
rect.height - window.innerHeight * 2,
|
||||
rect.height - window.innerHeight * HERO_TAIL_SCREENS,
|
||||
window.innerHeight * 0.8,
|
||||
);
|
||||
const p = Math.min(1, Math.max(0, -rect.top / span));
|
||||
|
||||
@@ -1,43 +1,83 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import * as THREE from "three";
|
||||
import HeroFieldScene, { prepareField } from "./HeroFieldScene";
|
||||
import {
|
||||
type BufferGeometry,
|
||||
type Group,
|
||||
type PerspectiveCamera,
|
||||
type Plane,
|
||||
Ray,
|
||||
type ShaderMaterial,
|
||||
type Vector3,
|
||||
} from "three";
|
||||
import {
|
||||
createField,
|
||||
type FieldEngine,
|
||||
needsFrame,
|
||||
prepareField,
|
||||
type Rig,
|
||||
type Tick,
|
||||
updatePointer,
|
||||
updateRig,
|
||||
} from "./HeroFieldScene";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base do quadro da cena 3D (o callback do useFrame de Field, CC alta) e de SceneBoundary:
|
||||
* - fim antecipado sem grupo, onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente);
|
||||
* Testes de caminhos-base do quadro da cena 3D (createField, sem React) e das contas que o compõem:
|
||||
* - onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente), entrada, escurecimento e abertura;
|
||||
* - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente;
|
||||
* - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve);
|
||||
* - quando pedir o próximo quadro (entrada, rolagem assentando, cursor) e quando não pedir;
|
||||
* - perda do contexto WebGL, limpeza e a falha de um filho (SceneBoundary).
|
||||
* O @react-three/fiber é trocado por um dublê que renderiza os filhos e entrega o callback do useFrame; o `three`
|
||||
* é o de verdade, com ShaderMaterial e BufferGeometry registrando as instâncias criadas.
|
||||
* - perda do contexto WebGL, falha ao desenhar e limpeza.
|
||||
* O WebGLRenderer é um dublê (o jsdom não tem WebGL); o `three` é o de verdade, com ShaderMaterial, BufferGeometry,
|
||||
* PerspectiveCamera e Group registrando as instâncias criadas. O quadro é chamado direto por `renderAt`, com o relógio
|
||||
* que o teste escolhe.
|
||||
*/
|
||||
|
||||
type FrameState = {
|
||||
clock: { elapsedTime: number };
|
||||
invalidate: () => void;
|
||||
};
|
||||
const mockState: {
|
||||
frame: ((state: FrameState, delta: number) => void) | null;
|
||||
three: unknown;
|
||||
throwOnUseThree: boolean;
|
||||
} = { frame: null, three: null, throwOnUseThree: false };
|
||||
const mockMaterials: THREE.ShaderMaterial[] = [];
|
||||
const mockMaterials: ShaderMaterial[] = [];
|
||||
const mockGeometries: BufferGeometry[] = [];
|
||||
const mockCameras: PerspectiveCamera[] = [];
|
||||
const mockGroups: Group[] = [];
|
||||
const mockCounts = { vectors: 0 };
|
||||
const mockGeometries: THREE.BufferGeometry[] = [];
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
let mockRenderThrows = false;
|
||||
|
||||
class MockRenderer {
|
||||
domElement = document.createElement("canvas");
|
||||
pixelRatio = 2;
|
||||
render = jest.fn(() => {
|
||||
if (mockRenderThrows) throw new Error("falha ao desenhar");
|
||||
});
|
||||
dispose = jest.fn();
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = jest.fn();
|
||||
getPixelRatio = () => this.pixelRatio;
|
||||
constructor() {
|
||||
mockRenderers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock("three", () => {
|
||||
const actual = jest.requireActual("three");
|
||||
class CapturingMaterial extends actual.ShaderMaterial {
|
||||
constructor(parameters: unknown) {
|
||||
super(parameters);
|
||||
mockMaterials.push(this as unknown as THREE.ShaderMaterial);
|
||||
mockMaterials.push(this as unknown as ShaderMaterial);
|
||||
}
|
||||
}
|
||||
class CapturingGeometry extends actual.BufferGeometry {
|
||||
constructor() {
|
||||
super();
|
||||
mockGeometries.push(this as unknown as THREE.BufferGeometry);
|
||||
mockGeometries.push(this as unknown as BufferGeometry);
|
||||
}
|
||||
}
|
||||
class CapturingCamera extends actual.PerspectiveCamera {
|
||||
constructor(...args: unknown[]) {
|
||||
super(...args);
|
||||
mockCameras.push(this as unknown as PerspectiveCamera);
|
||||
}
|
||||
}
|
||||
class CapturingGroup extends actual.Group {
|
||||
constructor() {
|
||||
super();
|
||||
mockGroups.push(this as unknown as Group);
|
||||
}
|
||||
}
|
||||
class CountingVector3 extends actual.Vector3 {
|
||||
@@ -48,22 +88,14 @@ jest.mock("three", () => {
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
WebGLRenderer: jest.fn(() => new MockRenderer()),
|
||||
ShaderMaterial: CapturingMaterial,
|
||||
BufferGeometry: CapturingGeometry,
|
||||
PerspectiveCamera: CapturingCamera,
|
||||
Group: CapturingGroup,
|
||||
Vector3: CountingVector3,
|
||||
};
|
||||
});
|
||||
jest.mock("@react-three/fiber", () => ({
|
||||
Canvas: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
useFrame: (callback: (state: FrameState, delta: number) => void) => {
|
||||
mockState.frame = callback;
|
||||
},
|
||||
useThree: () => {
|
||||
if (mockState.throwOnUseThree) throw new Error("sem WebGL");
|
||||
return mockState.three;
|
||||
},
|
||||
}));
|
||||
jest.mock("./FramePulse", () => ({ __esModule: true, default: () => null }));
|
||||
|
||||
type Setup = {
|
||||
width?: number;
|
||||
@@ -73,76 +105,66 @@ type Setup = {
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
let engines: FieldEngine[] = [];
|
||||
let frameRequests: jest.SpyInstance;
|
||||
|
||||
function setup({
|
||||
width = 1200,
|
||||
height = 800,
|
||||
progress = 0,
|
||||
visible = true,
|
||||
}: Setup = {}) {
|
||||
const domElement = document.createElement("canvas");
|
||||
const camera = new THREE.PerspectiveCamera(35, width / height, 0.05, 120);
|
||||
camera.position.set(0, 0, 9);
|
||||
camera.updateMatrixWorld();
|
||||
mockState.three = {
|
||||
gl: { domElement, getPixelRatio: () => 2 },
|
||||
camera,
|
||||
size: { width, height },
|
||||
};
|
||||
const host = document.createElement("div");
|
||||
Object.defineProperty(host, "clientWidth", { value: width });
|
||||
Object.defineProperty(host, "clientHeight", { value: height });
|
||||
document.body.append(host);
|
||||
const progressRef = {
|
||||
current: progress === null ? undefined : progress,
|
||||
} as { current: number };
|
||||
const onReady = jest.fn();
|
||||
const onError = jest.fn();
|
||||
const element = (isVisible: boolean) => (
|
||||
<HeroFieldScene
|
||||
progressRef={progressRef}
|
||||
visible={isVisible}
|
||||
onReady={onReady}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
const view = render(element(visible));
|
||||
const engine = createField(host, { progressRef, onReady, onError });
|
||||
engines.push(engine);
|
||||
engine.setVisible(visible);
|
||||
|
||||
// <group> é um elemento desconhecido no jsdom: ganha o que o três espera de um THREE.Group
|
||||
const group = view.container.querySelector("group") as HTMLElement | null;
|
||||
const real = new THREE.Group();
|
||||
if (group)
|
||||
Object.assign(group, {
|
||||
rotation: real.rotation,
|
||||
position: real.position,
|
||||
matrixWorld: real.matrixWorld,
|
||||
updateMatrixWorld: () => real.updateMatrixWorld(true),
|
||||
worldToLocal: (point: THREE.Vector3) => point,
|
||||
});
|
||||
const renderer = mockRenderers[mockRenderers.length - 1];
|
||||
const camera = mockCameras[mockCameras.length - 1];
|
||||
const group = mockGroups[mockGroups.length - 1];
|
||||
const uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms;
|
||||
let clock = 1000;
|
||||
|
||||
function frame(time: number, delta = 1 / 60) {
|
||||
const invalidate = jest.fn();
|
||||
act(() => {
|
||||
mockState.frame?.({ clock: { elapsedTime: time }, invalidate }, delta);
|
||||
});
|
||||
return invalidate;
|
||||
/**
|
||||
* Um quadro `deltaMs` depois do anterior; devolve quantos quadros ele pediu em seguida. O primeiro quadro de uma
|
||||
* cena não tem anterior: o passo dele é 1/60 de segundo, seja qual for `deltaMs`.
|
||||
*/
|
||||
function frame(deltaMs = 1000 / 60) {
|
||||
frameRequests.mockClear();
|
||||
clock += deltaMs;
|
||||
engine.renderAt(clock);
|
||||
return frameRequests.mock.calls.length;
|
||||
}
|
||||
|
||||
/** Roda `count` quadros e devolve quantas vezes o próximo quadro foi pedido no último. */
|
||||
function settle(count: number, delta = 1 / 20, startTime = 100) {
|
||||
let last = jest.fn();
|
||||
for (let i = 0; i < count; i++) last = frame(startTime + i * delta, delta);
|
||||
return last;
|
||||
function settle(count: number, deltaMs = 50) {
|
||||
let asked = 0;
|
||||
for (let i = 0; i < count; i++) asked = frame(deltaMs);
|
||||
return asked;
|
||||
}
|
||||
|
||||
return {
|
||||
...view,
|
||||
host,
|
||||
engine,
|
||||
renderer,
|
||||
camera,
|
||||
domElement,
|
||||
group,
|
||||
progressRef,
|
||||
onReady,
|
||||
onError,
|
||||
group: real,
|
||||
uniforms,
|
||||
frame,
|
||||
settle,
|
||||
rerender: (isVisible: boolean) => view.rerender(element(isVisible)),
|
||||
/** O relógio do quadro (ms), para os testes que pedem o tempo em segundos. */
|
||||
now: () => clock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,61 +180,59 @@ function setCores(value: number | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
function setWindowWidth(value: number) {
|
||||
function setWindowSize(width: number, height = 800) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value,
|
||||
value: width,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: height,
|
||||
});
|
||||
}
|
||||
|
||||
function pointerMove(type: string, x = 600, y = 400) {
|
||||
const event = new MouseEvent("pointermove", { clientX: x, clientY: y });
|
||||
Object.defineProperty(event, "pointerType", { value: type });
|
||||
act(() => {
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCounts.vectors = 0;
|
||||
mockMaterials.length = 0;
|
||||
mockGeometries.length = 0;
|
||||
mockState.frame = null;
|
||||
mockState.throwOnUseThree = false;
|
||||
mockCameras.length = 0;
|
||||
mockGroups.length = 0;
|
||||
mockRenderers.length = 0;
|
||||
mockRenderThrows = false;
|
||||
engines = [];
|
||||
setCores(8);
|
||||
setWindowWidth(1200);
|
||||
// as tags do R3F (group, points) são elementos desconhecidos no DOM do jsdom
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
setWindowSize(1200);
|
||||
// o relógio do cursor: performance.now() em segundos é o que `lastMove` guarda
|
||||
jest.spyOn(performance, "now").mockReturnValue(1000);
|
||||
frameRequests = jest
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation(() => 1 as unknown as number);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const engine of engines) engine.dispose();
|
||||
jest.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
if (originalHardware)
|
||||
Object.defineProperty(navigator, "hardwareConcurrency", originalHardware);
|
||||
});
|
||||
|
||||
describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
||||
it("path: the group is gone (unmounted) -> the callback returns at once, nothing is requested", () => {
|
||||
const scene = setup();
|
||||
const callback = mockState.frame;
|
||||
scene.unmount();
|
||||
const invalidate = jest.fn();
|
||||
|
||||
callback?.({ clock: { elapsedTime: 1 }, invalidate }, 1 / 60);
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("createField: quadro — começo e rolagem", () => {
|
||||
it("path: onReady fires on the second frame, once", () => {
|
||||
const scene = setup();
|
||||
|
||||
scene.frame(0);
|
||||
scene.frame();
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
scene.frame(0.02);
|
||||
scene.frame();
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
scene.frame(0.04);
|
||||
scene.frame(0.06);
|
||||
scene.frame();
|
||||
scene.frame();
|
||||
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -220,9 +240,11 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
||||
it("path: the scroll progress is damped toward the target (and a long frame cannot overshoot)", () => {
|
||||
const scene = setup({ progress: 1 });
|
||||
|
||||
scene.frame(0, 5); // delta enorme: o passo é limitado a 1/20 de segundo
|
||||
scene.frame(); // o primeiro quadro: passo de 1/60 de segundo
|
||||
const afterFirst = 1 - Math.exp(-(1 / 60) * 5);
|
||||
scene.frame(5000); // delta enorme: o passo é limitado a 1/20 de segundo
|
||||
|
||||
const expected = 1 - Math.exp(-(1 / 20) * 5);
|
||||
const expected = 1 - (1 - afterFirst) * Math.exp(-(1 / 20) * 5);
|
||||
expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9);
|
||||
expect(scene.uniforms().uScroll.value).toBeLessThan(1);
|
||||
});
|
||||
@@ -238,8 +260,10 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
||||
it("path: the intro advances every frame and stops at 1", () => {
|
||||
const scene = setup();
|
||||
|
||||
scene.frame(0, 1 / 20);
|
||||
expect(scene.uniforms().uIntro.value).toBeCloseTo(0.05 / 2.6, 9);
|
||||
scene.frame(50); // o primeiro quadro: 1/60 de segundo
|
||||
expect(scene.uniforms().uIntro.value).toBeCloseTo(1 / 60 / 2.6, 9);
|
||||
scene.frame(50);
|
||||
expect(scene.uniforms().uIntro.value).toBeCloseTo((1 / 60 + 0.05) / 2.6, 9);
|
||||
|
||||
scene.settle(80);
|
||||
|
||||
@@ -271,13 +295,20 @@ describe("Field: quadro (useFrame) — começo e rolagem", () => {
|
||||
|
||||
expect(scene.uniforms().uSpread.value).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("path: the clock reaches the shader in seconds since the scene started", () => {
|
||||
const scene = setup();
|
||||
scene.frame(0);
|
||||
scene.frame(2000);
|
||||
expect(scene.uniforms().uTime.value).toBeCloseTo(2, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: enquadramento e foco", () => {
|
||||
describe("createField: enquadramento e foco", () => {
|
||||
it("path: landscape window -> the group sits just below center and the camera rests at 9", () => {
|
||||
const scene = setup({ width: 1200, height: 800 });
|
||||
|
||||
scene.frame(0);
|
||||
scene.frame();
|
||||
|
||||
expect(scene.group.position.y).toBeCloseTo(-0.5, 9);
|
||||
expect(scene.camera.position.z).toBeCloseTo(9, 9);
|
||||
@@ -286,12 +317,26 @@ describe("Field: enquadramento e foco", () => {
|
||||
it("path: portrait window -> the group sits lower and the camera backs off to keep the layer in frame", () => {
|
||||
const scene = setup({ width: 400, height: 800 });
|
||||
|
||||
scene.frame(0);
|
||||
scene.frame();
|
||||
|
||||
expect(scene.group.position.y).toBeLessThan(-0.5);
|
||||
expect(scene.camera.position.z).toBeGreaterThan(9);
|
||||
});
|
||||
|
||||
it("path: the renderer and the camera follow the size of the host", () => {
|
||||
const scene = setup({ width: 900, height: 300 });
|
||||
|
||||
expect(scene.renderer.setSize).toHaveBeenCalledWith(900, 300, false);
|
||||
expect(scene.camera.aspect).toBeCloseTo(3, 9);
|
||||
});
|
||||
|
||||
it("path: a host without size yet (0 x 0) -> never a zero-size canvas or a NaN aspect", () => {
|
||||
const scene = setup({ width: 0, height: 0 });
|
||||
|
||||
expect(scene.renderer.setSize).toHaveBeenCalledWith(1, 1, false);
|
||||
expect(Number.isFinite(scene.camera.aspect)).toBe(true);
|
||||
});
|
||||
|
||||
it("path: end of the dive -> the group has settled back to the center and the camera stopped in front of the data layer", () => {
|
||||
const scene = setup({ progress: 1 });
|
||||
|
||||
@@ -308,7 +353,7 @@ describe("Field: enquadramento e foco", () => {
|
||||
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
|
||||
const scene = setup({ progress: 0 });
|
||||
|
||||
scene.frame(0);
|
||||
scene.frame();
|
||||
|
||||
// câmera em 9 e primeira camada em 1,05: foco a 9 - 1,05
|
||||
expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6);
|
||||
@@ -341,56 +386,56 @@ describe("Field: enquadramento e foco", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: mouse", () => {
|
||||
describe("createField: mouse", () => {
|
||||
it("path: pointer type other than mouse (touch, pen) -> ignored, the cursor stays on its own path", () => {
|
||||
const scene = setup();
|
||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||
pointerMove("touch");
|
||||
pointerMove("pen");
|
||||
|
||||
scene.frame(5.5);
|
||||
|
||||
// sem mouse, o cursor fantasma passa a valer 0,4 (não 0,8)
|
||||
scene.settle(120, 1 / 20, 5.5);
|
||||
scene.settle(120);
|
||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
||||
});
|
||||
|
||||
it("path: a mouse moved a moment ago -> the pointer drives the cursor strength to 0.8", () => {
|
||||
const scene = setup();
|
||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||
scene.frame(); // o relógio da cena começa
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 120; i++) scene.frame(5.1 + i * 0.01, 1 / 20);
|
||||
for (let i = 0; i < 40; i++) scene.frame(50); // 2 s: o mouse ainda conta
|
||||
|
||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.8, 2);
|
||||
});
|
||||
|
||||
it("path: the mouse stopped long ago (over 2.5 s) -> back to the ghost cursor", () => {
|
||||
const scene = setup();
|
||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||
scene.frame(0);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 120; i++) scene.frame(9 + i * 0.01, 1 / 20);
|
||||
for (let i = 0; i < 120; i++) scene.frame(50); // 6 s depois
|
||||
|
||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
||||
});
|
||||
|
||||
it("path: a mouse event at timestamp 0 is not counted as activity", () => {
|
||||
const scene = setup();
|
||||
jest.spyOn(performance, "now").mockReturnValue(0);
|
||||
(performance.now as jest.Mock).mockReturnValue(0);
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 120; i++) scene.frame(0.1 + i * 0.001, 1 / 20);
|
||||
for (let i = 0; i < 30; i++) scene.frame(50);
|
||||
|
||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
||||
});
|
||||
|
||||
it("path: cursor strength fades to 0 once the dive is under way", () => {
|
||||
const scene = setup({ progress: 1 });
|
||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||
scene.frame();
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 200; i++) scene.frame(5.1 + i * 0.005, 1 / 20);
|
||||
for (let i = 0; i < 200; i++) scene.frame(5);
|
||||
|
||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2);
|
||||
});
|
||||
@@ -398,90 +443,91 @@ describe("Field: mouse", () => {
|
||||
it("path: active mouse and the ray hits the layer plane -> the shader cursor follows the hit point", () => {
|
||||
const scene = setup();
|
||||
jest
|
||||
.spyOn(THREE.Ray.prototype, "intersectPlane")
|
||||
.mockImplementation(((_plane: THREE.Plane, target: THREE.Vector3) =>
|
||||
.spyOn(Ray.prototype, "intersectPlane")
|
||||
.mockImplementation(((_plane: Plane, target: Vector3) =>
|
||||
target.set(1, 0, 2)) as never);
|
||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||
// o grupo real está girado e abaixado: aqui o ponto do raio já vale como ponto do grupo
|
||||
scene.group.worldToLocal = ((point: Vector3) => point) as never;
|
||||
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
|
||||
const before = scene.uniforms().uMouse.value.clone();
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
scene.frame(5.1, 1 / 20);
|
||||
scene.frame(50);
|
||||
|
||||
const factor = 1 - Math.exp(-(1 / 20) * 10);
|
||||
expect(scene.uniforms().uMouse.value.x).toBeCloseTo(factor * 1, 6);
|
||||
expect(scene.uniforms().uMouse.value.y).toBeCloseTo(factor * 2, 6);
|
||||
expect(scene.uniforms().uMouse.value.x).toBeCloseTo(
|
||||
before.x + (1 - before.x) * factor,
|
||||
6,
|
||||
);
|
||||
expect(scene.uniforms().uMouse.value.y).toBeCloseTo(
|
||||
before.y + (2 - before.y) * factor,
|
||||
6,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: active mouse but the ray misses the plane -> the shader cursor stays where it was", () => {
|
||||
const scene = setup();
|
||||
jest
|
||||
.spyOn(THREE.Ray.prototype, "intersectPlane")
|
||||
.mockReturnValue(null as never);
|
||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||
jest.spyOn(Ray.prototype, "intersectPlane").mockReturnValue(null as never);
|
||||
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
|
||||
const before = scene.uniforms().uMouse.value.clone();
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
scene.frame(5.1, 1 / 20);
|
||||
scene.frame(50);
|
||||
|
||||
expect(scene.uniforms().uMouse.value.x).toBe(0);
|
||||
expect(scene.uniforms().uMouse.value.y).toBe(0);
|
||||
expect(scene.uniforms().uMouse.value.x).toBe(before.x);
|
||||
expect(scene.uniforms().uMouse.value.y).toBe(before.y);
|
||||
});
|
||||
|
||||
it("path: idle mouse -> the ghost cursor wanders on its own", () => {
|
||||
const scene = setup();
|
||||
|
||||
scene.frame(10, 1 / 20);
|
||||
scene.frame(0);
|
||||
scene.frame(9000);
|
||||
|
||||
const { x, y } = scene.uniforms().uMouse.value;
|
||||
expect(Math.hypot(x, y)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: quando pedir o próximo quadro", () => {
|
||||
describe("createField: quando pedir o próximo quadro", () => {
|
||||
it("path: the intro is still running -> a frame is requested even when the hero is not visible", () => {
|
||||
const scene = setup({ visible: false });
|
||||
|
||||
const invalidate = scene.frame(0, 1 / 60);
|
||||
|
||||
expect(invalidate).toHaveBeenCalled();
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: everything settled and the hero is visible but nothing interacts -> no extra frame (the pulse does it)", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
|
||||
const invalidate = scene.frame(500, 1 / 60);
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
expect(scene.frame()).toBe(0);
|
||||
});
|
||||
|
||||
it("path: everything settled and the hero is not visible -> no frame", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
|
||||
const invalidate = scene.frame(500, 1 / 60);
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
expect(scene.frame()).toBe(0);
|
||||
});
|
||||
|
||||
it("path: settled, visible, and the mouse is moving -> frames are requested", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
const invalidate = scene.frame(600.1, 1 / 60);
|
||||
|
||||
expect(invalidate).toHaveBeenCalled();
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
|
||||
const invalidate = scene.frame(600.1, 1 / 60);
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
expect(scene.frame()).toBe(0);
|
||||
});
|
||||
|
||||
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
|
||||
@@ -489,25 +535,160 @@ describe("Field: quando pedir o próximo quadro", () => {
|
||||
scene.settle(200);
|
||||
scene.progressRef.current = 0.5;
|
||||
|
||||
const invalidate = scene.frame(500, 1 / 60);
|
||||
|
||||
expect(invalidate).toHaveBeenCalled();
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: becoming visible later -> the new callback sees the new flag", () => {
|
||||
it("path: becoming visible later -> the next frame sees the new flag", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
expect(scene.frame(600.1, 1 / 60)).not.toHaveBeenCalled();
|
||||
expect(scene.frame()).toBe(0);
|
||||
|
||||
scene.rerender(true);
|
||||
scene.engine.setVisible(true);
|
||||
|
||||
expect(scene.frame(600.2, 1 / 60)).toHaveBeenCalled();
|
||||
expect(scene.frame()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: several requests before a frame become ONE animation frame", () => {
|
||||
const scene = setup({ visible: true });
|
||||
scene.settle(200);
|
||||
frameRequests.mockClear();
|
||||
// ligar a batida pede um quadro; religá-la sem que ele tenha vindo não pede outro
|
||||
scene.engine.setVisible(false);
|
||||
scene.engine.setVisible(true);
|
||||
scene.engine.setVisible(false);
|
||||
scene.engine.setVisible(true);
|
||||
expect(frameRequests.mock.calls.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: geometria pré-calculada e quadros sem alocação", () => {
|
||||
describe("needsFrame / updatePointer / updateRig (contas puras)", () => {
|
||||
function rig(): Rig {
|
||||
const scene = setup();
|
||||
scene.frame();
|
||||
const material = mockMaterials[mockMaterials.length - 1];
|
||||
return {
|
||||
group: scene.group,
|
||||
camera: scene.camera,
|
||||
uniforms: material.uniforms,
|
||||
layout: {
|
||||
wide: true,
|
||||
tanHalf: Math.tan((35 / 2) * (Math.PI / 180)),
|
||||
spreadMax: 5.5,
|
||||
rest: 9,
|
||||
scale: 1000,
|
||||
camera: [
|
||||
[0, 9],
|
||||
[1, -1],
|
||||
],
|
||||
} as unknown as Rig["layout"],
|
||||
state: {
|
||||
progress: 0,
|
||||
pointer: { set() {}, x: 0, y: 0 } as never,
|
||||
lastMove: -10,
|
||||
mouse: { lerp() {}, x: 0, y: 0, copy() {} } as never,
|
||||
tilt: { lerp() {}, x: 0, y: 0 } as never,
|
||||
frames: 10,
|
||||
},
|
||||
helpers: {} as Rig["helpers"],
|
||||
};
|
||||
}
|
||||
|
||||
const tick = (overrides: Partial<Tick> = {}): Tick => ({
|
||||
p: 0,
|
||||
face: 0,
|
||||
time: 1,
|
||||
dt: 1 / 60,
|
||||
cursorActive: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("needsFrame: settling wins over everything, interaction only counts on screen", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.uniforms.uMouseAmt.value = 0.4;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(false);
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, true, true)).toBe(true);
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, true, false)).toBe(false);
|
||||
expect(needsFrame(r.uniforms, r.state, 0.5, 0.4, false, false)).toBe(true);
|
||||
r.uniforms.uIntro.value = 0.5;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("needsFrame: the first frames always ask for another (the cursor strength still has to settle)", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.state.frames = 1;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.4, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("updatePointer: returns the strength it is heading for (0.8 with the mouse, 0.4 without, none in the dive)", () => {
|
||||
const r = rig();
|
||||
r.helpers = {
|
||||
target: { set: () => ({}), x: 0, y: 0 },
|
||||
ray: { setFromCamera() {}, ray: { intersectPlane: () => null } },
|
||||
plane: { set: () => ({ applyMatrix4() {} }) },
|
||||
up: {},
|
||||
hit: {},
|
||||
} as never;
|
||||
expect(updatePointer(r, tick({ cursorActive: true }))).toBeCloseTo(0.8, 9);
|
||||
expect(updatePointer(r, tick({ cursorActive: false }))).toBeCloseTo(0.4, 9);
|
||||
expect(updatePointer(r, tick({ p: 1 }))).toBe(0);
|
||||
});
|
||||
|
||||
it("updateRig: the rotation goes from three quarters (resting) to facing the viewer (diving)", () => {
|
||||
const r = rig();
|
||||
updateRig(r, tick({ face: 0 }));
|
||||
const resting = r.group.rotation.x;
|
||||
updateRig(r, tick({ face: 1 }));
|
||||
expect(resting).toBeCloseTo(0.42, 9);
|
||||
expect(r.group.rotation.x).toBeCloseTo(Math.PI / 2, 9);
|
||||
// de frente, o balanço lateral some
|
||||
expect(r.group.rotation.y).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it("needsFrame: the cursor strength alone still far from its target asks for a frame, but only on screen", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.uniforms.uMouseAmt.value = 0;
|
||||
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, true)).toBe(true);
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, false)).toBe(false);
|
||||
// dentro da tolerância de 0,01 já está assentado
|
||||
r.uniforms.uMouseAmt.value = 0.795;
|
||||
expect(needsFrame(r.uniforms, r.state, 0, 0.8, false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("needsFrame: the scroll damping counts as settling only beyond 0.0004 of the target", () => {
|
||||
const r = rig();
|
||||
r.uniforms.uIntro.value = 1;
|
||||
r.uniforms.uMouseAmt.value = 0.4;
|
||||
r.state.progress = 0.5;
|
||||
|
||||
expect(needsFrame(r.uniforms, r.state, 0.50039, 0.4, false, false)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(needsFrame(r.uniforms, r.state, 0.5005, 0.4, false, false)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("updateRig: the camera follows the choreography -> the first frame before it starts, a smoothed midpoint, the last frame after it ends", () => {
|
||||
const r = rig();
|
||||
|
||||
updateRig(r, tick({ p: -0.5 }));
|
||||
expect(r.camera.position.z).toBe(9);
|
||||
|
||||
updateRig(r, tick({ p: 0.5 }));
|
||||
expect(r.camera.position.z).toBeCloseTo(4, 9);
|
||||
|
||||
updateRig(r, tick({ p: 2 }));
|
||||
expect(r.camera.position.z).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createField: geometria pré-calculada e quadros sem alocação", () => {
|
||||
it("path: prepareField builds the points before the scene mounts, and mounting reuses them", () => {
|
||||
prepareField();
|
||||
expect(mockGeometries).toHaveLength(1);
|
||||
@@ -531,40 +712,42 @@ describe("Field: geometria pré-calculada e quadros sem alocação", () => {
|
||||
expect(mockGeometries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("path: no vector is created per frame, not even with the mouse driving the cursor over the layer", () => {
|
||||
it("path: no vector is created per frame beyond the few the frame itself needs, not even with the mouse over the layer", () => {
|
||||
const scene = setup();
|
||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||
scene.frame(0);
|
||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
||||
pointerMove("mouse");
|
||||
scene.frame(5.1, 1 / 60);
|
||||
scene.frame();
|
||||
const before = mockCounts.vectors;
|
||||
|
||||
for (let i = 0; i < 60; i++) scene.frame(5.2 + i / 60, 1 / 60);
|
||||
for (let i = 0; i < 60; i++) scene.frame();
|
||||
|
||||
// nenhum Vector3 novo: os auxiliares são criados uma vez só
|
||||
expect(mockCounts.vectors).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: densidade, contexto WebGL e limpeza", () => {
|
||||
describe("createField: densidade, contexto WebGL, erros e limpeza", () => {
|
||||
const pointCount = () => {
|
||||
const geometry = mockGeometries[mockGeometries.length - 1];
|
||||
return geometry.getAttribute("position").count;
|
||||
};
|
||||
|
||||
it("path: wide window and 4+ cores -> the dense field", () => {
|
||||
setWindowWidth(1200);
|
||||
setWindowSize(1200);
|
||||
setCores(8);
|
||||
setup();
|
||||
const dense = pointCount();
|
||||
|
||||
mockGeometries.length = 0;
|
||||
setWindowWidth(600);
|
||||
setWindowSize(600);
|
||||
setup();
|
||||
|
||||
expect(dense).toBeGreaterThan(pointCount());
|
||||
});
|
||||
|
||||
it("path: wide window but 2 cores -> the light field", () => {
|
||||
setWindowWidth(1200);
|
||||
setWindowSize(1200);
|
||||
setCores(2);
|
||||
setup();
|
||||
const light = pointCount();
|
||||
@@ -577,7 +760,7 @@ describe("Field: densidade, contexto WebGL e limpeza", () => {
|
||||
});
|
||||
|
||||
it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => {
|
||||
setWindowWidth(1200);
|
||||
setWindowSize(1200);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const [label, cores] of [
|
||||
["dense", 8],
|
||||
@@ -599,15 +782,22 @@ describe("Field: densidade, contexto WebGL e limpeza", () => {
|
||||
const scene = setup();
|
||||
const event = new Event("webglcontextlost", { cancelable: true });
|
||||
|
||||
act(() => {
|
||||
scene.domElement.dispatchEvent(event);
|
||||
});
|
||||
scene.renderer.domElement.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: unmount -> geometry and material are disposed and the listeners go away", () => {
|
||||
it("path: drawing throws -> the page is told and the scene does not crash the frame", () => {
|
||||
const scene = setup();
|
||||
mockRenderThrows = true;
|
||||
|
||||
expect(() => scene.frame()).not.toThrow();
|
||||
|
||||
expect(scene.onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: dispose -> geometry, material and renderer are released, the canvas leaves, and the listeners go away", () => {
|
||||
const scene = setup();
|
||||
const geometry = mockGeometries[mockGeometries.length - 1];
|
||||
const material = mockMaterials[mockMaterials.length - 1];
|
||||
@@ -615,38 +805,60 @@ describe("Field: densidade, contexto WebGL e limpeza", () => {
|
||||
const disposeMaterial = jest.spyOn(material, "dispose");
|
||||
const event = new Event("webglcontextlost", { cancelable: true });
|
||||
|
||||
scene.unmount();
|
||||
scene.domElement.dispatchEvent(event);
|
||||
scene.engine.dispose();
|
||||
scene.renderer.domElement.dispatchEvent(event);
|
||||
pointerMove("mouse");
|
||||
|
||||
expect(disposeGeometry).toHaveBeenCalledTimes(1);
|
||||
expect(disposeMaterial).toHaveBeenCalledTimes(1);
|
||||
expect(scene.renderer.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(scene.renderer.forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(scene.renderer.domElement.isConnected).toBe(false);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SceneBoundary", () => {
|
||||
it("path: a child fails while drawing -> nothing is rendered and the page is told once", () => {
|
||||
mockState.throwOnUseThree = true;
|
||||
const onError = jest.fn();
|
||||
|
||||
const { container } = render(
|
||||
<HeroFieldScene
|
||||
progressRef={{ current: 0 }}
|
||||
visible
|
||||
onReady={() => {}}
|
||||
onError={onError}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: no failure -> the scene is rendered and onError is not called", () => {
|
||||
it("path: a frame after dispose does nothing", () => {
|
||||
const scene = setup();
|
||||
scene.engine.dispose();
|
||||
scene.renderer.render.mockClear();
|
||||
|
||||
expect(scene.container.querySelector("group")).not.toBeNull();
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
scene.frame();
|
||||
|
||||
expect(scene.renderer.render).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: no ResizeObserver (old browser) -> the window resize event resizes the scene", () => {
|
||||
const original = globalThis.ResizeObserver;
|
||||
// @ts-expect-error o jsdom não tem ResizeObserver; o teste garante a ausência
|
||||
globalThis.ResizeObserver = undefined;
|
||||
try {
|
||||
const scene = setup({ width: 600, height: 400 });
|
||||
scene.renderer.setSize.mockClear();
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(scene.renderer.setSize).toHaveBeenCalledWith(600, 400, false);
|
||||
} finally {
|
||||
globalThis.ResizeObserver = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createField: a batida de 30 quadros por segundo", () => {
|
||||
it("path: setVisible(true) twice in a row -> a single pulse; leaving and coming back starts a new one", () => {
|
||||
const interval = jest
|
||||
.spyOn(window, "setInterval")
|
||||
.mockReturnValue(7 as unknown as ReturnType<typeof setInterval>);
|
||||
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
|
||||
const { engine } = setup({ visible: false });
|
||||
expect(interval).not.toHaveBeenCalled();
|
||||
|
||||
engine.setVisible(true);
|
||||
engine.setVisible(true);
|
||||
expect(interval).toHaveBeenCalledTimes(1);
|
||||
|
||||
engine.setVisible(false);
|
||||
engine.setVisible(false); // sair de novo sem estar à vista não desliga nada duas vezes
|
||||
engine.setVisible(true);
|
||||
expect(interval).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +1,241 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import HeroFieldScene from "./HeroFieldScene";
|
||||
|
||||
type CanvasProps = {
|
||||
dpr?: [number, number];
|
||||
frameloop?: string;
|
||||
gl?: Record<string, unknown>;
|
||||
};
|
||||
const mockCanvas: CanvasProps[] = [];
|
||||
/*
|
||||
* O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página,
|
||||
* liga a batida só com o hero à vista e solta tudo ao sair. O WebGL do jsdom não existe: o renderizador é um dublê.
|
||||
*/
|
||||
|
||||
jest.mock("@react-three/fiber", () => ({
|
||||
Canvas: (props: CanvasProps) => {
|
||||
mockCanvas.push(props);
|
||||
return null;
|
||||
},
|
||||
useFrame: jest.fn(),
|
||||
useThree: jest.fn(),
|
||||
type RendererOptions = {
|
||||
alpha?: boolean;
|
||||
antialias?: boolean;
|
||||
powerPreference?: string;
|
||||
};
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
let mockFailToCreate = false;
|
||||
|
||||
class MockRenderer {
|
||||
domElement = document.createElement("canvas");
|
||||
pixelRatio = 1;
|
||||
options: RendererOptions;
|
||||
render = jest.fn();
|
||||
dispose = jest.fn();
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = (value: number) => {
|
||||
this.pixelRatio = value;
|
||||
};
|
||||
getPixelRatio = () => this.pixelRatio;
|
||||
constructor(options: RendererOptions) {
|
||||
if (mockFailToCreate) throw new Error("sem WebGL");
|
||||
this.options = options;
|
||||
mockRenderers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
// a classe é criada só quando a cena pede o renderizador (o mock é içado antes da declaração dela)
|
||||
jest.mock("three", () => ({
|
||||
...jest.requireActual("three"),
|
||||
WebGLRenderer: jest.fn(
|
||||
(options: RendererOptions) => new MockRenderer(options),
|
||||
),
|
||||
}));
|
||||
|
||||
const props = () => ({
|
||||
progressRef: { current: 0 },
|
||||
visible: false,
|
||||
onReady: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
});
|
||||
|
||||
function setDevicePixelRatio(value: number) {
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockRenderers.length = 0;
|
||||
mockFailToCreate = false;
|
||||
setDevicePixelRatio(1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("HeroFieldScene (ambient 3D)", () => {
|
||||
beforeEach(() => {
|
||||
mockCanvas.length = 0;
|
||||
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", () => {
|
||||
const { container } = render(<HeroFieldScene {...props()} />);
|
||||
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].options).toMatchObject({
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
const canvas = container.querySelector("canvas");
|
||||
expect(canvas).toBe(mockRenderers[0].domElement);
|
||||
expect(canvas).toHaveAttribute("aria-hidden", "true");
|
||||
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it("draws on demand, never on a fixed 60 fps loop, and caps the pixel ratio at 1.5", () => {
|
||||
render(
|
||||
<HeroFieldScene
|
||||
progressRef={{ current: 0 }}
|
||||
visible
|
||||
onReady={() => {}}
|
||||
onError={() => {}}
|
||||
/>,
|
||||
it.each([
|
||||
[0.5, 1],
|
||||
[1, 1],
|
||||
[1.25, 1.25],
|
||||
[2, 1.5],
|
||||
[3, 1.5],
|
||||
])("caps the pixel ratio at 1.5 (device %s -> %s)", (device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
render(<HeroFieldScene {...props()} />);
|
||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||
});
|
||||
|
||||
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
render(<HeroFieldScene {...props()} />);
|
||||
expect(frames).toHaveBeenCalledTimes(1);
|
||||
|
||||
// a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada)
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
const settled = frames.mock.calls.length;
|
||||
expect(settled).toBeGreaterThan(2);
|
||||
expect(settled).toBeLessThan(5000 / 16);
|
||||
|
||||
// hero fora de cena e tudo assentado: nenhum quadro a mais
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(frames.mock.calls.length).toBe(settled);
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it("tells the page it is ready after the second frame", () => {
|
||||
const scene = props();
|
||||
render(<HeroFieldScene {...scene} />);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const scene = props();
|
||||
const { rerender } = render(<HeroFieldScene {...scene} visible={false} />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(6000);
|
||||
});
|
||||
const hidden = frames.mock.calls.length;
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
rerender(<HeroFieldScene {...scene} visible />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(20); // o quadro que a batida pediu sai
|
||||
});
|
||||
expect(jest.getTimerCount()).toBe(1); // um setInterval, nenhum segundo laço
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(980);
|
||||
});
|
||||
const shown = frames.mock.calls.length - hidden;
|
||||
expect(shown).toBeGreaterThan(20);
|
||||
expect(shown).toBeLessThanOrEqual(34);
|
||||
|
||||
rerender(<HeroFieldScene {...scene} visible={false} />);
|
||||
const stopped = frames.mock.calls.length;
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(frames.mock.calls.length).toBe(stopped);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("releases the context, the canvas and the timers when it unmounts", () => {
|
||||
const scene = props();
|
||||
const { container, unmount } = render(
|
||||
<HeroFieldScene {...scene} visible />,
|
||||
);
|
||||
const props = mockCanvas.at(-1);
|
||||
expect(props?.frameloop).toBe("demand");
|
||||
expect(props?.dpr).toEqual([1, 1.5]);
|
||||
expect(props?.gl).toMatchObject({ alpha: true, antialias: false });
|
||||
expect(container.querySelector("canvas")).not.toBeNull();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
const drawn = mockRenderers[0].render.mock.calls.length;
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||
});
|
||||
|
||||
it("keeps the same on-demand loop when the hero is off screen (the pulse is what stops)", () => {
|
||||
render(
|
||||
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", () => {
|
||||
const first = props();
|
||||
const { rerender } = render(<HeroFieldScene {...first} />);
|
||||
const second = props();
|
||||
rerender(
|
||||
<HeroFieldScene
|
||||
progressRef={{ current: 0 }}
|
||||
progressRef={first.progressRef}
|
||||
visible={false}
|
||||
onReady={() => {}}
|
||||
onError={() => {}}
|
||||
onReady={second.onReady}
|
||||
onError={second.onError}
|
||||
/>,
|
||||
);
|
||||
expect(mockCanvas.at(-1)?.frameloop).toBe("demand");
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(first.onReady).not.toHaveBeenCalled();
|
||||
expect(second.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tells the page and renders nothing but the empty host when WebGL cannot be created", () => {
|
||||
mockFailToCreate = true;
|
||||
const scene = props();
|
||||
|
||||
const { container } = render(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 1], // aparelho que não informa a proporção: 1
|
||||
[0.5, 1], // nunca abaixo de 1
|
||||
[1.25, 1.25],
|
||||
[3, 1.5], // nunca acima de 1,5: cada ponto custa fragmentos e o fundo é ambiente
|
||||
])(
|
||||
"draws at a pixel ratio of %p -> the renderer gets %p",
|
||||
(device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
|
||||
render(<HeroFieldScene {...props()} />);
|
||||
|
||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("tells the page when the WebGL context is lost after mounting, through the newest callback", () => {
|
||||
const scene = props();
|
||||
render(<HeroFieldScene {...scene} />);
|
||||
|
||||
act(() => {
|
||||
mockRenderers[0].domElement.dispatchEvent(
|
||||
new Event("webglcontextlost", { cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { type RefObject, useEffect, useRef } from "react";
|
||||
import {
|
||||
Component,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
import FramePulse from "./FramePulse";
|
||||
AdditiveBlending,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
type Camera,
|
||||
Float32BufferAttribute,
|
||||
Group,
|
||||
MathUtils,
|
||||
PerspectiveCamera,
|
||||
Plane,
|
||||
Points,
|
||||
Raycaster,
|
||||
Scene,
|
||||
ShaderMaterial,
|
||||
Sphere,
|
||||
Vector2,
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
} from "three";
|
||||
import { startFramePulse } from "./FramePulse";
|
||||
|
||||
export type HeroFieldSceneProps = {
|
||||
progressRef: RefObject<number>;
|
||||
@@ -30,9 +40,10 @@ export type HeroFieldSceneProps = {
|
||||
* Tudo se move no vertex shader; o JavaScript só atualiza alguns uniforms por quadro.
|
||||
*
|
||||
* É um fundo ambiente: HeroField o deixa com pouca opacidade e uma máscara que o afasta do título e das
|
||||
* molduras da direita. O desenho é sob demanda (frameloop "demand"): a cena pede um quadro a 30 por segundo
|
||||
* e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, ou depois de uns segundos
|
||||
* sem cursor nem rolagem (FramePulse), não desenha nada.
|
||||
* molduras da direita. O Three.js é usado direto (sem React Three Fiber, que levava junto o reconciliador do React
|
||||
* só para uma malha e um shader). O desenho é sob demanda: a cena pede um quadro a 30 por segundo (FramePulse, um
|
||||
* setInterval) e a 60 só enquanto algo muda (entrada, rolagem, cursor); sem estar à vista, ou depois de uns segundos
|
||||
* sem cursor nem rolagem, não desenha nada.
|
||||
*/
|
||||
|
||||
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
|
||||
@@ -259,19 +270,16 @@ function buildField(dense: boolean) {
|
||||
n * 4,
|
||||
);
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.Float32BufferAttribute(f.base, 3),
|
||||
);
|
||||
geometry.setAttribute("aTo", new THREE.Float32BufferAttribute(f.to, 3));
|
||||
geometry.setAttribute("aMove", new THREE.Float32BufferAttribute(f.move, 1));
|
||||
geometry.setAttribute("aTone", new THREE.Float32BufferAttribute(f.tone, 1));
|
||||
geometry.setAttribute("aSize", new THREE.Float32BufferAttribute(f.size, 1));
|
||||
geometry.setAttribute("aAlpha", new THREE.Float32BufferAttribute(f.alpha, 1));
|
||||
geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3));
|
||||
geometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 4));
|
||||
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 12);
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(f.base, 3));
|
||||
geometry.setAttribute("aTo", new Float32BufferAttribute(f.to, 3));
|
||||
geometry.setAttribute("aMove", new Float32BufferAttribute(f.move, 1));
|
||||
geometry.setAttribute("aTone", new Float32BufferAttribute(f.tone, 1));
|
||||
geometry.setAttribute("aSize", new Float32BufferAttribute(f.size, 1));
|
||||
geometry.setAttribute("aAlpha", new Float32BufferAttribute(f.alpha, 1));
|
||||
geometry.setAttribute("aScatter", new BufferAttribute(scatter, 3));
|
||||
geometry.setAttribute("aRand", new BufferAttribute(rand, 4));
|
||||
geometry.boundingSphere = new Sphere(new Vector3(), 12);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
@@ -374,8 +382,9 @@ const FRAME = 5.2;
|
||||
/*
|
||||
* Coreografia da câmera (z): afasta para enquadrar a tela inteira, atravessa, enquadra as integrações,
|
||||
* atravessa, enquadra os dados e fica ali (o mergulho não acaba no escuro: Projetos sobe sobre a última
|
||||
* imagem, uma tabela de dados). As legendas de Hero.tsx (--from/--to em globals.css) seguem
|
||||
* estes mesmos intervalos: tela 0,26–0,46; integrações 0,52–0,71; dados 0,76–0,92.
|
||||
* imagem, uma tabela de dados). As legendas de Hero.tsx (--from/--to em globals.css) entram um pouco antes de a
|
||||
* câmera chegar a cada camada e saem um pouco depois: tela 0,18–0,49; integrações 0,51–0,75; dados 0,79 em diante
|
||||
* (a última fica até Projetos subir e cobri-la).
|
||||
* Em tela vertical (celular) a distância de enquadramento cresce para a camada caber na largura, e as
|
||||
* camadas se afastam mais para a câmera caber entre elas.
|
||||
*/
|
||||
@@ -398,7 +407,7 @@ function cameraFrames(rest: number, spread: number, frame: number) {
|
||||
const isDense = () =>
|
||||
window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4;
|
||||
|
||||
let prepared: THREE.BufferGeometry | null = null;
|
||||
let prepared: BufferGeometry | null = null;
|
||||
|
||||
/**
|
||||
* Monta a geometria (dezenas de milhares de pontos) fora do caminho crítico: quem carrega este módulo chama
|
||||
@@ -416,7 +425,7 @@ function takeField() {
|
||||
return geometry;
|
||||
}
|
||||
|
||||
type Uniforms = THREE.ShaderMaterial["uniforms"];
|
||||
type Uniforms = ShaderMaterial["uniforms"];
|
||||
|
||||
/** Enquadramento da câmera para o tamanho atual da tela: só muda quando a tela muda, não a cada quadro. */
|
||||
function frameLayout(
|
||||
@@ -426,7 +435,7 @@ function frameLayout(
|
||||
pixelRatio: number,
|
||||
) {
|
||||
const aspect = width / height;
|
||||
const tanHalf = Math.tan(THREE.MathUtils.degToRad(fov / 2));
|
||||
const tanHalf = Math.tan(MathUtils.degToRad(fov / 2));
|
||||
// enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé
|
||||
const frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect));
|
||||
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
|
||||
@@ -466,120 +475,140 @@ function updateUniforms(
|
||||
|
||||
type FieldState = {
|
||||
progress: number;
|
||||
pointer: THREE.Vector2;
|
||||
pointer: Vector2;
|
||||
lastMove: number;
|
||||
mouse: THREE.Vector2;
|
||||
tilt: THREE.Vector2;
|
||||
mouse: Vector2;
|
||||
tilt: Vector2;
|
||||
frames: number;
|
||||
};
|
||||
|
||||
/** Objetos reaproveitados a cada quadro (nada é alocado dentro do useFrame). */
|
||||
type Helpers = {
|
||||
ray: THREE.Raycaster;
|
||||
plane: THREE.Plane;
|
||||
up: THREE.Vector3;
|
||||
hit: THREE.Vector3;
|
||||
zero: THREE.Vector2;
|
||||
target: THREE.Vector2;
|
||||
ray: Raycaster;
|
||||
plane: Plane;
|
||||
up: Vector3;
|
||||
hit: Vector3;
|
||||
zero: Vector2;
|
||||
target: Vector2;
|
||||
};
|
||||
|
||||
/** O mouse se mexeu há pouco? Sem ele (toque, ou mouse parado), um cursor fantasma percorre a tela. */
|
||||
const cursorIsActive = (s: FieldState, time: number) =>
|
||||
s.lastMove > 0 && time - s.lastMove < 2.5;
|
||||
|
||||
/** Posição do grupo, rotação, câmera e foco: a vista de três quartos que balança e depois se endireita no mergulho. */
|
||||
function updateRig(
|
||||
g: THREE.Group,
|
||||
camera: THREE.Camera,
|
||||
u: Uniforms,
|
||||
layout: Layout,
|
||||
s: FieldState,
|
||||
h: Helpers,
|
||||
p: number,
|
||||
face: number,
|
||||
time: number,
|
||||
dt: number,
|
||||
cursorActive: boolean,
|
||||
) {
|
||||
s.tilt.lerp(cursorActive ? s.pointer : h.zero, 1 - Math.exp(-dt * 3));
|
||||
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
|
||||
g.rotation.set(
|
||||
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
|
||||
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
|
||||
s.tilt.x * 0.15 * (1 - face),
|
||||
0,
|
||||
/** Tudo o que um quadro lê e move: o grupo, a câmera, os uniforms, o enquadramento atual, o estado e os auxiliares. */
|
||||
export type Rig = {
|
||||
group: Group;
|
||||
camera: Camera;
|
||||
uniforms: Uniforms;
|
||||
layout: Layout;
|
||||
state: FieldState;
|
||||
helpers: Helpers;
|
||||
};
|
||||
|
||||
/** O que muda de um quadro para o outro: progresso, quanto as camadas já estão de frente, relógio, passo e cursor. */
|
||||
export type Tick = {
|
||||
p: number;
|
||||
face: number;
|
||||
time: number;
|
||||
dt: number;
|
||||
cursorActive: boolean;
|
||||
};
|
||||
|
||||
/** A cena se inclina devagar para o cursor (ou volta ao centro, sem cursor). */
|
||||
function updateTilt({ state, helpers }: Rig, { dt, cursorActive }: Tick) {
|
||||
state.tilt.lerp(
|
||||
cursorActive ? state.pointer : helpers.zero,
|
||||
1 - Math.exp(-dt * 3),
|
||||
);
|
||||
// em repouso: ao centro e um pouco abaixo (a máscara de HeroField afasta o desenho do título e das molduras
|
||||
// da direita); no celular, mais abaixo, atrás dos botões
|
||||
const settle = 1 - smooth(0, 0.28, p);
|
||||
g.position.set(
|
||||
0,
|
||||
(layout.wide ? -0.5 : -layout.rest * layout.tanHalf * 0.38) * settle,
|
||||
0,
|
||||
);
|
||||
camera.position.z = keyframes(layout.camera, p);
|
||||
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
|
||||
const spread = u.uSpread.value;
|
||||
const behind = camera.position.z - 0.2;
|
||||
const ahead = spread < behind ? spread : 0 < behind ? 0 : -spread;
|
||||
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
|
||||
g.updateMatrixWorld();
|
||||
}
|
||||
|
||||
/** Ponto da camada sob o cursor, em coordenadas do grupo, deixado em h.target. Falso se o raio não toca a camada. */
|
||||
function cursorOnLayer(
|
||||
g: THREE.Group,
|
||||
camera: THREE.Camera,
|
||||
s: FieldState,
|
||||
h: Helpers,
|
||||
spread: number,
|
||||
) {
|
||||
h.ray.setFromCamera(s.pointer, camera);
|
||||
h.plane.set(h.up, -spread).applyMatrix4(g.matrixWorld);
|
||||
if (!h.ray.ray.intersectPlane(h.plane, h.hit)) return false;
|
||||
g.worldToLocal(h.hit);
|
||||
h.target.set(h.hit.x, h.hit.z);
|
||||
/** Em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas. */
|
||||
function updateRotation({ group, state }: Rig, { face, time }: Tick) {
|
||||
group.rotation.set(
|
||||
0.42 + (Math.PI / 2 - 0.42) * face - state.tilt.y * 0.08,
|
||||
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
|
||||
state.tilt.x * 0.15 * (1 - face),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Em repouso: ao centro e um pouco abaixo (a máscara de HeroField afasta o desenho do título e das molduras da
|
||||
* direita); no celular, mais abaixo, atrás dos botões.
|
||||
*/
|
||||
function updatePosition({ group, layout }: Rig, { p }: Tick) {
|
||||
const settle = 1 - smooth(0, 0.28, p);
|
||||
const down = layout.wide ? -0.5 : -layout.rest * layout.tanHalf * 0.38;
|
||||
group.position.set(0, down * settle, 0);
|
||||
}
|
||||
|
||||
/** Camada que a lente enquadra: a próxima à frente da câmera (as de trás desfocam como em uma lente). */
|
||||
function updateFocus({ camera, uniforms }: Rig) {
|
||||
const spread = uniforms.uSpread.value;
|
||||
const behind = camera.position.z - 0.2;
|
||||
let ahead = -spread;
|
||||
if (spread < behind) ahead = spread;
|
||||
else if (0 < behind) ahead = 0;
|
||||
uniforms.uFocus.value = Math.max(1.5, camera.position.z - ahead);
|
||||
}
|
||||
|
||||
/** Posição do grupo, rotação, câmera e foco: a vista de três quartos que balança e depois se endireita no mergulho. */
|
||||
export function updateRig(rig: Rig, tick: Tick) {
|
||||
updateTilt(rig, tick);
|
||||
updateRotation(rig, tick);
|
||||
updatePosition(rig, tick);
|
||||
rig.camera.position.z = keyframes(rig.layout.camera, tick.p);
|
||||
updateFocus(rig);
|
||||
rig.group.updateMatrixWorld();
|
||||
}
|
||||
|
||||
/** Ponto da camada sob o cursor, em coordenadas do grupo, deixado em helpers.target. Falso se o raio não toca a camada. */
|
||||
function cursorOnLayer({ group, camera, state, helpers }: Rig, spread: number) {
|
||||
helpers.ray.setFromCamera(state.pointer, camera);
|
||||
helpers.plane.set(helpers.up, -spread).applyMatrix4(group.matrixWorld);
|
||||
if (!helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) return false;
|
||||
group.worldToLocal(helpers.hit);
|
||||
helpers.target.set(helpers.hit.x, helpers.hit.z);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Onda do cursor: segue o mouse na camada ou, sem mouse, o cursor fantasma. Devolve a força que ela busca. */
|
||||
function updatePointer(
|
||||
g: THREE.Group,
|
||||
camera: THREE.Camera,
|
||||
u: Uniforms,
|
||||
s: FieldState,
|
||||
h: Helpers,
|
||||
p: number,
|
||||
time: number,
|
||||
dt: number,
|
||||
cursorActive: boolean,
|
||||
) {
|
||||
/** Leva a onda do cursor ao mouse (na camada) ou, sem mouse, a um cursor fantasma que percorre a tela. */
|
||||
function followCursor(rig: Rig, { time, dt, cursorActive }: Tick) {
|
||||
const { state, helpers, uniforms } = rig;
|
||||
if (cursorActive) {
|
||||
if (cursorOnLayer(g, camera, s, h, u.uSpread.value))
|
||||
s.mouse.lerp(h.target, 1 - Math.exp(-dt * 10));
|
||||
} else {
|
||||
s.mouse.lerp(
|
||||
h.target.set(
|
||||
Math.sin(time * 0.31) * 1.5,
|
||||
Math.sin(time * 0.23 + 1.3) * 1.1,
|
||||
),
|
||||
1 - Math.exp(-dt * 1.5),
|
||||
);
|
||||
if (cursorOnLayer(rig, uniforms.uSpread.value))
|
||||
state.mouse.lerp(helpers.target, 1 - Math.exp(-dt * 10));
|
||||
return;
|
||||
}
|
||||
u.uMouse.value.copy(s.mouse);
|
||||
const targetAmt = (cursorActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
|
||||
u.uMouseAmt.value +=
|
||||
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
||||
state.mouse.lerp(
|
||||
helpers.target.set(
|
||||
Math.sin(time * 0.31) * 1.5,
|
||||
Math.sin(time * 0.23 + 1.3) * 1.1,
|
||||
),
|
||||
1 - Math.exp(-dt * 1.5),
|
||||
);
|
||||
}
|
||||
|
||||
/** Onda do cursor: segue o mouse na camada ou, sem mouse, o cursor fantasma. Devolve a força que ela busca. */
|
||||
export function updatePointer(rig: Rig, tick: Tick) {
|
||||
const { uniforms, state } = rig;
|
||||
followCursor(rig, tick);
|
||||
uniforms.uMouse.value.copy(state.mouse);
|
||||
const targetAmt =
|
||||
(tick.cursorActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, tick.p));
|
||||
uniforms.uMouseAmt.value +=
|
||||
(targetAmt - uniforms.uMouseAmt.value) * (1 - Math.exp(-tick.dt * 4));
|
||||
return targetAmt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sob demanda: o próximo quadro vem do FramePulse (30 por segundo, e nenhum depois de uns segundos sem cursor nem
|
||||
* rolagem); este pede o quadro seguinte (a 60) só enquanto algo ainda muda. A entrada e a rolagem terminam de
|
||||
* rolagem); o quadro pede o seguinte (a 60) só enquanto algo ainda muda. A entrada e a rolagem terminam de
|
||||
* assentar mesmo depois de o hero sair de cena (o último quadro tem de ser o final); o cursor só conta com o hero
|
||||
* à vista.
|
||||
*/
|
||||
function needsFrame(
|
||||
export function needsFrame(
|
||||
u: Uniforms,
|
||||
s: FieldState,
|
||||
target: number,
|
||||
@@ -593,176 +622,339 @@ function needsFrame(
|
||||
return settling || (visible && interacting);
|
||||
}
|
||||
|
||||
function Field({
|
||||
/** O material de pontos: o shader, a mistura aditiva e os uniforms de partida. */
|
||||
function createMaterial() {
|
||||
return new ShaderMaterial({
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: AdditiveBlending,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uIntro: { value: 0 },
|
||||
uSpread: { value: 1.05 },
|
||||
uScroll: { value: 0 },
|
||||
uScale: { value: 1 },
|
||||
uFocus: { value: 9 },
|
||||
uFade: { value: 1 },
|
||||
uMouse: { value: new Vector2(9, 9) },
|
||||
uMouseAmt: { value: 0 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const createHelpers = (): Helpers => ({
|
||||
ray: new Raycaster(),
|
||||
plane: new Plane(),
|
||||
up: new Vector3(0, 1, 0),
|
||||
hit: new Vector3(),
|
||||
zero: new Vector2(),
|
||||
target: new Vector2(),
|
||||
});
|
||||
|
||||
const createState = (): FieldState => ({
|
||||
progress: 0,
|
||||
pointer: new Vector2(),
|
||||
lastMove: -10,
|
||||
mouse: new Vector2(),
|
||||
tilt: new Vector2(),
|
||||
frames: 0,
|
||||
});
|
||||
|
||||
/** A resolução de desenho: a do aparelho, de 1 a 1,5 (cada ponto custa fragmentos, e o fundo é ambiente). */
|
||||
const pixelRatio = () =>
|
||||
Math.min(1.5, Math.max(1, window.devicePixelRatio || 1));
|
||||
|
||||
export type FieldCallbacks = {
|
||||
/** Progresso do mergulho (0 a 1), escrito pela rolagem. */
|
||||
progressRef: RefObject<number>;
|
||||
/** Dois quadros já foram desenhados: o fundo estático pode sair. */
|
||||
onReady: () => void;
|
||||
/** Sem WebGL, contexto perdido ou erro ao desenhar: o hero volta ao fundo estático. */
|
||||
onError: () => void;
|
||||
};
|
||||
|
||||
export type FieldEngine = {
|
||||
/** Hero à vista (e aba visível): liga a batida de 30 quadros por segundo; fora disso, nenhum quadro é pedido. */
|
||||
setVisible: (visible: boolean) => void;
|
||||
/** O quadro que o requestAnimationFrame chama, com o relógio (ms) dado. Os testes o chamam direto. */
|
||||
renderAt: (now: number) => void;
|
||||
/** Solta o contexto WebGL, os ouvintes e tudo o que a cena criou. */
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
/** O renderizador transparente, sem antialias, na GPU de alto desempenho, com o canvas dentro de `host`. */
|
||||
function createRenderer(host: HTMLElement) {
|
||||
const renderer = new WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
renderer.setPixelRatio(pixelRatio());
|
||||
const canvas = renderer.domElement;
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
canvas.style.cssText = "display:block;width:100%;height:100%";
|
||||
host.append(canvas);
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/** A cena em si: a câmera, o grupo com a nuvem de pontos e o material do shader. */
|
||||
type World = {
|
||||
scene: Scene;
|
||||
camera: PerspectiveCamera;
|
||||
group: Group;
|
||||
geometry: BufferGeometry;
|
||||
material: ShaderMaterial;
|
||||
};
|
||||
|
||||
function buildWorld(): World {
|
||||
const camera = new PerspectiveCamera(35, 1, 0.05, 120);
|
||||
camera.position.set(0, 0, 9);
|
||||
const geometry = takeField();
|
||||
const material = createMaterial();
|
||||
const points = new Points(geometry, material);
|
||||
points.frustumCulled = false;
|
||||
const group = new Group();
|
||||
group.add(points);
|
||||
const scene = new Scene();
|
||||
scene.add(group);
|
||||
return { scene, camera, group, geometry, material };
|
||||
}
|
||||
|
||||
/** O que o quadro lê de fora: o progresso da rolagem, se o hero está à vista e o relógio. */
|
||||
type StepInput = {
|
||||
target: number;
|
||||
visible: boolean;
|
||||
time: number;
|
||||
delta: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Um quadro: rolagem amortecida, uniforms, câmera e cursor. Diz se foi o segundo quadro (a página pode tirar o fundo
|
||||
* estático) e se algo ainda muda (então o próximo quadro é pedido de imediato).
|
||||
*/
|
||||
function stepField(
|
||||
world: World,
|
||||
state: FieldState,
|
||||
helpers: Helpers,
|
||||
layout: Layout,
|
||||
{ target, visible, time, delta }: StepInput,
|
||||
) {
|
||||
const dt = Math.min(delta, 1 / 20);
|
||||
const u = world.material.uniforms;
|
||||
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
|
||||
state.progress += (target - state.progress) * (1 - Math.exp(-dt * 5));
|
||||
const p = state.progress;
|
||||
const cursorActive = cursorIsActive(state, time);
|
||||
const face = updateUniforms(u, layout, p, time, dt);
|
||||
const rig: Rig = {
|
||||
group: world.group,
|
||||
camera: world.camera,
|
||||
uniforms: u,
|
||||
layout,
|
||||
state,
|
||||
helpers,
|
||||
};
|
||||
const tick: Tick = { p, face, time, dt, cursorActive };
|
||||
updateRig(rig, tick);
|
||||
const amount = updatePointer(rig, tick);
|
||||
state.frames += 1;
|
||||
return {
|
||||
ready: state.frames === 2,
|
||||
again: needsFrame(u, state, target, amount, cursorActive, visible),
|
||||
};
|
||||
}
|
||||
|
||||
/** Põe o renderizador e a câmera no tamanho do `host` (nunca zero) e devolve o enquadramento novo. */
|
||||
function fitToHost(
|
||||
renderer: WebGLRenderer,
|
||||
camera: PerspectiveCamera,
|
||||
host: HTMLElement,
|
||||
) {
|
||||
const width = Math.max(1, host.clientWidth);
|
||||
const height = Math.max(1, host.clientHeight);
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
return frameLayout(width, height, camera.fov, renderer.getPixelRatio());
|
||||
}
|
||||
|
||||
type FieldListeners = {
|
||||
host: HTMLElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
resize: () => void;
|
||||
/** O mouse (não o toque) se mexeu: posição de -1 a 1 na janela. */
|
||||
onPointer: (x: number, y: number) => void;
|
||||
/** O contexto WebGL se perdeu. */
|
||||
onLost: () => void;
|
||||
};
|
||||
|
||||
/** Tamanho do host, mouse e perda do contexto. Devolve a função que solta os ouvintes. */
|
||||
function listenToField({
|
||||
host,
|
||||
canvas,
|
||||
resize,
|
||||
onPointer,
|
||||
onLost,
|
||||
}: FieldListeners) {
|
||||
const onMove = (event: PointerEvent) => {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
onPointer(
|
||||
(event.clientX / window.innerWidth) * 2 - 1,
|
||||
-(event.clientY / window.innerHeight) * 2 + 1,
|
||||
);
|
||||
};
|
||||
const lost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onLost();
|
||||
};
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
|
||||
observer?.observe(host);
|
||||
if (!observer) window.addEventListener("resize", resize);
|
||||
window.addEventListener("pointermove", onMove, { passive: true });
|
||||
canvas.addEventListener("webglcontextlost", lost);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", resize);
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
canvas.removeEventListener("webglcontextlost", lost);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A cena sem React: cria o renderizador dentro de `host` e desenha sob demanda. `invalidate` pede um quadro no
|
||||
* próximo requestAnimationFrame (vários pedidos viram um); cada quadro atualiza os uniforms, a câmera e o cursor e
|
||||
* pede o seguinte só se algo ainda muda. Lança se o aparelho não cria um contexto WebGL.
|
||||
*/
|
||||
export function createField(
|
||||
host: HTMLElement,
|
||||
{ progressRef, onReady, onError }: FieldCallbacks,
|
||||
): FieldEngine {
|
||||
const renderer = createRenderer(host);
|
||||
const world = buildWorld();
|
||||
const state = createState();
|
||||
const helpers = createHelpers();
|
||||
const started = performance.now();
|
||||
let layout = frameLayout(1, 1, world.camera.fov, renderer.getPixelRatio());
|
||||
let visible = false;
|
||||
let alive = true;
|
||||
let raf = 0;
|
||||
let last = 0;
|
||||
let stopPulse: (() => void) | undefined;
|
||||
|
||||
const invalidate = () => {
|
||||
if (!raf && alive) raf = requestAnimationFrame(render);
|
||||
};
|
||||
const fail = () => {
|
||||
if (alive) onError();
|
||||
};
|
||||
|
||||
function render(now: number) {
|
||||
raf = 0;
|
||||
if (!alive) return;
|
||||
const delta = last ? (now - last) / 1000 : 1 / 60;
|
||||
last = now;
|
||||
try {
|
||||
const step = stepField(world, state, helpers, layout, {
|
||||
target: progressRef.current ?? 0,
|
||||
visible,
|
||||
time: (now - started) / 1000,
|
||||
delta,
|
||||
});
|
||||
if (step.ready) onReady();
|
||||
if (step.again) invalidate();
|
||||
renderer.render(world.scene, world.camera);
|
||||
} catch {
|
||||
fail();
|
||||
}
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
layout = fitToHost(renderer, world.camera, host);
|
||||
invalidate();
|
||||
};
|
||||
const stopListening = listenToField({
|
||||
host,
|
||||
canvas: renderer.domElement,
|
||||
resize,
|
||||
onPointer: (x, y) => {
|
||||
state.pointer.set(x, y);
|
||||
// na mesma base do relógio do quadro (segundos desde que a cena nasceu)
|
||||
state.lastMove = (performance.now() - started) / 1000;
|
||||
},
|
||||
onLost: fail,
|
||||
});
|
||||
resize();
|
||||
|
||||
return {
|
||||
renderAt: render,
|
||||
setVisible(next) {
|
||||
visible = next;
|
||||
if (next) stopPulse ??= startFramePulse(invalidate);
|
||||
else {
|
||||
stopPulse?.();
|
||||
stopPulse = undefined;
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
alive = false;
|
||||
cancelAnimationFrame(raf);
|
||||
stopPulse?.();
|
||||
stopListening();
|
||||
world.geometry.dispose();
|
||||
world.material.dispose();
|
||||
renderer.dispose();
|
||||
renderer.forceContextLoss();
|
||||
renderer.domElement.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function HeroFieldScene({
|
||||
progressRef,
|
||||
visible,
|
||||
onReady,
|
||||
onError,
|
||||
}: HeroFieldSceneProps) {
|
||||
const { gl, camera, size } = useThree();
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const geometry = useMemo(takeField, []);
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.ShaderMaterial({
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uIntro: { value: 0 },
|
||||
uSpread: { value: 1.05 },
|
||||
uScroll: { value: 0 },
|
||||
uScale: { value: 1 },
|
||||
uFocus: { value: 9 },
|
||||
uFade: { value: 1 },
|
||||
uMouse: { value: new THREE.Vector2(9, 9) },
|
||||
uMouseAmt: { value: 0 },
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
frameLayout(
|
||||
size.width,
|
||||
size.height,
|
||||
(camera as THREE.PerspectiveCamera).fov,
|
||||
gl.getPixelRatio(),
|
||||
),
|
||||
[camera, gl, size.height, size.width],
|
||||
);
|
||||
const state = useRef<FieldState>({
|
||||
progress: 0,
|
||||
pointer: new THREE.Vector2(),
|
||||
lastMove: -10,
|
||||
mouse: new THREE.Vector2(),
|
||||
tilt: new THREE.Vector2(),
|
||||
frames: 0,
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const engine = useRef<FieldEngine | null>(null);
|
||||
// sempre a última versão dos avisos, sem refazer a cena quando o pai troca de função
|
||||
const callbacks = useRef({ onReady, onError });
|
||||
useEffect(() => {
|
||||
callbacks.current = { onReady, onError };
|
||||
});
|
||||
const helpers = useMemo<Helpers>(
|
||||
() => ({
|
||||
ray: new THREE.Raycaster(),
|
||||
plane: new THREE.Plane(),
|
||||
up: new THREE.Vector3(0, 1, 0),
|
||||
hit: new THREE.Vector3(),
|
||||
zero: new THREE.Vector2(),
|
||||
target: new THREE.Vector2(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (event: PointerEvent) => {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
const s = state.current;
|
||||
s.pointer.set(
|
||||
(event.clientX / window.innerWidth) * 2 - 1,
|
||||
-(event.clientY / window.innerHeight) * 2 + 1,
|
||||
);
|
||||
s.lastMove = performance.now() / 1000;
|
||||
};
|
||||
const onLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onError();
|
||||
};
|
||||
window.addEventListener("pointermove", onMove, { passive: true });
|
||||
gl.domElement.addEventListener("webglcontextlost", onLost);
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
try {
|
||||
engine.current = createField(host, {
|
||||
progressRef,
|
||||
onReady: () => callbacks.current.onReady(),
|
||||
onError: () => callbacks.current.onError(),
|
||||
});
|
||||
} catch {
|
||||
// sem WebGL (ou o contexto não abriu): o hero fica com o fundo estático
|
||||
callbacks.current.onError();
|
||||
return;
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
gl.domElement.removeEventListener("webglcontextlost", onLost);
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
engine.current?.dispose();
|
||||
engine.current = null;
|
||||
};
|
||||
}, [gl, geometry, material, onError]);
|
||||
}, [progressRef]);
|
||||
|
||||
useFrame((frame, delta) => {
|
||||
const g = group.current;
|
||||
if (!g) return;
|
||||
const dt = Math.min(delta, 1 / 20);
|
||||
const s = state.current;
|
||||
const u = material.uniforms;
|
||||
const time = frame.clock.elapsedTime;
|
||||
const target = progressRef.current ?? 0;
|
||||
|
||||
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
|
||||
s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5));
|
||||
const p = s.progress;
|
||||
const cursorActive = cursorIsActive(s, time);
|
||||
const face = updateUniforms(u, layout, p, time, dt);
|
||||
updateRig(
|
||||
g,
|
||||
camera,
|
||||
u,
|
||||
layout,
|
||||
s,
|
||||
helpers,
|
||||
p,
|
||||
face,
|
||||
time,
|
||||
dt,
|
||||
cursorActive,
|
||||
);
|
||||
const amount = updatePointer(
|
||||
g,
|
||||
camera,
|
||||
u,
|
||||
s,
|
||||
helpers,
|
||||
p,
|
||||
time,
|
||||
dt,
|
||||
cursorActive,
|
||||
);
|
||||
if (++s.frames === 2) onReady();
|
||||
|
||||
if (needsFrame(u, s, target, amount, cursorActive, visible))
|
||||
frame.invalidate();
|
||||
});
|
||||
useEffect(() => {
|
||||
engine.current?.setVisible(visible);
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<group ref={group}>
|
||||
<points geometry={geometry} material={material} frustumCulled={false} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
class SceneBoundary extends Component<
|
||||
{ children: ReactNode; onError: () => void },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
componentDidCatch() {
|
||||
this.props.onError();
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? null : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default function HeroFieldScene(props: HeroFieldSceneProps) {
|
||||
return (
|
||||
<SceneBoundary onError={props.onError}>
|
||||
<Canvas
|
||||
aria-hidden="true"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }}
|
||||
dpr={[1, 1.5]}
|
||||
frameloop="demand"
|
||||
gl={{
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
>
|
||||
<FramePulse active={props.visible} />
|
||||
<Field {...props} />
|
||||
</Canvas>
|
||||
</SceneBoundary>
|
||||
<div
|
||||
ref={hostRef}
|
||||
aria-hidden="true"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { elementForHash, samePageHashTarget } from "./fragment-nav";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de elementForHash e de samePageHashTarget (complemento de fragment-nav.test.tsx):
|
||||
* hash vazio, só "#", id com percent-encoding, hash malformado, id que não existe, e o clique que o `<a>` de um
|
||||
* SVG recebe (não é um HTMLAnchorElement, então não é âncora de página).
|
||||
*/
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("elementForHash", () => {
|
||||
it.each([
|
||||
["an empty hash", ""],
|
||||
["a lone #", "#"],
|
||||
["an id that does not exist", "#nada"],
|
||||
["a malformed percent-encoding", "#%E0%A4%A"],
|
||||
])("path: %s -> null", (_label, hash) => {
|
||||
document.body.innerHTML = `<section id="alvo"></section>`;
|
||||
|
||||
expect(elementForHash(hash)).toBeNull();
|
||||
});
|
||||
|
||||
it("path: an id that exists -> that element", () => {
|
||||
document.body.innerHTML = `<section id="alvo"></section>`;
|
||||
|
||||
expect(elementForHash("#alvo")?.id).toBe("alvo");
|
||||
});
|
||||
|
||||
it("path: an id written with percent-encoding -> decoded before the lookup", () => {
|
||||
document.body.innerHTML = `<section id="seção"></section>`;
|
||||
|
||||
expect(elementForHash("#se%C3%A7%C3%A3o")?.id).toBe("seção");
|
||||
});
|
||||
});
|
||||
|
||||
describe("samePageHashTarget: o que não é âncora de página", () => {
|
||||
it("path: the closest link is an SVG <a> -> null, even with an href to this page", () => {
|
||||
document.body.innerHTML = `
|
||||
<svg><a href="/#alvo"><text id="rotulo">ir</text></a></svg>
|
||||
<section id="alvo"></section>`;
|
||||
const event = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(event, "target", {
|
||||
value: document.getElementById("rotulo"),
|
||||
});
|
||||
|
||||
expect(samePageHashTarget(event)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import {
|
||||
FRAGMENT_NAV_BOOT_SCRIPT,
|
||||
focusFragment,
|
||||
isPlainPrimaryClick,
|
||||
onFragmentClick,
|
||||
samePageHashTarget,
|
||||
setScrollRestoration,
|
||||
} from "./fragment-nav";
|
||||
|
||||
/*
|
||||
* Âncoras da home num lugar só: a leitura do clique (quem é um link de âncora da mesma página), o único ouvinte
|
||||
* de clique e o único dono de history.scrollRestoration.
|
||||
*/
|
||||
|
||||
describe("isPlainPrimaryClick", () => {
|
||||
const click = (init: MouseEventInit = {}) =>
|
||||
new MouseEvent("click", { cancelable: true, button: 0, ...init });
|
||||
|
||||
it("accepts a plain click of the main button", () => {
|
||||
expect(isPlainPrimaryClick(click())).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)(
|
||||
"refuses a click with %s held (new tab, selection)",
|
||||
(modifier) => {
|
||||
expect(isPlainPrimaryClick(click({ [modifier]: true }))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1, 2])("refuses mouse button %i", (button) => {
|
||||
expect(isPlainPrimaryClick(click({ button }))).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a click somebody else already cancelled", () => {
|
||||
const event = click();
|
||||
event.preventDefault();
|
||||
|
||||
expect(isPlainPrimaryClick(event)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("samePageHashTarget", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function clickOn(markup: string, selector: string) {
|
||||
document.body.innerHTML = markup;
|
||||
const target = document.querySelector(selector) as Element;
|
||||
const event = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(event, "target", { value: target });
|
||||
return event;
|
||||
}
|
||||
|
||||
it("finds the element a same-page #link points to", () => {
|
||||
const event = clickOn(
|
||||
`<a href="/#alvo">ir</a><section id="alvo"></section>`,
|
||||
"a",
|
||||
);
|
||||
|
||||
const hit = samePageHashTarget(event);
|
||||
|
||||
expect(hit?.hash).toBe("#alvo");
|
||||
expect(hit?.target.id).toBe("alvo");
|
||||
});
|
||||
|
||||
it("works from inside the link (the text, an icon)", () => {
|
||||
const event = clickOn(
|
||||
`<a href="/#alvo"><span id="inside">ir</span></a><section id="alvo"></section>`,
|
||||
"#inside",
|
||||
);
|
||||
|
||||
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["another site", `<a href="https://example.com/#alvo">x</a>`],
|
||||
["another page of this site", `<a href="/outra#alvo">x</a>`],
|
||||
["no anchor", `<a href="/">x</a>`],
|
||||
["an empty anchor", `<a href="/#">x</a>`],
|
||||
["a new tab", `<a href="/#alvo" target="_blank">x</a>`],
|
||||
["an id that does not exist", `<a href="/#nada">x</a>`],
|
||||
["a malformed percent-encoding", `<a href="/#%E0%A4%A">x</a>`],
|
||||
["no link at all", `<p>x</p>`],
|
||||
])("returns null for %s", (_label, markup) => {
|
||||
const event = clickOn(
|
||||
`${markup}<section id="alvo"></section>`,
|
||||
markup.startsWith("<a") ? "a" : "p",
|
||||
);
|
||||
|
||||
expect(samePageHashTarget(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles target=_self like a plain link", () => {
|
||||
const event = clickOn(
|
||||
`<a href="/#alvo" target="_self">x</a><section id="alvo"></section>`,
|
||||
"a",
|
||||
);
|
||||
|
||||
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
|
||||
});
|
||||
|
||||
it("ignores a click whose target is not an element", () => {
|
||||
const event = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(event, "target", { value: document });
|
||||
|
||||
expect(samePageHashTarget(event)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("samePageHashTarget (rules shared with the arrival code)", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
function clickOn(markup: string) {
|
||||
document.body.innerHTML = `${markup}<section id="alvo"></section>`;
|
||||
const target = document.querySelector("a") as Element;
|
||||
const event = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(event, "target", { value: target });
|
||||
return event;
|
||||
}
|
||||
|
||||
it("refuses a link that downloads a file", () => {
|
||||
expect(
|
||||
samePageHashTarget(clickOn(`<a href="/#alvo" download>x</a>`)),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a link to the same path with another query string", () => {
|
||||
window.history.replaceState(null, "", "/?a=1");
|
||||
expect(
|
||||
samePageHashTarget(clickOn(`<a href="/?a=2#alvo">x</a>`)),
|
||||
).toBeNull();
|
||||
expect(
|
||||
samePageHashTarget(clickOn(`<a href="/?a=1#alvo">x</a>`))?.hash,
|
||||
).toBe("#alvo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onFragmentClick", () => {
|
||||
let add: jest.SpyInstance;
|
||||
let remove: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
add = jest.spyOn(document, "addEventListener");
|
||||
remove = jest.spyOn(document, "removeEventListener");
|
||||
document.body.innerHTML = `<a href="/#alvo">ir</a><a href="/outra">fora</a><section id="alvo"></section>`;
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const clicks = () => add.mock.calls.filter(([type]) => type === "click");
|
||||
const unclicks = () => remove.mock.calls.filter(([type]) => type === "click");
|
||||
const link = (name: string) => screen.getByRole("link", { name });
|
||||
|
||||
it("installs ONE document click listener no matter how many subscribe, and removes it with the last", () => {
|
||||
const first = jest.fn();
|
||||
const second = jest.fn();
|
||||
const stopFirst = onFragmentClick(first);
|
||||
const stopSecond = onFragmentClick(second);
|
||||
expect(clicks()).toHaveLength(1);
|
||||
expect(clicks()[0][2]).toBe(true);
|
||||
|
||||
fireEvent.click(link("ir"));
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
|
||||
stopFirst();
|
||||
expect(unclicks()).toHaveLength(0);
|
||||
fireEvent.click(link("ir"));
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(second).toHaveBeenCalledTimes(2);
|
||||
|
||||
stopSecond();
|
||||
expect(unclicks()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hands every subscriber the same interpreted hit, in subscription order", () => {
|
||||
const order: string[] = [];
|
||||
const stops = [
|
||||
onFragmentClick((hit) => order.push(`a:${hit.hash}:${hit.target.id}`)),
|
||||
onFragmentClick((hit) => order.push(`b:${hit.hash}`)),
|
||||
];
|
||||
fireEvent.click(link("ir"));
|
||||
expect(order).toEqual(["a:#alvo:alvo", "b:#alvo"]);
|
||||
for (const stop of stops) stop();
|
||||
});
|
||||
|
||||
it("ignores links to other pages, modified clicks and clicks somebody already cancelled", () => {
|
||||
const handler = jest.fn();
|
||||
const stop = onFragmentClick(handler);
|
||||
fireEvent.click(link("fora"));
|
||||
fireEvent.click(link("ir"), { ctrlKey: true });
|
||||
fireEvent.click(link("ir"), { button: 1 });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
stop();
|
||||
});
|
||||
|
||||
it("lets a subscriber cancel the navigation (preventDefault) without the others being skipped", () => {
|
||||
const second = jest.fn();
|
||||
const stops = [
|
||||
onFragmentClick((_hit, event) => event.preventDefault()),
|
||||
onFragmentClick(second),
|
||||
];
|
||||
expect(fireEvent.click(link("ir"))).toBe(false);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
for (const stop of stops) stop();
|
||||
});
|
||||
|
||||
it("tolerates a subscriber that leaves while the click is being delivered", () => {
|
||||
const calls: string[] = [];
|
||||
const stopFirst = onFragmentClick(() => {
|
||||
calls.push("first");
|
||||
stopFirst();
|
||||
});
|
||||
const stopSecond = onFragmentClick(() => calls.push("second"));
|
||||
fireEvent.click(link("ir"));
|
||||
expect(calls).toEqual(["first", "second"]);
|
||||
stopSecond();
|
||||
});
|
||||
});
|
||||
|
||||
describe("focusFragment", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("focuses the destination without scrolling and gives it a tabindex only when it has none", () => {
|
||||
document.body.innerHTML = `<section id="a"></section><section id="b" tabindex="0"></section>`;
|
||||
const a = document.getElementById("a") as HTMLElement;
|
||||
const b = document.getElementById("b") as HTMLElement;
|
||||
const focus = jest.spyOn(a, "focus");
|
||||
|
||||
focusFragment(a);
|
||||
focusFragment(b);
|
||||
|
||||
expect(a).toHaveAttribute("tabindex", "-1");
|
||||
expect(b).toHaveAttribute("tabindex", "0");
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
expect(b).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe("history.scrollRestoration ownership", () => {
|
||||
const run = () => new Function(FRAGMENT_NAV_BOOT_SCRIPT)();
|
||||
// o jsdom não tem performance.getEntriesByType: o teste o define e o tira no fim
|
||||
const navigationType = (type: string) =>
|
||||
Object.defineProperty(performance, "getEntriesByType", {
|
||||
configurable: true,
|
||||
value: () => [{ type }],
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(performance, "getEntriesByType");
|
||||
window.history.scrollRestoration = "auto";
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
it("setScrollRestoration is the runtime writer", () => {
|
||||
setScrollRestoration("manual");
|
||||
expect(window.history.scrollRestoration).toBe("manual");
|
||||
setScrollRestoration("auto");
|
||||
expect(window.history.scrollRestoration).toBe("auto");
|
||||
});
|
||||
|
||||
it("the boot script (run before hydration) goes manual on a fresh arrival at / and back to auto on pagehide", () => {
|
||||
navigationType("navigate");
|
||||
run();
|
||||
expect(window.history.scrollRestoration).toBe("manual");
|
||||
window.dispatchEvent(new Event("pagehide"));
|
||||
expect(window.history.scrollRestoration).toBe("auto");
|
||||
});
|
||||
|
||||
it("the boot script leaves a history traversal to the browser", () => {
|
||||
navigationType("back_forward");
|
||||
run();
|
||||
expect(window.history.scrollRestoration).toBe("auto");
|
||||
});
|
||||
|
||||
it("the boot script drops the stale #fragment on a reload", () => {
|
||||
window.history.replaceState(null, "", "/#contato");
|
||||
navigationType("reload");
|
||||
run();
|
||||
expect(window.location.hash).toBe("");
|
||||
});
|
||||
|
||||
it("the boot script does nothing outside the home", () => {
|
||||
window.history.replaceState(null, "", "/cases");
|
||||
navigationType("navigate");
|
||||
run();
|
||||
expect(window.history.scrollRestoration).toBe("auto");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Â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 });
|
||||
}
|
||||
}`;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { HERO_TAIL_SCREENS } from "./HeroField";
|
||||
|
||||
/*
|
||||
* O mergulho do hero é uma conta que vive em dois lugares: o CSS (altura do hero e margem de Projetos) e o
|
||||
* HeroField (de onde o progresso --hero-p sai). Se um mudar sem o outro, o progresso acaba antes ou depois de
|
||||
* Projetos começar a subir e volta o tempo morto (ou a tela vazia) no fim do mergulho.
|
||||
*/
|
||||
const css = readFileSync(join(__dirname, "../../globals.css"), "utf8");
|
||||
|
||||
function diveBlock() {
|
||||
const start = css.indexOf(".hero[data-dive] {");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
return css.slice(start, start + 6000);
|
||||
}
|
||||
|
||||
describe("hero dive (globals.css <-> HeroField)", () => {
|
||||
it("the hero is HERO_TAIL_SCREENS screens taller than the dive", () => {
|
||||
const height = /height: calc\((\d+)svh \+ var\(--hero-dive\)\)/.exec(
|
||||
diveBlock(),
|
||||
);
|
||||
expect(height).not.toBeNull();
|
||||
expect(Number(height?.[1]) / 100).toBe(HERO_TAIL_SCREENS);
|
||||
});
|
||||
|
||||
it("Projects rises over the stage by the tail minus the pinned stage (one screen)", () => {
|
||||
const margin = /margin-top: -(\d+)svh/.exec(diveBlock());
|
||||
expect(margin).not.toBeNull();
|
||||
expect(Number(margin?.[1]) / 100).toBeCloseTo(HERO_TAIL_SCREENS - 1);
|
||||
});
|
||||
|
||||
it("the last caption stays on stage until Projects covers it (its end is past the end of the dive)", () => {
|
||||
const last =
|
||||
/li:nth-child\(3\) \{\s*--from: ([\d.]+);\s*--to: ([\d.]+);/.exec(
|
||||
diveBlock(),
|
||||
);
|
||||
expect(last).not.toBeNull();
|
||||
expect(Number(last?.[2])).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("each caption starts before the previous one is gone far enough that the gap between them is short", () => {
|
||||
const ranges = [
|
||||
...diveBlock().matchAll(
|
||||
/li:nth-child\((\d)\) \{\s*--from: ([\d.]+);\s*--to: ([\d.]+);/g,
|
||||
),
|
||||
].map((match) => [Number(match[2]), Number(match[3])]);
|
||||
expect(ranges).toHaveLength(3);
|
||||
for (let i = 1; i < ranges.length; i++) {
|
||||
// sem texto entre uma legenda e a seguinte: no máximo 0,1 de progresso (~100 px de rolagem)
|
||||
expect(ranges[i][0] - ranges[i - 1][1]).toBeLessThanOrEqual(0.1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,8 @@ import {
|
||||
anchorDuration,
|
||||
anchorTop,
|
||||
IDLE_SECONDS,
|
||||
isPlainPrimaryClick,
|
||||
JUMP_SCREENS,
|
||||
MAX_ANCHOR_SECONDS,
|
||||
samePageHashTarget,
|
||||
useCinematicScroll,
|
||||
} from "./useCinematicScroll";
|
||||
|
||||
@@ -183,102 +181,6 @@ describe("anchor travel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPlainPrimaryClick", () => {
|
||||
const click = (init: MouseEventInit = {}) =>
|
||||
new MouseEvent("click", { cancelable: true, button: 0, ...init });
|
||||
|
||||
it("accepts a plain click of the main button", () => {
|
||||
expect(isPlainPrimaryClick(click())).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)(
|
||||
"refuses a click with %s held (new tab, selection)",
|
||||
(modifier) => {
|
||||
expect(isPlainPrimaryClick(click({ [modifier]: true }))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1, 2])("refuses mouse button %i", (button) => {
|
||||
expect(isPlainPrimaryClick(click({ button }))).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a click somebody else already cancelled", () => {
|
||||
const event = click();
|
||||
event.preventDefault();
|
||||
|
||||
expect(isPlainPrimaryClick(event)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("samePageHashTarget", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function clickOn(markup: string, selector: string) {
|
||||
document.body.innerHTML = markup;
|
||||
const target = document.querySelector(selector) as Element;
|
||||
const event = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(event, "target", { value: target });
|
||||
return event;
|
||||
}
|
||||
|
||||
it("finds the element a same-page #link points to", () => {
|
||||
const event = clickOn(
|
||||
`<a href="/#alvo">ir</a><section id="alvo"></section>`,
|
||||
"a",
|
||||
);
|
||||
|
||||
const hit = samePageHashTarget(event);
|
||||
|
||||
expect(hit?.hash).toBe("#alvo");
|
||||
expect(hit?.target.id).toBe("alvo");
|
||||
});
|
||||
|
||||
it("works from inside the link (the text, an icon)", () => {
|
||||
const event = clickOn(
|
||||
`<a href="/#alvo"><span id="inside">ir</span></a><section id="alvo"></section>`,
|
||||
"#inside",
|
||||
);
|
||||
|
||||
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["another site", `<a href="https://example.com/#alvo">x</a>`],
|
||||
["another page of this site", `<a href="/outra#alvo">x</a>`],
|
||||
["no anchor", `<a href="/">x</a>`],
|
||||
["an empty anchor", `<a href="/#">x</a>`],
|
||||
["a new tab", `<a href="/#alvo" target="_blank">x</a>`],
|
||||
["an id that does not exist", `<a href="/#nada">x</a>`],
|
||||
["a malformed percent-encoding", `<a href="/#%E0%A4%A">x</a>`],
|
||||
["no link at all", `<p>x</p>`],
|
||||
])("returns null for %s", (_label, markup) => {
|
||||
const event = clickOn(
|
||||
`${markup}<section id="alvo"></section>`,
|
||||
markup.startsWith("<a") ? "a" : "p",
|
||||
);
|
||||
|
||||
expect(samePageHashTarget(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles target=_self like a plain link", () => {
|
||||
const event = clickOn(
|
||||
`<a href="/#alvo" target="_self">x</a><section id="alvo"></section>`,
|
||||
"a",
|
||||
);
|
||||
|
||||
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
|
||||
});
|
||||
|
||||
it("ignores a click whose target is not an element", () => {
|
||||
const event = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(event, "target", { value: document });
|
||||
|
||||
expect(samePageHashTarget(event)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("anchorTop", () => {
|
||||
const scenes = () => {
|
||||
document.body.innerHTML = `
|
||||
@@ -467,6 +369,24 @@ describe("useCinematicScroll", () => {
|
||||
expect(lenis.raf).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("eases the anchor travel in and out: slow at both ends, exactly half way at the middle, from 0 to 1", async () => {
|
||||
await mount();
|
||||
const lenis = mockLenis[0];
|
||||
|
||||
act(() => {
|
||||
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
|
||||
});
|
||||
const { easing } = lenis.scrollTo.mock.calls[0][1] as {
|
||||
easing: (t: number) => number;
|
||||
};
|
||||
|
||||
expect(easing(0)).toBe(0);
|
||||
expect(easing(0.25)).toBeCloseTo(0.0625, 9);
|
||||
expect(easing(0.5)).toBeCloseTo(0.5, 9);
|
||||
expect(easing(0.75)).toBeCloseTo(0.9375, 9);
|
||||
expect(easing(1)).toBe(1);
|
||||
});
|
||||
|
||||
it("without Lenis (touch) a far anchor is an instant jump and a near one is a native smooth scroll", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
await mount();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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:
|
||||
@@ -13,7 +14,8 @@ import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
|
||||
* 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.
|
||||
* destino em vez de saltar (o clique vem de fragment-nav). 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).
|
||||
*/
|
||||
@@ -49,50 +51,6 @@ export function anchorDuration(distance: number, viewport: number) {
|
||||
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.
|
||||
@@ -181,25 +139,21 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
});
|
||||
};
|
||||
|
||||
// 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;
|
||||
// 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
|
||||
if (!hit.target.hasAttribute("tabindex")) hit.target.tabIndex = -1;
|
||||
hit.target.focus({ preventScroll: true });
|
||||
};
|
||||
document.addEventListener("click", onClick, true);
|
||||
focusFragment(hit.target);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
document.removeEventListener("click", onClick, true);
|
||||
stopClicks();
|
||||
smooth?.destroy();
|
||||
};
|
||||
}, [mainRef]);
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
resetHomeArrivalState,
|
||||
useHomeFragmentNavigation,
|
||||
} from "./useHomeFragmentNavigation";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base da chegada à home (complemento de CinematicHome.test.tsx): como o navegador chegou
|
||||
* (nova, volta/avança, sem a API de navegação), o destino do fragmento (existe, não existe, ficou velho), as teclas
|
||||
* e gestos que cancelam a chegada, o clique em âncora antes e depois do layout pronto, o hashchange e a limpeza.
|
||||
*/
|
||||
|
||||
let layoutReady: () => void = () => {};
|
||||
|
||||
function Home() {
|
||||
layoutReady = useHomeFragmentNavigation();
|
||||
return (
|
||||
<>
|
||||
<a href="/#about">Sobre</a>
|
||||
<section id="about" aria-label="Sobre">
|
||||
<button type="button">Ler</button>
|
||||
</section>
|
||||
<section id="contact" aria-label="Contato" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const about = () => screen.getByRole("region", { name: "Sobre" });
|
||||
const contact = () => screen.getByRole("region", { name: "Contato" });
|
||||
const ready = () => act(() => layoutReady());
|
||||
/** O jsdom não tem performance.getEntriesByType (é o caso "navegador sem a API"); aqui ela passa a existir. */
|
||||
function navigationEntries(type?: string) {
|
||||
Object.defineProperty(performance, "getEntriesByType", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: jest.fn().mockReturnValue(type ? [{ type }] : []),
|
||||
});
|
||||
}
|
||||
let scrollTo: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
resetHomeArrivalState();
|
||||
window.history.replaceState(null, "", "/");
|
||||
window.history.scrollRestoration = "auto";
|
||||
scrollTo = jest.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
Reflect.deleteProperty(performance, "getEntriesByType");
|
||||
window.history.replaceState(null, "", "/");
|
||||
window.history.scrollRestoration = "auto";
|
||||
});
|
||||
|
||||
describe("useHomeFragmentNavigation: como o navegador chegou", () => {
|
||||
it("path: a fresh arrival -> scroll restoration is manual until the layout is ready, then automatic again", () => {
|
||||
navigationEntries("navigate");
|
||||
render(<Home />);
|
||||
expect(window.history.scrollRestoration).toBe("manual");
|
||||
|
||||
ready();
|
||||
|
||||
expect(window.history.scrollRestoration).toBe("auto");
|
||||
});
|
||||
|
||||
it("path: back/forward traversal with a fragment in the URL -> the browser keeps its own position: no manual mode, no scroll, no focus", () => {
|
||||
navigationEntries("back_forward");
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
render(<Home />);
|
||||
expect(window.history.scrollRestoration).toBe("auto");
|
||||
|
||||
ready();
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
expect(contact()).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it("path: no navigation entry in the timeline -> treated as a fresh arrival", () => {
|
||||
navigationEntries();
|
||||
render(<Home />);
|
||||
expect(window.history.scrollRestoration).toBe("manual");
|
||||
|
||||
ready();
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 0,
|
||||
left: 0,
|
||||
behavior: "instant",
|
||||
});
|
||||
});
|
||||
|
||||
it("path: a browser without getEntriesByType (the jsdom default) -> treated as a fresh arrival", () => {
|
||||
expect(performance.getEntriesByType).toBeUndefined();
|
||||
render(<Home />);
|
||||
expect(window.history.scrollRestoration).toBe("manual");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useHomeFragmentNavigation: o destino do fragmento", () => {
|
||||
/** Mede o destino e devolve o elemento (capturado antes de getComputedStyle ser trocado). */
|
||||
function measure(top: number, padding: string, margin: string) {
|
||||
Object.defineProperty(window, "scrollY", {
|
||||
configurable: true,
|
||||
value: 100,
|
||||
});
|
||||
const target = about();
|
||||
target.getBoundingClientRect = () => ({ top }) as DOMRect;
|
||||
jest.spyOn(window, "getComputedStyle").mockImplementation(
|
||||
(element) =>
|
||||
({
|
||||
scrollPaddingTop: element === document.documentElement ? padding : "",
|
||||
scrollMarginTop: element === target ? margin : "",
|
||||
}) as unknown as CSSStyleDeclaration,
|
||||
);
|
||||
return target;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "scrollY");
|
||||
});
|
||||
|
||||
it("path: fragment that exists -> waits for the layout, then focuses it and scrolls under the fixed header and its own margin", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<Home />);
|
||||
const target = measure(500, "80px", "10px");
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
expect(target).not.toHaveFocus();
|
||||
|
||||
ready();
|
||||
|
||||
expect(target).toHaveFocus();
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 100 + 500 - 80 - 10,
|
||||
left: 0,
|
||||
behavior: "instant",
|
||||
});
|
||||
});
|
||||
|
||||
it("path: no readable header offset or margin -> both count as zero", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<Home />);
|
||||
measure(500, "", "");
|
||||
|
||||
ready();
|
||||
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({
|
||||
top: 600,
|
||||
left: 0,
|
||||
behavior: "instant",
|
||||
});
|
||||
});
|
||||
|
||||
it("path: a target far above the current position -> never scrolls to a negative position", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<Home />);
|
||||
measure(-900, "80px", "0px");
|
||||
|
||||
ready();
|
||||
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({
|
||||
top: 0,
|
||||
left: 0,
|
||||
behavior: "instant",
|
||||
});
|
||||
});
|
||||
|
||||
it("path: fragment of an element that does not exist -> nothing is focused or scrolled, and nothing throws", () => {
|
||||
window.history.replaceState(null, "", "/#nao-existe");
|
||||
render(<Home />);
|
||||
|
||||
expect(() => ready()).not.toThrow();
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
expect(document.body).toHaveFocus();
|
||||
});
|
||||
|
||||
it("path: the URL changed without a hashchange event before the layout was ready -> the old fragment is not applied", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<Home />);
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
|
||||
ready();
|
||||
|
||||
expect(about()).not.toHaveFocus();
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useHomeFragmentNavigation: o visitante assume a rolagem", () => {
|
||||
it.each([
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"Home",
|
||||
"End",
|
||||
" ",
|
||||
"Tab",
|
||||
])(
|
||||
"path: the %j key -> the pending arrival is dropped and the browser takes back its scroll restoration",
|
||||
(key) => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<Home />);
|
||||
expect(window.history.scrollRestoration).toBe("manual");
|
||||
|
||||
fireEvent.keyDown(window, { key });
|
||||
expect(window.history.scrollRestoration).toBe("auto");
|
||||
ready();
|
||||
|
||||
expect(about()).not.toHaveFocus();
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["a", "Enter", "Escape", "Shift"])(
|
||||
"path: the %j key (not a scroll key) -> the pending arrival still happens",
|
||||
(key) => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<Home />);
|
||||
|
||||
fireEvent.keyDown(window, { key });
|
||||
ready();
|
||||
|
||||
expect(about()).toHaveFocus();
|
||||
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["touchmove", () => fireEvent.touchMove(window)],
|
||||
["pointerdown", () => fireEvent.pointerDown(window)],
|
||||
["wheel", () => fireEvent.wheel(window, { deltaY: 40 })],
|
||||
])("path: %s -> the pending arrival is dropped", (_gesture, gesture) => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<Home />);
|
||||
|
||||
gesture();
|
||||
ready();
|
||||
|
||||
expect(about()).not.toHaveFocus();
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useHomeFragmentNavigation: cliques e hashchange", () => {
|
||||
it("path: click on an anchor before the layout is ready -> the destination gets the focus now and the scroll waits for the layout", () => {
|
||||
render(<Home />);
|
||||
fireEvent.click(screen.getByRole("link", { name: "Sobre" }));
|
||||
// o jsdom não segue a âncora sozinho: o navegador deixaria a URL já no fragmento
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
expect(about()).toHaveFocus();
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
ready();
|
||||
|
||||
// a âncora vira a chegada pendente (não a rolagem ao topo)
|
||||
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: click on an anchor after the layout is ready -> the focus moves, no arrival scroll is left pending", () => {
|
||||
render(<Home />);
|
||||
ready();
|
||||
scrollTo.mockClear();
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "Sobre" }));
|
||||
ready();
|
||||
|
||||
expect(about()).toHaveFocus();
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: hashchange to an element that exists, after the layout is ready -> it gets the focus", () => {
|
||||
render(<Home />);
|
||||
ready();
|
||||
|
||||
act(() => {
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(contact()).toHaveFocus();
|
||||
});
|
||||
|
||||
it("path: hashchange to an element that does not exist -> nothing happens", () => {
|
||||
render(<Home />);
|
||||
ready();
|
||||
|
||||
act(() => {
|
||||
window.history.replaceState(null, "", "/#nao-existe");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(document.body).toHaveFocus();
|
||||
});
|
||||
|
||||
it("path: hashchange before the layout is ready -> no focus yet (the arrival is not resolved early)", () => {
|
||||
render(<Home />);
|
||||
|
||||
act(() => {
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(contact()).not.toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useHomeFragmentNavigation: desmontagem", () => {
|
||||
it("path: unmount -> scroll restoration goes back to auto and the listeners are gone", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
const view = render(<Home />);
|
||||
expect(window.history.scrollRestoration).toBe("manual");
|
||||
|
||||
view.unmount();
|
||||
expect(window.history.scrollRestoration).toBe("auto");
|
||||
|
||||
window.history.scrollRestoration = "manual";
|
||||
fireEvent.wheel(window, { deltaY: 40 });
|
||||
expect(window.history.scrollRestoration).toBe("manual");
|
||||
});
|
||||
|
||||
it("path: the ready callback called after unmount -> nothing to resolve, no error, restoration stays automatic", () => {
|
||||
const view = render(<Home />);
|
||||
view.unmount();
|
||||
|
||||
expect(() => act(() => layoutReady())).not.toThrow();
|
||||
|
||||
expect(window.history.scrollRestoration).toBe("auto");
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
elementForHash,
|
||||
type FragmentHit,
|
||||
focusFragment,
|
||||
onFragmentClick,
|
||||
setScrollRestoration,
|
||||
} from "./fragment-nav";
|
||||
|
||||
function fragmentTarget(hash: string) {
|
||||
if (!hash) return null;
|
||||
try {
|
||||
return document.getElementById(decodeURIComponent(hash.slice(1)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function focusFragment(target: HTMLElement) {
|
||||
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
|
||||
target.focus({ preventScroll: true });
|
||||
}
|
||||
/** Teclas de rolagem: quem as usa já escolheu para onde ir, e a chegada por fragmento deixa de corrigir. */
|
||||
const SCROLL_KEYS = new Set([
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"Home",
|
||||
"End",
|
||||
" ",
|
||||
"Tab",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A document owns one outstanding homepage arrival. Later mounts are same-document
|
||||
@@ -27,135 +32,113 @@ export function resetHomeArrivalState() {
|
||||
arrivalSettled = false;
|
||||
}
|
||||
|
||||
/** Rola a página até o destino do fragmento, descontando o cabeçalho fixo (scroll-padding) e a margem do próprio alvo. */
|
||||
function scrollToFragment(target: HTMLElement) {
|
||||
const padding =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).scrollPaddingTop,
|
||||
) || 0;
|
||||
const margin =
|
||||
Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
|
||||
window.scrollTo({
|
||||
top: Math.max(
|
||||
0,
|
||||
window.scrollY + target.getBoundingClientRect().top - padding - margin,
|
||||
),
|
||||
left: 0,
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A chegada (uma por documento): quer o topo, quando a URL não traz fragmento, ou o destino do fragmento, depois
|
||||
* que o layout da home está pronto. Corrige só uma chegada pendente; links comuns e histórico seguem nativos.
|
||||
*/
|
||||
function createArrival(isReady: () => boolean) {
|
||||
const navigation = performance.getEntriesByType?.("navigation")[0] as
|
||||
| PerformanceNavigationTiming
|
||||
| undefined;
|
||||
// A history restoration owns its saved position, not the URL's old fragment.
|
||||
const restoration = navigation?.type === "back_forward";
|
||||
const arrival = !restoration && !arrivalSettled;
|
||||
let pending = restoration ? "" : window.location.hash;
|
||||
let pendingTop = arrival && !window.location.hash;
|
||||
if (arrival) setScrollRestoration("manual");
|
||||
|
||||
const goToTop = () => {
|
||||
pendingTop = false;
|
||||
if (!window.location.hash)
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
||||
};
|
||||
|
||||
return {
|
||||
/** Aplica o que ficou pendente, se o layout já está pronto. */
|
||||
resolve() {
|
||||
if (!isReady()) return;
|
||||
if (pendingTop) goToTop();
|
||||
if (!pending) return;
|
||||
const hash = pending;
|
||||
pending = "";
|
||||
if (hash !== window.location.hash) return;
|
||||
const target = elementForHash(hash);
|
||||
if (!target) return;
|
||||
focusFragment(target);
|
||||
scrollToFragment(target);
|
||||
},
|
||||
/** O visitante assumiu a rolagem: nada mais é corrigido, e a restauração do navegador volta. */
|
||||
cancel() {
|
||||
pending = "";
|
||||
pendingTop = false;
|
||||
setScrollRestoration("auto");
|
||||
},
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
if (SCROLL_KEYS.has(event.key)) this.cancel();
|
||||
},
|
||||
/** Clique num link de âncora (o ouvinte é de fragment-nav): sem cancelar a navegação, o foco vai ao destino. */
|
||||
onFragmentClick({ target, hash }: FragmentHit) {
|
||||
pendingTop = false;
|
||||
pending = isReady() ? "" : hash;
|
||||
// Capture before a mobile menu removes its activated link.
|
||||
focusFragment(target);
|
||||
},
|
||||
onHashChange() {
|
||||
pendingTop = false;
|
||||
if (pending && pending !== window.location.hash) this.cancel();
|
||||
if (!isReady()) return;
|
||||
const target = elementForHash(window.location.hash);
|
||||
if (target) focusFragment(target);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Correct only an outstanding arrival; ordinary links and history keep native scrolling. */
|
||||
export function useHomeFragmentNavigation() {
|
||||
const ready = useRef(false);
|
||||
const resolveArrival = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const navigation = performance.getEntriesByType?.("navigation")[0] as
|
||||
| PerformanceNavigationTiming
|
||||
| undefined;
|
||||
// A history restoration owns its saved position, not the URL's old fragment.
|
||||
const restoration = navigation?.type === "back_forward";
|
||||
const arrival = !restoration && !arrivalSettled;
|
||||
let pending = restoration ? "" : window.location.hash;
|
||||
let pendingTop = arrival && !window.location.hash;
|
||||
if (arrival) window.history.scrollRestoration = "manual";
|
||||
const arrival = createArrival(() => ready.current);
|
||||
const cancel = () => arrival.cancel();
|
||||
const onKeyDown = (event: KeyboardEvent) => arrival.onKeyDown(event);
|
||||
const onHashChange = () => arrival.onHashChange();
|
||||
|
||||
const resolve = () => {
|
||||
if (!ready.current) return;
|
||||
if (pendingTop) {
|
||||
pendingTop = false;
|
||||
if (!window.location.hash)
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
||||
}
|
||||
if (!pending) return;
|
||||
const hash = pending;
|
||||
pending = "";
|
||||
if (hash !== window.location.hash) return;
|
||||
const target = fragmentTarget(hash);
|
||||
if (!target) return;
|
||||
focusFragment(target);
|
||||
const padding =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).scrollPaddingTop,
|
||||
) || 0;
|
||||
const margin =
|
||||
Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
|
||||
window.scrollTo({
|
||||
top: Math.max(
|
||||
0,
|
||||
window.scrollY +
|
||||
target.getBoundingClientRect().top -
|
||||
padding -
|
||||
margin,
|
||||
),
|
||||
left: 0,
|
||||
behavior: "instant",
|
||||
});
|
||||
};
|
||||
|
||||
const cancelArrival = () => {
|
||||
pending = "";
|
||||
pendingTop = false;
|
||||
window.history.scrollRestoration = "auto";
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
[
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"Home",
|
||||
"End",
|
||||
" ",
|
||||
"Tab",
|
||||
].includes(event.key)
|
||||
) {
|
||||
cancelArrival();
|
||||
}
|
||||
};
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const link =
|
||||
event.target instanceof Element
|
||||
? event.target.closest("a[href]")
|
||||
: null;
|
||||
if (
|
||||
!(link instanceof HTMLAnchorElement) ||
|
||||
link.hasAttribute("download") ||
|
||||
(link.target && link.target !== "_self")
|
||||
)
|
||||
return;
|
||||
const url = new URL(link.href);
|
||||
if (
|
||||
url.origin !== window.location.origin ||
|
||||
url.pathname !== window.location.pathname ||
|
||||
url.search !== window.location.search
|
||||
)
|
||||
return;
|
||||
const target = fragmentTarget(url.hash);
|
||||
if (!target) return;
|
||||
pendingTop = false;
|
||||
pending = ready.current ? "" : url.hash;
|
||||
// Capture before a mobile menu removes its activated link. Do not cancel navigation.
|
||||
focusFragment(target);
|
||||
};
|
||||
const onHashChange = () => {
|
||||
pendingTop = false;
|
||||
if (pending && pending !== window.location.hash) cancelArrival();
|
||||
if (!ready.current) return;
|
||||
const target = fragmentTarget(window.location.hash);
|
||||
if (target) focusFragment(target);
|
||||
};
|
||||
|
||||
resolveArrival.current = resolve;
|
||||
resolve();
|
||||
document.addEventListener("click", onClick, true);
|
||||
resolveArrival.current = () => arrival.resolve();
|
||||
arrival.resolve();
|
||||
const stopClicks = onFragmentClick((hit) => arrival.onFragmentClick(hit));
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
window.addEventListener("wheel", cancelArrival, { passive: true });
|
||||
window.addEventListener("touchmove", cancelArrival, { passive: true });
|
||||
window.addEventListener("wheel", cancel, { passive: true });
|
||||
window.addEventListener("touchmove", cancel, { passive: true });
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("pointerdown", cancelArrival, { passive: true });
|
||||
window.addEventListener("pointerdown", cancel, { passive: true });
|
||||
return () => {
|
||||
resolveArrival.current = null;
|
||||
window.history.scrollRestoration = "auto";
|
||||
document.removeEventListener("click", onClick, true);
|
||||
setScrollRestoration("auto");
|
||||
stopClicks();
|
||||
window.removeEventListener("hashchange", onHashChange);
|
||||
window.removeEventListener("wheel", cancelArrival);
|
||||
window.removeEventListener("touchmove", cancelArrival);
|
||||
window.removeEventListener("wheel", cancel);
|
||||
window.removeEventListener("touchmove", cancel);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("pointerdown", cancelArrival);
|
||||
window.removeEventListener("pointerdown", cancel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -163,6 +146,6 @@ export function useHomeFragmentNavigation() {
|
||||
ready.current = true;
|
||||
resolveArrival.current?.();
|
||||
arrivalSettled = true;
|
||||
window.history.scrollRestoration = "auto";
|
||||
setScrollRestoration("auto");
|
||||
}, []);
|
||||
}
|
||||
Reference in new issue
Block a user