perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
This commit is contained in:
1 parent
8d07b8c6bb
commit
c9a0b80656
48 files changed
+7210
-1241
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import FilmGrain from "./FilmGrain";
|
||||
|
||||
describe("static cinematic effects", () => {
|
||||
@@ -11,3 +11,66 @@ describe("static cinematic effects", () => {
|
||||
expect(grain.container.querySelector("canvas")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("film grain over the project screens", () => {
|
||||
let screenTop = 5000;
|
||||
|
||||
function addScreen(className: string) {
|
||||
const element = document.createElement("div");
|
||||
element.className = className;
|
||||
element.getBoundingClientRect = () =>
|
||||
({ top: screenTop, bottom: screenTop + 600, height: 600 }) as DOMRect;
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
function scroll(to: number) {
|
||||
screenTop = to;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
screenTop = 5000;
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("steps aside while a project screen is in view and comes back when it leaves", () => {
|
||||
addScreen("ds-reel");
|
||||
const { container } = render(<FilmGrain />);
|
||||
const grain = container.firstElementChild as HTMLElement;
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
|
||||
scroll(300); // a vitrine entrou na janela
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
|
||||
scroll(-900); // passou: a cena acabou 300 px acima do topo
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("also covers the browser and phone frames", () => {
|
||||
addScreen("ds-bframe");
|
||||
const { container } = render(<FilmGrain />);
|
||||
scroll(100);
|
||||
expect(container.firstElementChild).toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("stays on when there is no project screen on the page", () => {
|
||||
const { container } = render(<FilmGrain />);
|
||||
scroll(100);
|
||||
expect(container.firstElementChild).not.toHaveAttribute(
|
||||
"data-over-projects",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import FilmGrain from "./FilmGrain";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de FilmGrain (complemento de CinematicEffects.test.tsx): cada condição do retângulo
|
||||
* de uma tela de projeto (altura, fim acima do topo da janela, início abaixo da janela), várias telas, quadros
|
||||
* agrupados e a limpeza ao desmontar.
|
||||
*/
|
||||
|
||||
const INNER_HEIGHT = 800;
|
||||
|
||||
function addScreen(
|
||||
className: string,
|
||||
rect: { top: number; bottom: number; height: number },
|
||||
) {
|
||||
const element = document.createElement("div");
|
||||
element.className = className;
|
||||
element.getBoundingClientRect = () => rect as DOMRect;
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: INNER_HEIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function renderGrain() {
|
||||
const view = render(<FilmGrain />);
|
||||
return { ...view, grain: view.container.firstElementChild as HTMLElement };
|
||||
}
|
||||
|
||||
describe("FilmGrain: is a project screen in view?", () => {
|
||||
it("path: a visible screen -> the grain steps aside", () => {
|
||||
addScreen("ds-phone", { top: 100, bottom: 700, height: 600 });
|
||||
|
||||
const { grain } = renderGrain();
|
||||
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["has no height (hidden by CSS)", { top: 100, bottom: 100, height: 0 }],
|
||||
["ended exactly at the top edge", { top: -600, bottom: 0, height: 600 }],
|
||||
["ended above the window", { top: -900, bottom: -300, height: 600 }],
|
||||
[
|
||||
"starts exactly at the bottom edge",
|
||||
{ top: INNER_HEIGHT, bottom: 1400, height: 600 },
|
||||
],
|
||||
["starts below the window", { top: 2000, bottom: 2600, height: 600 }],
|
||||
])("path: a screen that %s -> does not count", (_label, rect) => {
|
||||
addScreen("ds-bframe", rect);
|
||||
|
||||
const { grain } = renderGrain();
|
||||
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: just inside the window by one pixel at either edge -> counts", () => {
|
||||
addScreen("ds-frame", { top: -599, bottom: 1, height: 600 });
|
||||
const top = renderGrain();
|
||||
expect(top.grain).toHaveAttribute("data-over-projects");
|
||||
top.unmount();
|
||||
document.body.innerHTML = "";
|
||||
|
||||
addScreen("ds-frame", { top: INNER_HEIGHT - 1, bottom: 1400, height: 600 });
|
||||
const bottom = renderGrain();
|
||||
expect(bottom.grain).toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: one screen off the window and another in view -> counts (any is enough)", () => {
|
||||
addScreen("ds-reel", { top: 5000, bottom: 5600, height: 600 });
|
||||
addScreen("ds-phone", { top: 100, bottom: 700, height: 600 });
|
||||
|
||||
const { grain } = renderGrain();
|
||||
|
||||
expect(grain).toHaveAttribute("data-over-projects");
|
||||
});
|
||||
|
||||
it("path: no project screen on the page -> the grain stays", () => {
|
||||
const { grain } = renderGrain();
|
||||
|
||||
expect(grain).not.toHaveAttribute("data-over-projects");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilmGrain: frames and cleanup", () => {
|
||||
it("path: many scroll events inside one frame -> the page is measured once", () => {
|
||||
const screenEl = addScreen("ds-reel", {
|
||||
top: 5000,
|
||||
bottom: 5600,
|
||||
height: 600,
|
||||
});
|
||||
const measure = jest.spyOn(screenEl, "getBoundingClientRect");
|
||||
renderGrain();
|
||||
measure.mockClear();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
|
||||
expect(measure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: unmount -> listeners removed and no measurement afterwards", () => {
|
||||
const screenEl = addScreen("ds-reel", {
|
||||
top: 5000,
|
||||
bottom: 5600,
|
||||
height: 600,
|
||||
});
|
||||
const measure = jest.spyOn(screenEl, "getBoundingClientRect");
|
||||
const { unmount } = renderGrain();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
unmount();
|
||||
measure.mockClear();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(measure).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/** Telas dos projetos (vitrine e capas): nenhum efeito pode ficar por cima delas. */
|
||||
const PROJECT_SCREENS = ".ds-reel, .ds-frame, .ds-bframe, .ds-phone";
|
||||
|
||||
/** Grão de filme sobre a página. Some enquanto qualquer tela de projeto está à vista (data-over-projects). */
|
||||
export default function FilmGrain() {
|
||||
return <div aria-hidden="true" className="film-grain" />;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const grain = ref.current;
|
||||
if (!grain) return;
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const over = Array.from(document.querySelectorAll(PROJECT_SCREENS)).some(
|
||||
(screen) => {
|
||||
const rect = screen.getBoundingClientRect();
|
||||
return (
|
||||
rect.height > 0 && rect.bottom > 0 && rect.top < window.innerHeight
|
||||
);
|
||||
},
|
||||
);
|
||||
if (over) grain.dataset.overProjects = "";
|
||||
else delete grain.dataset.overProjects;
|
||||
};
|
||||
const queue = () => {
|
||||
if (!frame) frame = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("scroll", queue, { passive: true });
|
||||
window.addEventListener("resize", queue);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("scroll", queue);
|
||||
window.removeEventListener("resize", queue);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={ref} aria-hidden="true" className="film-grain" />;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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();
|
||||
});
|
||||
|
||||
describe("FramePulse", () => {
|
||||
it("asks for frames at about 30 per second while active, not 60", () => {
|
||||
render(<FramePulse active />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(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} />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(mockInvalidate).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<FramePulse active />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
const running = mockInvalidate.mock.calls.length;
|
||||
expect(running).toBeGreaterThan(0);
|
||||
|
||||
rerender(<FramePulse active={false} />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(mockInvalidate.mock.calls.length).toBe(running);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Quem precisa de mais (entrada, rolagem, cursor) pede o próximo quadro com `invalidate()` dentro do useFrame.
|
||||
*/
|
||||
export default function FramePulse({
|
||||
active,
|
||||
fps = 30,
|
||||
}: {
|
||||
active: boolean;
|
||||
fps?: number;
|
||||
}) {
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const gap = 1000 / fps - 2;
|
||||
let id = 0;
|
||||
let last = 0;
|
||||
const beat = (now: number) => {
|
||||
id = requestAnimationFrame(beat);
|
||||
if (now - last < gap) return;
|
||||
last = now;
|
||||
invalidate();
|
||||
};
|
||||
id = requestAnimationFrame(beat);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [active, fps, invalidate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { isLowPowerDevice } from "@/lib/device";
|
||||
import HeroBackground from "./HeroBackground";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de HeroBackground: movimento reduzido (nada), aparelho fraco (só as camadas escuras,
|
||||
* sem baixar a cena 3D), aparelho capaz (cena montada depois do efeito) e o HTML do servidor (sem cena).
|
||||
*/
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
jest.mock("@/lib/device", () => ({ isLowPowerDevice: jest.fn() }));
|
||||
jest.mock("next/dynamic", () => ({
|
||||
__esModule: true,
|
||||
default: () =>
|
||||
function MockScene() {
|
||||
return require("react").createElement("canvas", {
|
||||
"data-testid": "hero-scene",
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const lowPower = jest.mocked(isLowPowerDevice);
|
||||
|
||||
beforeEach(() => {
|
||||
reducedMotion = false;
|
||||
lowPower.mockReset();
|
||||
lowPower.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe("HeroBackground", () => {
|
||||
it("path: reduced motion -> renders nothing, even on a capable device", () => {
|
||||
reducedMotion = true;
|
||||
|
||||
const { container } = render(<HeroBackground />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("path: capable device -> the 3D scene plus the three dark layers", () => {
|
||||
const { container, getByTestId } = render(<HeroBackground />);
|
||||
|
||||
expect(getByTestId("hero-scene")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".pointer-events-none")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("path: low-power device -> only the dark layers, the 3D scene is never mounted", () => {
|
||||
lowPower.mockReturnValue(true);
|
||||
|
||||
const { container, queryByTestId } = render(<HeroBackground />);
|
||||
|
||||
expect(queryByTestId("hero-scene")).toBeNull();
|
||||
expect(container.querySelectorAll(".pointer-events-none")).toHaveLength(3);
|
||||
expect(lowPower).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: server HTML -> layers without the scene (capability is only known in the browser)", () => {
|
||||
const html = renderToString(<HeroBackground />);
|
||||
|
||||
expect(html).not.toContain("canvas");
|
||||
expect(html).toContain("pointer-events-none");
|
||||
expect(lowPower).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { isLowPowerDevice } from "@/lib/device";
|
||||
|
||||
const HeroScene = dynamic(() => import("./HeroScene"), { ssr: false });
|
||||
|
||||
/**
|
||||
* Reusable 3D animated background for internal hero sections.
|
||||
* Drop this inside any `relative overflow-hidden` dark section.
|
||||
* A cena WebGL só monta em aparelho que aguenta (sem toque, sem pouca CPU/memória, sem "economizar dados"):
|
||||
* nos outros fica o fundo escuro, sem baixar o Three.js.
|
||||
*/
|
||||
export default function HeroBackground() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [capable, setCapable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCapable(!isLowPowerDevice());
|
||||
}, []);
|
||||
|
||||
if (shouldReduceMotion) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 3D particle scene */}
|
||||
<HeroScene />
|
||||
{capable && <HeroScene />}
|
||||
|
||||
{/* Cinematic radial vignette */}
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.5)_55%,var(--ink)_100%)]" />
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import HeroField from "./HeroField";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de HeroField (complemento de HeroField.test.tsx): a decisão de montar o WebGL
|
||||
* (requestIdleCallback x temporizador, desmontagem antes da hora, falhas ao pedir o contexto WebGL2),
|
||||
* o fundo sem a seção `.hero`, as variáveis de rolagem e a visibilidade (observador, aba oculta, fim do mergulho).
|
||||
*/
|
||||
|
||||
type SceneProps = {
|
||||
visible: boolean;
|
||||
progressRef: { current: number };
|
||||
onReady: () => void;
|
||||
onError: () => void;
|
||||
};
|
||||
const mockScenes: SceneProps[] = [];
|
||||
|
||||
jest.mock("next/dynamic", () => ({
|
||||
__esModule: true,
|
||||
default: () =>
|
||||
function MockScene(props: SceneProps) {
|
||||
mockScenes.push(props);
|
||||
return require("react").createElement("canvas", {
|
||||
"data-testid": "scene",
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
const original = {
|
||||
matchMedia: window.matchMedia,
|
||||
webgl2: win.WebGL2RenderingContext,
|
||||
getContext: HTMLCanvasElement.prototype.getContext,
|
||||
observer: global.IntersectionObserver,
|
||||
idle: win.requestIdleCallback,
|
||||
cancelIdle: win.cancelIdleCallback,
|
||||
};
|
||||
|
||||
function mockMedia(active: string[]) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
function mockNavigator(values: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(values))
|
||||
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||
}
|
||||
|
||||
function setContext(factory: () => unknown) {
|
||||
HTMLCanvasElement.prototype.getContext = factory as never;
|
||||
}
|
||||
|
||||
type Observer = {
|
||||
callback: IntersectionObserverCallback;
|
||||
disconnect: jest.Mock;
|
||||
};
|
||||
let observers: Observer[] = [];
|
||||
|
||||
function Hero({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<section className="hero">
|
||||
<HeroField />
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function setHidden(value: boolean) {
|
||||
Object.defineProperty(document, "hidden", { configurable: true, value });
|
||||
}
|
||||
|
||||
function reportIntersecting(value: boolean) {
|
||||
act(() => {
|
||||
for (const observer of observers)
|
||||
observer.callback(
|
||||
[{ isIntersecting: value } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockScenes.length = 0;
|
||||
observers = [];
|
||||
mockMedia([]);
|
||||
mockNavigator({
|
||||
hardwareConcurrency: 8,
|
||||
deviceMemory: undefined,
|
||||
connection: undefined,
|
||||
});
|
||||
win.WebGL2RenderingContext = function WebGL2RenderingContext() {};
|
||||
setContext(() => ({ getExtension: () => null }));
|
||||
win.requestIdleCallback = undefined;
|
||||
win.cancelIdleCallback = undefined;
|
||||
global.IntersectionObserver = class {
|
||||
private readonly record: Observer;
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.record = { callback, disconnect: jest.fn() };
|
||||
observers.push(this.record);
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {
|
||||
this.record.disconnect();
|
||||
}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
window.matchMedia = original.matchMedia;
|
||||
win.WebGL2RenderingContext = original.webgl2;
|
||||
win.requestIdleCallback = original.idle;
|
||||
win.cancelIdleCallback = original.cancelIdle;
|
||||
HTMLCanvasElement.prototype.getContext = original.getContext;
|
||||
global.IntersectionObserver = original.observer;
|
||||
Reflect.deleteProperty(document, "hidden");
|
||||
});
|
||||
|
||||
describe("HeroField: quando o WebGL monta", () => {
|
||||
it("path: no requestIdleCallback -> the scene mounts after 1.2 s of timer, not before", () => {
|
||||
render(<Hero />);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1199);
|
||||
});
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1);
|
||||
});
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: requestIdleCallback available -> mounts when the browser is idle (2.5 s timeout), not on the timer", () => {
|
||||
let idle: (() => void) | undefined;
|
||||
const request = jest.fn((callback: () => void) => {
|
||||
idle = callback;
|
||||
return 42;
|
||||
});
|
||||
win.requestIdleCallback = request;
|
||||
win.cancelIdleCallback = jest.fn();
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(request).toHaveBeenCalledWith(expect.any(Function), {
|
||||
timeout: 2500,
|
||||
});
|
||||
|
||||
act(() => idle?.());
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: unmounted before the browser got idle -> the idle request is cancelled and no scene mounts", () => {
|
||||
const cancel = jest.fn();
|
||||
win.requestIdleCallback = jest.fn(() => 7);
|
||||
win.cancelIdleCallback = cancel;
|
||||
|
||||
const view = render(<Hero />);
|
||||
view.unmount();
|
||||
|
||||
expect(cancel).toHaveBeenCalledWith(7);
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: unmounted before the fallback timer fires -> nothing mounts afterwards", () => {
|
||||
const view = render(<Hero />);
|
||||
view.unmount();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: WebGL2 constructor exists but creating the context throws -> static background", () => {
|
||||
setContext(() => {
|
||||
throw new Error("sem GPU");
|
||||
});
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: WebGL2 constructor exists but the context is null -> static background", () => {
|
||||
setContext(() => null);
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: the probe context is released right away (WEBGL_lose_context) before the real scene mounts", () => {
|
||||
const loseContext = jest.fn();
|
||||
setContext(() => ({
|
||||
getExtension: (name: string) =>
|
||||
name === "WEBGL_lose_context" ? { loseContext } : null,
|
||||
}));
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(loseContext).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: the lose-context extension is missing -> the probe still succeeds", () => {
|
||||
setContext(() => ({ getExtension: () => null }));
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: low power -> the WebGL probe and the timer are never started", () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
const probe = jest.fn(() => ({ getExtension: () => null }));
|
||||
setContext(probe);
|
||||
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroField: rolagem e visibilidade", () => {
|
||||
function section() {
|
||||
return document.querySelector(".hero") as HTMLElement;
|
||||
}
|
||||
|
||||
function place(top: number, height = 2000) {
|
||||
section().getBoundingClientRect = () =>
|
||||
({ top, height, bottom: top + height }) as DOMRect;
|
||||
}
|
||||
|
||||
function scroll(top: number) {
|
||||
place(top);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
});
|
||||
|
||||
it("path: no .hero ancestor -> no scroll variables and no observer, but the scene still mounts (not visible)", () => {
|
||||
render(<HeroField />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(observers).toHaveLength(0);
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("path: scrolling to the same progress twice -> the CSS variables are written once", () => {
|
||||
render(<Hero />);
|
||||
const setProperty = jest.spyOn(section().style, "setProperty");
|
||||
|
||||
scroll(-600);
|
||||
const afterFirst = setProperty.mock.calls.length;
|
||||
expect(afterFirst).toBeGreaterThan(0);
|
||||
scroll(-600);
|
||||
|
||||
expect(setProperty.mock.calls).toHaveLength(afterFirst);
|
||||
});
|
||||
|
||||
it("path: resize recomputes the progress like a scroll", () => {
|
||||
render(<Hero />);
|
||||
place(-600);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
|
||||
expect(Number(section().style.getPropertyValue("--hero-p"))).toBeCloseTo(
|
||||
0.5,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: scroll above the hero (positive top) -> progress clamps at 0", () => {
|
||||
render(<Hero />);
|
||||
|
||||
scroll(300);
|
||||
|
||||
expect(Number(section().style.getPropertyValue("--hero-p") || 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("path: end of the dive then back up -> drawing stops, then resumes", () => {
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
reportIntersecting(true);
|
||||
|
||||
scroll(-1200);
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
|
||||
scroll(-300);
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the hero leaves the screen -> not visible; returns -> visible", () => {
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
reportIntersecting(true);
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
|
||||
reportIntersecting(false);
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
|
||||
reportIntersecting(true);
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the tab is hidden while the hero is on screen -> not visible; shown again -> visible", () => {
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
reportIntersecting(true);
|
||||
|
||||
setHidden(true);
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
|
||||
setHidden(false);
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the tab shows but the hero is off screen -> still not visible", () => {
|
||||
render(<Hero />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
reportIntersecting(false);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("path: unmount -> listeners removed, observer disconnected, pending frame cancelled", () => {
|
||||
const removeListener = jest.spyOn(window, "removeEventListener");
|
||||
const removeDocListener = jest.spyOn(document, "removeEventListener");
|
||||
const cancelFrame = jest.spyOn(window, "cancelAnimationFrame");
|
||||
const view = render(<Hero />);
|
||||
|
||||
view.unmount();
|
||||
|
||||
const removed = removeListener.mock.calls.map(([type]) => type);
|
||||
expect(removed).toEqual(expect.arrayContaining(["scroll", "resize"]));
|
||||
expect(removeDocListener.mock.calls.map(([type]) => type)).toContain(
|
||||
"visibilitychange",
|
||||
);
|
||||
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(cancelFrame).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import HeroField from "./HeroField";
|
||||
|
||||
type SceneProps = {
|
||||
visible: boolean;
|
||||
progressRef: { current: number };
|
||||
labelsRef?: unknown;
|
||||
onReady: () => void;
|
||||
onError: () => void;
|
||||
};
|
||||
const mockScenes: SceneProps[] = [];
|
||||
|
||||
jest.mock("next/dynamic", () => ({
|
||||
__esModule: true,
|
||||
default: () =>
|
||||
function MockScene(props: SceneProps) {
|
||||
mockScenes.push(props);
|
||||
return require("react").createElement("canvas", {
|
||||
"data-testid": "scene",
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const original = {
|
||||
matchMedia: window.matchMedia,
|
||||
webgl2: (window as unknown as Record<string, unknown>).WebGL2RenderingContext,
|
||||
getContext: HTMLCanvasElement.prototype.getContext,
|
||||
};
|
||||
|
||||
function mockMedia(active: string[]) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
function mockNavigator(values: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(values))
|
||||
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||
}
|
||||
|
||||
function Hero() {
|
||||
return (
|
||||
<section className="hero">
|
||||
<HeroField />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** O navegador ocioso: o Three.js só entra depois do tempo de espera. */
|
||||
function idle() {
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockScenes.length = 0;
|
||||
mockMedia([]);
|
||||
mockNavigator({
|
||||
hardwareConcurrency: 8,
|
||||
deviceMemory: undefined,
|
||||
connection: undefined,
|
||||
});
|
||||
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
||||
function WebGL2RenderingContext() {};
|
||||
HTMLCanvasElement.prototype.getContext = (() => ({
|
||||
getExtension: () => null,
|
||||
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
window.matchMedia = original.matchMedia;
|
||||
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
||||
original.webgl2;
|
||||
HTMLCanvasElement.prototype.getContext = original.getContext;
|
||||
});
|
||||
|
||||
describe("HeroField (ambient background)", () => {
|
||||
it("mounts the WebGL scene only after the page is idle, on a capable desktop", () => {
|
||||
render(<Hero />);
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
idle();
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
// o IntersectionObserver do jest.setup avisa que o hero está à vista: o desenho está liberado
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("is decorative: hidden from assistive technology and with no labels over the 3D", () => {
|
||||
const { container } = render(<Hero />);
|
||||
idle();
|
||||
const field = container.querySelector(".hero-field");
|
||||
expect(field).toHaveAttribute("aria-hidden", "true");
|
||||
expect(container.querySelector(".hero-tag")).toBeNull();
|
||||
for (const label of ["A tela", "As integrações", "Os dados"])
|
||||
expect(screen.queryByText(label, { exact: false })).toBeNull();
|
||||
expect(mockScenes.at(-1)?.labelsRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a touch pointer", () => mockMedia(["(pointer: coarse)"])],
|
||||
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
|
||||
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
||||
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
|
||||
["4 GB of memory", () => mockNavigator({ deviceMemory: 4 })],
|
||||
[
|
||||
"no WebGL2",
|
||||
() => {
|
||||
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
||||
undefined;
|
||||
},
|
||||
],
|
||||
])(
|
||||
"keeps the light static background (no WebGL) with %s",
|
||||
(_label, arrange) => {
|
||||
arrange();
|
||||
const { container } = render(<Hero />);
|
||||
idle();
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
||||
"data-ready",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("follows the hero scroll with --hero-p and --hero-open, and stops drawing once the dive is over", () => {
|
||||
const { container } = render(<Hero />);
|
||||
idle();
|
||||
const section = container.querySelector(".hero") as HTMLElement;
|
||||
let top = 0;
|
||||
section.getBoundingClientRect = () =>
|
||||
({ top, height: 2000, bottom: top + 2000 }) as DOMRect;
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
const scroll = (to: number) => {
|
||||
top = -to;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
};
|
||||
|
||||
scroll(0);
|
||||
expect(mockScenes.at(-1)?.progressRef.current).toBe(0);
|
||||
|
||||
scroll(600); // meio do mergulho (1200 px de trilho)
|
||||
expect(Number(section.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
|
||||
// a máscara já se abriu por completo: o fundo aparece inteiro
|
||||
expect(Number(section.style.getPropertyValue("--hero-open"))).toBe(1);
|
||||
|
||||
scroll(1200);
|
||||
expect(Number(section.style.getPropertyValue("--hero-p"))).toBe(1);
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("reveals the scene (data-ready) once it has drawn its first frames", () => {
|
||||
const { container } = render(<Hero />);
|
||||
idle();
|
||||
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
||||
"data-ready",
|
||||
);
|
||||
act(() => mockScenes.at(-1)?.onReady());
|
||||
expect(container.querySelector(".hero-field")).toHaveAttribute(
|
||||
"data-ready",
|
||||
);
|
||||
act(() => mockScenes.at(-1)?.onError());
|
||||
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
||||
"data-ready",
|
||||
);
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,19 +2,18 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { isLowPowerDevice } from "@/lib/device";
|
||||
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
||||
|
||||
/** Etiquetas presas às camadas da cena (a cena calcula a posição de cada uma a cada quadro). */
|
||||
const TAGS = [
|
||||
["A tela", "o que sua equipe e seus clientes usam"],
|
||||
["As integrações", "seus sistemas conversando entre si"],
|
||||
["Os dados", "suas informações organizadas"],
|
||||
] as const;
|
||||
|
||||
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const smooth = (a: number, b: number, x: number) => {
|
||||
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
|
||||
function supportsWebGL2() {
|
||||
if (typeof window.WebGL2RenderingContext === "undefined") return false;
|
||||
try {
|
||||
@@ -27,13 +26,15 @@ function supportsWebGL2() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fundo do hero: liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho)
|
||||
* e monta a cena WebGL só quando a página fica ociosa, com WebGL2 e sem preferência por movimento reduzido.
|
||||
* Fundo ambiente do hero. Liga a rolagem do hero às variáveis CSS --hero-p (0 no topo, 1 no fim do mergulho)
|
||||
* e --hero-open (0 em repouso, 1 quando a máscara do globals.css já se abriu para o mergulho), e monta a cena
|
||||
* WebGL só quando a página fica ociosa e o aparelho aguenta: WebGL2, sem toque (pointer: coarse), com mais de
|
||||
* 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido. Nos outros casos fica o fundo
|
||||
* estático leve do CSS (.hero-glow). Sem legendas sobre o 3D: o espaço da direita é das molduras.
|
||||
*/
|
||||
export default function HeroField() {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const labelsRef = useRef<(HTMLElement | null)[]>([]);
|
||||
const [mount, setMount] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
@@ -53,8 +54,12 @@ export default function HeroField() {
|
||||
window.innerHeight * 0.8,
|
||||
);
|
||||
const p = Math.min(1, Math.max(0, -rect.top / span));
|
||||
// depois do mergulho (ou antes dele) a rolagem não muda mais nada: sem reescrever as variáveis CSS
|
||||
if (p === progressRef.current) return;
|
||||
progressRef.current = p;
|
||||
section.style.setProperty("--hero-p", p.toFixed(4));
|
||||
// a máscara só se abre quando o texto e a coluna da direita já quase saíram (o conteúdo some até p ≈ 0,24)
|
||||
section.style.setProperty("--hero-open", smooth(0.16, 0.3, p).toFixed(4));
|
||||
setDone(p >= 0.999);
|
||||
};
|
||||
const queue = () => {
|
||||
@@ -82,7 +87,7 @@ export default function HeroField() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
if (isLowPowerDevice()) return;
|
||||
if (!supportsWebGL2()) return;
|
||||
// Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso.
|
||||
if (typeof window.requestIdleCallback === "function") {
|
||||
@@ -108,26 +113,11 @@ export default function HeroField() {
|
||||
{mount && !failed && (
|
||||
<Scene
|
||||
progressRef={progressRef}
|
||||
labelsRef={labelsRef}
|
||||
visible={visible && !done}
|
||||
onReady={handleReady}
|
||||
onError={handleError}
|
||||
/>
|
||||
)}
|
||||
{mount &&
|
||||
!failed &&
|
||||
TAGS.map(([title, text], index) => (
|
||||
<span
|
||||
key={title}
|
||||
ref={(element) => {
|
||||
labelsRef.current[index] = element;
|
||||
}}
|
||||
className="hero-tag"
|
||||
>
|
||||
<span className="hero-tag-title">{title}</span>
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import * as THREE from "three";
|
||||
import HeroFieldScene 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);
|
||||
* - 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.
|
||||
*/
|
||||
|
||||
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 mockGeometries: THREE.BufferGeometry[] = [];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
class CapturingGeometry extends actual.BufferGeometry {
|
||||
constructor() {
|
||||
super();
|
||||
mockGeometries.push(this as unknown as THREE.BufferGeometry);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
ShaderMaterial: CapturingMaterial,
|
||||
BufferGeometry: CapturingGeometry,
|
||||
};
|
||||
});
|
||||
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;
|
||||
height?: number;
|
||||
/** `null` simula uma ref sem valor (o código trata como 0). */
|
||||
progress?: number | null;
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
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 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));
|
||||
|
||||
// <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 uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms;
|
||||
|
||||
function frame(time: number, delta = 1 / 60) {
|
||||
const invalidate = jest.fn();
|
||||
act(() => {
|
||||
mockState.frame?.({ clock: { elapsedTime: time }, invalidate }, delta);
|
||||
});
|
||||
return invalidate;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
return {
|
||||
...view,
|
||||
camera,
|
||||
domElement,
|
||||
progressRef,
|
||||
onReady,
|
||||
onError,
|
||||
group: real,
|
||||
uniforms,
|
||||
frame,
|
||||
settle,
|
||||
rerender: (isVisible: boolean) => view.rerender(element(isVisible)),
|
||||
};
|
||||
}
|
||||
|
||||
const originalHardware = Object.getOwnPropertyDescriptor(
|
||||
navigator,
|
||||
"hardwareConcurrency",
|
||||
);
|
||||
|
||||
function setCores(value: number | undefined) {
|
||||
Object.defineProperty(navigator, "hardwareConcurrency", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function setWindowWidth(value: number) {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockMaterials.length = 0;
|
||||
mockGeometries.length = 0;
|
||||
mockState.frame = null;
|
||||
mockState.throwOnUseThree = false;
|
||||
setCores(8);
|
||||
setWindowWidth(1200);
|
||||
// as tags do R3F (group, points) são elementos desconhecidos no DOM do jsdom
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
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();
|
||||
});
|
||||
|
||||
it("path: onReady fires on the second frame, once", () => {
|
||||
const scene = setup();
|
||||
|
||||
scene.frame(0);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
scene.frame(0.02);
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
scene.frame(0.04);
|
||||
scene.frame(0.06);
|
||||
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
const expected = 1 - Math.exp(-(1 / 20) * 5);
|
||||
expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9);
|
||||
expect(scene.uniforms().uScroll.value).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("path: no progress available (undefined) -> treated as the top of the page", () => {
|
||||
const scene = setup({ progress: null });
|
||||
|
||||
scene.settle(5);
|
||||
|
||||
expect(scene.uniforms().uScroll.value).toBe(0);
|
||||
});
|
||||
|
||||
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.settle(80);
|
||||
|
||||
expect(scene.uniforms().uIntro.value).toBe(1);
|
||||
});
|
||||
|
||||
it("path: the scene fades out only at the very end of the dive", () => {
|
||||
const scene = setup({ progress: 0 });
|
||||
scene.settle(10);
|
||||
expect(scene.uniforms().uFade.value).toBe(1);
|
||||
|
||||
scene.progressRef.current = 1;
|
||||
scene.settle(120);
|
||||
|
||||
expect(scene.uniforms().uFade.value).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it("path: layers spread apart only while they face the viewer", () => {
|
||||
const scene = setup({ progress: 0 });
|
||||
scene.settle(5);
|
||||
expect(scene.uniforms().uSpread.value).toBeCloseTo(1.05, 6);
|
||||
|
||||
scene.progressRef.current = 0.5;
|
||||
scene.settle(150);
|
||||
|
||||
expect(scene.uniforms().uSpread.value).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: 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);
|
||||
|
||||
expect(scene.group.position.y).toBeCloseTo(-0.5, 9);
|
||||
expect(scene.camera.position.z).toBeCloseTo(9, 9);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(scene.group.position.y).toBeLessThan(-0.5);
|
||||
expect(scene.camera.position.z).toBeGreaterThan(9);
|
||||
});
|
||||
|
||||
it("path: end of the dive -> the group has settled back to the center and the camera went through the layers", () => {
|
||||
const scene = setup({ progress: 1 });
|
||||
|
||||
scene.settle(200);
|
||||
|
||||
expect(scene.group.position.y).toBeCloseTo(0, 6);
|
||||
expect(scene.camera.position.z).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
|
||||
const scene = setup({ progress: 0 });
|
||||
|
||||
scene.frame(0);
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
it("path: camera past every layer -> the focus never goes below 1.5", () => {
|
||||
const scene = setup({ progress: 1 });
|
||||
|
||||
scene.settle(200);
|
||||
|
||||
expect(scene.uniforms().uFocus.value).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: 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);
|
||||
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);
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 120; i++) scene.frame(5.1 + i * 0.01, 1 / 20);
|
||||
|
||||
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);
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 120; i++) scene.frame(9 + i * 0.01, 1 / 20);
|
||||
|
||||
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);
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 120; i++) scene.frame(0.1 + i * 0.001, 1 / 20);
|
||||
|
||||
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);
|
||||
pointerMove("mouse");
|
||||
|
||||
for (let i = 0; i < 200; i++) scene.frame(5.1 + i * 0.005, 1 / 20);
|
||||
|
||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2);
|
||||
});
|
||||
|
||||
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) =>
|
||||
target.set(1, 0, 2)) as never);
|
||||
jest.spyOn(performance, "now").mockReturnValue(5000);
|
||||
pointerMove("mouse");
|
||||
|
||||
scene.frame(5.1, 1 / 20);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
pointerMove("mouse");
|
||||
|
||||
scene.frame(5.1, 1 / 20);
|
||||
|
||||
expect(scene.uniforms().uMouse.value.x).toBe(0);
|
||||
expect(scene.uniforms().uMouse.value.y).toBe(0);
|
||||
});
|
||||
|
||||
it("path: idle mouse -> the ghost cursor wanders on its own", () => {
|
||||
const scene = setup();
|
||||
|
||||
scene.frame(10, 1 / 20);
|
||||
|
||||
const { x, y } = scene.uniforms().uMouse.value;
|
||||
expect(Math.hypot(x, y)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: 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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
pointerMove("mouse");
|
||||
|
||||
const invalidate = scene.frame(600.1, 1 / 60);
|
||||
|
||||
expect(invalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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);
|
||||
pointerMove("mouse");
|
||||
|
||||
const invalidate = scene.frame(600.1, 1 / 60);
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
scene.progressRef.current = 0.5;
|
||||
|
||||
const invalidate = scene.frame(500, 1 / 60);
|
||||
|
||||
expect(invalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: becoming visible later -> the new callback sees the new flag", () => {
|
||||
const scene = setup({ visible: false });
|
||||
scene.settle(200);
|
||||
jest.spyOn(performance, "now").mockReturnValue(600_000);
|
||||
pointerMove("mouse");
|
||||
expect(scene.frame(600.1, 1 / 60)).not.toHaveBeenCalled();
|
||||
|
||||
scene.rerender(true);
|
||||
|
||||
expect(scene.frame(600.2, 1 / 60)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field: densidade, contexto WebGL 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);
|
||||
setCores(8);
|
||||
setup();
|
||||
const dense = pointCount();
|
||||
|
||||
mockGeometries.length = 0;
|
||||
setWindowWidth(600);
|
||||
setup();
|
||||
|
||||
expect(dense).toBeGreaterThan(pointCount());
|
||||
});
|
||||
|
||||
it("path: wide window but 2 cores -> the light field", () => {
|
||||
setWindowWidth(1200);
|
||||
setCores(2);
|
||||
setup();
|
||||
const light = pointCount();
|
||||
|
||||
mockGeometries.length = 0;
|
||||
setCores(8);
|
||||
setup();
|
||||
|
||||
expect(pointCount()).toBeGreaterThan(light);
|
||||
});
|
||||
|
||||
it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => {
|
||||
setWindowWidth(1200);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const [label, cores] of [
|
||||
["dense", 8],
|
||||
["light", 2],
|
||||
["unknown", undefined],
|
||||
] as const) {
|
||||
mockGeometries.length = 0;
|
||||
setCores(cores);
|
||||
setup();
|
||||
counts[label] = pointCount();
|
||||
}
|
||||
|
||||
expect(Math.abs(counts.unknown - counts.dense)).toBeLessThan(
|
||||
Math.abs(counts.unknown - counts.light),
|
||||
);
|
||||
});
|
||||
|
||||
it("path: the WebGL context is lost -> the default recovery is cancelled and the page is told", () => {
|
||||
const scene = setup();
|
||||
const event = new Event("webglcontextlost", { cancelable: true });
|
||||
|
||||
act(() => {
|
||||
scene.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", () => {
|
||||
const scene = setup();
|
||||
const geometry = mockGeometries[mockGeometries.length - 1];
|
||||
const material = mockMaterials[mockMaterials.length - 1];
|
||||
const disposeGeometry = jest.spyOn(geometry, "dispose");
|
||||
const disposeMaterial = jest.spyOn(material, "dispose");
|
||||
const event = new Event("webglcontextlost", { cancelable: true });
|
||||
|
||||
scene.unmount();
|
||||
scene.domElement.dispatchEvent(event);
|
||||
|
||||
expect(disposeGeometry).toHaveBeenCalledTimes(1);
|
||||
expect(disposeMaterial).toHaveBeenCalledTimes(1);
|
||||
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", () => {
|
||||
const scene = setup();
|
||||
|
||||
expect(scene.container.querySelector("group")).not.toBeNull();
|
||||
expect(scene.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import HeroFieldScene from "./HeroFieldScene";
|
||||
|
||||
type CanvasProps = {
|
||||
dpr?: [number, number];
|
||||
frameloop?: string;
|
||||
gl?: Record<string, unknown>;
|
||||
};
|
||||
const mockCanvas: CanvasProps[] = [];
|
||||
|
||||
jest.mock("@react-three/fiber", () => ({
|
||||
Canvas: (props: CanvasProps) => {
|
||||
mockCanvas.push(props);
|
||||
return null;
|
||||
},
|
||||
useFrame: jest.fn(),
|
||||
useThree: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("HeroFieldScene (ambient 3D)", () => {
|
||||
beforeEach(() => {
|
||||
mockCanvas.length = 0;
|
||||
});
|
||||
|
||||
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={() => {}}
|
||||
/>,
|
||||
);
|
||||
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 });
|
||||
});
|
||||
|
||||
it("keeps the same on-demand loop when the hero is off screen (the pulse is what stops)", () => {
|
||||
render(
|
||||
<HeroFieldScene
|
||||
progressRef={{ current: 0 }}
|
||||
visible={false}
|
||||
onReady={() => {}}
|
||||
onError={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(mockCanvas.at(-1)?.frameloop).toBe("demand");
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,10 @@ import {
|
||||
useRef,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
import FramePulse from "./FramePulse";
|
||||
|
||||
export type HeroFieldSceneProps = {
|
||||
progressRef: RefObject<number>;
|
||||
/** etiquetas em HTML presas às camadas (tela, integrações, dados), posicionadas a cada quadro */
|
||||
labelsRef: RefObject<(HTMLElement | null)[]>;
|
||||
visible: boolean;
|
||||
onReady: () => void;
|
||||
onError: () => void;
|
||||
@@ -26,8 +25,12 @@ export type HeroFieldSceneProps = {
|
||||
* ligados a uma central, com mensagens correndo pelas linhas); embaixo, os DADOS (uma tabela).
|
||||
* Colunas de partículas sobem da tabela até a tela: a informação chegando a quem usa.
|
||||
* Na entrada, os pontos saem de uma nuvem e se organizam; ao rolar, as camadas se afastam, ficam de
|
||||
* frente e a câmera atravessa uma a uma (as legendas de HeroField explicam cada camada nessa hora).
|
||||
* frente e a câmera atravessa uma a uma (as legendas de Hero explicam cada camada nessa hora).
|
||||
* 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, não desenha nada.
|
||||
*/
|
||||
|
||||
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
|
||||
@@ -389,16 +392,9 @@ function cameraFrames(rest: number, spread: number, frame: number) {
|
||||
] as const;
|
||||
}
|
||||
|
||||
// ponto de cada camada onde a etiqueta se prende (canto direito da frente), na ordem tela, integrações, dados
|
||||
const LABEL_ANCHORS: [number, Layer, number][] = [
|
||||
[2.1, 1, 1.4],
|
||||
[1.95, 0, 0.9],
|
||||
[2, -1, 1.3],
|
||||
];
|
||||
|
||||
function Field({
|
||||
progressRef,
|
||||
labelsRef,
|
||||
visible,
|
||||
onReady,
|
||||
onError,
|
||||
}: HeroFieldSceneProps) {
|
||||
@@ -444,7 +440,6 @@ function Field({
|
||||
ray: new THREE.Raycaster(),
|
||||
plane: new THREE.Plane(),
|
||||
hit: new THREE.Vector3(),
|
||||
anchor: new THREE.Vector3(),
|
||||
zero: new THREE.Vector2(),
|
||||
target: new THREE.Vector2(),
|
||||
}),
|
||||
@@ -521,12 +516,9 @@ function Field({
|
||||
0,
|
||||
);
|
||||
const settle = 1 - smooth(0, 0.28, p);
|
||||
// em repouso: à direita do texto na tela larga; no celular, mais abaixo, atrás dos botões
|
||||
g.position.set(
|
||||
wide ? 1.7 * settle : 0,
|
||||
(wide ? -0.1 : -rest * tanHalf * 0.38) * settle,
|
||||
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
|
||||
g.position.set(0, (wide ? -0.5 : -rest * tanHalf * 0.38) * settle, 0);
|
||||
camera.position.z = keyframes(cameraFrames(rest, spreadMax, frameDist), p);
|
||||
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
|
||||
const spread = u.uSpread.value;
|
||||
@@ -561,23 +553,19 @@ function Field({
|
||||
u.uMouseAmt.value +=
|
||||
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
||||
|
||||
// etiquetas: projeta o canto de cada camada na tela e as esconde quando o mergulho começa
|
||||
const labels = labelsRef.current ?? [];
|
||||
const shown = smooth(0.75, 1, u.uIntro.value) * (1 - smooth(0.04, 0.16, p));
|
||||
LABEL_ANCHORS.forEach(([x, layer, z], index) => {
|
||||
const label = labels[index];
|
||||
if (!label) return;
|
||||
helpers.anchor
|
||||
.set(x, layer * u.uSpread.value, z)
|
||||
.applyMatrix4(g.matrixWorld)
|
||||
.project(camera);
|
||||
const px = ((helpers.anchor.x + 1) / 2) * size.width;
|
||||
const py = ((1 - helpers.anchor.y) / 2) * size.height;
|
||||
label.style.transform = `translate3d(${px.toFixed(1)}px, ${py.toFixed(1)}px, 0)`;
|
||||
label.style.opacity = shown.toFixed(3);
|
||||
});
|
||||
|
||||
if (++s.frames === 2) onReady();
|
||||
|
||||
// sob demanda: o próximo quadro vem do FramePulse (30 por segundo); 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, com tudo já apagado); o cursor só conta com o hero à vista.
|
||||
const settling =
|
||||
u.uIntro.value < 1 ||
|
||||
Math.abs((progressRef.current ?? 0) - s.progress) > 4e-4;
|
||||
const interacting =
|
||||
mouseActive === 1 ||
|
||||
Math.abs(targetAmt - u.uMouseAmt.value) > 0.01 ||
|
||||
s.frames < 3;
|
||||
if (settling || (visible && interacting)) frame.invalidate();
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -610,14 +598,15 @@ export default function HeroFieldScene(props: HeroFieldSceneProps) {
|
||||
aria-hidden="true"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }}
|
||||
dpr={[1, 1.75]}
|
||||
frameloop={props.visible ? "always" : "never"}
|
||||
dpr={[1, 1.5]}
|
||||
frameloop="demand"
|
||||
gl={{
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
>
|
||||
<FramePulse active={props.visible} />
|
||||
<Field {...props} />
|
||||
</Canvas>
|
||||
</SceneBoundary>
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import HeroScene from "./HeroScene";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de HeroScene (fundo 3D das páginas internas): quando o desenho está ligado
|
||||
* (visível E à vista), a câmera que segue o mouse (CameraRig) e o relógio do chão em grade (GridFloor).
|
||||
* O @react-three/fiber é trocado por um dublê que renderiza os filhos e entrega os callbacks do useFrame.
|
||||
*/
|
||||
|
||||
const mockFrames: Array<(state: unknown, delta: number) => void> = [];
|
||||
const mockCamera = {
|
||||
position: { x: 0, y: 0.5, z: 8 },
|
||||
lookAt: jest.fn(),
|
||||
};
|
||||
const mockPulse = jest.fn();
|
||||
|
||||
jest.mock("@react-three/fiber", () => ({
|
||||
Canvas: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
useFrame: (callback: (state: unknown, delta: number) => void) => {
|
||||
mockFrames.push(callback);
|
||||
},
|
||||
useThree: () => ({ camera: mockCamera }),
|
||||
}));
|
||||
jest.mock("./FramePulse", () => ({
|
||||
__esModule: true,
|
||||
default: (props: { active: boolean }) => {
|
||||
mockPulse(props.active);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
type Observer = {
|
||||
callback: IntersectionObserverCallback;
|
||||
disconnect: jest.Mock;
|
||||
};
|
||||
let observers: Observer[] = [];
|
||||
const OriginalObserver = global.IntersectionObserver;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFrames.length = 0;
|
||||
mockPulse.mockClear();
|
||||
mockCamera.position = { x: 0, y: 0.5, z: 8 };
|
||||
mockCamera.lookAt.mockClear();
|
||||
observers = [];
|
||||
global.IntersectionObserver = class {
|
||||
private readonly record: Observer;
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.record = { callback, disconnect: jest.fn() };
|
||||
observers.push(this.record);
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {
|
||||
this.record.disconnect();
|
||||
}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
// as tags do R3F (fog, mesh, ...) são elementos desconhecidos no DOM do jsdom
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.IntersectionObserver = OriginalObserver;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const lastPulse = () =>
|
||||
mockPulse.mock.calls[mockPulse.mock.calls.length - 1][0];
|
||||
|
||||
function report(isIntersecting: boolean) {
|
||||
act(() => {
|
||||
observers[0].callback(
|
||||
[{ isIntersecting } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("HeroScene: quando desenha", () => {
|
||||
it("path: visible by prop and in view (default) -> the pulse is on", () => {
|
||||
render(<HeroScene />);
|
||||
|
||||
expect(lastPulse()).toBe(true);
|
||||
});
|
||||
|
||||
it("path: isVisible=false -> the pulse is off even though the scene is in view", () => {
|
||||
render(<HeroScene isVisible={false} />);
|
||||
|
||||
expect(lastPulse()).toBe(false);
|
||||
});
|
||||
|
||||
it("path: scrolled out of view -> off; back in view -> on", () => {
|
||||
render(<HeroScene />);
|
||||
|
||||
report(false);
|
||||
expect(lastPulse()).toBe(false);
|
||||
|
||||
report(true);
|
||||
expect(lastPulse()).toBe(true);
|
||||
});
|
||||
|
||||
it("path: out of view and isVisible=false -> off", () => {
|
||||
render(<HeroScene isVisible={false} />);
|
||||
|
||||
report(false);
|
||||
|
||||
expect(lastPulse()).toBe(false);
|
||||
});
|
||||
|
||||
it("path: unmount -> the observer is disconnected", () => {
|
||||
const view = render(<HeroScene />);
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroScene: câmera que segue o mouse", () => {
|
||||
// a cada renderização CameraRig e GridFloor chamam useFrame, nessa ordem: valem os dois últimos
|
||||
const rigFrame = () => mockFrames[mockFrames.length - 2];
|
||||
|
||||
it("path: mouse at the center -> the camera eases toward the resting height and looks at the origin", () => {
|
||||
render(<HeroScene />);
|
||||
|
||||
act(() => {
|
||||
rigFrame()({}, 0.016);
|
||||
});
|
||||
|
||||
expect(mockCamera.position.x).toBeCloseTo(0, 9);
|
||||
expect(mockCamera.position.y).toBeCloseTo(0.5, 9);
|
||||
expect(mockCamera.lookAt).toHaveBeenCalledWith(0, 0, 0);
|
||||
});
|
||||
|
||||
it("path: mouse moved to the top-right corner -> the camera drifts right and up, a little per frame", () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
render(<HeroScene />);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MouseEvent("mousemove", { clientX: 1000, clientY: 0 }),
|
||||
);
|
||||
rigFrame()({}, 0.016);
|
||||
});
|
||||
|
||||
// alvo x = 1 * 0.5; alvo y = 1 * 0.3 + 0.5; passo de 2% por quadro
|
||||
expect(mockCamera.position.x).toBeCloseTo(0.5 * 0.02, 9);
|
||||
expect(mockCamera.position.y).toBeCloseTo(0.5 + (0.8 - 0.5) * 0.02, 9);
|
||||
});
|
||||
|
||||
it("path: unmount -> the mouse listener is removed", () => {
|
||||
const remove = jest.spyOn(window, "removeEventListener");
|
||||
const view = render(<HeroScene />);
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(remove.mock.calls.map(([type]) => type)).toContain("mousemove");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HeroScene: relógio do chão em grade", () => {
|
||||
const gridFrame = () => mockFrames[mockFrames.length - 1];
|
||||
|
||||
function attachMaterial(material: unknown) {
|
||||
const mesh = document.querySelector("mesh") as HTMLElement & {
|
||||
material?: unknown;
|
||||
};
|
||||
mesh.material = material;
|
||||
}
|
||||
|
||||
it("path: the mesh has shader uniforms -> uTime follows the clock", () => {
|
||||
render(<HeroScene />);
|
||||
const uniforms = { uTime: { value: 0 } };
|
||||
attachMaterial({ uniforms });
|
||||
|
||||
gridFrame()({ clock: { elapsedTime: 12.5 } }, 0.016);
|
||||
|
||||
expect(uniforms.uTime.value).toBe(12.5);
|
||||
});
|
||||
|
||||
it("path: the material has no uniforms yet -> nothing happens", () => {
|
||||
render(<HeroScene />);
|
||||
attachMaterial({});
|
||||
|
||||
expect(() =>
|
||||
gridFrame()({ clock: { elapsedTime: 3 } }, 0.016),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("path: the mesh is gone (unmounted) -> the frame callback does nothing", () => {
|
||||
const view = render(<HeroScene />);
|
||||
const frame = gridFrame();
|
||||
view.unmount();
|
||||
|
||||
expect(() => frame({ clock: { elapsedTime: 3 } }, 0.016)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import FramePulse from "./FramePulse";
|
||||
|
||||
function GridFloor() {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
@@ -95,20 +96,36 @@ function CameraRig() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fundo ambiente das páginas internas: desenha sob demanda, a 30 quadros por segundo e só à vista. */
|
||||
export default function HeroScene({
|
||||
isVisible = true,
|
||||
}: {
|
||||
isVisible?: boolean;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [inView, setInView] = useState(true);
|
||||
|
||||
// fora da tela não desenha nada
|
||||
useEffect(() => {
|
||||
const element = rootRef.current;
|
||||
if (!element) return;
|
||||
const observer = new IntersectionObserver(([entry]) =>
|
||||
setInView(entry.isIntersecting),
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="absolute inset-0 z-0"
|
||||
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
|
||||
>
|
||||
<Canvas
|
||||
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
|
||||
dpr={[1, 1.5]}
|
||||
frameloop={isVisible ? "always" : "never"}
|
||||
frameloop="demand"
|
||||
gl={{
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
@@ -133,6 +150,7 @@ export default function HeroScene({
|
||||
/>
|
||||
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
|
||||
|
||||
<FramePulse active={isVisible && inView} />
|
||||
<CameraRig />
|
||||
<GridFloor />
|
||||
</Canvas>
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { useRef } from "react";
|
||||
import { IDLE_SECONDS, useCinematicScroll } from "./useCinematicScroll";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de useCinematicScroll (complemento de useCinematicScroll.test.tsx):
|
||||
* - onClick (âncoras): cada guarda que devolve o clique ao navegador, alvo dentro de uma cena x a própria cena
|
||||
* x fora de qualquer cena, desconto do cabeçalho, pushState só quando a âncora muda, foco no alvo;
|
||||
* - salto direto (acima de 3 telas) x rolagem de até 0,8 s, nos limites exatos;
|
||||
* - criação do Lenis (opções, ligação com o ScrollTrigger), relógio que dorme e acorda, desmontagem antes das
|
||||
* importações terminarem, ref vazia, e o ResizeObserver que remede as passagens com atraso.
|
||||
* GSAP, ScrollTrigger e Lenis são trocados por fakes que registram o que recebem.
|
||||
*/
|
||||
|
||||
type Call = unknown[];
|
||||
const mockTimelines: Array<{ calls: Call[] }> = [];
|
||||
const mockTicker = {
|
||||
add: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
lagSmoothing: jest.fn(),
|
||||
time: 0,
|
||||
};
|
||||
const mockLenis: MockLenis[] = [];
|
||||
|
||||
class MockLenis {
|
||||
isScrolling: false | "smooth" = false;
|
||||
on = jest.fn();
|
||||
raf = jest.fn();
|
||||
scrollTo = jest.fn();
|
||||
destroy = jest.fn();
|
||||
constructor(public options: unknown) {
|
||||
mockLenis.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock("gsap", () => ({
|
||||
gsap: {
|
||||
registerPlugin: jest.fn(),
|
||||
ticker: mockTicker,
|
||||
context: (fn: () => void) => {
|
||||
fn();
|
||||
return { revert: jest.fn() };
|
||||
},
|
||||
timeline: () => {
|
||||
const record = { calls: [] as Call[] };
|
||||
mockTimelines.push(record);
|
||||
const chain = {
|
||||
fromTo: (...args: Call) => {
|
||||
record.calls.push(args);
|
||||
return chain;
|
||||
},
|
||||
};
|
||||
return chain;
|
||||
},
|
||||
},
|
||||
}));
|
||||
jest.mock("gsap/ScrollTrigger", () => ({
|
||||
ScrollTrigger: { update: jest.fn(), refresh: jest.fn() },
|
||||
}));
|
||||
jest.mock("lenis", () => ({
|
||||
__esModule: true,
|
||||
get default() {
|
||||
return MockLenis;
|
||||
},
|
||||
}));
|
||||
|
||||
const TOPS: Record<string, number> = {
|
||||
home: 0,
|
||||
projects: 1800,
|
||||
"projects-title": 1900,
|
||||
edge: 2400,
|
||||
contact: 25000,
|
||||
"footer-note": 26000,
|
||||
};
|
||||
|
||||
function Home({ attach = true }: { attach?: boolean }) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
useCinematicScroll(ref);
|
||||
return (
|
||||
<>
|
||||
<main ref={attach ? ref : undefined}>
|
||||
<section id="home" data-top={TOPS.home}>
|
||||
<a href="/#contact">contato</a>
|
||||
<a href="/#projects">projetos</a>
|
||||
<a href="/#projects-title">titulo dos projetos</a>
|
||||
<a href="/#edge">limite</a>
|
||||
<a href="/#footer-note">nota fora da cena</a>
|
||||
<a href="/#nao-existe">fantasma</a>
|
||||
<a href="/#%E0%A4%A">malformado</a>
|
||||
<a href="/outra#contact">outra pagina</a>
|
||||
<a href="https://example.com/#contact">outro site</a>
|
||||
<a href="/">sem ancora</a>
|
||||
<a href="/#">ancora vazia</a>
|
||||
<a href="/#contact" target="_blank" rel="noopener">
|
||||
nova aba
|
||||
</a>
|
||||
<a href="/#contact" target="_self">
|
||||
mesma aba
|
||||
</a>
|
||||
<svg aria-hidden="true">
|
||||
<a href="/#contact">
|
||||
<text>svg</text>
|
||||
</a>
|
||||
</svg>
|
||||
<p id="plain">texto</p>
|
||||
</section>
|
||||
<section id="projects" data-top={TOPS.projects}>
|
||||
<h2 id="projects-title" data-top={TOPS["projects-title"]}>
|
||||
Projetos
|
||||
</h2>
|
||||
</section>
|
||||
<section id="edge" data-top={TOPS.edge}>
|
||||
<h2>Limite</h2>
|
||||
</section>
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: alvo de âncora que já traz tabindex, para testar que ele é mantido */}
|
||||
<section id="contact" data-top={TOPS.contact} tabIndex={0}>
|
||||
<h2>Contato</h2>
|
||||
</section>
|
||||
</main>
|
||||
<p id="footer-note" data-top={TOPS["footer-note"]}>
|
||||
Nota
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const OriginalResizeObserver = global.ResizeObserver;
|
||||
const originalOffsetTop = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetTop",
|
||||
);
|
||||
|
||||
function mockMedia(active: string[]) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
async function flushImports() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function mount(props: { attach?: boolean } = {}) {
|
||||
const view = render(<Home {...props} />);
|
||||
await flushImports();
|
||||
return view;
|
||||
}
|
||||
|
||||
function link(text: string) {
|
||||
const found = [...document.querySelectorAll("a, svg a")].find(
|
||||
(element) => element.textContent === text,
|
||||
);
|
||||
if (!found) throw new Error(`Missing link ${text}`);
|
||||
return found as Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispara um clique e diz se o hook cancelou a ação padrão. A ação padrão do navegador (navegar) é cancelada
|
||||
* por um ouvinte na fase de bolha, depois de o hook ter decidido, para o jsdom não navegar de verdade.
|
||||
*/
|
||||
function click(
|
||||
target: Element | Document,
|
||||
init: MouseEventInit = {},
|
||||
): { prevented: boolean } {
|
||||
const event = new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
...init,
|
||||
});
|
||||
let prevented = false;
|
||||
const observe = (clickEvent: Event) => {
|
||||
prevented = clickEvent.defaultPrevented;
|
||||
clickEvent.preventDefault();
|
||||
};
|
||||
window.addEventListener("click", observe);
|
||||
try {
|
||||
act(() => {
|
||||
target.dispatchEvent(event);
|
||||
});
|
||||
} finally {
|
||||
window.removeEventListener("click", observe);
|
||||
}
|
||||
return { prevented };
|
||||
}
|
||||
|
||||
function mockHeaderOffset(px: string) {
|
||||
jest
|
||||
.spyOn(window, "getComputedStyle")
|
||||
.mockReturnValue({ scrollPaddingTop: px } as CSSStyleDeclaration);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return Number(this.dataset.top ?? 0);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalOffsetTop)
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"offsetTop",
|
||||
originalOffsetTop,
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockTimelines.length = 0;
|
||||
mockLenis.length = 0;
|
||||
mockTicker.add.mockClear();
|
||||
mockTicker.remove.mockClear();
|
||||
mockTicker.lagSmoothing.mockClear();
|
||||
mockTicker.time = 0;
|
||||
jest.mocked(ScrollTrigger.update).mockClear();
|
||||
jest.mocked(ScrollTrigger.refresh).mockClear();
|
||||
mockMedia([]);
|
||||
window.history.replaceState(null, "", "/");
|
||||
jest.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
global.ResizeObserver = OriginalResizeObserver;
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: cliques que voltam ao navegador", () => {
|
||||
it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)(
|
||||
"path: click with %s held -> left alone (new tab / selection behavior)",
|
||||
async (modifier) => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("contato"), { [modifier]: true });
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1, 2])(
|
||||
"path: click with mouse button %i -> left alone",
|
||||
async (button) => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("contato"), { button });
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("path: another handler already cancelled the click -> not handled again", async () => {
|
||||
await mount();
|
||||
const cancel = (event: Event) => event.preventDefault();
|
||||
window.addEventListener("click", cancel, true);
|
||||
try {
|
||||
click(link("contato"));
|
||||
} finally {
|
||||
window.removeEventListener("click", cancel, true);
|
||||
}
|
||||
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: click on text that is not inside a link -> ignored", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(document.getElementById("plain") as Element);
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: the click target is the document itself (not an element) -> ignored", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(document);
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: the closest link is an SVG anchor -> ignored", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(
|
||||
document.querySelector("svg a text") as Element,
|
||||
);
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: link opens in a new tab -> ignored", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("nova aba"));
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: link with target=_self -> handled like a plain link", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("mesma aba"));
|
||||
|
||||
expect(prevented).toBe(true);
|
||||
expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["to another site", "outro site"],
|
||||
["to another page of this site", "outra pagina"],
|
||||
["without an anchor", "sem ancora"],
|
||||
["with an empty anchor (#)", "ancora vazia"],
|
||||
])("path: link %s -> ignored", async (_label, text) => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link(text));
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: anchor with broken percent-encoding -> no error, left to the browser", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("malformado"));
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: anchor to an id that does not exist -> left to the browser", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("fantasma"));
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: para onde a página vai", () => {
|
||||
it("path: target inside a scene -> goes to the target minus the fixed header, never above the scene top", async () => {
|
||||
await mount();
|
||||
mockHeaderOffset("80px");
|
||||
|
||||
click(link("titulo dos projetos"));
|
||||
|
||||
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1900 - 80);
|
||||
});
|
||||
|
||||
it("path: header taller than the gap to the scene top -> clamped to the scene top", async () => {
|
||||
await mount();
|
||||
mockHeaderOffset("300px");
|
||||
|
||||
click(link("titulo dos projetos"));
|
||||
|
||||
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1800);
|
||||
});
|
||||
|
||||
it("path: target is the whole scene -> the scene top, ignoring the header offset", async () => {
|
||||
await mount();
|
||||
mockHeaderOffset("80px");
|
||||
|
||||
click(link("projetos"));
|
||||
|
||||
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1800);
|
||||
});
|
||||
|
||||
it("path: target outside every scene -> measured from the page top, minus the header", async () => {
|
||||
await mount();
|
||||
mockHeaderOffset("100px");
|
||||
|
||||
const { prevented } = click(link("nota fora da cena"));
|
||||
|
||||
expect(prevented).toBe(true);
|
||||
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(26000 - 100);
|
||||
});
|
||||
|
||||
it("path: no readable header offset -> treated as zero", async () => {
|
||||
await mount();
|
||||
mockHeaderOffset("");
|
||||
|
||||
click(link("titulo dos projetos"));
|
||||
|
||||
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1900);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: histórico e foco", () => {
|
||||
it("path: a new anchor -> pushes it to the history and cancels the default jump", async () => {
|
||||
await mount();
|
||||
const push = jest.spyOn(window.history, "pushState");
|
||||
|
||||
const { prevented } = click(link("projetos"));
|
||||
|
||||
expect(prevented).toBe(true);
|
||||
expect(push).toHaveBeenCalledWith(null, "", "#projects");
|
||||
expect(window.location.hash).toBe("#projects");
|
||||
});
|
||||
|
||||
it("path: the anchor is already the current one -> the history is not touched, the page still scrolls", async () => {
|
||||
window.history.replaceState(null, "", "/#projects");
|
||||
await mount();
|
||||
const push = jest.spyOn(window.history, "pushState");
|
||||
|
||||
click(link("projetos"));
|
||||
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: destination without tabindex -> gets tabindex -1 and the focus (no scroll jump)", async () => {
|
||||
await mount();
|
||||
const focus = jest.spyOn(HTMLElement.prototype, "focus");
|
||||
const target = document.getElementById("projects-title") as HTMLElement;
|
||||
expect(target.hasAttribute("tabindex")).toBe(false);
|
||||
|
||||
click(link("titulo dos projetos"));
|
||||
|
||||
expect(target.getAttribute("tabindex")).toBe("-1");
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
expect(document.activeElement).toBe(target);
|
||||
});
|
||||
|
||||
it("path: destination that already has a tabindex -> kept as it was", async () => {
|
||||
await mount();
|
||||
const target = document.getElementById("contact") as HTMLElement;
|
||||
expect(target.getAttribute("tabindex")).toBe("0");
|
||||
|
||||
click(link("contato"));
|
||||
|
||||
expect(target.getAttribute("tabindex")).toBe("0");
|
||||
expect(document.activeElement).toBe(target);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: salto direto x rolagem curta", () => {
|
||||
it("path: anchor exactly 3 screens away -> still animated, capped at 0.8 s", async () => {
|
||||
await mount();
|
||||
|
||||
click(link("limite")); // 2400 px = 3 telas de 800 px
|
||||
|
||||
const [, options] = mockLenis[0].scrollTo.mock.calls[0];
|
||||
expect(options.immediate).toBe(false);
|
||||
expect(options.duration).toBe(0.8);
|
||||
});
|
||||
|
||||
it("path: anchor just beyond 3 screens -> instant jump with zero duration", async () => {
|
||||
await mount();
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 799,
|
||||
});
|
||||
|
||||
click(link("limite")); // 2400 px passam de 3 x 799
|
||||
|
||||
const [, options] = mockLenis[0].scrollTo.mock.calls[0];
|
||||
expect(options.immediate).toBe(true);
|
||||
expect(options.duration).toBe(0);
|
||||
});
|
||||
|
||||
it("path: anchor already at the current position -> a minimal animation (0.3 s), not a jump", async () => {
|
||||
Object.defineProperty(window, "scrollY", {
|
||||
configurable: true,
|
||||
value: 1800,
|
||||
});
|
||||
await mount();
|
||||
|
||||
click(link("projetos"));
|
||||
|
||||
const [top, options] = mockLenis[0].scrollTo.mock.calls[0];
|
||||
expect(top).toBe(1800);
|
||||
expect(options.immediate).toBe(false);
|
||||
expect(options.duration).toBeCloseTo(0.3, 9);
|
||||
});
|
||||
|
||||
it("path: scrolled far down, a near anchor above -> distance counts in absolute value", async () => {
|
||||
Object.defineProperty(window, "scrollY", {
|
||||
configurable: true,
|
||||
value: 2400,
|
||||
});
|
||||
await mount();
|
||||
|
||||
click(link("projetos")); // 600 px acima
|
||||
|
||||
const [, options] = mockLenis[0].scrollTo.mock.calls[0];
|
||||
expect(options.immediate).toBe(false);
|
||||
expect(options.duration).toBeCloseTo(0.3 + (600 / 800) * 0.2, 9);
|
||||
});
|
||||
|
||||
it("path: touch screen (no Lenis) -> the native scroll is used: smooth when near, instant when far", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
await mount();
|
||||
|
||||
click(link("projetos"));
|
||||
click(link("contato"));
|
||||
|
||||
expect(window.scrollTo).toHaveBeenNthCalledWith(1, {
|
||||
top: 1800,
|
||||
behavior: "smooth",
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenNthCalledWith(2, {
|
||||
top: 25000,
|
||||
behavior: "instant",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: Lenis e o relógio do GSAP", () => {
|
||||
it("path: mouse -> Lenis gets the smoothing options and drives ScrollTrigger; the GSAP lag smoothing is turned off", async () => {
|
||||
await mount();
|
||||
|
||||
expect(mockLenis).toHaveLength(1);
|
||||
expect(mockLenis[0].options).toEqual({
|
||||
lerp: 0.075,
|
||||
wheelMultiplier: 0.85,
|
||||
});
|
||||
expect(mockLenis[0].on).toHaveBeenCalledWith(
|
||||
"scroll",
|
||||
ScrollTrigger.update,
|
||||
);
|
||||
expect(mockTicker.lagSmoothing).toHaveBeenCalledWith(0);
|
||||
expect(mockTicker.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: touch -> no Lenis, no ticker, no lag-smoothing change and the passages are still created", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
await mount();
|
||||
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
expect(mockTicker.lagSmoothing).not.toHaveBeenCalled();
|
||||
expect(mockTimelines.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: touch -> window scroll events do not touch the clock", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
await mount();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
});
|
||||
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: the clock went to sleep, then a native scroll event -> wakes it", async () => {
|
||||
await mount();
|
||||
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||
tick(IDLE_SECONDS + 1); // parado há mais que o tempo de espera
|
||||
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
|
||||
mockTicker.time = 5;
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(mockTicker.add).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("path: the clock went to sleep, then an anchor click -> wakes it before scrolling", async () => {
|
||||
await mount();
|
||||
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||
tick(IDLE_SECONDS + 1);
|
||||
|
||||
click(link("projetos"));
|
||||
|
||||
expect(mockTicker.add).toHaveBeenCalledTimes(2);
|
||||
expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: the clock is already asleep and the page unmounts -> it is not removed twice", async () => {
|
||||
const view = await mount();
|
||||
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||
tick(IDLE_SECONDS + 1);
|
||||
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
|
||||
expect(mockLenis[0].destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: Lenis still scrolling -> the tick keeps the clock awake and feeds it in milliseconds", async () => {
|
||||
await mount();
|
||||
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||
mockLenis[0].isScrolling = "smooth";
|
||||
|
||||
tick(IDLE_SECONDS * 10);
|
||||
tick(IDLE_SECONDS * 10 + IDLE_SECONDS / 2);
|
||||
|
||||
expect(mockLenis[0].raf).toHaveBeenLastCalledWith(
|
||||
(IDLE_SECONDS * 10 + IDLE_SECONDS / 2) * 1000,
|
||||
);
|
||||
expect(mockTicker.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: montagem e desmontagem", () => {
|
||||
it("path: unmounted before the dynamic imports finish -> nothing is created", async () => {
|
||||
const view = render(<Home />);
|
||||
view.unmount();
|
||||
await flushImports();
|
||||
|
||||
expect(mockTimelines).toHaveLength(0);
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: ref not attached to an element -> the hook does nothing", async () => {
|
||||
await mount({ attach: false });
|
||||
|
||||
expect(mockTimelines).toHaveLength(0);
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: reduced motion -> nothing, and anchor clicks are left to the browser", async () => {
|
||||
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("projetos"));
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: unmount -> the document click listener and the wheel/scroll listeners are removed", async () => {
|
||||
const view = await mount();
|
||||
const removeDocument = jest.spyOn(document, "removeEventListener");
|
||||
const removeWindow = jest.spyOn(window, "removeEventListener");
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(removeDocument).toHaveBeenCalledWith(
|
||||
"click",
|
||||
expect.any(Function),
|
||||
true,
|
||||
);
|
||||
expect(removeWindow.mock.calls.map(([type]) => type)).toEqual(
|
||||
expect.arrayContaining(["wheel", "scroll"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: remedida depois de a página mudar de altura", () => {
|
||||
type Resize = { callback: () => void; disconnect: jest.Mock };
|
||||
let resizers: Resize[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
resizers = [];
|
||||
global.ResizeObserver = class {
|
||||
private readonly record: Resize;
|
||||
constructor(callback: () => void) {
|
||||
this.record = { callback, disconnect: jest.fn() };
|
||||
resizers.push(this.record);
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {
|
||||
this.record.disconnect();
|
||||
}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
it("path: several size changes in a row -> one refresh, 150 ms after the last", async () => {
|
||||
jest.useFakeTimers();
|
||||
await mount();
|
||||
|
||||
act(() => {
|
||||
resizers[0].callback();
|
||||
jest.advanceTimersByTime(100);
|
||||
resizers[0].callback();
|
||||
jest.advanceTimersByTime(149);
|
||||
});
|
||||
expect(ScrollTrigger.refresh).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1);
|
||||
});
|
||||
expect(ScrollTrigger.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: unmount with a refresh pending -> the refresh never happens and the observer is disconnected", async () => {
|
||||
jest.useFakeTimers();
|
||||
const view = await mount();
|
||||
act(() => {
|
||||
resizers[0].callback();
|
||||
});
|
||||
|
||||
view.unmount();
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(ScrollTrigger.refresh).not.toHaveBeenCalled();
|
||||
expect(resizers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,517 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
anchorDuration,
|
||||
ENTER_OFFSET,
|
||||
enterScene,
|
||||
exitScene,
|
||||
IDLE_SECONDS,
|
||||
JUMP_SCREENS,
|
||||
MAX_ANCHOR_SECONDS,
|
||||
PASSAGE_END,
|
||||
useCinematicScroll,
|
||||
} from "./useCinematicScroll";
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------
|
||||
* Passagem de verdade (GSAP real, sem ScrollTrigger): o que cada cena recebe no começo e no fim.
|
||||
* ------------------------------------------------------------------------------------------------ */
|
||||
|
||||
// o resto do arquivo troca o GSAP por um fake: aqui vale o de verdade
|
||||
const { gsap: realGsap } = jest.requireActual<typeof import("gsap")>("gsap");
|
||||
|
||||
function scene() {
|
||||
const element = document.createElement("section");
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
function timeline() {
|
||||
return realGsap.timeline({
|
||||
paused: true,
|
||||
defaults: { ease: "none", duration: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
const translateY = (element: HTMLElement) =>
|
||||
Number(
|
||||
/translate(?:3d)?\(\s*[-\d.]+(?:px)?,\s*([-\d.]+)px/.exec(
|
||||
element.style.transform,
|
||||
)?.[1] ?? Number.NaN,
|
||||
);
|
||||
|
||||
describe("the single passage between scenes", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("brings the next scene in with a fade and a short vertical offset, nothing else", () => {
|
||||
expect(ENTER_OFFSET).toBeGreaterThanOrEqual(24);
|
||||
expect(ENTER_OFFSET).toBeLessThanOrEqual(40);
|
||||
|
||||
const next = scene();
|
||||
const tl = timeline();
|
||||
enterScene(tl, next);
|
||||
|
||||
tl.progress(0.0001);
|
||||
expect(Number(next.style.opacity)).toBeLessThan(0.01);
|
||||
expect(translateY(next)).toBeCloseTo(ENTER_OFFSET, 0);
|
||||
|
||||
tl.progress(0.5);
|
||||
expect(Number(next.style.opacity)).toBeGreaterThan(0.2);
|
||||
expect(Number(next.style.opacity)).toBeLessThan(1);
|
||||
expect(translateY(next)).toBeGreaterThan(0);
|
||||
expect(translateY(next)).toBeLessThan(ENTER_OFFSET);
|
||||
|
||||
// sem recorte, escala, giro nem visibility em nenhum ponto
|
||||
for (const progress of [0.0001, 0.3, 0.7]) {
|
||||
tl.progress(progress);
|
||||
expect(next.style.clipPath).toBe("");
|
||||
expect(next.style.visibility).toBe("");
|
||||
expect(next.style.transform).not.toMatch(/scale|rotate/);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves no inline style behind once the scene has arrived, and brings it back on the way up", () => {
|
||||
const next = scene();
|
||||
const tl = timeline();
|
||||
enterScene(tl, next);
|
||||
|
||||
tl.progress(1);
|
||||
expect(next.style.opacity).toBe("");
|
||||
expect(next.style.transform).toBe("");
|
||||
|
||||
tl.progress(0.4);
|
||||
expect(Number(next.style.opacity)).toBeLessThan(1);
|
||||
expect(next.style.transform).not.toBe("");
|
||||
});
|
||||
|
||||
it("fades the previous scene all the way to opacity 0 at the end of every passage", () => {
|
||||
const previous = scene();
|
||||
const tl = timeline();
|
||||
exitScene(tl, previous);
|
||||
|
||||
tl.progress(0.5);
|
||||
expect(Number(previous.style.opacity)).toBeGreaterThan(0);
|
||||
expect(Number(previous.style.opacity)).toBeLessThan(1);
|
||||
expect(previous.style.visibility).toBe("");
|
||||
expect(previous.style.clipPath).toBe("");
|
||||
|
||||
tl.progress(1);
|
||||
expect(previous.style.opacity).toBe("0");
|
||||
|
||||
// rolando de volta, a cena reaparece inteira
|
||||
tl.progress(0.0001);
|
||||
expect(Number(previous.style.opacity)).toBeGreaterThan(0.99);
|
||||
});
|
||||
|
||||
it("a scene in the middle of the page arrives, stays, and leaves at 0 without fighting itself", () => {
|
||||
const middle = scene();
|
||||
const arrive = timeline();
|
||||
const leave = timeline();
|
||||
enterScene(arrive, middle);
|
||||
exitScene(leave, middle);
|
||||
|
||||
arrive.progress(1);
|
||||
expect(middle.style.opacity).toBe("");
|
||||
leave.progress(1);
|
||||
expect(middle.style.opacity).toBe("0");
|
||||
leave.progress(0);
|
||||
arrive.progress(0.2);
|
||||
expect(Number(middle.style.opacity)).toBeLessThan(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("anchor travel", () => {
|
||||
it("jumps straight to far anchors and animates near ones for 0.8 s at most", () => {
|
||||
expect(MAX_ANCHOR_SECONDS).toBe(0.8);
|
||||
const viewport = 800;
|
||||
expect(anchorDuration(25000, viewport)).toBeNull();
|
||||
expect(anchorDuration(-25000, viewport)).toBeNull();
|
||||
expect(
|
||||
anchorDuration(viewport * (JUMP_SCREENS + 0.5), viewport),
|
||||
).toBeNull();
|
||||
|
||||
for (const distance of [0, 200, 800, 1600, viewport * JUMP_SCREENS]) {
|
||||
const seconds = anchorDuration(distance, viewport);
|
||||
expect(seconds).not.toBeNull();
|
||||
expect(seconds as number).toBeGreaterThan(0);
|
||||
expect(seconds as number).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------
|
||||
* O hook: GSAP, ScrollTrigger e Lenis trocados por fakes que registram o que recebem.
|
||||
* ------------------------------------------------------------------------------------------------ */
|
||||
|
||||
type Call = unknown[];
|
||||
const mockTimelines: Array<{ vars: ScrollTriggerLike; calls: Call[] }> = [];
|
||||
const mockTicker = {
|
||||
add: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
lagSmoothing: jest.fn(),
|
||||
time: 0,
|
||||
};
|
||||
const mockLenis: MockLenis[] = [];
|
||||
|
||||
interface ScrollTriggerLike {
|
||||
scrollTrigger: {
|
||||
trigger: HTMLElement;
|
||||
start: () => number;
|
||||
end: () => number;
|
||||
scrub: boolean;
|
||||
onEnter?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
class MockLenis {
|
||||
isScrolling: false | "smooth" = false;
|
||||
on = jest.fn();
|
||||
raf = jest.fn();
|
||||
scrollTo = jest.fn();
|
||||
destroy = jest.fn();
|
||||
constructor(public options: unknown) {
|
||||
mockLenis.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock("gsap", () => ({
|
||||
gsap: {
|
||||
registerPlugin: jest.fn(),
|
||||
ticker: mockTicker,
|
||||
context: (fn: () => void) => {
|
||||
fn();
|
||||
return { revert: jest.fn() };
|
||||
},
|
||||
timeline: (vars: ScrollTriggerLike) => {
|
||||
const record = { vars, calls: [] as Call[] };
|
||||
mockTimelines.push(record);
|
||||
const chain = {
|
||||
fromTo: (...args: Call) => {
|
||||
record.calls.push(args);
|
||||
return chain;
|
||||
},
|
||||
};
|
||||
return chain;
|
||||
},
|
||||
},
|
||||
}));
|
||||
jest.mock("gsap/ScrollTrigger", () => ({
|
||||
ScrollTrigger: { update: jest.fn(), refresh: jest.fn() },
|
||||
}));
|
||||
jest.mock("lenis", () => ({
|
||||
__esModule: true,
|
||||
get default() {
|
||||
return MockLenis;
|
||||
},
|
||||
}));
|
||||
|
||||
const TOPS: Record<string, number> = {
|
||||
home: 0,
|
||||
projects: 1800,
|
||||
short: 3000,
|
||||
about: 3300,
|
||||
contact: 25000,
|
||||
};
|
||||
|
||||
function Home() {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
useCinematicScroll(ref);
|
||||
return (
|
||||
<main ref={ref}>
|
||||
<section id="home" data-top={TOPS.home}>
|
||||
<a href="/#contact">Conversar</a>
|
||||
<a href="/#projects">Projetos</a>
|
||||
</section>
|
||||
<section id="projects" data-top={TOPS.projects}>
|
||||
<h2>Projetos</h2>
|
||||
</section>
|
||||
{/* uma cena mais curta que a tela: a saída dela não pode começar antes de a entrada terminar */}
|
||||
<section id="short" data-top={TOPS.short}>
|
||||
<h2>Curta</h2>
|
||||
</section>
|
||||
<section id="about" data-top={TOPS.about}>
|
||||
<h2>Sobre</h2>
|
||||
</section>
|
||||
<section id="contact" data-top={TOPS.contact}>
|
||||
<h2>Contato</h2>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const originalOffsetTop = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetTop",
|
||||
);
|
||||
|
||||
function mockMedia(active: string[]) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
async function mount() {
|
||||
const view = render(<Home />);
|
||||
// as importações dinâmicas do hook (gsap, ScrollTrigger, Lenis) resolvem em microtarefas
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
const scenes = () =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>("main > section"));
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return Number(this.dataset.top ?? 0);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalOffsetTop)
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"offsetTop",
|
||||
originalOffsetTop,
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockTimelines.length = 0;
|
||||
mockLenis.length = 0;
|
||||
mockTicker.add.mockClear();
|
||||
mockTicker.remove.mockClear();
|
||||
mockTicker.lagSmoothing.mockClear();
|
||||
mockTicker.time = 0;
|
||||
mockMedia([]);
|
||||
window.history.replaceState(null, "", "/");
|
||||
jest.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useCinematicScroll", () => {
|
||||
it("never hides a scene: no visibility, no sticky pin, nothing inline before the scroll runs", async () => {
|
||||
await mount();
|
||||
for (const element of scenes()) {
|
||||
expect(element.style.visibility).toBe("");
|
||||
expect(element.style.position).toBe("");
|
||||
expect(element.style.top).toBe("");
|
||||
expect(element.style.opacity).toBe("");
|
||||
expect(element.style.clipPath).toBe("");
|
||||
}
|
||||
// o texto do contato continua achável (Ctrl+F) e visível para leitores de tela
|
||||
const contact = document.getElementById("contact") as HTMLElement;
|
||||
expect(contact).toBeVisible();
|
||||
expect(contact).toHaveTextContent("Contato");
|
||||
expect(contact.closest("[aria-hidden]")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses one passage only: opacity and a vertical offset, with the previous scene ending at 0", async () => {
|
||||
await mount();
|
||||
const allowed = new Set([
|
||||
"opacity",
|
||||
"y",
|
||||
"ease",
|
||||
"clearProps",
|
||||
"immediateRender",
|
||||
]);
|
||||
const entering: HTMLElement[] = [];
|
||||
const leaving: HTMLElement[] = [];
|
||||
|
||||
for (const { vars, calls } of mockTimelines) {
|
||||
expect(vars.scrollTrigger.scrub).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
const [target, from, to] = calls[0] as [
|
||||
HTMLElement,
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
for (const key of [...Object.keys(from), ...Object.keys(to)])
|
||||
expect(allowed.has(key)).toBe(true);
|
||||
expect(JSON.stringify([from, to])).not.toMatch(
|
||||
/autoAlpha|visibility|clipPath|scale|rotation|xPercent/,
|
||||
);
|
||||
if (to.opacity === 1) {
|
||||
entering.push(target);
|
||||
expect(from).toMatchObject({ opacity: 0, y: ENTER_OFFSET });
|
||||
expect(vars.scrollTrigger.trigger).toBe(target);
|
||||
} else {
|
||||
leaving.push(target);
|
||||
expect(from).toMatchObject({ opacity: 1 });
|
||||
expect(to.opacity).toBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
const all = scenes();
|
||||
// todas entram, menos a primeira; todas saem, menos a última
|
||||
expect(entering).toEqual(all.slice(1));
|
||||
expect(leaving).toEqual(all.slice(0, -1));
|
||||
});
|
||||
|
||||
it("starts each exit only after the same scene finished entering, even for a scene shorter than the screen", async () => {
|
||||
await mount();
|
||||
const all = scenes();
|
||||
const ranges = new Map<
|
||||
HTMLElement,
|
||||
{ enter?: [number, number]; exit?: [number, number] }
|
||||
>();
|
||||
for (const { vars, calls } of mockTimelines) {
|
||||
const target = calls[0][0] as HTMLElement;
|
||||
const to = calls[0][2] as { opacity: number };
|
||||
const { start, end } = vars.scrollTrigger;
|
||||
const entry = ranges.get(target) ?? {};
|
||||
if (to.opacity === 1) entry.enter = [start(), end()];
|
||||
else entry.exit = [start(), end()];
|
||||
ranges.set(target, entry);
|
||||
}
|
||||
|
||||
const vh = window.innerHeight;
|
||||
for (const element of all.slice(1)) {
|
||||
const top = TOPS[element.id];
|
||||
expect(ranges.get(element)?.enter).toEqual([
|
||||
top - vh,
|
||||
top - vh * PASSAGE_END,
|
||||
]);
|
||||
}
|
||||
for (const element of all.slice(0, -1)) {
|
||||
const { enter, exit } = ranges.get(element) ?? {};
|
||||
expect(exit).toBeDefined();
|
||||
const [start, end] = exit as [number, number];
|
||||
expect(start).toBeLessThan(end);
|
||||
// termina quando o topo da cena seguinte chegou ao fim da passagem dela
|
||||
const next = all[all.indexOf(element) + 1];
|
||||
expect(end).toBe(TOPS[next.id] - vh * PASSAGE_END);
|
||||
if (enter) expect(start).toBeGreaterThanOrEqual(enter[1]);
|
||||
}
|
||||
// a cena curta (300 px): a saída começa onde a entrada acabou, não 500 px antes
|
||||
const short = ranges.get(document.getElementById("short") as HTMLElement);
|
||||
expect(short?.exit?.[0]).toBe(short?.enter?.[1]);
|
||||
});
|
||||
|
||||
it("does no scroll work for users who prefer reduced motion", async () => {
|
||||
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
||||
await mount();
|
||||
expect(mockTimelines).toHaveLength(0);
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("smooth scroll", () => {
|
||||
it("is not created on a touch screen, where the passages still run on the native scroll", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
await mount();
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
expect(mockTicker.lagSmoothing).not.toHaveBeenCalled();
|
||||
expect(mockTimelines.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("is created with a mouse, and the GSAP clock sleeps once the scroll is idle and wakes on the wheel", async () => {
|
||||
await mount();
|
||||
expect(mockLenis).toHaveLength(1);
|
||||
const lenis = mockLenis[0];
|
||||
expect(mockTicker.add).toHaveBeenCalledTimes(1);
|
||||
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||
|
||||
// rolando: segue ligado e alimenta o Lenis em milissegundos
|
||||
lenis.isScrolling = "smooth";
|
||||
tick(0.5);
|
||||
expect(lenis.raf).toHaveBeenLastCalledWith(500);
|
||||
expect(mockTicker.remove).not.toHaveBeenCalled();
|
||||
|
||||
// parado há mais que o tempo de espera: o relógio é solto
|
||||
lenis.isScrolling = false;
|
||||
tick(0.5 + IDLE_SECONDS / 2);
|
||||
expect(mockTicker.remove).not.toHaveBeenCalled();
|
||||
tick(0.5 + IDLE_SECONDS + 0.1);
|
||||
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
|
||||
expect(mockTicker.remove).toHaveBeenCalledWith(tick);
|
||||
|
||||
// a roda do mouse o acorda de novo
|
||||
mockTicker.time = 10;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
});
|
||||
expect(mockTicker.add).toHaveBeenCalledTimes(2);
|
||||
// e uma roda seguinte, com o relógio já ligado, não o liga duas vezes
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
});
|
||||
expect(mockTicker.add).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("jumps straight to a far anchor (the header button to #contact) and animates a near one for 0.8 s at most", async () => {
|
||||
await mount();
|
||||
const lenis = mockLenis[0];
|
||||
|
||||
act(() => {
|
||||
(document.querySelector('a[href="/#contact"]') as HTMLElement).click();
|
||||
});
|
||||
expect(lenis.scrollTo).toHaveBeenCalledTimes(1);
|
||||
const [farTop, farOptions] = lenis.scrollTo.mock.calls[0];
|
||||
expect(farTop).toBe(TOPS.contact);
|
||||
expect(farOptions).toMatchObject({ immediate: true });
|
||||
expect(window.location.hash).toBe("#contact");
|
||||
expect(document.getElementById("contact")).toHaveFocus();
|
||||
|
||||
act(() => {
|
||||
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
|
||||
});
|
||||
const [nearTop, nearOptions] = lenis.scrollTo.mock.calls[1];
|
||||
expect(nearTop).toBe(TOPS.projects);
|
||||
expect(nearOptions.immediate).toBe(false);
|
||||
expect(nearOptions.duration).toBeGreaterThan(0);
|
||||
expect(nearOptions.duration).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
|
||||
});
|
||||
|
||||
it("without Lenis (touch) a far anchor is an instant jump", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
await mount();
|
||||
act(() => {
|
||||
(document.querySelector('a[href="/#contact"]') as HTMLElement).click();
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({
|
||||
top: TOPS.contact,
|
||||
behavior: "instant",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenLastCalledWith({
|
||||
top: TOPS.projects,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the clock, the listeners and Lenis when the page unmounts", async () => {
|
||||
const view = await mount();
|
||||
const lenis = mockLenis[0];
|
||||
view.unmount();
|
||||
expect(lenis.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(mockTicker.remove).toHaveBeenCalled();
|
||||
mockTicker.add.mockClear();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
});
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { type RefObject, useEffect } from "react";
|
||||
|
||||
type Transition = (
|
||||
tl: gsap.core.Timeline,
|
||||
prev: HTMLElement,
|
||||
next: HTMLElement,
|
||||
touch: boolean,
|
||||
) => void;
|
||||
import { isCoarsePointer, prefersReducedMotion } from "@/lib/device";
|
||||
|
||||
/*
|
||||
* Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição
|
||||
* da rolagem (rolar para cima volta o "vídeo").
|
||||
* Rolagem como um vídeo: o GSAP liga cada passagem de uma cena para a seguinte à posição da rolagem (rolar
|
||||
* para cima volta o "vídeo") e, no mouse, o Lenis suaviza a roda.
|
||||
*
|
||||
* Quem segura as seções paradas durante uma passagem:
|
||||
* - a que SAI fica com position: sticky (topo = altura da tela − altura da seção): ela para quando o fim
|
||||
* dela chega ao fim da tela. Quem mantém isso é o compositor do navegador, na mesma batida da rolagem;
|
||||
* - a que ENTRA só fica parada no topo no desktop (deslocamento pelo GSAP). Com a roda do mouse, o Lenis
|
||||
* rola e desloca no mesmo quadro. No toque, a rolagem nativa corre em outra thread e o deslocamento
|
||||
* chegava um quadro atrasado: em rolagens rápidas a seção tremia. Por isso, no toque, ela sobe com o
|
||||
* dedo e a passagem usa só efeitos que não disputam a posição (recorte, transparência, giro, escala).
|
||||
* Uma única passagem para a home inteira, só com opacidade e um deslocamento vertical curto:
|
||||
* - a cena que entra chega com fade e sobe ENTER_OFFSET px até o lugar dela;
|
||||
* - a cena que sai some com fade e termina SEMPRE em opacidade 0 (nenhuma cena anterior fica "fantasma" atrás
|
||||
* das seguintes);
|
||||
* - nada de recorte (clip-path), escala ou rotação, e nenhuma cena é escondida com visibility: o conteúdo
|
||||
* nasce visível no HTML do servidor e a opacidade só muda por rolagem (Ctrl+F e leitores de tela enxergam
|
||||
* tudo, e sem JavaScript tudo aparece).
|
||||
* As cenas ficam no fluxo normal da página, uma depois da outra. A única rolagem "presa" é a do CSS: o hero
|
||||
* (sticky) e a vitrine de projetos (sticky); este arquivo não prende nada.
|
||||
*
|
||||
* Regras que mantêm tudo estável:
|
||||
* - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1);
|
||||
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
|
||||
* - cada cena só fica visível da passagem em que entra até a passagem em que sai (com folga), para não
|
||||
* aparecer atrás de transparências e para o navegador não pintar o que está coberto.
|
||||
* Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh).
|
||||
* Cada cena tem uma entrada e uma saída, cada uma com o próprio gatilho, e a saída só começa depois que a
|
||||
* entrada da mesma cena terminou (cenas curtas não brigam pela mesma opacidade). Qualquer filho direto de
|
||||
* <main> é uma cena; nada aqui depende de uma seção específica existir.
|
||||
*/
|
||||
|
||||
const vh = () => window.innerHeight;
|
||||
// origem no meio da parte visível da seção que sai (a última altura de tela dela)
|
||||
const visibleCenter = (el: HTMLElement) => () =>
|
||||
`50% ${el.offsetHeight - vh() / 2}px`;
|
||||
/** Deslocamento vertical, em px, com que a cena chega (entre 24 e 40). */
|
||||
export const ENTER_OFFSET = 32;
|
||||
/** A passagem corre enquanto o topo da cena que entra sobe de 1 altura de tela até esta fração dela. */
|
||||
export const PASSAGE_END = 0.2;
|
||||
/** Âncoras mais distantes que isto (em alturas de tela) são um salto direto. */
|
||||
export const JUMP_SCREENS = 3;
|
||||
/** Nenhuma âncora anima por mais que isto. */
|
||||
export const MAX_ANCHOR_SECONDS = 0.8;
|
||||
/** Depois deste tempo parada, a rolagem suave solta o relógio do GSAP. */
|
||||
export const IDLE_SECONDS = 1;
|
||||
|
||||
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */
|
||||
const vh = () => window.innerHeight;
|
||||
|
||||
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das passagens, getBoundingClientRect não). */
|
||||
function pageTop(el: HTMLElement) {
|
||||
let top = 0;
|
||||
for (
|
||||
@@ -46,125 +47,38 @@ function pageTop(el: HTMLElement) {
|
||||
return top;
|
||||
}
|
||||
|
||||
// a seção que entra fica parada no topo (só desktop, ver o comentário acima)
|
||||
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
|
||||
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
|
||||
/** A cena chega: fade e subida curta. Ao terminar, o estilo inline some (sem transform sobrando na cena). */
|
||||
export function enterScene(tl: gsap.core.Timeline, scene: HTMLElement) {
|
||||
tl.fromTo(
|
||||
scene,
|
||||
{ opacity: 0, y: ENTER_OFFSET },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
ease: "power2.out",
|
||||
clearProps: "opacity,transform",
|
||||
immediateRender: false,
|
||||
},
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// uma linguagem diferente por passagem, na ordem das seções da home
|
||||
const TRANSITIONS: Transition[] = [
|
||||
// hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema
|
||||
(tl, prev, next, touch) => {
|
||||
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0);
|
||||
if (touch) {
|
||||
tl.fromTo(
|
||||
next,
|
||||
{
|
||||
opacity: 0.3,
|
||||
clipPath: "inset(0% 5% 0% 5% round 28px 28px 0px 0px)",
|
||||
},
|
||||
{ opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" },
|
||||
0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
holdNext(tl, next);
|
||||
const band = (open: number) => () =>
|
||||
`inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`;
|
||||
tl.fromTo(
|
||||
next,
|
||||
{ opacity: 0, clipPath: band(0.36) },
|
||||
{ opacity: 1, clipPath: band(1), duration: 0.65 },
|
||||
0.35,
|
||||
);
|
||||
},
|
||||
// projetos → modalidades: uma íris se abre enquanto a anterior avança contra a câmera
|
||||
(tl, prev, next, touch) => {
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
||||
{ scale: 1.18, opacity: 0.2 },
|
||||
0,
|
||||
);
|
||||
// no toque a seção sobe com o dedo: a íris nasce do topo dela, como uma bolha subindo
|
||||
const center = () => (touch ? "at 50% 0px" : `at 50% ${vh() / 2}px`);
|
||||
const radius = () =>
|
||||
touch
|
||||
? Math.hypot(window.innerWidth / 2, vh())
|
||||
: Math.hypot(window.innerWidth / 2, vh() / 2);
|
||||
if (!touch) holdNext(tl, next);
|
||||
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
|
||||
tl.fromTo(
|
||||
next,
|
||||
{ clipPath: () => `circle(0px ${center()})` },
|
||||
{
|
||||
clipPath: () => `circle(${radius()}px ${center()})`,
|
||||
ease: "power1.in",
|
||||
},
|
||||
0,
|
||||
);
|
||||
},
|
||||
// modalidades → IA: corte lateral, uma tela empurra a outra
|
||||
(tl, prev, next, touch) => {
|
||||
if (!touch) holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ xPercent: 0, opacity: 1 },
|
||||
{ xPercent: -35, opacity: 0.3 },
|
||||
0,
|
||||
).fromTo(next, { xPercent: 100 }, { xPercent: 0, ease: "power2.out" }, 0);
|
||||
},
|
||||
// IA → qualidade: a seção sobe como um cartão sobre a anterior, que escurece.
|
||||
// Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o
|
||||
// desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha
|
||||
// uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter).
|
||||
(tl, prev, next, touch) => {
|
||||
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0);
|
||||
// no desktop ela sobe um pouco mais rápido que a rolagem; no toque, só com o dedo
|
||||
if (!touch)
|
||||
tl.fromTo(
|
||||
next,
|
||||
{ y: () => vh() * 0.35 },
|
||||
{ y: 0, ease: "power2.out" },
|
||||
0,
|
||||
);
|
||||
},
|
||||
// qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform)
|
||||
(tl, prev, next, touch) => {
|
||||
if (!touch) holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ opacity: 1, xPercent: 0 },
|
||||
{ opacity: 0.25, xPercent: 20 },
|
||||
0,
|
||||
).fromTo(
|
||||
next,
|
||||
{ xPercent: -100, rotation: -7, transformOrigin: "0% 0%" },
|
||||
{ xPercent: 0, rotation: 0, ease: "power2.out" },
|
||||
0,
|
||||
);
|
||||
},
|
||||
// sobre → contato: o contato cresce do centro, como um zoom de câmera
|
||||
(tl, prev, next, touch) => {
|
||||
if (!touch) holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
||||
{ scale: 0.92, opacity: 0.2 },
|
||||
0,
|
||||
)
|
||||
.fromTo(
|
||||
next,
|
||||
{
|
||||
scale: touch ? 0.8 : 0.55,
|
||||
clipPath: "inset(0% round 56px)",
|
||||
transformOrigin: () => (touch ? "50% 0%" : `50% ${vh() / 2}px`),
|
||||
},
|
||||
{ scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" },
|
||||
0,
|
||||
)
|
||||
.fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0);
|
||||
},
|
||||
];
|
||||
/** A cena sai: fade até 0. É o último estado dela até a rolagem voltar. */
|
||||
export function exitScene(tl: gsap.core.Timeline, scene: HTMLElement) {
|
||||
tl.fromTo(
|
||||
scene,
|
||||
{ opacity: 1 },
|
||||
{ opacity: 0, ease: "power2.in", immediateRender: false },
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Duração (s) da rolagem até uma âncora a `distance` px; `null` = salto direto. */
|
||||
export function anchorDuration(distance: number, viewport: number) {
|
||||
const screens = Math.abs(distance) / Math.max(1, viewport);
|
||||
if (screens > JUMP_SCREENS) return null;
|
||||
return Math.min(MAX_ANCHOR_SECONDS, 0.3 + screens * 0.2);
|
||||
}
|
||||
|
||||
const easeInOutCubic = (t: number) =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
@@ -173,109 +87,103 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
const main = mainRef.current;
|
||||
if (!main) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
let cancelled = false;
|
||||
let cleanup = () => {};
|
||||
|
||||
(async () => {
|
||||
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] =
|
||||
await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
import("lenis"),
|
||||
]);
|
||||
// no toque a rolagem nativa corre em outra thread: sem Lenis (e sem baixar o pacote)
|
||||
const smooth = !isCoarsePointer();
|
||||
const [{ gsap }, { ScrollTrigger }, lenisModule] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
smooth ? import("lenis") : Promise.resolve(null),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 });
|
||||
lenis.on("scroll", ScrollTrigger.update);
|
||||
const tick = (time: number) => lenis.raf(time * 1000);
|
||||
gsap.ticker.add(tick);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
// Lenis: o relógio do GSAP só roda enquanto a rolagem anima; parada por IDLE_SECONDS, ele é solto
|
||||
const lenis = lenisModule
|
||||
? new lenisModule.default({ lerp: 0.075, wheelMultiplier: 0.85 })
|
||||
: null;
|
||||
let awake = false;
|
||||
let lastBusy = 0;
|
||||
const tick = (time: number) => {
|
||||
lenis?.raf(time * 1000);
|
||||
if (lenis?.isScrolling) lastBusy = time;
|
||||
else if (time - lastBusy > IDLE_SECONDS) sleep();
|
||||
};
|
||||
function sleep() {
|
||||
if (!awake) return;
|
||||
awake = false;
|
||||
gsap.ticker.remove(tick);
|
||||
}
|
||||
const wake = () => {
|
||||
if (!lenis) return;
|
||||
lastBusy = gsap.ticker.time;
|
||||
if (awake) return;
|
||||
awake = true;
|
||||
gsap.ticker.add(tick);
|
||||
};
|
||||
if (lenis) {
|
||||
lenis.on("scroll", ScrollTrigger.update);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
wake();
|
||||
window.addEventListener("wheel", wake, { passive: true });
|
||||
window.addEventListener("scroll", wake, { passive: true });
|
||||
}
|
||||
|
||||
// toque: rolagem nativa em outra thread, sem trava de posição pelo JavaScript
|
||||
const touch = window.matchMedia(
|
||||
"(hover: none) and (pointer: coarse)",
|
||||
).matches;
|
||||
const scenes = Array.from(main.children) as HTMLElement[];
|
||||
// Topo de cada cena somando as alturas das anteriores (as cenas são contíguas): com sticky, o
|
||||
// offsetTop de uma cena presa passa a incluir o deslocamento dela.
|
||||
const sceneTops = () => {
|
||||
let top = pageTop(main);
|
||||
return scenes.map((scene) => {
|
||||
const current = top;
|
||||
top += scene.offsetHeight;
|
||||
return current;
|
||||
});
|
||||
};
|
||||
const stick = () => {
|
||||
for (const scene of scenes)
|
||||
scene.style.top = `${Math.min(0, vh() - scene.offsetHeight)}px`;
|
||||
};
|
||||
|
||||
const context = gsap.context(() => {
|
||||
scenes.forEach((scene, index) => {
|
||||
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte
|
||||
gsap.set(scene, {
|
||||
position: "sticky",
|
||||
zIndex: index === 0 ? 30 : index + 1,
|
||||
const following = scenes[index + 1];
|
||||
const trigger = (
|
||||
start: () => number,
|
||||
end: () => number,
|
||||
extra: ScrollTrigger.Vars = {},
|
||||
) => ({
|
||||
trigger: scene,
|
||||
start,
|
||||
end,
|
||||
scrub: true,
|
||||
invalidateOnRefresh: true,
|
||||
...extra,
|
||||
});
|
||||
});
|
||||
stick();
|
||||
TRANSITIONS.forEach((transition, index) => {
|
||||
const prev = scenes[index];
|
||||
const next = scenes[index + 1];
|
||||
if (!prev || !next) return;
|
||||
const tl = gsap.timeline({
|
||||
defaults: { ease: "none", duration: 1 },
|
||||
scrollTrigger: {
|
||||
trigger: next,
|
||||
start: () => sceneTops()[index + 1] - vh(),
|
||||
end: () => sceneTops()[index + 1],
|
||||
scrub: true,
|
||||
invalidateOnRefresh: true,
|
||||
// passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente
|
||||
onLeave: () => gsap.set(next, { clipPath: "none" }),
|
||||
// diagrama de qualidade: desenha uma vez quando a seção começa a entrar
|
||||
onEnter: () => next.classList.add("is-drawing"),
|
||||
},
|
||||
});
|
||||
transition(tl, prev, next, touch);
|
||||
const timeline = (vars: ScrollTrigger.Vars) =>
|
||||
gsap.timeline({
|
||||
defaults: { ease: "none", duration: 1 },
|
||||
scrollTrigger: vars,
|
||||
});
|
||||
|
||||
if (index > 0)
|
||||
enterScene(
|
||||
timeline(
|
||||
trigger(
|
||||
() => pageTop(scene) - vh(),
|
||||
() => pageTop(scene) - vh() * PASSAGE_END,
|
||||
),
|
||||
),
|
||||
scene,
|
||||
);
|
||||
if (following)
|
||||
exitScene(
|
||||
timeline(
|
||||
trigger(
|
||||
// só depois de a própria entrada terminar, mesmo numa cena mais curta que a tela
|
||||
() =>
|
||||
Math.max(
|
||||
pageTop(scene) - vh() * PASSAGE_END,
|
||||
pageTop(following) - vh(),
|
||||
),
|
||||
() => pageTop(following) - vh() * PASSAGE_END,
|
||||
),
|
||||
),
|
||||
scene,
|
||||
);
|
||||
});
|
||||
}, main);
|
||||
|
||||
// Visibilidade pela posição da rolagem: cada cena só existe na tela da passagem em que entra até a
|
||||
// passagem em que sai. Folga para o quadro de atraso entre a rolagem nativa e este cálculo: um quarto
|
||||
// de tela depois da saída e, no toque, meia tela antes da entrada (lá a cena sobe com o dedo; no
|
||||
// desktop ela já nasce presa no topo e só pode aparecer quando a passagem começa).
|
||||
let frame = 0;
|
||||
const syncVisibility = () => {
|
||||
frame = 0;
|
||||
const y = window.scrollY;
|
||||
const tops = sceneTops();
|
||||
scenes.forEach((scene, index) => {
|
||||
const from =
|
||||
index === 0 ? -Infinity : tops[index] - vh() * (touch ? 1.5 : 1);
|
||||
const to =
|
||||
index === scenes.length - 1
|
||||
? Infinity
|
||||
: tops[index + 1] + vh() * 0.25;
|
||||
const visibility = y >= from && y <= to ? "" : "hidden";
|
||||
if (scene.style.visibility !== visibility)
|
||||
scene.style.visibility = visibility;
|
||||
});
|
||||
};
|
||||
const queueSync = () => {
|
||||
if (!frame) frame = requestAnimationFrame(syncVisibility);
|
||||
};
|
||||
const onRefresh = () => {
|
||||
stick();
|
||||
queueSync();
|
||||
};
|
||||
syncVisibility();
|
||||
ScrollTrigger.addEventListener("refresh", onRefresh);
|
||||
window.addEventListener("scroll", queueSync, { passive: true });
|
||||
// a página mudou de altura (fontes, imagens, "Experiência" aberta ou fechada): o GSAP remede as passagens
|
||||
// a página mudou de altura (fontes, imagens, seções que abrem e fecham): o GSAP remede as passagens
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
clearTimeout(refreshTimer);
|
||||
@@ -283,7 +191,25 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
});
|
||||
resizeObserver.observe(main);
|
||||
|
||||
// botões e links internos: em vez do salto, a página "avança o vídeo" até o destino
|
||||
const scrollToTop = (top: number) => {
|
||||
const distance = top - window.scrollY;
|
||||
const duration = anchorDuration(distance, vh());
|
||||
if (lenis) {
|
||||
wake();
|
||||
lenis.scrollTo(top, {
|
||||
immediate: duration === null,
|
||||
duration: duration ?? 0,
|
||||
easing: easeInOutCubic,
|
||||
});
|
||||
} else {
|
||||
window.scrollTo({
|
||||
top,
|
||||
behavior: duration === null ? "instant" : "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// botões e links internos: em vez do salto, a página "avança o vídeo" (de pouco) até o destino
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
@@ -318,55 +244,37 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
} catch {}
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
// nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo;
|
||||
// alvos internos descontam o cabeçalho fixo (posição relativa à cena, que não muda se ela estiver presa)
|
||||
const index = scenes.findIndex((candidate) =>
|
||||
candidate.contains(target),
|
||||
// o alvo é a cena inteira: para no topo dela; alvo interno: desconta o cabeçalho fixo, sem passar do topo da cena
|
||||
const destination = target;
|
||||
const scene = scenes.find((candidate) =>
|
||||
candidate.contains(destination),
|
||||
);
|
||||
const offset =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).scrollPaddingTop,
|
||||
) || 0;
|
||||
const sceneTop = index < 0 ? 0 : sceneTops()[index];
|
||||
const sceneTop = scene ? pageTop(scene) : 0;
|
||||
const top =
|
||||
index <= 0
|
||||
? 0
|
||||
: Math.max(
|
||||
sceneTop,
|
||||
scenes[index] === target
|
||||
? 0
|
||||
: sceneTop +
|
||||
pageTop(target) -
|
||||
pageTop(scenes[index]) -
|
||||
offset,
|
||||
);
|
||||
scene === destination
|
||||
? sceneTop
|
||||
: Math.max(sceneTop, pageTop(destination) - offset);
|
||||
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
|
||||
lenis.scrollTo(top, {
|
||||
duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000),
|
||||
easing: easeInOutCubic,
|
||||
// o foco vai depois: antes de a cena entrar ela está invisível e não aceita foco
|
||||
onComplete: () => {
|
||||
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
|
||||
target.focus({ preventScroll: true });
|
||||
},
|
||||
});
|
||||
scrollToTop(top);
|
||||
// o foco vai junto: a cena já está no fluxo, visível e pronta para recebê-lo
|
||||
if (!destination.hasAttribute("tabindex")) destination.tabIndex = -1;
|
||||
destination.focus({ preventScroll: true });
|
||||
};
|
||||
document.addEventListener("click", onClick, true);
|
||||
|
||||
cleanup = () => {
|
||||
document.removeEventListener("click", onClick, true);
|
||||
ScrollTrigger.removeEventListener("refresh", onRefresh);
|
||||
window.removeEventListener("scroll", queueSync);
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("wheel", wake);
|
||||
window.removeEventListener("scroll", wake);
|
||||
clearTimeout(refreshTimer);
|
||||
resizeObserver.disconnect();
|
||||
context.revert();
|
||||
for (const scene of scenes) {
|
||||
scene.style.visibility = "";
|
||||
scene.style.top = "";
|
||||
}
|
||||
gsap.ticker.remove(tick);
|
||||
lenis.destroy();
|
||||
sleep();
|
||||
lenis?.destroy();
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
Reference in new issue
Block a user