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
+360 -213

No files matched your search

-10
View File
@@ -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
+18 -7
View File
@@ -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 });
+14 -11
View File
@@ -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>
);
}
+43 -26
View File
@@ -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();
+210 -136
View File
@@ -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>
);
}
+28 -2
View File
@@ -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", () => {
+29 -18
View File
@@ -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
View File
@@ -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.