feat(story): build procedural notebook animation
This commit is contained in:
1 parent
f3ed9da777
commit
b079afdd73
9 files changed
+330
-183
No files matched your search
-10
@@ -1,15 +1,5 @@
|
|||||||
# Credits / Third-party assets
|
# Credits / Third-party assets
|
||||||
|
|
||||||
## 3D model — “Laptop”
|
|
||||||
- Author: **Kenney** (https://kenney.nl)
|
|
||||||
- Source: https://poly.pizza/m/GnbwSUiVty
|
|
||||||
- License: **CC0 1.0 Universal (Public Domain)** (https://creativecommons.org/publicdomain/zero/1.0/)
|
|
||||||
- Used in: `app/components/story/NotebookStage3D.tsx`,
|
|
||||||
file at `public/models/laptop-kenney.glb`.
|
|
||||||
- Modifications: the downloaded open-laptop geometry was split into
|
|
||||||
`Laptop_Base` and `Laptop_Lid` nodes around the hinge for scroll-driven
|
|
||||||
animation. CC0 does not require attribution; credit given anyway.
|
|
||||||
|
|
||||||
## Project screenshots
|
## Project screenshots
|
||||||
- `public/assets/prints/*.png` are captures taken with headless Chrome from
|
- `public/assets/prints/*.png` are captures taken with headless Chrome from
|
||||||
production deployments owned by the site author (evandro.dev.br), used as
|
production deployments owned by the site author (evandro.dev.br), used as
|
||||||
|
|||||||
@@ -126,18 +126,29 @@ describe("NotebookBrowser", () => {
|
|||||||
const { container } = renderBrowser({
|
const { container } = renderBrowser({
|
||||||
frame: { ...frame, cursorClicking: true },
|
frame: { ...frame, cursorClicking: true },
|
||||||
});
|
});
|
||||||
|
const cursor = container.querySelector(".notebook-cursor");
|
||||||
|
|
||||||
expect(container.querySelector(".notebook-cursor")).toHaveAttribute(
|
expect(cursor).toHaveAttribute("aria-hidden", "true");
|
||||||
"aria-hidden",
|
expect(cursor).toHaveStyle({ left: "40%", top: "60%" });
|
||||||
"true",
|
expect(cursor?.closest(".notebook-browser")).toBe(
|
||||||
|
container.querySelector(".notebook-browser"),
|
||||||
);
|
);
|
||||||
expect(container.querySelector(".notebook-cursor")).toHaveStyle({
|
|
||||||
left: "40%",
|
|
||||||
top: "60%",
|
|
||||||
});
|
|
||||||
expect(container.querySelector("iframe")).not.toBeInTheDocument();
|
expect(container.querySelector("iframe")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks only the selected tab as pressed by the choreographed cursor", () => {
|
||||||
|
const { container } = renderBrowser({
|
||||||
|
activeIndex: 1,
|
||||||
|
frame: { ...frame, cursorClicking: true },
|
||||||
|
});
|
||||||
|
const tabs = container.querySelectorAll(".notebook-tab");
|
||||||
|
|
||||||
|
expect(tabs[1]).toHaveAttribute("data-cursor-clicking", "true");
|
||||||
|
expect(tabs[0]).toHaveAttribute("data-cursor-clicking", "false");
|
||||||
|
expect(tabs[2]).toHaveAttribute("data-cursor-clicking", "false");
|
||||||
|
expect(tabs[3]).toHaveAttribute("data-cursor-clicking", "false");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the selected tab to label the panel and screenshot", () => {
|
it("uses the selected tab to label the panel and screenshot", () => {
|
||||||
renderBrowser({ activeIndex: 2 });
|
renderBrowser({ activeIndex: 2 });
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ export default function NotebookBrowser({
|
|||||||
aria-controls={panelId}
|
aria-controls={panelId}
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className="notebook-tab"
|
className="notebook-tab"
|
||||||
|
data-cursor-clicking={
|
||||||
|
isSelected && frame.cursorClicking ? "true" : "false"
|
||||||
|
}
|
||||||
id={tabId}
|
id={tabId}
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={() => onSelectTab(index)}
|
onClick={() => onSelectTab(index)}
|
||||||
@@ -213,6 +216,9 @@ export default function NotebookBrowser({
|
|||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{frame.cursorVisible ? (
|
{frame.cursorVisible ? (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -225,8 +231,5 @@ export default function NotebookBrowser({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
import { useGLTF } from "@react-three/drei";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { portfolio } from "@/lib/portfolio-data";
|
import { portfolio } from "@/lib/portfolio-data";
|
||||||
import NotebookStage3D, {
|
import NotebookStage3D, {
|
||||||
NOTEBOOK_MODEL_URL,
|
getNotebookLidRotation,
|
||||||
type NotebookStage3DProps,
|
type NotebookStage3DProps,
|
||||||
} from "./NotebookStage3D";
|
} from "./NotebookStage3D";
|
||||||
import { selectNotebookProjects } from "./notebookProjects";
|
import { selectNotebookProjects } from "./notebookProjects";
|
||||||
|
|
||||||
jest.mock("@react-three/fiber", () => ({
|
type MockCanvasProps = {
|
||||||
Canvas: ({
|
|
||||||
children,
|
|
||||||
frameloop,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
frameloop: string;
|
frameloop?: string;
|
||||||
}) => (
|
};
|
||||||
|
|
||||||
|
const mockCanvas = jest.fn(({ children, frameloop }: MockCanvasProps) => (
|
||||||
<div data-frameloop={frameloop} data-testid="mock-canvas">
|
<div data-frameloop={frameloop} data-testid="mock-canvas">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
),
|
));
|
||||||
|
|
||||||
|
jest.mock("@react-three/fiber", () => ({
|
||||||
|
Canvas: (props: MockCanvasProps) => mockCanvas(props),
|
||||||
useFrame: jest.fn(),
|
useFrame: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -27,10 +27,9 @@ jest.mock("@react-three/drei", () => ({
|
|||||||
Html: ({ children }: { children: React.ReactNode }) => (
|
Html: ({ children }: { children: React.ReactNode }) => (
|
||||||
<div data-testid="mock-html">{children}</div>
|
<div data-testid="mock-html">{children}</div>
|
||||||
),
|
),
|
||||||
useGLTF: jest.fn(() => ({ scene: new THREE.Group() })),
|
RoundedBox: () => <div data-testid="mock-roundedbox" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockedUseGLTF = jest.mocked(useGLTF);
|
|
||||||
const projects = selectNotebookProjects(portfolio.projects);
|
const projects = selectNotebookProjects(portfolio.projects);
|
||||||
const props: NotebookStage3DProps = {
|
const props: NotebookStage3DProps = {
|
||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
@@ -40,25 +39,43 @@ const props: NotebookStage3DProps = {
|
|||||||
projects,
|
projects,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe("getNotebookLidRotation", () => {
|
||||||
|
it("keeps the lid closed at rest and opens around the hinge", () => {
|
||||||
|
expect(getNotebookLidRotation(0)).toBe(0);
|
||||||
|
expect(getNotebookLidRotation(112)).toBeCloseTo(
|
||||||
|
-THREE.MathUtils.degToRad(112),
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
expect(getNotebookLidRotation(56)).toBeCloseTo(
|
||||||
|
-THREE.MathUtils.degToRad(56),
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps angles outside the physical hinge range", () => {
|
||||||
|
expect(getNotebookLidRotation(-10)).toBe(0);
|
||||||
|
expect(getNotebookLidRotation(200)).toBe(getNotebookLidRotation(180));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("NotebookStage3D", () => {
|
describe("NotebookStage3D", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
mockedUseGLTF.mockImplementation((() => ({
|
|
||||||
scene: new THREE.Group(),
|
|
||||||
})) as never);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
it("renders the procedural laptop and the browser surface", () => {
|
||||||
it("renders a single stage using the local model and browser surface", () => {
|
const { container } = render(<NotebookStage3D {...props} />);
|
||||||
render(<NotebookStage3D {...props} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument();
|
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("mock-canvas")).toBeInTheDocument();
|
expect(screen.getByTestId("mock-canvas")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("notebook-browser")).toBeInTheDocument();
|
expect(screen.getByTestId("notebook-browser")).toBeInTheDocument();
|
||||||
expect(mockedUseGLTF).toHaveBeenCalledWith(NOTEBOOK_MODEL_URL);
|
expect(
|
||||||
|
screen.getAllByTestId("mock-roundedbox").length,
|
||||||
|
).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(container.querySelector("canvas")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pauses the renderer when the stage is outside the viewport", () => {
|
it("pauses the renderer when the stage is outside the viewport", () => {
|
||||||
@@ -70,12 +87,12 @@ describe("NotebookStage3D", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns usable browser content when the GLB boundary fails", () => {
|
it("returns usable browser content when the stage boundary fails", () => {
|
||||||
const error = new Error("GLB failed");
|
|
||||||
const onStageError = jest.fn();
|
const onStageError = jest.fn();
|
||||||
mockedUseGLTF.mockImplementation(() => {
|
mockCanvas.mockImplementation(() => {
|
||||||
throw error;
|
throw new Error("canvas failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<NotebookStage3D {...props} onStageError={onStageError} />);
|
render(<NotebookStage3D {...props} onStageError={onStageError} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Html, useGLTF } from "@react-three/drei";
|
import { Html, RoundedBox } from "@react-three/drei";
|
||||||
import { Canvas, useFrame } from "@react-three/fiber";
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
Suspense,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -17,8 +17,6 @@ import NotebookFallback from "./NotebookFallback";
|
|||||||
import type { NotebookProject } from "./notebookProjects";
|
import type { NotebookProject } from "./notebookProjects";
|
||||||
import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline";
|
import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline";
|
||||||
|
|
||||||
export const NOTEBOOK_MODEL_URL = "/models/laptop-kenney.glb";
|
|
||||||
|
|
||||||
type ProgressRef = { current: number };
|
type ProgressRef = { current: number };
|
||||||
|
|
||||||
export type NotebookStage3DProps = {
|
export type NotebookStage3DProps = {
|
||||||
@@ -32,96 +30,199 @@ export type NotebookStage3DProps = {
|
|||||||
|
|
||||||
type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">;
|
type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">;
|
||||||
|
|
||||||
type MaterialMap = {
|
/**
|
||||||
body: THREE.MeshStandardMaterial;
|
* Maps the timeline lid angle onto the hinge rotation. 0° = closed (lid
|
||||||
frame: THREE.MeshStandardMaterial;
|
* resting on the deck), 112° = open (screen facing the camera, reclined
|
||||||
|
* ~22° past vertical). The lid pivots around the hinge axis — never around
|
||||||
|
* the display center.
|
||||||
|
*/
|
||||||
|
export function getNotebookLidRotation(lidAngle: number): number {
|
||||||
|
const angle = THREE.MathUtils.clamp(lidAngle, 0, 180);
|
||||||
|
const radians = THREE.MathUtils.degToRad(angle);
|
||||||
|
return radians === 0 ? 0 : -radians;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The laptop is built procedurally in world units and the rig stays at scale
|
||||||
|
// 1 so the DOM overlay (drei Html transform) aligns with the physical screen.
|
||||||
|
const RIG_POSITION: [number, number, number] = [0, -0.45, 0];
|
||||||
|
const CAMERA_POSITION: [number, number, number] = [0, 2, 7.4];
|
||||||
|
const CAMERA_ROTATION: [number, number, number] = [-0.2, 0, 0];
|
||||||
|
|
||||||
|
const BASE = { width: 3.2, height: 0.16, depth: 2.2 };
|
||||||
|
const HINGE: [number, number, number] = [0, 0.16, -1.06];
|
||||||
|
const LID = { width: 3.2, thickness: 0.1, depth: 2.2 };
|
||||||
|
const SCREEN_INSET = { width: 2.9, depth: 1.96 };
|
||||||
|
const SCREEN_GLASS = { width: 2.8, depth: 1.86 };
|
||||||
|
|
||||||
|
const KEY_COLUMNS = 12;
|
||||||
|
const KEY_ROWS = 5;
|
||||||
|
const KEY_SIZE: [number, number, number] = [0.185, 0.05, 0.155];
|
||||||
|
const KEY_GAP_X = 0.212;
|
||||||
|
const KEY_GAP_Z = 0.2;
|
||||||
|
const KEYBOARD_Z_START = -0.9;
|
||||||
|
const KEYBOARD_Y = 0.185;
|
||||||
|
const SPACEBAR_Z = 0.12;
|
||||||
|
|
||||||
|
const SCREEN_DOM_DISTANCE_FACTOR = 2.2;
|
||||||
|
|
||||||
|
type LaptopMaterials = {
|
||||||
|
chassis: THREE.MeshStandardMaterial;
|
||||||
|
keys: THREE.MeshStandardMaterial;
|
||||||
|
dark: THREE.MeshStandardMaterial;
|
||||||
|
accent: THREE.MeshStandardMaterial;
|
||||||
screen: THREE.MeshStandardMaterial;
|
screen: THREE.MeshStandardMaterial;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MODEL_SCALE = 3.5;
|
function createLaptopMaterials(): LaptopMaterials {
|
||||||
const OPEN_LID_ANGLE = 112;
|
|
||||||
|
|
||||||
function createMaterials(): MaterialMap {
|
|
||||||
return {
|
return {
|
||||||
body: new THREE.MeshStandardMaterial({
|
chassis: new THREE.MeshStandardMaterial({
|
||||||
color: "#183333",
|
color: "#3f545b",
|
||||||
metalness: 0.78,
|
metalness: 0.35,
|
||||||
roughness: 0.3,
|
roughness: 0.32,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
}),
|
}),
|
||||||
frame: new THREE.MeshStandardMaterial({
|
keys: new THREE.MeshStandardMaterial({
|
||||||
color: "#3f6264",
|
color: "#5a747c",
|
||||||
metalness: 0.64,
|
metalness: 0.2,
|
||||||
roughness: 0.38,
|
roughness: 0.5,
|
||||||
|
transparent: true,
|
||||||
|
}),
|
||||||
|
dark: new THREE.MeshStandardMaterial({
|
||||||
|
color: "#131f24",
|
||||||
|
metalness: 0.15,
|
||||||
|
roughness: 0.55,
|
||||||
|
transparent: true,
|
||||||
|
}),
|
||||||
|
accent: new THREE.MeshStandardMaterial({
|
||||||
|
color: "#465f66",
|
||||||
|
metalness: 0.3,
|
||||||
|
roughness: 0.4,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
}),
|
}),
|
||||||
screen: new THREE.MeshStandardMaterial({
|
screen: new THREE.MeshStandardMaterial({
|
||||||
color: "#a8b8b8",
|
color: "#0d1417",
|
||||||
metalness: 0.05,
|
emissive: "#22343a",
|
||||||
roughness: 0.72,
|
emissiveIntensity: 0.55,
|
||||||
|
metalness: 0.1,
|
||||||
|
roughness: 0.4,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClonedNotebookScene = {
|
function setLaptopOpacity(materials: LaptopMaterials, opacity: number) {
|
||||||
root: THREE.Object3D;
|
for (const material of Object.values(materials)) {
|
||||||
materials: MaterialMap;
|
material.opacity = opacity;
|
||||||
meshes: THREE.Mesh[];
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
function cloneNotebookScene(scene: THREE.Object3D): ClonedNotebookScene {
|
function disposeLaptopMaterials(materials: LaptopMaterials) {
|
||||||
const root = scene.clone(true);
|
for (const material of Object.values(materials)) {
|
||||||
const materials = createMaterials();
|
material.dispose();
|
||||||
const meshes: THREE.Mesh[] = [];
|
}
|
||||||
|
}
|
||||||
|
|
||||||
root.traverse((object) => {
|
function LaptopKeys({ material }: { material: THREE.MeshStandardMaterial }) {
|
||||||
if (!(object instanceof THREE.Mesh)) return;
|
const ref = useRef<THREE.InstancedMesh>(null);
|
||||||
meshes.push(object);
|
const positions = useMemo(() => {
|
||||||
|
const list: Array<[number, number]> = [];
|
||||||
|
const startX = (-(KEY_COLUMNS - 1) * KEY_GAP_X) / 2;
|
||||||
|
for (let row = 0; row < KEY_ROWS; row += 1) {
|
||||||
|
for (let column = 0; column < KEY_COLUMNS; column += 1) {
|
||||||
|
list.push([
|
||||||
|
startX + column * KEY_GAP_X,
|
||||||
|
KEYBOARD_Z_START + row * KEY_GAP_Z,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, []);
|
||||||
|
|
||||||
object.geometry = object.geometry.clone();
|
useLayoutEffect(() => {
|
||||||
const mapMaterial = (sourceMaterial: THREE.Material) => {
|
const mesh = ref.current;
|
||||||
const materialName = sourceMaterial.name;
|
if (!(mesh instanceof THREE.InstancedMesh)) return;
|
||||||
return materialName === "metal"
|
|
||||||
? materials.screen
|
const matrix = new THREE.Matrix4();
|
||||||
: materialName === "metalMedium"
|
positions.forEach(([x, z], index) => {
|
||||||
? materials.frame
|
matrix.makeTranslation(x, KEYBOARD_Y, z);
|
||||||
: materials.body;
|
mesh.setMatrixAt(index, matrix);
|
||||||
};
|
|
||||||
object.material = Array.isArray(object.material)
|
|
||||||
? object.material.map(mapMaterial)
|
|
||||||
: mapMaterial(object.material);
|
|
||||||
object.frustumCulled = false;
|
|
||||||
});
|
});
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
}, [positions]);
|
||||||
|
|
||||||
return { root, materials, meshes };
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMaterialOpacity(materials: MaterialMap, opacity: number) {
|
|
||||||
materials.body.opacity = opacity;
|
|
||||||
materials.frame.opacity = opacity;
|
|
||||||
materials.screen.opacity = opacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
function disposeNotebookScene(cloned: ClonedNotebookScene) {
|
|
||||||
for (const mesh of cloned.meshes) {
|
|
||||||
mesh.geometry.dispose();
|
|
||||||
}
|
|
||||||
cloned.materials.body.dispose();
|
|
||||||
cloned.materials.frame.dispose();
|
|
||||||
cloned.materials.screen.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingNotebook() {
|
|
||||||
return (
|
return (
|
||||||
<group position={[0, -0.3, 0]}>
|
<instancedMesh
|
||||||
<mesh>
|
args={[undefined, undefined, positions.length]}
|
||||||
<boxGeometry args={[2.4, 0.16, 1.5]} />
|
material={material}
|
||||||
<meshStandardMaterial
|
ref={ref}
|
||||||
color="#102020"
|
>
|
||||||
metalness={0.7}
|
<boxGeometry args={KEY_SIZE} />
|
||||||
roughness={0.34}
|
</instancedMesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LaptopBase({ materials }: { materials: LaptopMaterials }) {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<RoundedBox
|
||||||
|
args={[BASE.width, BASE.height, BASE.depth]}
|
||||||
|
material={materials.chassis}
|
||||||
|
position={[0, BASE.height / 2, 0]}
|
||||||
|
radius={0.05}
|
||||||
|
smoothness={3}
|
||||||
/>
|
/>
|
||||||
|
<RoundedBox
|
||||||
|
args={[2.66, 0.05, 1.3]}
|
||||||
|
material={materials.dark}
|
||||||
|
position={[0, 0.155, -0.38]}
|
||||||
|
radius={0.02}
|
||||||
|
smoothness={2}
|
||||||
|
/>
|
||||||
|
<LaptopKeys material={materials.keys} />
|
||||||
|
<mesh material={materials.keys} position={[0, KEYBOARD_Y, SPACEBAR_Z]}>
|
||||||
|
<boxGeometry args={[1.05, KEY_SIZE[1], KEY_SIZE[2]]} />
|
||||||
|
</mesh>
|
||||||
|
<RoundedBox
|
||||||
|
args={[1.15, 0.02, 0.6]}
|
||||||
|
material={materials.accent}
|
||||||
|
position={[0, 0.17, 0.68]}
|
||||||
|
radius={0.01}
|
||||||
|
smoothness={2}
|
||||||
|
/>
|
||||||
|
<mesh
|
||||||
|
material={materials.dark}
|
||||||
|
position={[0, 0.16, -1.06]}
|
||||||
|
rotation={[0, 0, Math.PI / 2]}
|
||||||
|
>
|
||||||
|
<cylinderGeometry args={[0.05, 0.05, 2.9, 16]} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LaptopLid({ materials }: { materials: LaptopMaterials }) {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<RoundedBox
|
||||||
|
args={[LID.width, LID.thickness, LID.depth]}
|
||||||
|
material={materials.chassis}
|
||||||
|
position={[0, LID.thickness / 2, LID.depth / 2]}
|
||||||
|
radius={0.05}
|
||||||
|
smoothness={3}
|
||||||
|
/>
|
||||||
|
<RoundedBox
|
||||||
|
args={[SCREEN_INSET.width, 0.05, SCREEN_INSET.depth]}
|
||||||
|
material={materials.dark}
|
||||||
|
position={[0, 0, LID.depth / 2]}
|
||||||
|
radius={0.02}
|
||||||
|
smoothness={2}
|
||||||
|
/>
|
||||||
|
<mesh
|
||||||
|
material={materials.screen}
|
||||||
|
position={[0, -0.028, LID.depth / 2]}
|
||||||
|
rotation={[Math.PI / 2, 0, 0]}
|
||||||
|
>
|
||||||
|
<planeGeometry args={[SCREEN_GLASS.width, SCREEN_GLASS.depth]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
@@ -141,9 +242,8 @@ function ScreenSurface({
|
|||||||
return (
|
return (
|
||||||
<Html
|
<Html
|
||||||
center
|
center
|
||||||
distanceFactor={0.52}
|
distanceFactor={SCREEN_DOM_DISTANCE_FACTOR}
|
||||||
position={[-0.132, 0.087, 0.081]}
|
position={[0, -0.06, LID.depth / 2]}
|
||||||
transform
|
|
||||||
zIndexRange={[20, 0]}
|
zIndexRange={[20, 0]}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -168,33 +268,25 @@ function NotebookScene({
|
|||||||
progress,
|
progress,
|
||||||
projects,
|
projects,
|
||||||
}: NotebookSceneProps) {
|
}: NotebookSceneProps) {
|
||||||
const { scene } = useGLTF(NOTEBOOK_MODEL_URL);
|
|
||||||
const rig = useRef<THREE.Group>(null);
|
|
||||||
const lidRig = useRef<THREE.Group>(null);
|
const lidRig = useRef<THREE.Group>(null);
|
||||||
const smoothProgress = useRef(progress.current);
|
const smoothProgress = useRef(progress.current);
|
||||||
const elapsedForDom = useRef(0);
|
const elapsedForDom = useRef(0);
|
||||||
const [frame, setFrame] = useState(() =>
|
const [frame, setFrame] = useState(() =>
|
||||||
getNotebookFrame(progress.current, projects.length),
|
getNotebookFrame(progress.current, projects.length),
|
||||||
);
|
);
|
||||||
const cloned = useMemo(() => cloneNotebookScene(scene), [scene]);
|
const materials = useMemo(createLaptopMaterials, []);
|
||||||
const lid = cloned.root.getObjectByName("Laptop_Lid");
|
|
||||||
const base = cloned.root.getObjectByName("Laptop_Base");
|
useEffect(() => () => disposeLaptopMaterials(materials), [materials]);
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
smoothProgress.current +=
|
smoothProgress.current +=
|
||||||
(progress.current - smoothProgress.current) * Math.min(1, delta * 8);
|
(progress.current - smoothProgress.current) * Math.min(1, delta * 8);
|
||||||
const nextFrame = getNotebookFrame(smoothProgress.current, projects.length);
|
const nextFrame = getNotebookFrame(smoothProgress.current, projects.length);
|
||||||
|
|
||||||
if (rig.current) {
|
|
||||||
rig.current.position.set(0.46, -0.34, 0);
|
|
||||||
rig.current.scale.setScalar(MODEL_SCALE);
|
|
||||||
}
|
|
||||||
if (lidRig.current) {
|
if (lidRig.current) {
|
||||||
lidRig.current.rotation.x = THREE.MathUtils.degToRad(
|
lidRig.current.rotation.x = getNotebookLidRotation(nextFrame.lidAngle);
|
||||||
nextFrame.lidAngle - OPEN_LID_ANGLE,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
updateMaterialOpacity(cloned.materials, nextFrame.deviceOpacity);
|
setLaptopOpacity(materials, nextFrame.deviceOpacity);
|
||||||
|
|
||||||
elapsedForDom.current += delta;
|
elapsedForDom.current += delta;
|
||||||
if (elapsedForDom.current >= 1 / 24) {
|
if (elapsedForDom.current >= 1 / 24) {
|
||||||
@@ -203,33 +295,15 @@ function NotebookScene({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => () => disposeNotebookScene(cloned), [cloned]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={rig}>
|
<group position={RIG_POSITION}>
|
||||||
<ambientLight intensity={0.66} />
|
<ambientLight intensity={1.35} />
|
||||||
<directionalLight color="#f4f2ed" intensity={1.8} position={[3, 5, 4]} />
|
<hemisphereLight args={["#f4f2ed", "#0a0a0a", 1.15]} />
|
||||||
<directionalLight
|
<directionalLight color="#f4f2ed" intensity={2.6} position={[3, 6, 6]} />
|
||||||
color="#ccff00"
|
<directionalLight color="#ccff00" intensity={0.7} position={[-5, 2, 3]} />
|
||||||
intensity={0.45}
|
<LaptopBase materials={materials} />
|
||||||
position={[-4, 1, 2]}
|
<group position={HINGE} ref={lidRig}>
|
||||||
/>
|
<LaptopLid materials={materials} />
|
||||||
{base && lid ? (
|
|
||||||
<>
|
|
||||||
<primitive object={base} />
|
|
||||||
<primitive ref={lidRig} object={lid}>
|
|
||||||
<ScreenSurface
|
|
||||||
activeIndex={activeIndex}
|
|
||||||
frame={frame}
|
|
||||||
onSelectTab={onSelectTab}
|
|
||||||
projects={projects}
|
|
||||||
/>
|
|
||||||
</primitive>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<primitive object={cloned.root} />
|
|
||||||
<group ref={lidRig}>
|
|
||||||
<ScreenSurface
|
<ScreenSurface
|
||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
frame={frame}
|
frame={frame}
|
||||||
@@ -237,8 +311,6 @@ function NotebookScene({
|
|||||||
projects={projects}
|
projects={projects}
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -269,7 +341,11 @@ class NotebookStageBoundary extends Component<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ fov: 30, position: [0, 0, 6.8] }}
|
camera={{
|
||||||
|
fov: 30,
|
||||||
|
position: CAMERA_POSITION,
|
||||||
|
rotation: CAMERA_ROTATION,
|
||||||
|
}}
|
||||||
className="notebook-story-canvas"
|
className="notebook-story-canvas"
|
||||||
dpr={[1, 1.5]}
|
dpr={[1, 1.5]}
|
||||||
frameloop={this.props.isVisible ? "always" : "never"}
|
frameloop={this.props.isVisible ? "always" : "never"}
|
||||||
@@ -279,14 +355,12 @@ class NotebookStageBoundary extends Component<
|
|||||||
powerPreference: "high-performance",
|
powerPreference: "high-performance",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<LoadingNotebook />}>
|
|
||||||
<NotebookScene
|
<NotebookScene
|
||||||
activeIndex={this.props.activeIndex}
|
activeIndex={this.props.activeIndex}
|
||||||
onSelectTab={this.props.onSelectTab}
|
onSelectTab={this.props.onSelectTab}
|
||||||
progress={this.props.progress}
|
progress={this.props.progress}
|
||||||
projects={this.props.projects}
|
projects={this.props.projects}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
|
||||||
</Canvas>
|
</Canvas>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ describe("getNotebookFrame", () => {
|
|||||||
expect(getNotebookFrame(0.4, 4).cursorVisible).toBe(true);
|
expect(getNotebookFrame(0.4, 4).cursorVisible).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("turns the screen off before the lid closes", () => {
|
||||||
|
const screenOff = getNotebookFrame(0.9, 4);
|
||||||
|
const closing = getNotebookFrame(0.95, 4);
|
||||||
|
|
||||||
|
expect(screenOff).toMatchObject({
|
||||||
|
phase: "close",
|
||||||
|
lidAngle: 112,
|
||||||
|
screenOpacity: 0,
|
||||||
|
});
|
||||||
|
expect(closing.screenOpacity).toBe(0);
|
||||||
|
expect(closing.lidAngle).toBeGreaterThan(0);
|
||||||
|
expect(closing.lidAngle).toBeLessThan(112);
|
||||||
|
});
|
||||||
|
|
||||||
it("selects each tab only inside the browse window", () => {
|
it("selects each tab only inside the browse window", () => {
|
||||||
expect(getNotebookFrame(0.43, 4).activeTabIndex).toBe(-1);
|
expect(getNotebookFrame(0.43, 4).activeTabIndex).toBe(-1);
|
||||||
expect(getNotebookFrame(0.48, 4).activeTabIndex).toBe(0);
|
expect(getNotebookFrame(0.48, 4).activeTabIndex).toBe(0);
|
||||||
@@ -82,8 +96,20 @@ describe("getNotebookFrame", () => {
|
|||||||
|
|
||||||
expect(moving.cursorX).toBeGreaterThan(previous.cursorX);
|
expect(moving.cursorX).toBeGreaterThan(previous.cursorX);
|
||||||
expect(moving.cursorX).toBeLessThan(target.cursorX);
|
expect(moving.cursorX).toBeLessThan(target.cursorX);
|
||||||
expect(moving.cursorY).toBeLessThan(previous.cursorY);
|
expect(moving.cursorY).toBe(target.cursorY);
|
||||||
expect(moving.cursorY).toBeGreaterThan(target.cursorY);
|
});
|
||||||
|
|
||||||
|
it("lands the click pulse on the center of the selected tab", () => {
|
||||||
|
for (const progress of [0.475, 0.56, 0.65, 0.735]) {
|
||||||
|
const frame = getNotebookFrame(progress, 4);
|
||||||
|
expect(frame.cursorClicking).toBe(true);
|
||||||
|
expect(frame.cursorX).toBeCloseTo((frame.activeTabIndex + 0.5) / 4, 6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks the real number of tabs", () => {
|
||||||
|
expect(getNotebookFrame(0.68, 2).cursorX).toBeCloseTo(0.75, 6);
|
||||||
|
expect(getNotebookFrame(0.6, 3).cursorX).toBeCloseTo(0.5, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is deterministic when rewound to the same progress", () => {
|
it("is deterministic when rewound to the same progress", () => {
|
||||||
|
|||||||
@@ -23,12 +23,9 @@ const OPEN_ANGLE = 112;
|
|||||||
const BROWSE_START = 0.44;
|
const BROWSE_START = 0.44;
|
||||||
const BROWSE_END = 0.78;
|
const BROWSE_END = 0.78;
|
||||||
const HOLD_END = 0.87;
|
const HOLD_END = 0.87;
|
||||||
const CURSOR_POINTS = [
|
const CLOSE_LID_START = 0.9;
|
||||||
{ x: 0.26, y: 0.68 },
|
const TAB_CLICK_Y = 0.3;
|
||||||
{ x: 0.58, y: 0.32 },
|
const PARK = { x: 0.5, y: 0.95 };
|
||||||
{ x: 0.74, y: 0.72 },
|
|
||||||
{ x: 0.38, y: 0.28 },
|
|
||||||
];
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
return Math.min(Math.max(value, min), max);
|
return Math.min(Math.max(value, min), max);
|
||||||
@@ -77,6 +74,11 @@ function getActiveTabIndex(
|
|||||||
return clamp(Math.floor(browseProgress * tabCount), 0, tabCount - 1);
|
return clamp(Math.floor(browseProgress * tabCount), 0, tabCount - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tabCenterX(index: number, tabCount: number): number {
|
||||||
|
if (tabCount < 1) return PARK.x;
|
||||||
|
return (index + 0.5) / tabCount;
|
||||||
|
}
|
||||||
|
|
||||||
function getCursorPoint(
|
function getCursorPoint(
|
||||||
progress: number,
|
progress: number,
|
||||||
phase: NotebookPhase,
|
phase: NotebookPhase,
|
||||||
@@ -86,27 +88,30 @@ function getCursorPoint(
|
|||||||
if (phase === "cursor-in") {
|
if (phase === "cursor-in") {
|
||||||
const cursorProgress = (progress - 0.34) / 0.1;
|
const cursorProgress = (progress - 0.34) / 0.1;
|
||||||
return {
|
return {
|
||||||
x: interpolate(0.06, 0.26, cursorProgress),
|
x: interpolate(PARK.x, tabCenterX(0, tabCount), cursorProgress),
|
||||||
y: interpolate(0.88, 0.68, cursorProgress),
|
y: interpolate(PARK.y, TAB_CLICK_Y, cursorProgress),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTabIndex < 0) {
|
if (activeTabIndex < 0) {
|
||||||
return { x: 0.06, y: 0.88 };
|
return { ...PARK };
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = CURSOR_POINTS[activeTabIndex % CURSOR_POINTS.length];
|
const target = { x: tabCenterX(activeTabIndex, tabCount), y: TAB_CLICK_Y };
|
||||||
if (phase !== "browse" || activeTabIndex === 0) return target;
|
if (phase !== "browse" || activeTabIndex === 0) return target;
|
||||||
|
|
||||||
const browseProgress =
|
const browseProgress =
|
||||||
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
|
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
|
||||||
const slotProgress = browseProgress * tabCount - activeTabIndex;
|
const slotProgress = browseProgress * tabCount - activeTabIndex;
|
||||||
const previous = CURSOR_POINTS[(activeTabIndex - 1) % CURSOR_POINTS.length];
|
const previous = {
|
||||||
|
x: tabCenterX(activeTabIndex - 1, tabCount),
|
||||||
|
y: TAB_CLICK_Y,
|
||||||
|
};
|
||||||
const moveProgress = clamp(slotProgress / 0.36, 0, 1);
|
const moveProgress = clamp(slotProgress / 0.36, 0, 1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: interpolate(previous.x, target.x, moveProgress),
|
x: interpolate(previous.x, target.x, moveProgress),
|
||||||
y: interpolate(previous.y, target.y, moveProgress),
|
y: target.y,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,10 +163,12 @@ export function getNotebookFrame(
|
|||||||
const lidAngle = isOpening
|
const lidAngle = isOpening
|
||||||
? interpolate(0, OPEN_ANGLE, (normalizedProgress - 0.1) / 0.15)
|
? interpolate(0, OPEN_ANGLE, (normalizedProgress - 0.1) / 0.15)
|
||||||
: isClosing
|
: isClosing
|
||||||
? interpolate(
|
? normalizedProgress < CLOSE_LID_START
|
||||||
|
? OPEN_ANGLE
|
||||||
|
: interpolate(
|
||||||
OPEN_ANGLE,
|
OPEN_ANGLE,
|
||||||
0,
|
0,
|
||||||
(normalizedProgress - HOLD_END) / (1 - HOLD_END),
|
(normalizedProgress - CLOSE_LID_START) / (1 - CLOSE_LID_START),
|
||||||
)
|
)
|
||||||
: normalizedProgress >= 0.25
|
: normalizedProgress >= 0.25
|
||||||
? OPEN_ANGLE
|
? OPEN_ANGLE
|
||||||
@@ -171,7 +178,11 @@ export function getNotebookFrame(
|
|||||||
: normalizedProgress >= 0.34 && !isClosing
|
: normalizedProgress >= 0.34 && !isClosing
|
||||||
? 1
|
? 1
|
||||||
: isClosing
|
: isClosing
|
||||||
? interpolate(1, 0, (normalizedProgress - HOLD_END) / (1 - HOLD_END))
|
? interpolate(
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
(normalizedProgress - HOLD_END) / (CLOSE_LID_START - HOLD_END),
|
||||||
|
)
|
||||||
: 0;
|
: 0;
|
||||||
const deviceOpacity = isApproach
|
const deviceOpacity = isApproach
|
||||||
? interpolate(0, 1, normalizedProgress / 0.1)
|
? interpolate(0, 1, normalizedProgress / 0.1)
|
||||||
|
|||||||
+18
-3
@@ -905,6 +905,11 @@ textarea {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.notebook-story-canvas > div:first-child,
|
||||||
|
.notebook-story-canvas canvas {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.notebook-story-static-content {
|
.notebook-story-static-content {
|
||||||
width: min(1080px, 100%);
|
width: min(1080px, 100%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -919,6 +924,7 @@ textarea {
|
|||||||
|
|
||||||
.notebook-screen-surface {
|
.notebook-screen-surface {
|
||||||
width: min(660px, 68vw);
|
width: min(660px, 68vw);
|
||||||
|
pointer-events: auto;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
@@ -1036,7 +1042,8 @@ textarea {
|
|||||||
transition:
|
transition:
|
||||||
color 180ms ease,
|
color 180ms ease,
|
||||||
border-color 180ms ease,
|
border-color 180ms ease,
|
||||||
background-color 180ms ease;
|
background-color 180ms ease,
|
||||||
|
transform 160ms var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.notebook-tab:hover,
|
.notebook-tab:hover,
|
||||||
@@ -1046,6 +1053,10 @@ textarea {
|
|||||||
color: var(--white);
|
color: var(--white);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notebook-tab[data-cursor-clicking="true"] {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
.notebook-tab-number {
|
.notebook-tab-number {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: var(--slash);
|
color: var(--slash);
|
||||||
@@ -1199,14 +1210,14 @@ textarea {
|
|||||||
|
|
||||||
.notebook-cursor {
|
.notebook-cursor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 3;
|
||||||
width: 15px;
|
width: 15px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border: 2px solid var(--white);
|
border: 2px solid var(--white);
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
box-shadow: 2px 2px 0 var(--slash);
|
box-shadow: 2px 2px 0 var(--slash);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translate(-2px, -2px) rotate(-18deg);
|
transform: translate(-50%, -45%) rotate(-18deg);
|
||||||
transition:
|
transition:
|
||||||
width 120ms ease,
|
width 120ms ease,
|
||||||
height 120ms ease,
|
height 120ms ease,
|
||||||
@@ -1296,4 +1307,8 @@ textarea {
|
|||||||
transition: none;
|
transition: none;
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notebook-tab[data-cursor-clicking="true"] {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
Reference in new issue
Block a user