"use client"; import { useId, useRef, useState, type FocusEvent, type KeyboardEvent, type PointerEvent } from "react"; import { motion, useReducedMotion } from "motion/react"; export type ContactSheetFrame = { src: string; alt: string; /** Crop inside the frame, as CSS object-position. Two crops of one picture read as a burst. */ position?: string; }; type ContactSheetProps = { frames: ContactSheetFrame[]; /** Frames on each film strip. */ perRow?: number; /** Frames circled at the start, by index. */ picked?: number[]; /** How many times the loupe enlarges. */ zoom?: number; /** Printed on the film edge. */ edge?: string; /** Written on the paper margin. */ title?: string; /** Called with the picked indexes after each press. */ onPick?: (picked: number[]) => void; className?: string; }; type Lens = { i: number; w: number; h: number; x: number; y: number; fx: number; fy: number }; const HOLES = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='14'%3E%3Crect x='6' y='2' width='12' height='10' rx='2.6' fill='%23ece7da'/%3E%3C/svg%3E")`; const LOOP = "M13 44C6 20 34 6 58 7c26 1 39 17 34 37-4 16-34 23-58 19C16 60 6 48 15 29"; const HAND = { fontFamily: '"Segoe Print", "Bradley Hand", "Comic Sans MS", cursive' }; function Holes() { return ; } /* ContactSheet: a darkroom print of a whole roll. Hover a frame for a loupe, press it to circle it in red grease pencil. */ export function ContactSheet({ frames, perRow = 4, picked = [], zoom = 2.6, edge = "SAFETY FILM 400", title = "Roll 12", onPick, className = "" }: ContactSheetProps) { const reduce = useReducedMotion(); const id = useId(); const root = useRef(null); const [marks, setMarks] = useState(picked); const [cursor, setCursor] = useState(0); const [lens, setLens] = useState(null); const [lensOn, setLensOn] = useState(false); const rows = Array.from({ length: Math.ceil(frames.length / perRow) }, (_, r) => r * perRow); function aim(i: number, el: HTMLElement, cx: number, cy: number) { const box = root.current!.getBoundingClientRect(); const f = el.getBoundingClientRect(); const edge = box.width * 0.18; // Half the loupe, so all of it stays on the sheet. const x = Math.min(box.width - edge, Math.max(edge, cx - box.left)); const y = Math.min(box.height - edge, Math.max(edge, cy - box.top)); setLens({ i, w: f.width, h: f.height, x, y, fx: (cx - f.left) / f.width, fy: (cy - f.top) / f.height }); setLensOn(true); } function hover(i: number, e: PointerEvent) { if (e.pointerType === "mouse") aim(i, e.currentTarget, e.clientX, e.clientY); } function focused(i: number, e: FocusEvent) { setCursor(i); if (!e.currentTarget.matches(":focus-visible")) return; const f = e.currentTarget.getBoundingClientRect(); aim(i, e.currentTarget, f.left + f.width / 2, f.top + f.height / 2); } function toggle(i: number) { const next = marks.includes(i) ? marks.filter((m) => m !== i) : [...marks, i]; setMarks(next); onPick?.(next); } function arrows(i: number, e: KeyboardEvent) { const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: perRow, ArrowUp: -perRow }[e.key]; if (!step) return; e.preventDefault(); const to = i + step; if (to < 0 || to >= frames.length) return; root.current!.querySelectorAll("[data-frame]")[to]?.focus(); } return (
setLensOn(false)} className="relative overflow-hidden bg-[#ece7da] p-[1em] text-[2.4cqw] leading-none shadow-[0_1em_2em_-0.8em_rgba(0,0,0,0.7)]">
{rows.map((start, r) => (
{Array.from({ length: perRow }, (_, c) => ( {start + c + 1}{r % 2 ? "A" : ""} ))}
{Array.from({ length: perRow }, (_, c) => { const i = start + c; const frame = frames[i]; if (!frame) return ; const on = marks.includes(i); return ( ); })}
{edge} {edge}
))}
{title} {marks.length} picked
{lens && (
)}
); }