feat(story): build procedural notebook animation
This commit is contained in:
1 parent
f3ed9da777
commit
b079afdd73
9 files changed
+360
-213
No files matched your search
-10
@@ -1,15 +1,5 @@
|
||||
# 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
|
||||
- `public/assets/prints/*.png` are captures taken with headless Chrome from
|
||||
production deployments owned by the site author (evandro.dev.br), used as
|
||||
|
||||
@@ -126,18 +126,29 @@ describe("NotebookBrowser", () => {
|
||||
const { container } = renderBrowser({
|
||||
frame: { ...frame, cursorClicking: true },
|
||||
});
|
||||
const cursor = container.querySelector(".notebook-cursor");
|
||||
|
||||
expect(container.querySelector(".notebook-cursor")).toHaveAttribute(
|
||||
"aria-hidden",
|
||||
"true",
|
||||
expect(cursor).toHaveAttribute("aria-hidden", "true");
|
||||
expect(cursor).toHaveStyle({ left: "40%", top: "60%" });
|
||||
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();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
renderBrowser({ activeIndex: 2 });
|
||||
|
||||
|
||||
@@ -142,6 +142,9 @@ export default function NotebookBrowser({
|
||||
aria-controls={panelId}
|
||||
aria-selected={isSelected}
|
||||
className="notebook-tab"
|
||||
data-cursor-clicking={
|
||||
isSelected && frame.cursorClicking ? "true" : "false"
|
||||
}
|
||||
id={tabId}
|
||||
key={project.id}
|
||||
onClick={() => onSelectTab(index)}
|
||||
@@ -213,20 +216,20 @@ export default function NotebookBrowser({
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
{frame.cursorVisible ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`notebook-cursor${frame.cursorClicking ? " is-clicking" : ""}`}
|
||||
data-testid="notebook-cursor"
|
||||
style={{
|
||||
left: `${frame.cursorX * 100}%`,
|
||||
top: `${frame.cursorY * 100}%`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{frame.cursorVisible ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`notebook-cursor${frame.cursorClicking ? " is-clicking" : ""}`}
|
||||
data-testid="notebook-cursor"
|
||||
style={{
|
||||
left: `${frame.cursorX * 100}%`,
|
||||
top: `${frame.cursorY * 100}%`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import * as THREE from "three";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import NotebookStage3D, {
|
||||
NOTEBOOK_MODEL_URL,
|
||||
getNotebookLidRotation,
|
||||
type NotebookStage3DProps,
|
||||
} from "./NotebookStage3D";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
type MockCanvasProps = {
|
||||
children: React.ReactNode;
|
||||
frameloop?: string;
|
||||
};
|
||||
|
||||
const mockCanvas = jest.fn(({ children, frameloop }: MockCanvasProps) => (
|
||||
<div data-frameloop={frameloop} data-testid="mock-canvas">
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
|
||||
jest.mock("@react-three/fiber", () => ({
|
||||
Canvas: ({
|
||||
children,
|
||||
frameloop,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
frameloop: string;
|
||||
}) => (
|
||||
<div data-frameloop={frameloop} data-testid="mock-canvas">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Canvas: (props: MockCanvasProps) => mockCanvas(props),
|
||||
useFrame: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -27,10 +27,9 @@ jest.mock("@react-three/drei", () => ({
|
||||
Html: ({ children }: { children: React.ReactNode }) => (
|
||||
<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 props: NotebookStage3DProps = {
|
||||
activeIndex: 0,
|
||||
@@ -40,25 +39,43 @@ const props: NotebookStage3DProps = {
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
mockedUseGLTF.mockImplementation((() => ({
|
||||
scene: new THREE.Group(),
|
||||
})) as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a single stage using the local model and browser surface", () => {
|
||||
render(<NotebookStage3D {...props} />);
|
||||
it("renders the procedural laptop and the browser surface", () => {
|
||||
const { container } = render(<NotebookStage3D {...props} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-canvas")).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", () => {
|
||||
@@ -70,12 +87,12 @@ describe("NotebookStage3D", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns usable browser content when the GLB boundary fails", () => {
|
||||
const error = new Error("GLB failed");
|
||||
it("returns usable browser content when the stage boundary fails", () => {
|
||||
const onStageError = jest.fn();
|
||||
mockedUseGLTF.mockImplementation(() => {
|
||||
throw error;
|
||||
mockCanvas.mockImplementation(() => {
|
||||
throw new Error("canvas failed");
|
||||
});
|
||||
|
||||
render(<NotebookStage3D {...props} onStageError={onStageError} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Html, useGLTF } from "@react-three/drei";
|
||||
import { Html, RoundedBox } from "@react-three/drei";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
Component,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -17,8 +17,6 @@ import NotebookFallback from "./NotebookFallback";
|
||||
import type { NotebookProject } from "./notebookProjects";
|
||||
import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline";
|
||||
|
||||
export const NOTEBOOK_MODEL_URL = "/models/laptop-kenney.glb";
|
||||
|
||||
type ProgressRef = { current: number };
|
||||
|
||||
export type NotebookStage3DProps = {
|
||||
@@ -32,96 +30,199 @@ export type NotebookStage3DProps = {
|
||||
|
||||
type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">;
|
||||
|
||||
type MaterialMap = {
|
||||
body: THREE.MeshStandardMaterial;
|
||||
frame: THREE.MeshStandardMaterial;
|
||||
/**
|
||||
* Maps the timeline lid angle onto the hinge rotation. 0° = closed (lid
|
||||
* 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;
|
||||
};
|
||||
|
||||
const MODEL_SCALE = 3.5;
|
||||
const OPEN_LID_ANGLE = 112;
|
||||
|
||||
function createMaterials(): MaterialMap {
|
||||
function createLaptopMaterials(): LaptopMaterials {
|
||||
return {
|
||||
body: new THREE.MeshStandardMaterial({
|
||||
color: "#183333",
|
||||
metalness: 0.78,
|
||||
roughness: 0.3,
|
||||
chassis: new THREE.MeshStandardMaterial({
|
||||
color: "#3f545b",
|
||||
metalness: 0.35,
|
||||
roughness: 0.32,
|
||||
transparent: true,
|
||||
}),
|
||||
frame: new THREE.MeshStandardMaterial({
|
||||
color: "#3f6264",
|
||||
metalness: 0.64,
|
||||
roughness: 0.38,
|
||||
keys: new THREE.MeshStandardMaterial({
|
||||
color: "#5a747c",
|
||||
metalness: 0.2,
|
||||
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,
|
||||
}),
|
||||
screen: new THREE.MeshStandardMaterial({
|
||||
color: "#a8b8b8",
|
||||
metalness: 0.05,
|
||||
roughness: 0.72,
|
||||
color: "#0d1417",
|
||||
emissive: "#22343a",
|
||||
emissiveIntensity: 0.55,
|
||||
metalness: 0.1,
|
||||
roughness: 0.4,
|
||||
transparent: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
type ClonedNotebookScene = {
|
||||
root: THREE.Object3D;
|
||||
materials: MaterialMap;
|
||||
meshes: THREE.Mesh[];
|
||||
};
|
||||
|
||||
function cloneNotebookScene(scene: THREE.Object3D): ClonedNotebookScene {
|
||||
const root = scene.clone(true);
|
||||
const materials = createMaterials();
|
||||
const meshes: THREE.Mesh[] = [];
|
||||
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
meshes.push(object);
|
||||
|
||||
object.geometry = object.geometry.clone();
|
||||
const mapMaterial = (sourceMaterial: THREE.Material) => {
|
||||
const materialName = sourceMaterial.name;
|
||||
return materialName === "metal"
|
||||
? materials.screen
|
||||
: materialName === "metalMedium"
|
||||
? materials.frame
|
||||
: materials.body;
|
||||
};
|
||||
object.material = Array.isArray(object.material)
|
||||
? object.material.map(mapMaterial)
|
||||
: mapMaterial(object.material);
|
||||
object.frustumCulled = false;
|
||||
});
|
||||
|
||||
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();
|
||||
function setLaptopOpacity(materials: LaptopMaterials, opacity: number) {
|
||||
for (const material of Object.values(materials)) {
|
||||
material.opacity = opacity;
|
||||
}
|
||||
cloned.materials.body.dispose();
|
||||
cloned.materials.frame.dispose();
|
||||
cloned.materials.screen.dispose();
|
||||
}
|
||||
|
||||
function LoadingNotebook() {
|
||||
function disposeLaptopMaterials(materials: LaptopMaterials) {
|
||||
for (const material of Object.values(materials)) {
|
||||
material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function LaptopKeys({ material }: { material: THREE.MeshStandardMaterial }) {
|
||||
const ref = useRef<THREE.InstancedMesh>(null);
|
||||
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;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mesh = ref.current;
|
||||
if (!(mesh instanceof THREE.InstancedMesh)) return;
|
||||
|
||||
const matrix = new THREE.Matrix4();
|
||||
positions.forEach(([x, z], index) => {
|
||||
matrix.makeTranslation(x, KEYBOARD_Y, z);
|
||||
mesh.setMatrixAt(index, matrix);
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
}, [positions]);
|
||||
|
||||
return (
|
||||
<group position={[0, -0.3, 0]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[2.4, 0.16, 1.5]} />
|
||||
<meshStandardMaterial
|
||||
color="#102020"
|
||||
metalness={0.7}
|
||||
roughness={0.34}
|
||||
/>
|
||||
<instancedMesh
|
||||
args={[undefined, undefined, positions.length]}
|
||||
material={material}
|
||||
ref={ref}
|
||||
>
|
||||
<boxGeometry args={KEY_SIZE} />
|
||||
</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>
|
||||
</group>
|
||||
);
|
||||
@@ -141,9 +242,8 @@ function ScreenSurface({
|
||||
return (
|
||||
<Html
|
||||
center
|
||||
distanceFactor={0.52}
|
||||
position={[-0.132, 0.087, 0.081]}
|
||||
transform
|
||||
distanceFactor={SCREEN_DOM_DISTANCE_FACTOR}
|
||||
position={[0, -0.06, LID.depth / 2]}
|
||||
zIndexRange={[20, 0]}
|
||||
>
|
||||
<div
|
||||
@@ -168,33 +268,25 @@ function NotebookScene({
|
||||
progress,
|
||||
projects,
|
||||
}: NotebookSceneProps) {
|
||||
const { scene } = useGLTF(NOTEBOOK_MODEL_URL);
|
||||
const rig = useRef<THREE.Group>(null);
|
||||
const lidRig = useRef<THREE.Group>(null);
|
||||
const smoothProgress = useRef(progress.current);
|
||||
const elapsedForDom = useRef(0);
|
||||
const [frame, setFrame] = useState(() =>
|
||||
getNotebookFrame(progress.current, projects.length),
|
||||
);
|
||||
const cloned = useMemo(() => cloneNotebookScene(scene), [scene]);
|
||||
const lid = cloned.root.getObjectByName("Laptop_Lid");
|
||||
const base = cloned.root.getObjectByName("Laptop_Base");
|
||||
const materials = useMemo(createLaptopMaterials, []);
|
||||
|
||||
useEffect(() => () => disposeLaptopMaterials(materials), [materials]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
smoothProgress.current +=
|
||||
(progress.current - smoothProgress.current) * Math.min(1, delta * 8);
|
||||
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) {
|
||||
lidRig.current.rotation.x = THREE.MathUtils.degToRad(
|
||||
nextFrame.lidAngle - OPEN_LID_ANGLE,
|
||||
);
|
||||
lidRig.current.rotation.x = getNotebookLidRotation(nextFrame.lidAngle);
|
||||
}
|
||||
updateMaterialOpacity(cloned.materials, nextFrame.deviceOpacity);
|
||||
setLaptopOpacity(materials, nextFrame.deviceOpacity);
|
||||
|
||||
elapsedForDom.current += delta;
|
||||
if (elapsedForDom.current >= 1 / 24) {
|
||||
@@ -203,42 +295,22 @@ function NotebookScene({
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => () => disposeNotebookScene(cloned), [cloned]);
|
||||
|
||||
return (
|
||||
<group ref={rig}>
|
||||
<ambientLight intensity={0.66} />
|
||||
<directionalLight color="#f4f2ed" intensity={1.8} position={[3, 5, 4]} />
|
||||
<directionalLight
|
||||
color="#ccff00"
|
||||
intensity={0.45}
|
||||
position={[-4, 1, 2]}
|
||||
/>
|
||||
{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
|
||||
activeIndex={activeIndex}
|
||||
frame={frame}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
</group>
|
||||
</>
|
||||
)}
|
||||
<group position={RIG_POSITION}>
|
||||
<ambientLight intensity={1.35} />
|
||||
<hemisphereLight args={["#f4f2ed", "#0a0a0a", 1.15]} />
|
||||
<directionalLight color="#f4f2ed" intensity={2.6} position={[3, 6, 6]} />
|
||||
<directionalLight color="#ccff00" intensity={0.7} position={[-5, 2, 3]} />
|
||||
<LaptopBase materials={materials} />
|
||||
<group position={HINGE} ref={lidRig}>
|
||||
<LaptopLid materials={materials} />
|
||||
<ScreenSurface
|
||||
activeIndex={activeIndex}
|
||||
frame={frame}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -269,7 +341,11 @@ class NotebookStageBoundary extends Component<
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ fov: 30, position: [0, 0, 6.8] }}
|
||||
camera={{
|
||||
fov: 30,
|
||||
position: CAMERA_POSITION,
|
||||
rotation: CAMERA_ROTATION,
|
||||
}}
|
||||
className="notebook-story-canvas"
|
||||
dpr={[1, 1.5]}
|
||||
frameloop={this.props.isVisible ? "always" : "never"}
|
||||
@@ -279,14 +355,12 @@ class NotebookStageBoundary extends Component<
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<LoadingNotebook />}>
|
||||
<NotebookScene
|
||||
activeIndex={this.props.activeIndex}
|
||||
onSelectTab={this.props.onSelectTab}
|
||||
progress={this.props.progress}
|
||||
projects={this.props.projects}
|
||||
/>
|
||||
</Suspense>
|
||||
<NotebookScene
|
||||
activeIndex={this.props.activeIndex}
|
||||
onSelectTab={this.props.onSelectTab}
|
||||
progress={this.props.progress}
|
||||
projects={this.props.projects}
|
||||
/>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,20 @@ describe("getNotebookFrame", () => {
|
||||
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", () => {
|
||||
expect(getNotebookFrame(0.43, 4).activeTabIndex).toBe(-1);
|
||||
expect(getNotebookFrame(0.48, 4).activeTabIndex).toBe(0);
|
||||
@@ -82,8 +96,20 @@ describe("getNotebookFrame", () => {
|
||||
|
||||
expect(moving.cursorX).toBeGreaterThan(previous.cursorX);
|
||||
expect(moving.cursorX).toBeLessThan(target.cursorX);
|
||||
expect(moving.cursorY).toBeLessThan(previous.cursorY);
|
||||
expect(moving.cursorY).toBeGreaterThan(target.cursorY);
|
||||
expect(moving.cursorY).toBe(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", () => {
|
||||
|
||||
@@ -23,12 +23,9 @@ const OPEN_ANGLE = 112;
|
||||
const BROWSE_START = 0.44;
|
||||
const BROWSE_END = 0.78;
|
||||
const HOLD_END = 0.87;
|
||||
const CURSOR_POINTS = [
|
||||
{ x: 0.26, y: 0.68 },
|
||||
{ x: 0.58, y: 0.32 },
|
||||
{ x: 0.74, y: 0.72 },
|
||||
{ x: 0.38, y: 0.28 },
|
||||
];
|
||||
const CLOSE_LID_START = 0.9;
|
||||
const TAB_CLICK_Y = 0.3;
|
||||
const PARK = { x: 0.5, y: 0.95 };
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
@@ -77,6 +74,11 @@ function getActiveTabIndex(
|
||||
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(
|
||||
progress: number,
|
||||
phase: NotebookPhase,
|
||||
@@ -86,27 +88,30 @@ function getCursorPoint(
|
||||
if (phase === "cursor-in") {
|
||||
const cursorProgress = (progress - 0.34) / 0.1;
|
||||
return {
|
||||
x: interpolate(0.06, 0.26, cursorProgress),
|
||||
y: interpolate(0.88, 0.68, cursorProgress),
|
||||
x: interpolate(PARK.x, tabCenterX(0, tabCount), cursorProgress),
|
||||
y: interpolate(PARK.y, TAB_CLICK_Y, cursorProgress),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const browseProgress =
|
||||
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
|
||||
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);
|
||||
|
||||
return {
|
||||
x: interpolate(previous.x, target.x, moveProgress),
|
||||
y: interpolate(previous.y, target.y, moveProgress),
|
||||
y: target.y,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,11 +163,13 @@ export function getNotebookFrame(
|
||||
const lidAngle = isOpening
|
||||
? interpolate(0, OPEN_ANGLE, (normalizedProgress - 0.1) / 0.15)
|
||||
: isClosing
|
||||
? interpolate(
|
||||
OPEN_ANGLE,
|
||||
0,
|
||||
(normalizedProgress - HOLD_END) / (1 - HOLD_END),
|
||||
)
|
||||
? normalizedProgress < CLOSE_LID_START
|
||||
? OPEN_ANGLE
|
||||
: interpolate(
|
||||
OPEN_ANGLE,
|
||||
0,
|
||||
(normalizedProgress - CLOSE_LID_START) / (1 - CLOSE_LID_START),
|
||||
)
|
||||
: normalizedProgress >= 0.25
|
||||
? OPEN_ANGLE
|
||||
: 0;
|
||||
@@ -171,7 +178,11 @@ export function getNotebookFrame(
|
||||
: normalizedProgress >= 0.34 && !isClosing
|
||||
? 1
|
||||
: isClosing
|
||||
? interpolate(1, 0, (normalizedProgress - HOLD_END) / (1 - HOLD_END))
|
||||
? interpolate(
|
||||
1,
|
||||
0,
|
||||
(normalizedProgress - HOLD_END) / (CLOSE_LID_START - HOLD_END),
|
||||
)
|
||||
: 0;
|
||||
const deviceOpacity = isApproach
|
||||
? interpolate(0, 1, normalizedProgress / 0.1)
|
||||
|
||||
+18
-3
@@ -905,6 +905,11 @@ textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.notebook-story-canvas > div:first-child,
|
||||
.notebook-story-canvas canvas {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notebook-story-static-content {
|
||||
width: min(1080px, 100%);
|
||||
margin: 0 auto;
|
||||
@@ -919,6 +924,7 @@ textarea {
|
||||
|
||||
.notebook-screen-surface {
|
||||
width: min(660px, 68vw);
|
||||
pointer-events: auto;
|
||||
min-width: 280px;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
@@ -1036,7 +1042,8 @@ textarea {
|
||||
transition:
|
||||
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,
|
||||
@@ -1046,6 +1053,10 @@ textarea {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.notebook-tab[data-cursor-clicking="true"] {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.notebook-tab-number {
|
||||
flex: 0 0 auto;
|
||||
color: var(--slash);
|
||||
@@ -1199,14 +1210,14 @@ textarea {
|
||||
|
||||
.notebook-cursor {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
z-index: 3;
|
||||
width: 15px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--white);
|
||||
background: var(--ink);
|
||||
box-shadow: 2px 2px 0 var(--slash);
|
||||
pointer-events: none;
|
||||
transform: translate(-2px, -2px) rotate(-18deg);
|
||||
transform: translate(-50%, -45%) rotate(-18deg);
|
||||
transition:
|
||||
width 120ms ease,
|
||||
height 120ms ease,
|
||||
@@ -1296,4 +1307,8 @@ textarea {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.notebook-tab[data-cursor-clicking="true"] {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in new issue
Block a user