feat(story): build procedural notebook animation

This commit is contained in:
Evandro Fonseca Junior committed 2026-09-01 04:35:11 -03:00
1 parent f3ed9da777
commit b079afdd73
9 files changed
+330 -183

No files matched your search

-10
View File
@@ -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
+18 -7
View File
@@ -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 });
+6 -3
View File
@@ -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>
); );
} }
+40 -23
View File
@@ -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();
+194 -120
View File
@@ -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>
); );
} }
+28 -2
View File
@@ -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", () => {
+26 -15
View File
@@ -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
View File
@@ -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.