Align the notebook browser with the physical LCD, refresh real project captures and navigation, simplify service content, and stabilize hero hydration.
This commit is contained in:
1 parent
e2b20bc699
commit
8b12e51bc5
35 files changed
+2359
-1299
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser";
|
||||
@@ -24,6 +24,15 @@ function renderBrowser(overrides: Partial<NotebookBrowserProps> = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
function imageFor(container: HTMLElement, index: number) {
|
||||
const source = new URL(projects[index].screenshotSrc, document.baseURI).href;
|
||||
const image = Array.from(container.querySelectorAll("img")).find(
|
||||
(candidate) => candidate.src === source,
|
||||
);
|
||||
if (!image) throw new Error(`Missing screenshot for ${projects[index].id}`);
|
||||
return image;
|
||||
}
|
||||
|
||||
describe("NotebookBrowser", () => {
|
||||
it("renders the active project as an accessible tabpanel", () => {
|
||||
renderBrowser({ activeIndex: 1 });
|
||||
@@ -42,12 +51,12 @@ describe("NotebookBrowser", () => {
|
||||
"Projeto ativo: SC Plus Aquicultura",
|
||||
);
|
||||
});
|
||||
it("preloads the next screenshot and keeps a short outgoing crossfade", () => {
|
||||
it("keeps decoded content visible and ignores a superseded decode", async () => {
|
||||
const view = renderBrowser();
|
||||
|
||||
expect(
|
||||
view.container.querySelector(".notebook-screenshot-preload"),
|
||||
).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 0));
|
||||
});
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
|
||||
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
@@ -57,8 +66,91 @@ describe("NotebookBrowser", () => {
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
let finishDecode!: () => void;
|
||||
imageFor(view.container, 1).decode = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishDecode = resolve;
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 1));
|
||||
});
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
|
||||
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={2}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 2));
|
||||
});
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
|
||||
await act(async () => {
|
||||
finishDecode();
|
||||
});
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
|
||||
});
|
||||
|
||||
it("keeps the last capture with a truthful notice when the requested image fails", async () => {
|
||||
const view = renderBrowser();
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 0));
|
||||
});
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={1}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.error(imageFor(view.container, 1));
|
||||
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
|
||||
expect(
|
||||
view.container.querySelector(".notebook-screenshot-outgoing"),
|
||||
screen.getByText(/não foi possível carregar.*SC Plus/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/SC Plus/i);
|
||||
});
|
||||
|
||||
it("can reverse to an already-loaded outgoing image without another load event", async () => {
|
||||
const view = renderBrowser();
|
||||
const first = imageFor(view.container, 0);
|
||||
Object.defineProperties(first, {
|
||||
complete: { value: true },
|
||||
naturalWidth: { value: 1280 },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.load(first);
|
||||
});
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={1}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 1));
|
||||
});
|
||||
view.rerender(
|
||||
<NotebookBrowser
|
||||
projects={projects}
|
||||
activeIndex={0}
|
||||
frame={frame}
|
||||
onSelectTab={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("img", { name: /NutriScan/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
it("moves between tabs with arrow keys and Home/End", async () => {
|
||||
@@ -172,35 +264,11 @@ describe("NotebookBrowser", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the animated cursor decorative and never embeds an iframe", () => {
|
||||
const { container } = renderBrowser({
|
||||
frame: { ...frame, cursorClicking: true },
|
||||
it("uses the selected tab to label the panel and decoded screenshot", async () => {
|
||||
const view = renderBrowser({ activeIndex: 2 });
|
||||
await act(async () => {
|
||||
fireEvent.load(imageFor(view.container, 2));
|
||||
});
|
||||
const cursor = container.querySelector(".notebook-cursor");
|
||||
|
||||
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("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 });
|
||||
|
||||
const tab = screen.getAllByRole("tab")[2];
|
||||
const panel = screen.getByRole("tabpanel");
|
||||
@@ -209,6 +277,9 @@ describe("NotebookBrowser", () => {
|
||||
expect(tab).toHaveAttribute("aria-controls", panel.id);
|
||||
expect(panel).toHaveAttribute("aria-labelledby", tab.id);
|
||||
expect(image).toHaveAccessibleName(/SEV Moda Íntima/i);
|
||||
expect(image).toHaveAttribute("src", "/assets/prints/print-sev.png");
|
||||
expect(image).toHaveProperty(
|
||||
"src",
|
||||
new URL(projects[2].screenshotSrc, document.baseURI).href,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { NotebookProject } from "./notebookProjects";
|
||||
import type { NotebookFrame } from "./notebookTimeline";
|
||||
|
||||
type CursorFrame = Pick<
|
||||
NotebookFrame,
|
||||
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
|
||||
>;
|
||||
export type NotebookBrowserHandle = {
|
||||
updateFrame: (frame: CursorFrame) => void;
|
||||
};
|
||||
|
||||
export type NotebookBrowserProps = {
|
||||
projects: NotebookProject[];
|
||||
activeIndex: number;
|
||||
frame: Pick<
|
||||
NotebookFrame,
|
||||
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
|
||||
>;
|
||||
frame: CursorFrame;
|
||||
ref?: Ref<NotebookBrowserHandle>;
|
||||
onSelectTab: (index: number) => void;
|
||||
interactive?: boolean;
|
||||
};
|
||||
@@ -31,28 +45,167 @@ export default function NotebookBrowser({
|
||||
frame,
|
||||
onSelectTab,
|
||||
interactive = true,
|
||||
ref,
|
||||
}: NotebookBrowserProps) {
|
||||
const selectedIndex = clampIndex(activeIndex, projects.length);
|
||||
const selectedProject =
|
||||
selectedIndex >= 0 ? projects[selectedIndex] : undefined;
|
||||
const previousIndexRef = useRef(selectedIndex);
|
||||
const [outgoingIndex, setOutgoingIndex] = useState<number | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const cursorRef = useRef<SVGSVGElement>(null);
|
||||
const cursorFrame = useRef(frame);
|
||||
const tabPoints = useRef<Array<{ x: number; y: number }>>([]);
|
||||
const browserSize = useRef({ width: 0, height: 0 });
|
||||
const [displayed, setDisplayed] = useState<NotebookProject | null>(null);
|
||||
const [outgoing, setOutgoing] = useState<NotebookProject | null>(null);
|
||||
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
||||
const requestedSrc = selectedProject?.screenshotSrc;
|
||||
const requestRef = useRef(requestedSrc);
|
||||
const requestVersion = useRef(0);
|
||||
const displayedRef = useRef(displayed);
|
||||
const mounted = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
requestRef.current = requestedSrc;
|
||||
requestVersion.current += 1;
|
||||
setFailedSrc(null);
|
||||
}, [requestedSrc]);
|
||||
useEffect(() => {
|
||||
const previousIndex = previousIndexRef.current;
|
||||
if (previousIndex === selectedIndex) return undefined;
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
previousIndexRef.current = selectedIndex;
|
||||
setOutgoingIndex(previousIndex >= 0 ? previousIndex : null);
|
||||
const timeout = window.setTimeout(() => setOutgoingIndex(null), 180);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [selectedIndex]);
|
||||
// Only the immediate neighbours are fetched; the browser cache handles reuse.
|
||||
useEffect(() => {
|
||||
const images = [projects[selectedIndex - 1], projects[selectedIndex + 1]]
|
||||
.filter((project): project is NotebookProject => Boolean(project))
|
||||
.map((project) => {
|
||||
const image = new window.Image();
|
||||
image.decoding = "async";
|
||||
image.src = project.screenshotSrc;
|
||||
return image;
|
||||
});
|
||||
return () => {
|
||||
images.forEach((image) => {
|
||||
image.removeAttribute("src");
|
||||
});
|
||||
};
|
||||
}, [projects, selectedIndex]);
|
||||
|
||||
const outgoingProject =
|
||||
outgoingIndex !== null ? projects[outgoingIndex] : undefined;
|
||||
const nextProject =
|
||||
selectedIndex >= 0 && projects.length > 1
|
||||
? projects[(selectedIndex + 1) % projects.length]
|
||||
: undefined;
|
||||
const updateFrame = useCallback((next: CursorFrame) => {
|
||||
cursorFrame.current = next;
|
||||
const cursor = cursorRef.current;
|
||||
const points = tabPoints.current;
|
||||
if (!cursor || points.length === 0) return;
|
||||
const position = Math.max(
|
||||
0,
|
||||
Math.min(points.length - 1, next.cursorX * points.length - 0.5),
|
||||
);
|
||||
const left = Math.floor(position);
|
||||
const right = Math.min(left + 1, points.length - 1);
|
||||
const blend = position - left;
|
||||
const entry = Math.max(0, Math.min(1, (0.95 - next.cursorY) / 0.65));
|
||||
const tabX = points[left].x + (points[right].x - points[left].x) * blend;
|
||||
const tabY = points[left].y + (points[right].y - points[left].y) * blend;
|
||||
const x = browserSize.current.width * 0.5 * (1 - entry) + tabX * entry;
|
||||
const y = browserSize.current.height * 0.95 * (1 - entry) + tabY * entry;
|
||||
cursor.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${next.cursorClicking ? 0.88 : 1})`;
|
||||
cursor.style.opacity = next.cursorVisible ? "1" : "0";
|
||||
cursor.classList.toggle("is-clicking", next.cursorClicking);
|
||||
}, []);
|
||||
useImperativeHandle(ref, () => ({ updateFrame }), [updateFrame]);
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return undefined;
|
||||
const measure = () => {
|
||||
browserSize.current = {
|
||||
width: root.clientWidth,
|
||||
height: root.clientHeight,
|
||||
};
|
||||
const points = [];
|
||||
for (const project of projects) {
|
||||
const tab: HTMLButtonElement | null =
|
||||
root.querySelector<HTMLButtonElement>(
|
||||
`#notebook-tab-${toDomId(project.id)}`,
|
||||
);
|
||||
if (!tab) continue;
|
||||
let x = tab.offsetWidth / 2;
|
||||
let y = tab.offsetHeight / 2;
|
||||
let element: HTMLElement | null = tab;
|
||||
while (element && element !== root) {
|
||||
x += element.offsetLeft;
|
||||
y += element.offsetTop;
|
||||
element = element.offsetParent as HTMLElement | null;
|
||||
}
|
||||
points.push({ x, y });
|
||||
}
|
||||
tabPoints.current = points;
|
||||
updateFrame(cursorFrame.current);
|
||||
};
|
||||
measure();
|
||||
if (!("ResizeObserver" in window)) return undefined;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(root);
|
||||
return () => observer.disconnect();
|
||||
}, [projects, updateFrame]);
|
||||
useLayoutEffect(() => {
|
||||
updateFrame(frame);
|
||||
}, [frame, updateFrame]);
|
||||
|
||||
const revealImage = useCallback(
|
||||
async (image: HTMLImageElement, project: NotebookProject) => {
|
||||
const version = requestVersion.current;
|
||||
try {
|
||||
if (typeof image.decode === "function") await image.decode();
|
||||
} catch {
|
||||
if (
|
||||
mounted.current &&
|
||||
version === requestVersion.current &&
|
||||
requestRef.current === project.screenshotSrc
|
||||
) {
|
||||
setFailedSrc(project.screenshotSrc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!mounted.current ||
|
||||
version !== requestVersion.current ||
|
||||
requestRef.current !== project.screenshotSrc
|
||||
)
|
||||
return;
|
||||
if (displayedRef.current?.screenshotSrc === project.screenshotSrc) return;
|
||||
setOutgoing(
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||
? null
|
||||
: displayedRef.current,
|
||||
);
|
||||
displayedRef.current = project;
|
||||
setDisplayed(project);
|
||||
setFailedSrc(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (!selectedProject) return;
|
||||
// A quick reversal can select the still-mounted outgoing image: no new load event.
|
||||
const source = new URL(selectedProject.screenshotSrc, document.baseURI)
|
||||
.href;
|
||||
const image = Array.from(
|
||||
rootRef.current?.querySelectorAll("img") ?? [],
|
||||
).find((item) => item.src === source);
|
||||
if (image?.complete && image.naturalWidth > 0) {
|
||||
void revealImage(image, selectedProject);
|
||||
}
|
||||
}, [selectedProject, revealImage]);
|
||||
|
||||
const imageProjects = [outgoing, displayed, selectedProject].filter(
|
||||
(project, index, list): project is NotebookProject =>
|
||||
Boolean(project) &&
|
||||
list.findIndex(
|
||||
(item) => item?.screenshotSrc === project?.screenshotSrc,
|
||||
) === index,
|
||||
);
|
||||
const waiting = displayed?.screenshotSrc !== requestedSrc;
|
||||
const interactionProps = interactive
|
||||
? {}
|
||||
: {
|
||||
@@ -81,7 +234,7 @@ export default function NotebookBrowser({
|
||||
event.preventDefault();
|
||||
event.currentTarget.parentElement
|
||||
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
|
||||
[nextIndex]?.focus();
|
||||
[nextIndex]?.focus({ preventScroll: true });
|
||||
onSelectTab(nextIndex);
|
||||
}
|
||||
|
||||
@@ -106,6 +259,7 @@ export default function NotebookBrowser({
|
||||
{...interactionProps}
|
||||
className="notebook-browser"
|
||||
data-testid="notebook-browser"
|
||||
ref={rootRef}
|
||||
>
|
||||
<div className="notebook-browser-bar" aria-hidden="true">
|
||||
<span className="notebook-browser-dot" />
|
||||
@@ -142,9 +296,6 @@ 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)}
|
||||
@@ -198,51 +349,79 @@ export default function NotebookBrowser({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="notebook-screenshot-wrap">
|
||||
{outgoingProject ? (
|
||||
<Image
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="notebook-screenshot notebook-screenshot-outgoing"
|
||||
fill
|
||||
sizes="(min-width: 681px) 60vw, 100vw"
|
||||
src={outgoingProject.screenshotSrc}
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
<Image
|
||||
alt={`Captura de tela de ${selectedProject.title}`}
|
||||
className="notebook-screenshot notebook-screenshot-current"
|
||||
fill
|
||||
sizes="(min-width: 681px) 60vw, 100vw"
|
||||
src={selectedProject.screenshotSrc}
|
||||
unoptimized
|
||||
/>
|
||||
{nextProject ? (
|
||||
<Image
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="notebook-screenshot-preload"
|
||||
fill
|
||||
sizes="1px"
|
||||
src={nextProject.screenshotSrc}
|
||||
unoptimized
|
||||
/>
|
||||
<div
|
||||
className="notebook-screenshot-wrap"
|
||||
aria-busy={waiting && failedSrc !== requestedSrc}
|
||||
>
|
||||
{imageProjects.map((project) => {
|
||||
const current =
|
||||
project.screenshotSrc === displayed?.screenshotSrc;
|
||||
const previous =
|
||||
project.screenshotSrc === outgoing?.screenshotSrc;
|
||||
return (
|
||||
<Image
|
||||
key={project.screenshotSrc}
|
||||
alt={current ? `Captura de tela de ${project.title}` : ""}
|
||||
aria-hidden={!current}
|
||||
className={`notebook-screenshot ${current ? "notebook-screenshot-current" : previous ? "notebook-screenshot-outgoing" : "notebook-screenshot-pending"}`}
|
||||
width={project.screenshotWidth}
|
||||
height={project.screenshotHeight}
|
||||
loading="eager"
|
||||
sizes="(min-width: 1025px) 800px, 100vw"
|
||||
src={project.screenshotSrc}
|
||||
unoptimized
|
||||
onLoad={(event) => {
|
||||
void revealImage(event.currentTarget, project);
|
||||
}}
|
||||
onError={() => {
|
||||
if (project.screenshotSrc === requestRef.current)
|
||||
setFailedSrc(project.screenshotSrc);
|
||||
}}
|
||||
onAnimationEnd={() => {
|
||||
if (current) setOutgoing(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{waiting ? (
|
||||
<p className="notebook-screenshot-status" aria-live="polite">
|
||||
{failedSrc === requestedSrc
|
||||
? `Não foi possível carregar a captura de ${selectedProject.title}.`
|
||||
: `Carregando captura de ${selectedProject.title}…`}
|
||||
{displayed
|
||||
? ` Exibindo ${displayed.title} enquanto isso.`
|
||||
: null}
|
||||
</p>
|
||||
) : 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}%`,
|
||||
}}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="notebook-cursor"
|
||||
ref={cursorRef}
|
||||
viewBox="0 0 28 36"
|
||||
width="28"
|
||||
height="36"
|
||||
>
|
||||
<title>Cursor decorativo</title>
|
||||
<path
|
||||
d="M1 1 L1 26 L8 20 L14 33 L19 30 L13 18 L23 18 Z"
|
||||
fill="#0a0a0a"
|
||||
stroke="#f4f2ed"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
) : null}
|
||||
<circle
|
||||
className="notebook-cursor-pulse"
|
||||
cx="2"
|
||||
cy="2"
|
||||
r="8"
|
||||
fill="none"
|
||||
stroke="#ccff00"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { NotebookProject } from "./notebookProjects";
|
||||
export type NotebookFallbackProps = {
|
||||
projects: NotebookProject[];
|
||||
initialIndex?: number;
|
||||
onSelectTab?: (index: number) => void;
|
||||
};
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
@@ -17,6 +18,7 @@ function clampIndex(index: number, length: number): number {
|
||||
export default function NotebookFallback({
|
||||
projects,
|
||||
initialIndex = 0,
|
||||
onSelectTab,
|
||||
}: NotebookFallbackProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(() =>
|
||||
clampIndex(initialIndex, projects.length),
|
||||
@@ -39,7 +41,10 @@ export default function NotebookFallback({
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
}}
|
||||
onSelectTab={setActiveIndex}
|
||||
onSelectTab={(index) => {
|
||||
setActiveIndex(index);
|
||||
onSelectTab?.(index);
|
||||
}}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookStage3D, {
|
||||
getNotebookLidRotation,
|
||||
type NotebookStage3DProps,
|
||||
updateNotebookScreenMatrix,
|
||||
} from "./NotebookStage3D";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
@@ -36,6 +37,7 @@ const props: NotebookStage3DProps = {
|
||||
isVisible: true,
|
||||
onSelectTab: jest.fn(),
|
||||
progress: { current: 0.56 },
|
||||
requestRender: { current: null },
|
||||
projects,
|
||||
};
|
||||
|
||||
@@ -58,6 +60,50 @@ describe("getNotebookLidRotation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps browser corners on the physical LCD after opening and resizing", () => {
|
||||
const matrix = new THREE.Matrix4();
|
||||
const rig = new THREE.Group();
|
||||
rig.position.set(0, -0.45, 0);
|
||||
const hinge = new THREE.Group();
|
||||
hinge.position.set(0, 0.16, -1.06);
|
||||
rig.add(hinge);
|
||||
const lcd = new THREE.Object3D();
|
||||
lcd.position.set(0, -0.028, 1.1);
|
||||
lcd.rotation.x = Math.PI / 2;
|
||||
hinge.add(lcd);
|
||||
const domWidth = 800;
|
||||
const domHeight = (domWidth * 1.86) / 2.8;
|
||||
|
||||
for (const size of [
|
||||
{ width: 939.390625, height: 738, lidAngle: 74 },
|
||||
{ width: 1123.375, height: 629.75, lidAngle: 112 },
|
||||
]) {
|
||||
const camera = new THREE.PerspectiveCamera(30, size.width / size.height);
|
||||
camera.position.set(0, 1.65, 5.7);
|
||||
camera.rotation.x = -0.19;
|
||||
hinge.rotation.x = getNotebookLidRotation(size.lidAngle);
|
||||
updateNotebookScreenMatrix(matrix, lcd, camera, size);
|
||||
|
||||
for (const [u, v] of [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 1],
|
||||
]) {
|
||||
const expected = new THREE.Vector3((u - 0.5) * 2.8, (0.5 - v) * 1.86, 0)
|
||||
.applyMatrix4(lcd.matrixWorld)
|
||||
.project(camera);
|
||||
const actual = new THREE.Vector3(
|
||||
u * domWidth,
|
||||
v * domHeight,
|
||||
0,
|
||||
).applyMatrix4(matrix);
|
||||
expect(actual.x).toBeCloseTo(((expected.x + 1) * size.width) / 2, 6);
|
||||
expect(actual.y).toBeCloseTo(((1 - expected.y) * size.height) / 2, 6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("NotebookStage3D", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
@@ -66,26 +112,6 @@ describe("NotebookStage3D", () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
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(
|
||||
screen.getAllByTestId("mock-roundedbox").length,
|
||||
).toBeGreaterThanOrEqual(5);
|
||||
expect(container.querySelector("canvas")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pauses the renderer when the stage is outside the viewport", () => {
|
||||
render(<NotebookStage3D {...props} isVisible={false} />);
|
||||
|
||||
expect(screen.getByTestId("mock-canvas")).toHaveAttribute(
|
||||
"data-frameloop",
|
||||
"never",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns usable browser content when the stage boundary fails", () => {
|
||||
const onStageError = jest.fn();
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Html, RoundedBox } from "@react-three/drei";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import {
|
||||
Component,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
import NotebookBrowser from "./NotebookBrowser";
|
||||
import NotebookBrowser, { type NotebookBrowserHandle } from "./NotebookBrowser";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import type { NotebookProject } from "./notebookProjects";
|
||||
import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline";
|
||||
@@ -21,6 +21,7 @@ type ProgressRef = { current: number };
|
||||
|
||||
export type NotebookStage3DProps = {
|
||||
progress: ProgressRef;
|
||||
requestRender: { current: (() => void) | null };
|
||||
projects: NotebookProject[];
|
||||
activeIndex: number;
|
||||
onSelectTab: (index: number) => void;
|
||||
@@ -28,7 +29,7 @@ export type NotebookStage3DProps = {
|
||||
onStageError?: () => void;
|
||||
};
|
||||
|
||||
type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">;
|
||||
type NotebookSceneProps = NotebookStage3DProps;
|
||||
|
||||
/**
|
||||
* Maps the timeline lid angle onto the hinge rotation. 0° = closed (lid
|
||||
@@ -42,11 +43,10 @@ export function getNotebookLidRotation(lidAngle: number): number {
|
||||
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.
|
||||
// The laptop and its DOM screen share the same world-space geometry.
|
||||
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 CAMERA_POSITION: [number, number, number] = [0, 1.65, 5.7];
|
||||
const CAMERA_ROTATION: [number, number, number] = [-0.19, 0, 0];
|
||||
|
||||
const BASE = { width: 3.2, height: 0.16, depth: 2.2 };
|
||||
const HINGE: [number, number, number] = [0, 0.16, -1.06];
|
||||
@@ -63,7 +63,55 @@ const KEYBOARD_Z_START = -0.9;
|
||||
const KEYBOARD_Y = 0.185;
|
||||
const SPACEBAR_Z = 0.12;
|
||||
|
||||
const SCREEN_DOM_DISTANCE_FACTOR = 2.2;
|
||||
const SCREEN_DOM_WIDTH = 800;
|
||||
const SCREEN_DOM_HEIGHT =
|
||||
(SCREEN_DOM_WIDTH * SCREEN_GLASS.depth) / SCREEN_GLASS.width;
|
||||
const SCREEN_PIXELS_TO_WORLD = new THREE.Matrix4()
|
||||
.makeScale(
|
||||
SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
|
||||
-SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
|
||||
1,
|
||||
)
|
||||
.setPosition(-SCREEN_GLASS.width / 2, SCREEN_GLASS.depth / 2, 0);
|
||||
const SCREEN_ORIGIN: [number, number] = [0, 0];
|
||||
const getScreenOrigin = () => SCREEN_ORIGIN;
|
||||
|
||||
export function updateNotebookScreenMatrix(
|
||||
matrix: THREE.Matrix4,
|
||||
screen: THREE.Object3D,
|
||||
camera: THREE.Camera,
|
||||
size: { width: number; height: number },
|
||||
): THREE.Matrix4 {
|
||||
screen.updateWorldMatrix(true, false);
|
||||
camera.updateMatrixWorld();
|
||||
const halfWidth = size.width / 2;
|
||||
const halfHeight = size.height / 2;
|
||||
// One pixel-space matrix keeps CSS paint and hit-testing on the LCD plane.
|
||||
matrix
|
||||
.set(
|
||||
halfWidth,
|
||||
0,
|
||||
0,
|
||||
halfWidth,
|
||||
0,
|
||||
-halfHeight,
|
||||
0,
|
||||
halfHeight,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
.multiply(camera.projectionMatrix)
|
||||
.multiply(camera.matrixWorldInverse)
|
||||
.multiply(screen.matrixWorld)
|
||||
.multiply(SCREEN_PIXELS_TO_WORLD);
|
||||
return matrix.multiplyScalar(1 / matrix.elements[15]);
|
||||
}
|
||||
|
||||
type LaptopMaterials = {
|
||||
chassis: THREE.MeshStandardMaterial;
|
||||
@@ -111,9 +159,11 @@ function createLaptopMaterials(): LaptopMaterials {
|
||||
}
|
||||
|
||||
function setLaptopOpacity(materials: LaptopMaterials, opacity: number) {
|
||||
for (const material of Object.values(materials)) {
|
||||
material.opacity = opacity;
|
||||
}
|
||||
materials.chassis.opacity = opacity;
|
||||
materials.keys.opacity = opacity;
|
||||
materials.dark.opacity = opacity;
|
||||
materials.accent.opacity = opacity;
|
||||
materials.screen.opacity = opacity;
|
||||
}
|
||||
|
||||
function disposeLaptopMaterials(materials: LaptopMaterials) {
|
||||
@@ -217,48 +267,71 @@ function LaptopLid({ materials }: { materials: LaptopMaterials }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenSurface({
|
||||
frame,
|
||||
material,
|
||||
projects,
|
||||
activeIndex,
|
||||
onSelectTab,
|
||||
}: {
|
||||
frame: NotebookFrame;
|
||||
frame: RefObject<NotebookFrame>;
|
||||
material: THREE.MeshStandardMaterial;
|
||||
projects: NotebookProject[];
|
||||
activeIndex: number;
|
||||
onSelectTab: (index: number) => void;
|
||||
}) {
|
||||
const screen = useRef<THREE.Group>(null);
|
||||
const surface = useRef<HTMLDivElement>(null);
|
||||
const browser = useRef<NotebookBrowserHandle>(null);
|
||||
const projection = useMemo(() => new THREE.Matrix4(), []);
|
||||
useFrame(({ camera, size }) => {
|
||||
if (surface.current && screen.current) {
|
||||
updateNotebookScreenMatrix(projection, screen.current, camera, size);
|
||||
surface.current.style.transform = `matrix3d(${projection.elements.join(",")})`;
|
||||
surface.current.style.opacity = String(frame.current.screenOpacity);
|
||||
surface.current.inert = frame.current.screenOpacity < 0.95;
|
||||
surface.current.setAttribute(
|
||||
"aria-hidden",
|
||||
String(frame.current.screenOpacity < 0.95),
|
||||
);
|
||||
}
|
||||
browser.current?.updateFrame(frame.current);
|
||||
});
|
||||
return (
|
||||
<Html
|
||||
center
|
||||
distanceFactor={SCREEN_DOM_DISTANCE_FACTOR}
|
||||
position={[0, -0.06, LID.depth / 2]}
|
||||
zIndexRange={[20, 0]}
|
||||
<group
|
||||
ref={screen}
|
||||
position={[0, -0.028, LID.depth / 2]}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<div
|
||||
className="notebook-screen-surface"
|
||||
style={{ opacity: frame.screenOpacity }}
|
||||
>
|
||||
<NotebookBrowser
|
||||
activeIndex={activeIndex}
|
||||
frame={frame}
|
||||
interactive={frame.screenOpacity > 0.05}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
</Html>
|
||||
<mesh material={material}>
|
||||
<planeGeometry args={[SCREEN_GLASS.width, SCREEN_GLASS.depth]} />
|
||||
</mesh>
|
||||
<Html calculatePosition={getScreenOrigin} zIndexRange={[20, 0]}>
|
||||
<div
|
||||
ref={surface}
|
||||
className="notebook-screen-surface"
|
||||
style={{
|
||||
width: SCREEN_DOM_WIDTH,
|
||||
height: SCREEN_DOM_HEIGHT,
|
||||
opacity: frame.current.screenOpacity,
|
||||
}}
|
||||
inert={frame.current.screenOpacity < 0.95}
|
||||
aria-hidden={frame.current.screenOpacity < 0.95}
|
||||
>
|
||||
<NotebookBrowser
|
||||
ref={browser}
|
||||
activeIndex={activeIndex}
|
||||
frame={frame.current}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,33 +340,34 @@ function NotebookScene({
|
||||
onSelectTab,
|
||||
progress,
|
||||
projects,
|
||||
requestRender,
|
||||
isVisible,
|
||||
}: NotebookSceneProps) {
|
||||
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 frame = useRef(getNotebookFrame(progress.current, projects.length));
|
||||
const materials = useMemo(createLaptopMaterials, []);
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
|
||||
useEffect(() => () => disposeLaptopMaterials(materials), [materials]);
|
||||
useEffect(() => {
|
||||
requestRender.current = isVisible ? invalidate : null;
|
||||
if (isVisible) invalidate();
|
||||
return () => {
|
||||
requestRender.current = null;
|
||||
};
|
||||
}, [invalidate, isVisible, requestRender]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
smoothProgress.current +=
|
||||
(progress.current - smoothProgress.current) * Math.min(1, delta * 8);
|
||||
const nextFrame = getNotebookFrame(smoothProgress.current, projects.length);
|
||||
|
||||
useFrame(() => {
|
||||
const nextFrame = getNotebookFrame(
|
||||
progress.current,
|
||||
projects.length,
|
||||
frame.current,
|
||||
);
|
||||
if (lidRig.current) {
|
||||
lidRig.current.rotation.x = getNotebookLidRotation(nextFrame.lidAngle);
|
||||
}
|
||||
setLaptopOpacity(materials, nextFrame.deviceOpacity);
|
||||
|
||||
elapsedForDom.current += delta;
|
||||
if (elapsedForDom.current >= 1 / 24) {
|
||||
elapsedForDom.current = 0;
|
||||
setFrame(nextFrame);
|
||||
}
|
||||
});
|
||||
}, -1);
|
||||
|
||||
return (
|
||||
<group position={RIG_POSITION}>
|
||||
@@ -307,6 +381,7 @@ function NotebookScene({
|
||||
<ScreenSurface
|
||||
activeIndex={activeIndex}
|
||||
frame={frame}
|
||||
material={materials.screen}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
@@ -348,19 +423,14 @@ class NotebookStageBoundary extends Component<
|
||||
}}
|
||||
className="notebook-story-canvas"
|
||||
dpr={[1, 1.5]}
|
||||
frameloop={this.props.isVisible ? "always" : "never"}
|
||||
frameloop={this.props.isVisible ? "demand" : "never"}
|
||||
gl={{
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
>
|
||||
<NotebookScene
|
||||
activeIndex={this.props.activeIndex}
|
||||
onSelectTab={this.props.onSelectTab}
|
||||
progress={this.props.progress}
|
||||
projects={this.props.projects}
|
||||
/>
|
||||
<NotebookScene {...this.props} />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import NotebookStory, {
|
||||
canUseNotebook3D,
|
||||
getNotebookRunwayVh,
|
||||
getNotebookSeekProgress,
|
||||
} from "./NotebookStory";
|
||||
import NotebookStory, { canUseNotebook3D } from "./NotebookStory";
|
||||
|
||||
const mockNotebookStage = jest.fn(({ isVisible }: { isVisible: boolean }) => (
|
||||
<div data-is-visible={String(isVisible)} data-testid="notebook-stage" />
|
||||
@@ -135,15 +131,10 @@ describe("NotebookStory", () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByLabelText("Projetos em produção no notebook"),
|
||||
).toHaveAttribute("data-notebook-runway-vh", "520");
|
||||
});
|
||||
|
||||
it("stays paused when the tab returns while the section is offscreen", async () => {
|
||||
const addEventListener = jest.spyOn(document, "addEventListener");
|
||||
const removeEventListener = jest.spyOn(document, "removeEventListener");
|
||||
const { unmount } = render(<NotebookStory projects={projects} />);
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
const stage = await screen.findByTestId("notebook-stage");
|
||||
expect(stage).toHaveAttribute("data-is-visible", "false");
|
||||
@@ -163,18 +154,6 @@ describe("NotebookStory", () => {
|
||||
setDocumentHidden(false);
|
||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
expect(stage).toHaveAttribute("data-is-visible", "false");
|
||||
|
||||
expect(
|
||||
addEventListener.mock.calls.filter(
|
||||
([type]) => type === "visibilitychange",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
unmount();
|
||||
expect(
|
||||
removeEventListener.mock.calls.filter(
|
||||
([type]) => type === "visibilitychange",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
it("returns to the static fallback when the viewport loses desktop eligibility", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
@@ -208,12 +187,6 @@ describe("NotebookStory", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("scales the runway from the number of projects", () => {
|
||||
expect(getNotebookRunwayVh(1)).toBe(400);
|
||||
expect(getNotebookRunwayVh(4)).toBe(520);
|
||||
expect(getNotebookRunwayVh(99)).toBe(4320);
|
||||
});
|
||||
|
||||
it("requires desktop hover input without reduced motion", () => {
|
||||
const matchMedia = (query: string) =>
|
||||
({
|
||||
@@ -245,27 +218,4 @@ describe("NotebookStory", () => {
|
||||
screen.queryByRole("tab", { name: /Twins Lab/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maps tab seeks inside the browse window without a second scroll engine", () => {
|
||||
const first = getNotebookSeekProgress(0, 4);
|
||||
const middle = getNotebookSeekProgress(2, 4);
|
||||
const last = getNotebookSeekProgress(3, 4);
|
||||
|
||||
expect(first).toBeGreaterThanOrEqual(0.44);
|
||||
expect(first).toBeLessThan(0.78);
|
||||
expect(middle).toBeGreaterThan(first);
|
||||
expect(last).toBeLessThan(0.78);
|
||||
expect(getNotebookSeekProgress(99, 4)).toBeLessThan(0.78);
|
||||
});
|
||||
|
||||
it("does not register a global scroll listener", () => {
|
||||
const addEventListener = jest.spyOn(window, "addEventListener");
|
||||
mockMatchMedia({ reduced: true });
|
||||
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
expect(
|
||||
addEventListener.mock.calls.some(([type]) => type === "scroll"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,19 +6,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
import { getNotebookFrame } from "./notebookTimeline";
|
||||
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
|
||||
|
||||
const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const BROWSE_START = 0.44;
|
||||
const BROWSE_END = 0.78;
|
||||
|
||||
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
|
||||
|
||||
export type NotebookStoryProps = {
|
||||
projects: PortfolioProjectView[];
|
||||
onLayoutReady?: () => void;
|
||||
};
|
||||
|
||||
export function canUseNotebook3D(view: CapabilityWindow): boolean {
|
||||
@@ -36,18 +34,7 @@ export function getNotebookRunwayVh(tabCount: number): number {
|
||||
1,
|
||||
Number.isFinite(tabCount) ? Math.floor(tabCount) : 1,
|
||||
);
|
||||
return 360 + count * 40;
|
||||
}
|
||||
|
||||
export function getNotebookSeekProgress(
|
||||
index: number,
|
||||
tabCount: number,
|
||||
): number {
|
||||
const count = Math.max(0, Math.floor(tabCount));
|
||||
if (count < 1) return BROWSE_START;
|
||||
const safeIndex = Math.min(Math.max(Math.floor(index), 0), count - 1);
|
||||
const slot = (BROWSE_END - BROWSE_START) / count;
|
||||
return BROWSE_START + slot * (safeIndex + 0.5);
|
||||
return 220 + count * 85;
|
||||
}
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
@@ -55,13 +42,22 @@ function clampIndex(index: number, length: number): number {
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
export default function NotebookStory({
|
||||
projects,
|
||||
onLayoutReady,
|
||||
}: NotebookStoryProps) {
|
||||
const notebookProjects = useMemo(
|
||||
() => selectNotebookProjects(projects),
|
||||
[projects],
|
||||
);
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const requestRenderRef = useRef<(() => void) | null>(null);
|
||||
const seekRef = useRef<((progress: number) => void) | null>(null);
|
||||
const layoutReadyRef = useRef(onLayoutReady);
|
||||
const failedRef = useRef(false);
|
||||
const frameRef = useRef(getNotebookFrame(0, notebookProjects.length));
|
||||
const [capabilityResolved, setCapabilityResolved] = useState(false);
|
||||
const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1);
|
||||
const [engine, setEngine] = useState(false);
|
||||
const [sectionIntersects, setSectionIntersects] = useState(false);
|
||||
@@ -69,12 +65,21 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
() => typeof document === "undefined" || !document.hidden,
|
||||
);
|
||||
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
|
||||
useEffect(() => {
|
||||
layoutReadyRef.current = onLayoutReady;
|
||||
}, [onLayoutReady]);
|
||||
useEffect(() => {
|
||||
if (!engine && capabilityResolved) layoutReadyRef.current?.();
|
||||
}, [engine, capabilityResolved]);
|
||||
useEffect(() => {
|
||||
const updateEngine = () => {
|
||||
const nextEngine =
|
||||
notebookProjects.length > 0 && canUseNotebook3D(window);
|
||||
!failedRef.current &&
|
||||
notebookProjects.length > 0 &&
|
||||
canUseNotebook3D(window);
|
||||
if (!nextEngine) setSectionIntersects(false);
|
||||
setEngine(nextEngine);
|
||||
setCapabilityResolved(true);
|
||||
};
|
||||
updateEngine();
|
||||
if (typeof window.matchMedia !== "function") return undefined;
|
||||
@@ -108,7 +113,9 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!engine || !section || !("IntersectionObserver" in window)) {
|
||||
if (!engine || !section) return undefined;
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
setSectionIntersects(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -139,33 +146,58 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
const context = gsap.context(() => {
|
||||
const driver = { value: 0 };
|
||||
const applyProgress = () => {
|
||||
progressRef.current = driver.value;
|
||||
const nextIndex = getNotebookFrame(
|
||||
driver.value,
|
||||
notebookProjects.length,
|
||||
frameRef.current,
|
||||
).activeTabIndex;
|
||||
if (nextIndex !== activeIndexRef.current) {
|
||||
activeIndexRef.current = nextIndex;
|
||||
setActiveIndex(nextIndex);
|
||||
}
|
||||
requestRenderRef.current?.();
|
||||
};
|
||||
const timeline = gsap.timeline({
|
||||
defaults: { ease: "none" },
|
||||
onUpdate: applyProgress,
|
||||
scrollTrigger: {
|
||||
trigger: sectionRoot,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 0.85,
|
||||
scrub: true,
|
||||
invalidateOnRefresh: true,
|
||||
onUpdate: (self) => {
|
||||
progressRef.current = self.progress;
|
||||
const nextIndex = getNotebookFrame(
|
||||
self.progress,
|
||||
notebookProjects.length,
|
||||
).activeTabIndex;
|
||||
if (nextIndex >= 0 && nextIndex !== activeIndexRef.current) {
|
||||
activeIndexRef.current = nextIndex;
|
||||
setActiveIndex(nextIndex);
|
||||
}
|
||||
},
|
||||
onRefresh: () => layoutReadyRef.current?.(),
|
||||
},
|
||||
});
|
||||
timeline.to(driver, { duration: 1, value: 1 });
|
||||
seekRef.current = (progress) => {
|
||||
const trigger = timeline.scrollTrigger;
|
||||
if (!trigger) return;
|
||||
// Instant overrides the site's smooth CSS scrolling: no tour of intermediate tabs.
|
||||
window.scrollTo({
|
||||
top: trigger.start + (trigger.end - trigger.start) * progress,
|
||||
behavior: "instant",
|
||||
});
|
||||
ScrollTrigger.update();
|
||||
timeline.progress(progress);
|
||||
applyProgress();
|
||||
};
|
||||
timeline.scrollTrigger?.refresh();
|
||||
}, sectionRoot);
|
||||
cleanup = () => context.revert();
|
||||
cleanup = () => {
|
||||
seekRef.current = null;
|
||||
context.revert();
|
||||
};
|
||||
}
|
||||
|
||||
void setupTimeline();
|
||||
void setupTimeline().catch(() => {
|
||||
if (!cancelled) {
|
||||
failedRef.current = true;
|
||||
setEngine(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
@@ -173,47 +205,38 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
}, [engine, notebookProjects.length]);
|
||||
|
||||
const handleStageError = useCallback(() => {
|
||||
failedRef.current = true;
|
||||
setEngine(false);
|
||||
}, []);
|
||||
|
||||
const seekToTab = useCallback(
|
||||
(index: number) => {
|
||||
const section = sectionRef.current;
|
||||
if (!section || notebookProjects.length < 1) return;
|
||||
|
||||
const rect = section.getBoundingClientRect();
|
||||
const sectionTop = (window.scrollY || window.pageYOffset) + rect.top;
|
||||
const runwayDistance = Math.max(
|
||||
section.offsetHeight - window.innerHeight,
|
||||
0,
|
||||
);
|
||||
const target =
|
||||
sectionTop +
|
||||
runwayDistance *
|
||||
getNotebookSeekProgress(index, notebookProjects.length);
|
||||
window.scrollTo({ top: target, behavior: "smooth" });
|
||||
},
|
||||
[notebookProjects.length],
|
||||
);
|
||||
|
||||
const handleSelectTab = useCallback(
|
||||
(index: number) => {
|
||||
const safeIndex = clampIndex(index, notebookProjects.length);
|
||||
if (safeIndex < 0) return;
|
||||
activeIndexRef.current = safeIndex;
|
||||
setActiveIndex(safeIndex);
|
||||
seekToTab(safeIndex);
|
||||
if (engine) {
|
||||
seekRef.current?.(
|
||||
getNotebookSeekProgress(safeIndex, notebookProjects.length),
|
||||
);
|
||||
}
|
||||
},
|
||||
[notebookProjects.length, seekToTab],
|
||||
[engine, notebookProjects.length],
|
||||
);
|
||||
|
||||
if (!engine) {
|
||||
return (
|
||||
<section
|
||||
aria-label="Projetos em produção no notebook"
|
||||
className="notebook-story notebook-story-static"
|
||||
className={`notebook-story notebook-story-static${!capabilityResolved && notebookProjects.length > 0 ? " notebook-story-pending" : ""}`}
|
||||
data-testid="notebook-story"
|
||||
id="story"
|
||||
ref={sectionRef}
|
||||
style={
|
||||
{
|
||||
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="notebook-story-static-content">
|
||||
<div className="notebook-story-copy">
|
||||
@@ -224,7 +247,11 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
tecnologias em produção.
|
||||
</p>
|
||||
</div>
|
||||
<NotebookFallback projects={notebookProjects} />
|
||||
<NotebookFallback
|
||||
initialIndex={activeIndex}
|
||||
onSelectTab={handleSelectTab}
|
||||
projects={notebookProjects}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -265,6 +292,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
onSelectTab={handleSelectTab}
|
||||
onStageError={handleStageError}
|
||||
progress={progressRef}
|
||||
requestRender={requestRenderRef}
|
||||
projects={notebookProjects}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { selectNotebookProjects } from "./notebookProjects";
|
||||
const projectsFixture = getFeaturedProjects(null);
|
||||
|
||||
describe("selectNotebookProjects", () => {
|
||||
it("returns the four ordered projects with local screenshots", () => {
|
||||
it("returns featured projects in notebook order", () => {
|
||||
const result = selectNotebookProjects(projectsFixture);
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual([
|
||||
@@ -16,11 +16,6 @@ describe("selectNotebookProjects", () => {
|
||||
"sev-modaintima",
|
||||
"as-servicos",
|
||||
]);
|
||||
expect(
|
||||
result.every((project) =>
|
||||
project.screenshotSrc.startsWith("/assets/prints/"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("omits a project when its screenshot mapping is missing", () => {
|
||||
|
||||
@@ -9,6 +9,8 @@ export type NotebookProject = {
|
||||
demoCheckedAt: PortfolioProjectView["demoCheckedAt"];
|
||||
technologies: string[];
|
||||
screenshotSrc: string;
|
||||
screenshotWidth: number;
|
||||
screenshotHeight: number;
|
||||
};
|
||||
|
||||
const NOTEBOOK_ORDER = [
|
||||
@@ -22,10 +24,10 @@ const SCREENSHOT_BY_PROJECT_ID: Record<
|
||||
(typeof NOTEBOOK_ORDER)[number],
|
||||
string
|
||||
> = {
|
||||
nutriscan: "/assets/prints/print-nutriscan.png",
|
||||
"sc-plus": "/assets/prints/print-scplus.png",
|
||||
"sev-modaintima": "/assets/prints/print-sev.png",
|
||||
"as-servicos": "/assets/prints/print-asservicos.png",
|
||||
nutriscan: "/assets/notebook/nutriscan.webp",
|
||||
"sc-plus": "/assets/notebook/sc-plus.webp",
|
||||
"sev-modaintima": "/assets/notebook/sev-modaintima.webp",
|
||||
"as-servicos": "/assets/notebook/as-servicos.webp",
|
||||
};
|
||||
|
||||
function isApprovedDemoUrl(
|
||||
@@ -58,6 +60,8 @@ export function selectNotebookProjects(
|
||||
demoCheckedAt: project.demoCheckedAt,
|
||||
technologies: [...project.technologies],
|
||||
screenshotSrc,
|
||||
screenshotWidth: 1600,
|
||||
screenshotHeight: 1000,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -1,126 +1,75 @@
|
||||
import { getNotebookFrame } from "./notebookTimeline";
|
||||
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
|
||||
|
||||
describe("getNotebookFrame", () => {
|
||||
it("clamps progress below zero and starts with a closed device", () => {
|
||||
expect(getNotebookFrame(-1, 4)).toMatchObject({
|
||||
phase: "approach",
|
||||
lidAngle: 0,
|
||||
activeTabIndex: -1,
|
||||
cursorVisible: false,
|
||||
});
|
||||
describe("notebook choreography", () => {
|
||||
it("opens and boots before browsing, then blanks the display before closing", () => {
|
||||
const frames = Array.from({ length: 1001 }, (_, index) =>
|
||||
getNotebookFrame(index / 1000, 4),
|
||||
);
|
||||
const phases = [...new Set(frames.map((frame) => frame.phase))];
|
||||
expect(phases).toEqual([
|
||||
"approach",
|
||||
"open",
|
||||
"boot",
|
||||
"cursor-in",
|
||||
"browse",
|
||||
"hold",
|
||||
"close",
|
||||
]);
|
||||
for (const frame of frames) {
|
||||
if (frame.phase === "open") expect(frame.screenOpacity).toBe(0);
|
||||
if (frame.cursorVisible) expect(frame.screenOpacity).toBe(1);
|
||||
if (frame.phase === "close" && frame.lidAngle < 112)
|
||||
expect(frame.screenOpacity).toBe(0);
|
||||
}
|
||||
expect(getNotebookFrame(-1, 4).lidAngle).toBe(0);
|
||||
expect(getNotebookFrame(2, 4).lidAngle).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps progress above one and ends with a closed device", () => {
|
||||
expect(getNotebookFrame(2, 4)).toMatchObject({
|
||||
phase: "close",
|
||||
lidAngle: 0,
|
||||
activeTabIndex: -1,
|
||||
cursorVisible: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the phase boundaries deterministic", () => {
|
||||
expect(getNotebookFrame(0.1, 4).phase).toBe("open");
|
||||
expect(getNotebookFrame(0.25, 4).phase).toBe("boot");
|
||||
expect(getNotebookFrame(0.34, 4).phase).toBe("cursor-in");
|
||||
expect(getNotebookFrame(0.44, 4).phase).toBe("browse");
|
||||
expect(getNotebookFrame(0.78, 4).phase).toBe("hold");
|
||||
expect(getNotebookFrame(0.87, 4).phase).toBe("close");
|
||||
});
|
||||
|
||||
it("opens the lid before the browser starts", () => {
|
||||
const frame = getNotebookFrame(0.22, 4);
|
||||
|
||||
expect(frame.phase).toBe("open");
|
||||
expect(frame.lidAngle).toBeGreaterThan(0);
|
||||
expect(frame.screenOpacity).toBe(0);
|
||||
});
|
||||
|
||||
it("boots the screen before the cursor enters", () => {
|
||||
expect(getNotebookFrame(0.25, 4).screenOpacity).toBe(0);
|
||||
expect(getNotebookFrame(0.34, 4).screenOpacity).toBe(1);
|
||||
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);
|
||||
expect(getNotebookFrame(0.57, 4).activeTabIndex).toBe(1);
|
||||
expect(getNotebookFrame(0.67, 4).activeTabIndex).toBe(2);
|
||||
expect(getNotebookFrame(0.76, 4).activeTabIndex).toBe(3);
|
||||
});
|
||||
|
||||
it("holds the final tab until the closing phase", () => {
|
||||
expect(getNotebookFrame(0.84, 4)).toMatchObject({
|
||||
phase: "hold",
|
||||
activeTabIndex: 3,
|
||||
cursorVisible: true,
|
||||
});
|
||||
expect(getNotebookFrame(0.88, 4)).toMatchObject({
|
||||
phase: "close",
|
||||
activeTabIndex: -1,
|
||||
cursorVisible: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a deterministic cursor click pulse for each tab", () => {
|
||||
const first = getNotebookFrame(0.475, 4);
|
||||
const later = getNotebookFrame(0.56, 4);
|
||||
|
||||
expect(first.cursorClicking).toBe(true);
|
||||
expect(later.cursorClicking).toBe(true);
|
||||
expect(first.cursorX).toBeGreaterThanOrEqual(0);
|
||||
expect(first.cursorX).toBeLessThanOrEqual(1);
|
||||
expect(first.cursorY).toBeGreaterThanOrEqual(0);
|
||||
expect(first.cursorY).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("moves the cursor between tab targets instead of teleporting", () => {
|
||||
const previous = getNotebookFrame(0.525, 4);
|
||||
const moving = getNotebookFrame(0.532, 4);
|
||||
const target = getNotebookFrame(0.56, 4);
|
||||
|
||||
expect(moving.cursorX).toBeGreaterThan(previous.cursorX);
|
||||
expect(moving.cursorX).toBeLessThan(target.cursorX);
|
||||
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("keeps the previous project until cursor arrival, including reverse scroll", () => {
|
||||
const frames = Array.from({ length: 10001 }, (_, index) =>
|
||||
getNotebookFrame(index / 10000, 4),
|
||||
);
|
||||
for (let selected = 1; selected < 4; selected += 1) {
|
||||
const arrival = frames.findIndex(
|
||||
(frame) => frame.activeTabIndex === selected,
|
||||
);
|
||||
const before = getNotebookFrame((arrival - 1) / 10000, 4);
|
||||
const atClick = frames[arrival];
|
||||
expect(before.activeTabIndex).toBe(selected - 1);
|
||||
expect(before.cursorClicking).toBe(false);
|
||||
expect(atClick.cursorClicking).toBe(true);
|
||||
expect(atClick.cursorX).toBeCloseTo((selected + 0.5) / 4);
|
||||
const reversed = getNotebookFrame((arrival - 2) / 10000, 4);
|
||||
expect(reversed.activeTabIndex).toBe(selected - 1);
|
||||
expect(reversed.cursorX).toBeLessThan(atClick.cursorX);
|
||||
expect(reversed.cursorX).toBeGreaterThan((selected - 0.5) / 4);
|
||||
}
|
||||
});
|
||||
|
||||
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("seeks directly into a readable hold for the requested project", () => {
|
||||
for (const count of [1, 4]) {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const progress = getNotebookSeekProgress(index, count);
|
||||
const frame = getNotebookFrame(progress, count);
|
||||
expect(frame.activeTabIndex).toBe(index);
|
||||
expect(frame.screenOpacity).toBe(1);
|
||||
expect(frame.cursorClicking).toBe(false);
|
||||
expect(getNotebookFrame(progress + 0.03, count).activeTabIndex).toBe(
|
||||
index,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
getNotebookFrame(getNotebookSeekProgress(99, 4), 4).activeTabIndex,
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it("is deterministic when rewound to the same progress", () => {
|
||||
expect(getNotebookFrame(0.61, 4)).toEqual(getNotebookFrame(0.61, 4));
|
||||
});
|
||||
|
||||
it("does not expose an invalid tab when there are no projects", () => {
|
||||
it("does not expose a tab or cursor for empty or invalid project counts", () => {
|
||||
expect(getNotebookFrame(0.6, 0)).toMatchObject({
|
||||
activeTabIndex: -1,
|
||||
cursorVisible: false,
|
||||
cursorClicking: false,
|
||||
});
|
||||
expect(getNotebookFrame(0.6, Number.NaN).activeTabIndex).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -19,197 +19,99 @@ export type NotebookFrame = {
|
||||
cursorClicking: boolean;
|
||||
};
|
||||
|
||||
const OPEN_START = 0.06;
|
||||
const BOOT_START = 0.18;
|
||||
const CURSOR_START = 0.23;
|
||||
const BROWSE_START = 0.3;
|
||||
const BROWSE_END = 0.84;
|
||||
const CLOSE_START = 0.9;
|
||||
const CLOSE_LID_START = 0.93;
|
||||
const OPEN_ANGLE = 112;
|
||||
const BROWSE_START = 0.44;
|
||||
const BROWSE_END = 0.78;
|
||||
const HOLD_END = 0.87;
|
||||
const CLOSE_LID_START = 0.9;
|
||||
const TAB_CLICK_Y = 0.3;
|
||||
const PARK = { x: 0.5, y: 0.95 };
|
||||
const ARRIVAL = 0.18;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function normalizeProgress(progress: number): number {
|
||||
return clamp(Number.isFinite(progress) ? progress : 0, 0, 1);
|
||||
function between(value: number, start: number, end: number): number {
|
||||
return clamp((value - start) / (end - start), 0, 1);
|
||||
}
|
||||
|
||||
function interpolate(from: number, to: number, progress: number): number {
|
||||
return from + (to - from) * clamp(progress, 0, 1);
|
||||
}
|
||||
|
||||
function getPhase(progress: number): NotebookPhase {
|
||||
if (progress < 0.1) return "approach";
|
||||
if (progress < 0.25) return "open";
|
||||
if (progress < 0.34) return "boot";
|
||||
if (progress < BROWSE_START) return "cursor-in";
|
||||
if (progress < BROWSE_END) return "browse";
|
||||
if (progress < HOLD_END) return "hold";
|
||||
return "close";
|
||||
}
|
||||
|
||||
function getActiveTabIndex(
|
||||
progress: number,
|
||||
export function getNotebookSeekProgress(
|
||||
index: number,
|
||||
tabCount: number,
|
||||
phase: NotebookPhase,
|
||||
): number {
|
||||
if (
|
||||
tabCount < 1 ||
|
||||
phase === "approach" ||
|
||||
phase === "open" ||
|
||||
phase === "boot" ||
|
||||
phase === "cursor-in" ||
|
||||
phase === "close"
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (phase === "hold") {
|
||||
return tabCount - 1;
|
||||
}
|
||||
|
||||
const browseProgress =
|
||||
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
|
||||
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,
|
||||
activeTabIndex: number,
|
||||
tabCount: number,
|
||||
): { x: number; y: number } {
|
||||
if (phase === "cursor-in") {
|
||||
const cursorProgress = (progress - 0.34) / 0.1;
|
||||
return {
|
||||
x: interpolate(PARK.x, tabCenterX(0, tabCount), cursorProgress),
|
||||
y: interpolate(PARK.y, TAB_CLICK_Y, cursorProgress),
|
||||
};
|
||||
}
|
||||
|
||||
if (activeTabIndex < 0) {
|
||||
return { ...PARK };
|
||||
}
|
||||
|
||||
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 = {
|
||||
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: target.y,
|
||||
};
|
||||
}
|
||||
|
||||
function isCursorClicking(
|
||||
progress: number,
|
||||
phase: NotebookPhase,
|
||||
tabCount: number,
|
||||
): boolean {
|
||||
if (
|
||||
tabCount < 1 ||
|
||||
phase === "approach" ||
|
||||
phase === "open" ||
|
||||
phase === "boot" ||
|
||||
phase === "close"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (phase === "cursor-in") {
|
||||
return progress >= 0.43;
|
||||
}
|
||||
|
||||
if (phase === "hold") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const browseProgress =
|
||||
(progress - BROWSE_START) / (BROWSE_END - BROWSE_START);
|
||||
const slotProgress = (browseProgress * tabCount) % 1;
|
||||
return slotProgress >= 0.36 && slotProgress < 0.5;
|
||||
const count = Number.isFinite(tabCount)
|
||||
? Math.max(0, Math.floor(tabCount))
|
||||
: 0;
|
||||
if (!count) return BROWSE_START;
|
||||
const selected = clamp(
|
||||
Number.isFinite(index) ? Math.floor(index) : 0,
|
||||
0,
|
||||
count - 1,
|
||||
);
|
||||
return (
|
||||
BROWSE_START + ((BROWSE_END - BROWSE_START) * (selected + 0.6)) / count
|
||||
);
|
||||
}
|
||||
|
||||
/** One reversible clock. Each tab arrives/clicks before its long reading hold. */
|
||||
export function getNotebookFrame(
|
||||
progress: number,
|
||||
tabCount: number,
|
||||
target?: NotebookFrame,
|
||||
): NotebookFrame {
|
||||
const normalizedProgress = normalizeProgress(progress);
|
||||
const normalizedTabCount = Math.max(0, Math.floor(tabCount));
|
||||
const phase = getPhase(normalizedProgress);
|
||||
const activeTabIndex = getActiveTabIndex(
|
||||
normalizedProgress,
|
||||
normalizedTabCount,
|
||||
phase,
|
||||
);
|
||||
const isApproach = normalizedProgress < 0.1;
|
||||
const isOpening = phase === "open";
|
||||
const isBooting = phase === "boot";
|
||||
const isClosing = phase === "close";
|
||||
const lidAngle = isOpening
|
||||
? interpolate(0, OPEN_ANGLE, (normalizedProgress - 0.1) / 0.15)
|
||||
: isClosing
|
||||
? normalizedProgress < CLOSE_LID_START
|
||||
? OPEN_ANGLE
|
||||
: interpolate(
|
||||
OPEN_ANGLE,
|
||||
0,
|
||||
(normalizedProgress - CLOSE_LID_START) / (1 - CLOSE_LID_START),
|
||||
)
|
||||
: normalizedProgress >= 0.25
|
||||
? OPEN_ANGLE
|
||||
: 0;
|
||||
const screenOpacity = isBooting
|
||||
? interpolate(0, 1, (normalizedProgress - 0.25) / 0.09)
|
||||
: normalizedProgress >= 0.34 && !isClosing
|
||||
? 1
|
||||
: isClosing
|
||||
? interpolate(
|
||||
1,
|
||||
0,
|
||||
(normalizedProgress - HOLD_END) / (CLOSE_LID_START - HOLD_END),
|
||||
)
|
||||
: 0;
|
||||
const deviceOpacity = isApproach
|
||||
? interpolate(0, 1, normalizedProgress / 0.1)
|
||||
: isClosing
|
||||
? interpolate(1, 0.92, (normalizedProgress - HOLD_END) / (1 - HOLD_END))
|
||||
: 1;
|
||||
const cursorVisible =
|
||||
(phase === "cursor-in" || phase === "browse" || phase === "hold") &&
|
||||
normalizedTabCount > 0;
|
||||
const { x: cursorX, y: cursorY } = getCursorPoint(
|
||||
normalizedProgress,
|
||||
phase,
|
||||
activeTabIndex,
|
||||
normalizedTabCount,
|
||||
);
|
||||
|
||||
return {
|
||||
phase,
|
||||
lidAngle,
|
||||
deviceOpacity,
|
||||
screenOpacity,
|
||||
activeTabIndex,
|
||||
cursorVisible,
|
||||
cursorX,
|
||||
cursorY,
|
||||
cursorClicking:
|
||||
cursorVisible &&
|
||||
isCursorClicking(normalizedProgress, phase, normalizedTabCount),
|
||||
};
|
||||
const p = clamp(Number.isFinite(progress) ? progress : 0, 0, 1);
|
||||
const count = Number.isFinite(tabCount)
|
||||
? Math.max(0, Math.floor(tabCount))
|
||||
: 0;
|
||||
const phase: NotebookPhase =
|
||||
p < OPEN_START
|
||||
? "approach"
|
||||
: p < BOOT_START
|
||||
? "open"
|
||||
: p < CURSOR_START
|
||||
? "boot"
|
||||
: p < BROWSE_START
|
||||
? "cursor-in"
|
||||
: p < BROWSE_END
|
||||
? "browse"
|
||||
: p < CLOSE_START
|
||||
? "hold"
|
||||
: "close";
|
||||
const slot = between(p, BROWSE_START, BROWSE_END) * count;
|
||||
const destination = clamp(Math.floor(slot), 0, Math.max(0, count - 1));
|
||||
const local = slot - destination;
|
||||
const moving = phase === "browse" && destination > 0 && local < ARRIVAL;
|
||||
const activeTabIndex = !count ? -1 : moving ? destination - 1 : destination;
|
||||
const travel = between(local, 0, ARRIVAL);
|
||||
const cursorTab = moving
|
||||
? destination - 1 + travel * travel * (3 - 2 * travel)
|
||||
: destination;
|
||||
const entry = between(p, CURSOR_START, BROWSE_START);
|
||||
const frame = target ?? ({} as NotebookFrame);
|
||||
frame.phase = phase;
|
||||
frame.lidAngle =
|
||||
OPEN_ANGLE *
|
||||
between(p, OPEN_START, BOOT_START) *
|
||||
(1 - between(p, CLOSE_LID_START, 1));
|
||||
frame.deviceOpacity = between(p, 0, OPEN_START);
|
||||
frame.screenOpacity =
|
||||
between(p, BOOT_START, CURSOR_START) *
|
||||
(1 - between(p, CLOSE_START, CLOSE_LID_START));
|
||||
frame.activeTabIndex = activeTabIndex;
|
||||
frame.cursorVisible = count > 0 && p >= CURSOR_START && p < CLOSE_START;
|
||||
frame.cursorX = count
|
||||
? phase === "cursor-in"
|
||||
? 0.5 + (0.5 / count - 0.5) * entry
|
||||
: (cursorTab + 0.5) / count
|
||||
: 0.5;
|
||||
frame.cursorY = phase === "cursor-in" ? 0.95 - 0.65 * entry : 0.3;
|
||||
frame.cursorClicking =
|
||||
count > 0 &&
|
||||
phase === "browse" &&
|
||||
(destination === 0
|
||||
? local < 0.07
|
||||
: local >= ARRIVAL && local < ARRIVAL + 0.07);
|
||||
return frame;
|
||||
}
|
||||
Reference in new issue
Block a user