"use client"; import Image from "next/image"; 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: CursorFrame; ref?: Ref; onSelectTab: (index: number) => void; interactive?: boolean; }; function clampIndex(index: number, length: number): number { if (length < 1) return -1; return Math.min(Math.max(index, 0), length - 1); } function toDomId(value: string): string { return value.replace(/[^a-zA-Z0-9_-]/g, "-"); } export default function NotebookBrowser({ projects, activeIndex, frame, onSelectTab, interactive = true, ref, }: NotebookBrowserProps) { const selectedIndex = clampIndex(activeIndex, projects.length); const selectedProject = selectedIndex >= 0 ? projects[selectedIndex] : undefined; const rootRef = useRef(null); const cursorRef = useRef(null); const cursorFrame = useRef(frame); const tabPoints = useRef>([]); const browserSize = useRef({ width: 0, height: 0 }); const [displayed, setDisplayed] = useState(null); const [outgoing, setOutgoing] = useState(null); const [failedSrc, setFailedSrc] = useState(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(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); // 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 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( `#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 ? {} : { "aria-hidden": true, inert: true, }; function handleTabKeyDown( event: React.KeyboardEvent, index: number, ) { if (projects.length < 1) return; let nextIndex: number | null = null; if (event.key === "ArrowRight" || event.key === "ArrowDown") { nextIndex = Math.min(index + 1, projects.length - 1); } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { nextIndex = Math.max(index - 1, 0); } else if (event.key === "Home") { nextIndex = 0; } else if (event.key === "End") { nextIndex = projects.length - 1; } if (nextIndex === null) return; event.preventDefault(); event.currentTarget.parentElement ?.querySelectorAll('[role="tab"]') [nextIndex]?.focus({ preventScroll: true }); onSelectTab(nextIndex); } if (!selectedProject) { return (

Nenhum projeto disponível para esta cena.

); } const selectedId = toDomId(selectedProject.id); const panelId = `notebook-panel-${selectedId}`; return (
Projetos em produção {String(selectedIndex + 1).padStart(2, "0")} /{" "} {String(projects.length).padStart(2, "0")}
Projeto ativo: {selectedProject.title}
{projects.map((project, index) => { const tabId = `notebook-tab-${toDomId(project.id)}`; const isSelected = index === selectedIndex; return ( ); })}

{selectedProject.title}

{selectedProject.subtitle}

    {selectedProject.technologies.slice(0, 4).map((technology) => (
  • {technology}
  • ))}
{selectedProject.demoAvailability === "unavailable" ? ( Demo indisponível na última verificação ) : selectedProject.demoAvailability === "none" || !selectedProject.demoUrl ? null : ( Abrir projeto {selectedProject.demoAvailability === "unknown" ? ( {" "} Disponibilidade não verificada ) : null} )}
{imageProjects.map((project) => { const current = project.screenshotSrc === displayed?.screenshotSrc; const previous = project.screenshotSrc === outgoing?.screenshotSrc; return ( {current { void revealImage(event.currentTarget, project); }} onError={() => { if (project.screenshotSrc === requestRef.current) setFailedSrc(project.screenshotSrc); }} onAnimationEnd={() => { if (current) setOutgoing(null); }} /> ); })} {waiting ? (

{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}

) : null}
); }