Avatar cropperNew
The profile photo field without a modal: the avatar itself grows into the crop, and shrinks back with the face you framed.
Install with the shadcn CLI
$ npxnpx shadcn@latest add https://hairlineui.com/r/avatar-cropper.jsonOverview
Changing a profile photo usually means a modal with a crop box and a slider, and a result that pops in when it closes. Here the avatar is the crop. Choose, drop or paste a photo and the circle grows into the stage, the rest of the photo fading in dimmed around it. The photo moves like one you hold: it follows the finger exactly, resists past an edge the way iOS Photos does and springs back, so the crop never shows a gap, and it keeps the speed you let go with. Pinch, scroll or the slider zoom about where you point, and the zoom rolls to its new value when you let go. Rotate throws a quarter turn with a slight overshoot and the photo settles inside its new bounds. Save goes Saving, Saved, then the stage shrinks back into the circle carrying the face you framed: the same layer all the way, so the new avatar is already there when it lands.
States
- idle
- The avatar with Upload or Replace, and Remove when there's a photo. Hovering dims it and shows a camera; dragging a file over it scales it to 1.06 with a slight overshoot and thickens its ring.
- open
- The avatar's circle grows into a stage up to 320px in 560ms: it starts as exactly the avatar (the crop circle scaled onto it) and widens its clip to the stage's rounded square while the space below eases open. The dim and the controls fade in 160–220ms later.
- hold
- Dragging moves the photo one to one; past an edge it gives less the further it goes (iOS's 0.55 resistance) and the thirds appear inside the crop. Pinch and scroll zoom about the fingers or pointer, from the fit to 4x.
- release
- The photo carries its release speed and slows down; anything outside the bounds springs back critically damped, keeping that speed. The zoom then rolls to its new percent.
- rotate
- A quarter turn clockwise in 560ms with a slight overshoot; the photo springs inside the new bounds while it turns.
- save
- Save → Saving → Saved (StatusButton), then the stage shrinks back into the avatar's circle with the crop in it. The new photo is decoded before it lands, so the hand-over is invisible. A failed save shakes the button and reads Try again; the stage stays.
- cancel
- Escape or Cancel shrinks the stage back and fades it over the last 45% while the old photo (or the initials) fades back in.
- remove
- Remove → Removing, then the photo dissolves into the initials through a blur.
Usage
import { AvatarCropper } from "@/components/avatar-cropper";<AvatarCropper name={user.name} defaultValue={user.avatarUrl} onSave={async (photo) => { const { url } = await uploadAvatar(photo); return url; }}/>Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.
Works with
Installs with it
- Status buttonSave, Saving, Saved: the spinner opens in, the check draws itself, errors shake, and the label morphs between them.
- Number rollNumbers that change like an odometer: every digit rolls in the direction the number moved.
- Text morphText that changes the way it should: shared letters slide over, new ones rise out of a blur, the width eases.
Upload the crop to your storage
onSave gets a square WebP (512px by default) and where the crop sits in the original, in its own pixels, in case you keep originals and crop on the server. Return the stored URL and the avatar shows it; throw and the button reads Try again.
"use client";import { AvatarCropper } from "@/components/avatar-cropper";export function ProfilePhoto({ user }: { user: { name: string; avatarUrl: string | null } }) { return ( <AvatarCropper name={user.name} defaultValue={user.avatarUrl} onSave={async (photo, area) => { const body = new FormData(); body.append("photo", photo, "avatar.webp"); body.append("area", JSON.stringify(area)); const res = await fetch("/api/me/avatar", { method: "PUT", body }); if (!res.ok) throw new Error("Upload failed"); const { url } = await res.json(); return url; }} onRemove={async () => { await fetch("/api/me/avatar", { method: "DELETE" }); }} /> );}Props
| Prop | Type | Description |
|---|---|---|
| name | string | Whose photo: the initials when there's none, and the accessible names. |
| onSave | (photo: Blob, area: CropArea) => Promise<string | void> | Receives the cropped square and { centerX, centerY, size, rotation } in the original's pixels. Resolve with the stored URL (or nothing to show the crop itself); throw to show the error. |
| value | string | null | Controlled photo URL. |
| defaultValue | string | null | Starting photo URL when uncontrolled. |
| onValueChange | (url: string | null) => void | After a save lands or a removal. |
| onRemove | () => Promise<void> | void | Shows Remove while there's a photo. |
| shape | "circle" | "square"default "circle" | "square" crops a rounded square, for workspace and team logos. |
| size | numberdefault 64 | The avatar's size in px. |
| outputSize | numberdefault 512 | Side of the saved square in px. |
| outputType | stringdefault "image/webp" | Any type canvas.toBlob encodes, with outputQuality (0.9). |
| maxSize | numberdefault 10_000_000 | Largest file in bytes; bigger ones are refused with a shake and the limit. |
| maxZoom | numberdefault 4 | How far past the fit it zooms. |
| title | stringdefault "Profile photo" | Beside the avatar. |
| hint | string | Under the title; it gives way to how to crop while editing. |
| ref | Ref<AvatarCropperHandle> | open(file | blob | url) and browse(), e.g. for a photo from your own picker or a camera. |
Accessibility and motion
- The avatar is a button: Enter or Space opens the file picker, and paste takes a photo from the clipboard while focus is anywhere in the field. Dropping a file on the avatar or the stage opens it.
- The stage is focusable: arrow keys move the photo (Shift for bigger steps), plus and minus zoom, R rotates, Enter saves and Escape cancels. The zoom slider is a native range input; the zoom is announced when it settles, never while it moves.
- One frame loop with fixed 4ms steps writes transforms to three nested layers (move, zoom, turn) and sleeps once everything is still; React only hears about it then. The layers are promoted while they move and released after, so the photo is redrawn sharp at its new size.
- Only the stage takes touch (touch-action: none); the page itself is never locked. Wheel and trackpad pinch zoom about the pointer and don't scroll the page while over the photo.
- The crop is drawn from the original at full resolution with the same transform you see, so what lands in the circle is what you saved.
- Reduced motion opens and closes in place and the photo goes straight to its bounds instead of springing.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.
The CLI also installs Status button, Number roll, Text morph.
Source
"use client";import * as React from "react";import { Camera, Minus, Plus, RotateCw } from "lucide-react";import { NumberRoll } from "./number-roll";import { StatusButton, type ActionStatus } from "./status-button";import { useCropGesture, type CropArea, type CropState } from "./use-crop-gesture";/* ───────────────────────────────────────────────────────── * AVATAR CROPPER: the profile photo field, without a modal * * open choose, drop or paste a photo and the avatar * itself grows into the crop stage: its circle is * the crop, the rest of the photo fades in, dimmed, * around it * hold drag, pinch, scroll; past an edge the photo * resists and springs back, so the crop never shows * a gap; it keeps the speed you let go with * rotate a quarter turn, thrown with a slight overshoot * save Save → Saving → Saved, then the stage shrinks * back into the circle with the face you framed: * the same layer, never a swap * cancel shrinks back and hands over to the old photo * remove the photo dissolves into initials * ───────────────────────────────────────────────────────── */export type { CropArea } from "./use-crop-gesture";export interface AvatarCropperHandle { /** Opens a photo in the stage: a file, a blob or a URL. */ open: (photo: File | Blob | string) => void; /** Opens the file picker. */ browse: () => void;}export interface AvatarCropperProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title" | "defaultValue" | "onChange"> { /** The photo shown now (a URL), or null for initials. */ value?: string | null; defaultValue?: string | null; onValueChange?: (url: string | null) => void; /** Whose photo: used for the initials and the accessible names. */ name: string; /** Receives the cropped square. Resolve with the stored photo's URL (or nothing to show the crop itself); throw to show the error. */ onSave: (photo: Blob, area: CropArea) => Promise<string | void> | string | void; /** Shows Remove while there's a photo. */ onRemove?: () => Promise<void> | void; /** "square" for a workspace or team logo (rounded square). */ shape?: "circle" | "square"; /** The avatar's size, px. */ size?: number; /** Side of the saved square, px. */ outputSize?: number; outputType?: string; outputQuality?: number; accept?: string; /** Largest file taken, bytes. */ maxSize?: number; maxZoom?: number; title?: string; hint?: string; ref?: React.Ref<AvatarCropperHandle>;}type Phase = "idle" | "opening" | "open" | "closing";type Photo = { src: string; w: number; h: number; owned: boolean };type Note = { text: string; tone: "muted" | "error"; key: number };const EASE = "cubic-bezier(0.16,1,0.3,1)";const THROW = "cubic-bezier(0.34,1.36,0.64,1)";const MORPH = 560;const MARGIN = 22;const RADIUS = 22;const STAGE_MAX = 320;const SQUARE = 0.22;const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => { const query = window.matchMedia(reducedQuery); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange);};const useReducedMotion = () => React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);const swap = (on: boolean, reduced: boolean, delay = 0): React.CSSProperties => ({ opacity: on ? 1 : 0, transform: on ? "none" : "scale(0.9)", filter: on ? "none" : "blur(4px)", transition: reduced ? "none" : `opacity 300ms ${EASE} ${delay}ms, transform 420ms ${EASE} ${delay}ms, filter 300ms ${EASE} ${delay}ms`,});const initials = (name: string) => name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((w) => w[0]!.toUpperCase()) .join("");/** The crop's outline inside a stage of side s: a circle, or a rounded square. */function holePath(shape: "circle" | "square", s: number, d: number) { const c = s / 2; if (shape === "circle") return `M${c - d / 2} ${c}a${d / 2} ${d / 2} 0 1 0 ${d} 0a${d / 2} ${d / 2} 0 1 0 ${-d} 0Z`; const r = d * SQUARE; const x = c - d / 2; return `M${x + r} ${x}H${x + d - r}A${r} ${r} 0 0 1 ${x + d} ${x + r}V${x + d - r}A${r} ${r} 0 0 1 ${x + d - r} ${x + d}H${x + r}A${r} ${r} 0 0 1 ${x} ${x + d - r}V${x + r}A${r} ${r} 0 0 1 ${x + r} ${x}Z`;}/** A sentence that changes crossfades whole: the old one lifts away through a blur while the new one rises in. */function Line({ text, reduced }: { text: string; reduced: boolean }) { const [shown, setShown] = React.useState({ text, key: 0 }); const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]); if (text !== shown.text) { if (!reduced) setLeaving((l) => [...l, shown]); setShown({ text, key: shown.key + 1 }); } return ( <span className="relative block"> <span key={shown.key} className="block" ref={(el) => { if (!el || reduced || shown.key === 0 || el.dataset.in) return; el.dataset.in = "1"; el.animate( [ { opacity: 0, transform: "translateY(0.35em)", filter: "blur(4px)" }, { opacity: 1, transform: "none", filter: "blur(0px)" }, ], { duration: 420, delay: 60, easing: EASE, fill: "backwards" }, ); }} > {shown.text} </span> {leaving.map((l) => ( <span key={l.key} aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 block" ref={(el) => { if (!el || el.dataset.out) return; el.dataset.out = "1"; el.animate( [ { opacity: 1, transform: "none", filter: "blur(0px)" }, { opacity: 0, transform: "translateY(-0.35em)", filter: "blur(4px)" }, ], { duration: 200, easing: EASE, fill: "forwards" }, ).onfinish = () => setLeaving((all) => all.filter((x) => x.key !== l.key)); }} > {l.text} </span> ))} </span> );}function IconButton({ label, onClick, children, disabled }: { label: string; onClick: () => void; children: React.ReactNode; disabled?: boolean }) { return ( <button type="button" aria-label={label} title={label} onClick={onClick} disabled={disabled} className={`flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40 [&_svg]:size-3.5 ${FOCUS}`} > {children} </button> );}export function AvatarCropper({ value, defaultValue = null, onValueChange, name, onSave, onRemove, shape = "circle", size = 64, outputSize = 512, outputType = "image/webp", outputQuality = 0.9, accept = "image/*", maxSize = 10_000_000, maxZoom = 4, title = "Profile photo", hint = "JPG, PNG or WebP. Drop one on the circle.", ref, className = "", ...props}: AvatarCropperProps) { const reduced = useReducedMotion(); const uid = React.useId().replace(/[^\w-]/g, ""); const [own, setOwn] = React.useState<string | null>(defaultValue); const current = value === undefined ? own : value; const [phase, setPhase] = React.useState<Phase>("idle"); const [photo, setPhoto] = React.useState<Photo | null>(null); const [stageSize, setStageSize] = React.useState(STAGE_MAX); const [status, setStatus] = React.useState<ActionStatus>("idle"); const [removing, setRemoving] = React.useState<ActionStatus>("idle"); const [note, setNote] = React.useState<Note | null>(null); const [over, setOver] = React.useState(false); const [committed, setCommitted] = React.useState<CropState>({ x: 0, y: 0, z: 1, r: 0 }); const [announce, setAnnounce] = React.useState(""); // How the stage goes back: into the new photo (save) or handing over to the old one (cancel). const [landing, setLanding] = React.useState<{ src: string; save: boolean } | null>(null); /** The photo a save just landed on: shown at once, no fade, for the frame the stage disappears. */ const [justLanded, setJustLanded] = React.useState<string | null>(null); React.useEffect(() => { if (!justLanded) return; const frame = requestAnimationFrame(() => setJustLanded(null)); return () => cancelAnimationFrame(frame); }, [justLanded]); const rootRef = React.useRef<HTMLDivElement>(null); const slotRef = React.useRef<HTMLButtonElement>(null); const panelRef = React.useRef<HTMLDivElement>(null); const surfaceRef = React.useRef<HTMLDivElement>(null); const controlsRef = React.useRef<HTMLDivElement>(null); const imgRef = React.useRef<HTMLImageElement>(null); const fileRef = React.useRef<HTMLInputElement>(null); const ownedUrls = React.useRef(new Set<string>()); const phaseRef = React.useRef(phase); phaseRef.current = phase; /** Focus moves into the stage only when someone opened it (not a photo opened on load), and back to the avatar after. */ const takeFocus = React.useRef(false); const returnFocus = React.useRef(false); React.useEffect(() => { if (phase === "open" && takeFocus.current) { takeFocus.current = false; crop.refs.stage.current?.focus({ preventScroll: true }); } if (phase === "idle" && returnFocus.current) { returnFocus.current = false; slotRef.current?.focus({ preventScroll: true }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]); const d = stageSize - MARGIN * 2; const dims = React.useMemo(() => photo && { w: photo.w, h: photo.h }, [photo]); const lastZoom = React.useRef(1); const crop = useCropGesture({ size: d, image: dims, maxZoom, reduced, onSettle: (s) => { setCommitted(s); // Announced once it settles, never while it moves. if (Math.round(s.z * 100) !== Math.round(lastZoom.current * 100)) setAnnounce(`Zoom ${Math.round(s.z * 100)}%`); lastZoom.current = s.z; }, }); React.useEffect( () => () => { ownedUrls.current.forEach((u) => URL.revokeObjectURL(u)); }, [], ); const flash = (text: string, tone: Note["tone"] = "muted") => { const key = Date.now(); setNote({ text, tone, key }); window.setTimeout(() => setNote((n) => (n?.key === key ? null : n)), 2800); }; const shake = (el: HTMLElement | null) => { if (!el || reduced) return; el.animate([{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }], { duration: 380, easing: "ease-out", }); }; const setCurrent = (url: string | null) => { if (value === undefined) setOwn(url); onValueChange?.(url); }; // ── Opening a photo ──────────────────────────────────── const open = async (input: File | Blob | string) => { if (phaseRef.current === "opening" || phaseRef.current === "closing") return; const byHand = typeof navigator === "undefined" || !("userActivation" in navigator) || navigator.userActivation.isActive; if (typeof input !== "string") { if (!input.type.startsWith("image/")) { flash("That file isn't a photo.", "error"); return shake(slotRef.current); } if (input.size > maxSize) { flash(`That photo is over ${Math.round(maxSize / 1_000_000)} MB.`, "error"); return shake(slotRef.current); } } const src = typeof input === "string" ? input : URL.createObjectURL(input); if (typeof input !== "string") ownedUrls.current.add(src); const img = new Image(); img.src = src; try { await img.decode(); } catch { flash("Couldn't read that photo.", "error"); return shake(slotRef.current); } const width = rootRef.current?.offsetWidth ?? STAGE_MAX; setStageSize(Math.round(Math.min(STAGE_MAX, Math.max(220, width)))); setPhoto({ src, w: img.naturalWidth, h: img.naturalHeight, owned: typeof input !== "string" }); setStatus("idle"); setCommitted({ x: 0, y: 0, z: 1, r: 0 }); lastZoom.current = 1; crop.reset(); if (phaseRef.current === "idle") { takeFocus.current = byHand || !!rootRef.current?.contains(document.activeElement); setPhase("opening"); } else { // Already open: the new photo fades in where the old one was. imgRef.current?.animate([{ opacity: 0, filter: "blur(6px)" }, { opacity: 1, filter: "blur(0px)" }], { duration: 360, easing: EASE }); } setAnnounce("Photo opened. Drag to move it, pinch, scroll or use the slider to zoom, then Save."); }; const browse = () => fileRef.current?.click(); React.useImperativeHandle(ref, () => ({ open, browse })); // ── The morph: the avatar's circle grows into the stage, and back ── /** The stage's resting transform when it is the avatar: its crop on the slot's circle. */ const collapsedFrame = () => { const slot = slotRef.current!.getBoundingClientRect(); const stage = surfaceRef.current!; // Measured between morphs, when the stage carries no transform. const box = stage.getBoundingClientRect(); const dx = slot.left + slot.width / 2 - (box.left + stageSize / 2); const dy = slot.top + slot.height / 2 - (box.top + stageSize / 2); const hole = shape === "circle" ? d / 2 : d * SQUARE; return { transform: `translate(${dx}px, ${dy}px) scale(${slot.width / d})`, clipPath: `inset(${MARGIN}px round ${hole}px)` }; }; const openFrame = { transform: "translate(0px, 0px) scale(1)", clipPath: `inset(0px round ${RADIUS}px)` }; React.useLayoutEffect(() => { const surface = surfaceRef.current; const panel = panelRef.current; if (!surface || !panel) return; if (phase === "opening") { if (reduced) return setPhase("open"); const height = panel.scrollHeight; panel.animate([{ height: "0px" }, { height: `${height}px` }], { duration: MORPH, easing: EASE }); // The dim is there from the first frame: the clip starts as the crop's own outline, so it opens onto the rest of // the photo already dimmed while the circle stays the photo. const a = surface.animate([collapsedFrame(), openFrame], { duration: MORPH, easing: EASE }); controlsRef.current?.animate( [ { opacity: 0, transform: "translateY(-6px)", filter: "blur(4px)" }, { opacity: 1, transform: "none", filter: "blur(0px)" }, ], { duration: 420, delay: 220, easing: EASE, fill: "backwards" }, ); a.onfinish = () => setPhase("open"); return () => a.cancel(); } if (phase === "closing") { const done = () => { panel.getAnimations().forEach((a) => a.cancel()); setPhase("idle"); if (landing?.save) { setCurrent(landing.src); setJustLanded(landing.src); } setLanding(null); setPhoto(null); returnFocus.current = !!rootRef.current?.contains(document.activeElement); }; if (reduced) return done(); const height = panel.offsetHeight; panel.animate([{ height: `${height}px` }, { height: "0px" }], { duration: MORPH - 40, easing: EASE, fill: "forwards" }); controlsRef.current?.animate([{ opacity: 1 }, { opacity: 0, filter: "blur(4px)" }], { duration: 160, easing: EASE, fill: "forwards" }); const a = surface.animate([openFrame, collapsedFrame()], { duration: MORPH, easing: EASE, fill: "forwards" }); // Cancelling hands over to the old photo: once the stage is nearly home it fades while the old one comes back. if (!landing?.save) surface.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 240, delay: 220, easing: "linear", fill: "forwards" }); a.onfinish = done; return () => a.cancel(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]); const close = (save: { src: string } | null) => { if (phaseRef.current !== "open") return; setLanding(save ? { src: save.src, save: true } : { src: current ?? "", save: false }); setPhase("closing"); }; const cancel = () => { close(null); setAnnounce("Cancelled."); }; const save = async () => { if (status === "pending" || phase !== "open" || !imgRef.current) return; setStatus("pending"); setAnnounce("Saving."); try { const blob = await crop.toBlob(imgRef.current, outputSize, outputType, outputQuality); const area = crop.area()!; const local = URL.createObjectURL(blob); ownedUrls.current.add(local); const stored = await onSave(blob, area); const src = typeof stored === "string" && stored ? stored : local; // Ready before the stage lands, so the hand-over is invisible. const ready = new Image(); ready.src = src; await ready.decode().catch(() => undefined); setStatus("success"); setAnnounce("Photo saved."); window.setTimeout(() => { close({ src }); flash("Photo updated"); }, reduced ? 0 : 520); } catch { setStatus("error"); setAnnounce("Couldn't save the photo. Try again."); } }; const remove = async () => { if (!onRemove || removing === "pending") return; setRemoving("pending"); try { await onRemove(); setRemoving("idle"); setCurrent(null); flash("Photo removed"); setAnnounce("Photo removed."); } catch { setRemoving("error"); } }; // ── Files from outside: drop and paste ───────────────── const fromTransfer = (data: DataTransfer | null) => { const file = data?.files?.[0] ?? [...(data?.items ?? [])].find((i) => i.kind === "file")?.getAsFile() ?? null; if (file) open(file); return !!file; }; const dragProps = { onDragOver: (e: React.DragEvent) => { if (![...e.dataTransfer.types].includes("Files") || phase === "opening" || phase === "closing") return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; if (!over) setOver(true); }, onDragLeave: (e: React.DragEvent) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOver(false); }, onDrop: (e: React.DragEvent) => { e.preventDefault(); setOver(false); fromTransfer(e.dataTransfer); }, }; const onStageKey = (e: React.KeyboardEvent) => { const step = e.shiftKey ? 32 : 8; const keys: Record<string, () => void> = { ArrowLeft: () => crop.nudge(-step, 0), ArrowRight: () => crop.nudge(step, 0), ArrowUp: () => crop.nudge(0, -step), ArrowDown: () => crop.nudge(0, step), "+": () => crop.zoomBy(1.15), "=": () => crop.zoomBy(1.15), "-": () => crop.zoomBy(1 / 1.15), r: () => rotate(), R: () => rotate(), Enter: () => save(), }; const run = keys[e.key]; if (!run) return; e.preventDefault(); run(); }; const rotate = () => { crop.rotate(); setCommitted((c) => ({ ...c, r: c.r + 1 })); setAnnounce(`Rotated to ${(((crop.state().r % 4) + 4) % 4) * 90}°.`); }; // ── Render ───────────────────────────────────────────── const editing = phase !== "idle"; const round = shape === "circle" ? "9999px" : `${Math.round(size * SQUARE)}px`; const cover = photo ? d / Math.min(photo.w, photo.h) : 1; const hole = holePath(shape, stageSize, d); // What the slot shows: the current photo, or (once a save lands) the new one; nothing while the stage has it. const landed = phase === "closing" && landing?.save ? landing.src : null; const shownSrc = landed ?? current; const slotVisible = phase === "idle" || (phase === "closing" && !landing?.save); const layers = [...new Set([current, landed].filter((s): s is string => !!s))]; const line = note ?? (editing ? { text: "Drag to move, pinch or scroll to zoom", tone: "muted" as const, key: 0 } : over ? { text: "Drop to use it", tone: "muted" as const, key: 0 } : { text: hint, tone: "muted" as const, key: 0 }); return ( <div ref={rootRef} className={`w-full text-foreground ${className}`} onPaste={(e) => { if (fromTransfer(e.clipboardData)) e.preventDefault(); }} onKeyDown={(e) => { if (e.key === "Escape" && phase === "open") { e.preventDefault(); cancel(); } }} {...props} > <input ref={fileRef} type="file" accept={accept} hidden onChange={(e) => { const file = e.target.files?.[0]; e.target.value = ""; if (file) open(file); }} /> <div className="flex items-center gap-4"> <button ref={slotRef} type="button" onClick={browse} disabled={editing} aria-label={current ? `Change ${name}'s photo` : `Add a photo for ${name}`} aria-describedby={`${uid}-line`} {...dragProps} className={`group/slot relative shrink-0 cursor-pointer disabled:cursor-default ${FOCUS}`} style={{ width: size, height: size, borderRadius: round, transform: over ? "scale(1.06)" : "none", transition: reduced ? "none" : `transform 420ms ${THROW}`, }} > <span className="absolute inset-0 overflow-hidden bg-muted" style={{ borderRadius: round }}> <span aria-hidden="true" className="absolute inset-0 flex items-center justify-center font-medium text-muted-foreground" style={{ ...swap(!shownSrc && slotVisible, reduced, phase === "closing" ? 200 : 0), fontSize: Math.round(size * 0.32) }} > {initials(name)} </span> {layers.map((src) => ( // eslint-disable-next-line @next/next/no-img-element <img key={src} src={src} alt="" draggable={false} className="absolute inset-0 size-full object-cover" style={ // The new photo takes over the moment the stage lands on it: no fade, it's already there. src === landed ? { opacity: 0, transition: "none" } : src === justLanded ? { opacity: 1, transition: "none" } : swap(src === shownSrc && slotVisible, reduced, phase === "closing" ? 200 : 0) } /> ))} {/* Hover: a quiet dim and a camera, so the circle reads as the place to click or drop. */} <span aria-hidden="true" className="absolute inset-0 flex items-center justify-center text-background opacity-0 transition-opacity duration-200 group-hover/slot:opacity-100 group-disabled/slot:!opacity-0" style={{ background: "color-mix(in oklab, var(--foreground) 38%, transparent)" }} > <Camera className="size-[38%] max-h-5 max-w-5" strokeWidth={1.75} /> </span> </span> <span aria-hidden="true" className="pointer-events-none absolute inset-0 transition-shadow duration-200" style={{ borderRadius: round, boxShadow: over ? "inset 0 0 0 1.5px var(--ring)" : "inset 0 0 0 1px var(--border)" }} /> </button> <div className="min-w-0 flex-1"> <p className="text-[14px] font-medium tracking-[-0.01em]">{title}</p> <p id={`${uid}-line`} className={`mt-0.5 text-[12.5px] transition-colors duration-300 ${line.tone === "error" ? "text-red-600 dark:text-red-400" : "text-muted-foreground"}`}> <Line text={line.text} reduced={reduced} /> </p> <div className="-ml-2.5 mt-1.5 flex items-center gap-0.5 transition-[opacity,filter] duration-300" style={{ opacity: editing ? 0 : 1, filter: editing ? "blur(3px)" : "none" }} inert={editing} > <StatusButton variant="ghost" size="sm" status="idle" labels={{ idle: current ? "Replace" : "Upload" }} onClick={browse} /> {onRemove && ( <span className="grid transition-[grid-template-columns,opacity] duration-300" style={{ gridTemplateColumns: current ? "1fr" : "0fr", opacity: current ? 1 : 0, transitionTimingFunction: EASE }} inert={!current} > <span className="min-w-0 overflow-hidden"> <StatusButton variant="ghost" size="sm" status={removing} labels={{ idle: "Remove", pending: "Removing", success: "Removed", error: "Try again" }} onClick={remove} /> </span> </span> )} </div> </div> </div> {/* The stage: the same circle, grown. Its height is the panel's, which eases open and shut. */} <div ref={panelRef} style={{ height: editing ? undefined : 0 }}> {editing && photo && ( <div className="pt-4"> <div ref={surfaceRef} className="relative overflow-hidden bg-muted will-change-transform" style={{ width: stageSize, height: stageSize, borderRadius: RADIUS, transformOrigin: "50% 50%" }} {...dragProps} > <div ref={crop.refs.stage} tabIndex={0} role="group" aria-roledescription="crop area" aria-label={`Crop ${name}'s photo`} aria-describedby={`${uid}-how`} onKeyDown={onStageKey} {...crop.handlers} className={`absolute inset-0 touch-none select-none outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring ${ phase === "open" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none" }`} style={{ borderRadius: RADIUS }} > <div ref={crop.refs.pan} className="absolute left-1/2 top-1/2 size-0"> <div ref={crop.refs.zoom} className="absolute left-0 top-0 size-0"> <div ref={crop.refs.rot} className="absolute left-0 top-0 size-0"> {/* eslint-disable-next-line @next/next/no-img-element */} <img ref={imgRef} src={photo.src} alt="" draggable={false} className="pointer-events-none absolute max-w-none select-none" style={{ width: photo.w * cover, height: photo.h * cover, left: (-photo.w * cover) / 2, top: (-photo.h * cover) / 2 }} /> </div> </div> </div> <svg aria-hidden="true" className="pointer-events-none absolute inset-0 size-full" viewBox={`0 0 ${stageSize} ${stageSize}`}> <path fillRule="evenodd" d={`M0 0H${stageSize}V${stageSize}H0Z${hole}`} style={{ fill: "color-mix(in oklab, var(--background) 64%, transparent)" }} /> <clipPath id={`${uid}-hole`}> <path d={hole} /> </clipPath> {/* Thirds, only while a finger is on the photo. */} <g ref={crop.refs.grid} clipPath={`url(#${uid}-hole)`} style={{ opacity: 0, transition: reduced ? "none" : `opacity 240ms ${EASE}` }}> {[1, 2].map((i) => ( <React.Fragment key={i}> <path d={`M${MARGIN + (d * i) / 3} ${MARGIN}V${MARGIN + d}`} style={{ stroke: "color-mix(in oklab, var(--background) 55%, transparent)" }} strokeWidth={1} /> <path d={`M${MARGIN} ${MARGIN + (d * i) / 3}H${MARGIN + d}`} style={{ stroke: "color-mix(in oklab, var(--background) 55%, transparent)" }} strokeWidth={1} /> </React.Fragment> ))} </g> <path d={hole} fill="none" style={{ stroke: "var(--background)" }} strokeOpacity={0.9} strokeWidth={1.25} /> </svg> </div> </div> <div ref={controlsRef} style={{ width: stageSize }}> <div className="mt-3 flex items-center gap-1"> <IconButton label="Zoom out" onClick={() => crop.zoomBy(1 / 1.25)} disabled={committed.z <= 1.001}> <Minus strokeWidth={1.75} /> </IconButton> <input ref={crop.refs.slider} type="range" min={1} max={maxZoom} step={0.01} defaultValue={1} aria-label="Zoom" aria-valuetext={`${Math.round(committed.z * 100)}%`} onInput={(e) => crop.zoomTo(Number(e.currentTarget.value))} onPointerUp={() => crop.settle()} onKeyUp={() => crop.settle()} onBlur={() => crop.settle()} className={`h-7 min-w-0 flex-1 cursor-pointer appearance-none bg-transparent focus-visible:outline-none [&::-moz-range-thumb]:size-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-[0_0_0_1px_var(--foreground)] [&::-moz-range-track]:h-px [&::-moz-range-track]:bg-border [&::-webkit-slider-runnable-track]:h-px [&::-webkit-slider-runnable-track]:bg-border [&::-webkit-slider-thumb]:-mt-[6.5px] [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-[0_0_0_1px_var(--foreground)] focus-visible:[&::-webkit-slider-thumb]:shadow-[0_0_0_2px_var(--ring)] focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_2px_var(--ring)]`} /> <IconButton label="Zoom in" onClick={() => crop.zoomBy(1.25)} disabled={committed.z >= maxZoom - 0.001}> <Plus strokeWidth={1.75} /> </IconButton> <span className="w-11 shrink-0 text-right text-[12px] tabular-nums text-muted-foreground" aria-hidden="true"> <NumberRoll value={Math.round(committed.z * 100)} suffix="%" duration={700} /> </span> <IconButton label="Rotate a quarter turn" onClick={rotate}> <RotateCw strokeWidth={1.75} /> </IconButton> </div> <div className="mt-2.5 flex items-center justify-end gap-1.5"> <button type="button" onClick={cancel} disabled={status === "pending" || status === "success"} className={`inline-flex h-9 cursor-pointer items-center rounded-full px-4 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-50 ${FOCUS}`} > Cancel </button> <StatusButton status={status} labels={{ idle: "Save", pending: "Saving", success: "Saved", error: "Try again" }} onClick={save} /> </div> </div> </div> )} </div> <p id={`${uid}-how`} className="sr-only"> Arrow keys move the photo, plus and minus zoom, R rotates, Enter saves and Escape cancels. </p> <p className="sr-only" role="status" aria-live="polite"> {announce} </p> </div> );}"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * CROP GESTURE: a photo you hold, not a box you drag * * pan the photo follows the finger exactly; past an * edge it resists more the further it goes, the way * iOS Photos does, so the crop never shows a gap * pinch two fingers zoom about the point between them; * wheel and trackpad pinch zoom about the pointer; * below the fit or past the limit it resists too * release it keeps the speed you let go with and slows * down; anything out of bounds springs back, * critically damped, carrying that speed * rotate quarter turns, and the bounds re-solve so the * photo still covers the crop * * One frame loop with fixed 4ms steps, writing transforms to * three nested layers (pan, zoom, rotate) and sleeping the * moment everything is still. React hears about it once, when * it settles. Layers are promoted only while they move, so the * photo re-rasterises sharp at its new size. * ───────────────────────────────────────────────────────── */export type CropState = { x: number; y: number; z: number; r: number };/** The crop in the photo's own pixels: its centre, the side of the square, and the quarter turns applied (degrees). */export type CropArea = { centerX: number; centerY: number; size: number; rotation: number };type Options = { /** Side of the crop region at full size, px. */ size: number; /** Natural size of the photo. */ image: { w: number; h: number } | null; maxZoom: number; reduced: boolean; /** Called once a gesture, wheel, key or spring has settled. */ onSettle?: (state: CropState) => void;};type Vec = { x: number; y: number };const K = 230; // spring stiffness, 1/s²const C = 2 * Math.sqrt(K); // critical dampingconst FRICTION = 4.2; // momentum decay, 1/sconst STEP = 4; // msconst THROW = "cubic-bezier(0.34,1.36,0.64,1)";const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));/** Resistance past an edge: the further over, the less it gives (iOS's 0.55 constant). */const band = (over: number, dim: number) => Math.sign(over) * (1 - 1 / ((Math.abs(over) * 0.55) / dim + 1)) * dim;/** The raw overshoot that shows as `shown` once banded, so a gesture can start from a photo that's mid-spring. */const unband = (shown: number, dim: number) => { const u = Math.min(0.95, Math.abs(shown) / dim); return Math.sign(shown) * (1 / (1 - u) - 1) * (dim / 0.55);};export function useCropGesture({ size, image, maxZoom, reduced, onSettle }: Options) { const pan = React.useRef<HTMLDivElement>(null); const zoom = React.useRef<HTMLDivElement>(null); const rot = React.useRef<HTMLDivElement>(null); const slider = React.useRef<HTMLInputElement>(null); const grid = React.useRef<SVGGElement>(null); const stage = React.useRef<HTMLDivElement | null>(null); const state = React.useRef<CropState>({ x: 0, y: 0, z: 1, r: 0 }); const vel = React.useRef({ x: 0, y: 0, z: 0 }); const goal = React.useRef<Partial<CropState> | null>(null); const loop = React.useRef(0); const lastFrame = React.useRef(0); const pointers = React.useRef(new Map<number, Vec>()); const hold = React.useRef<{ x: number; y: number; z: number; mid: Vec; dist: number } | null>(null); const samples = React.useRef<{ x: number; y: number; t: number }[]>([]); const wheelTimer = React.useRef(0); const opts = React.useRef({ size, image, maxZoom, reduced, onSettle }); opts.current = { size, image, maxZoom, reduced, onSettle }; /** How far the photo may move each way at zoom z and rotation r before it would uncover the crop. */ const limits = (z: number, r: number): Vec => { const { image: img, size: d } = opts.current; if (!img) return { x: 0, y: 0 }; const cover = d / Math.min(img.w, img.h); const w = (r % 2 ? img.h : img.w) * cover * z; const h = (r % 2 ? img.w : img.h) * cover * z; return { x: Math.max(0, (w - d) / 2), y: Math.max(0, (h - d) / 2) }; }; const promote = (on: boolean) => { for (const el of [pan.current, zoom.current]) if (el) el.style.willChange = on ? "transform" : ""; if (grid.current) grid.current.style.opacity = on && hold.current ? "1" : "0"; }; const write = () => { const s = state.current; if (pan.current) pan.current.style.transform = `translate3d(${s.x}px, ${s.y}px, 0)`; if (zoom.current) zoom.current.style.transform = `scale(${s.z})`; const input = slider.current; if (input && document.activeElement !== input) input.value = String(clamp(s.z, 1, opts.current.maxZoom)); }; const settle = () => { cancelAnimationFrame(loop.current); loop.current = 0; vel.current = { x: 0, y: 0, z: 0 }; goal.current = null; promote(false); opts.current.onSettle?.({ ...state.current }); }; const tick = (now: number) => { const dt = Math.min(48, now - (lastFrame.current || now - 16)); lastFrame.current = now; const s = state.current; const v = vel.current; const { maxZoom: max } = opts.current; const steps = Math.max(1, Math.round(dt / STEP)); const h = dt / steps / 1000; for (let i = 0; i < steps; i++) { const zTo = clamp(goal.current?.z ?? s.z, 1, max); v.z += (-K * (s.z - zTo) - C * v.z) * h; s.z += v.z * h; const lim = limits(s.z, s.r); for (const axis of ["x", "y"] as const) { const target = goal.current?.[axis]; const bound = lim[axis]; if (target !== undefined || Math.abs(s[axis]) > bound) { // A goal (keys, rotation) or out of bounds: spring there, keeping whatever speed it has. const to = clamp(target ?? s[axis], -bound, bound); v[axis] += (-K * (s[axis] - to) - C * v[axis]) * h; } else v[axis] *= Math.exp(-FRICTION * h); s[axis] += v[axis] * h; } } write(); const lim = limits(s.z, s.r); const zTo = clamp(goal.current?.z ?? s.z, 1, max); const still = Math.abs(v.x) < 4 && Math.abs(v.y) < 4 && Math.abs(v.z) < 0.01 && Math.abs(s.z - zTo) < 0.0008 && Math.abs(s.x) <= lim.x + 0.3 && Math.abs(s.y) <= lim.y + 0.3 && (goal.current?.x === undefined || Math.abs(s.x - clamp(goal.current.x, -lim.x, lim.x)) < 0.3) && (goal.current?.y === undefined || Math.abs(s.y - clamp(goal.current.y, -lim.y, lim.y)) < 0.3); if (still) { s.z = zTo; const end = limits(s.z, s.r); s.x = clamp(goal.current?.x ?? s.x, -end.x, end.x); s.y = clamp(goal.current?.y ?? s.y, -end.y, end.y); write(); settle(); return; } loop.current = requestAnimationFrame(tick); }; const run = () => { if (opts.current.reduced) { // No springs: land where it would have, at once. const s = state.current; s.z = clamp(goal.current?.z ?? s.z, 1, opts.current.maxZoom); const lim = limits(s.z, s.r); s.x = clamp(goal.current?.x ?? s.x, -lim.x, lim.x); s.y = clamp(goal.current?.y ?? s.y, -lim.y, lim.y); write(); settle(); return; } promote(true); if (!loop.current) { lastFrame.current = 0; loop.current = requestAnimationFrame(tick); } }; const stop = () => { cancelAnimationFrame(loop.current); loop.current = 0; }; React.useEffect(() => () => cancelAnimationFrame(loop.current), []); // ── Pointers ─────────────────────────────────────────── const centre = (p: Vec): Vec => { const r = stage.current!.getBoundingClientRect(); return { x: p.x - (r.left + r.width / 2), y: p.y - (r.top + r.height / 2) }; }; const midpoint = (): { mid: Vec; dist: number } => { const ps = [...pointers.current.values()]; if (ps.length < 2) return { mid: centre(ps[0]), dist: 0 }; return { mid: centre({ x: (ps[0].x + ps[1].x) / 2, y: (ps[0].y + ps[1].y) / 2 }), dist: Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y) }; }; /** Start (or restart, when a finger joins or leaves) from what's shown, unbanded so nothing jumps. */ const begin = () => { const s = state.current; const { maxZoom: max } = opts.current; const z = s.z < 1 ? 1 - (1 - s.z) / 0.35 : s.z > max ? max + (s.z - max) / 0.3 : s.z; const lim = limits(s.z, s.r); const d = opts.current.size; const raw = (p: number, bound: number) => (Math.abs(p) > bound ? Math.sign(p) * bound + unband(p - Math.sign(p) * bound, d) : p); const { mid, dist } = midpoint(); hold.current = { x: raw(s.x, lim.x), y: raw(s.y, lim.y), z, mid, dist }; samples.current = []; }; const follow = () => { const g = hold.current; if (!g) return; const s = state.current; const { maxZoom: max, size: d } = opts.current; const { mid, dist } = midpoint(); let z = g.z; let x = g.x + (mid.x - g.mid.x); let y = g.y + (mid.y - g.mid.y); if (pointers.current.size > 1 && g.dist > 0) { z = g.z * (dist / g.dist); // The point of the photo that was under the fingers stays under them. x = mid.x - (g.mid.x - g.x) * (z / g.z); y = mid.y - (g.mid.y - g.y) * (z / g.z); } s.z = z < 1 ? Math.max(0.6, 1 - (1 - z) * 0.35) : z > max ? max + (z - max) * 0.3 : z; const lim = limits(s.z, s.r); s.x = Math.abs(x) > lim.x ? Math.sign(x) * lim.x + band(x - Math.sign(x) * lim.x, d) : x; s.y = Math.abs(y) > lim.y ? Math.sign(y) * lim.y + band(y - Math.sign(y) * lim.y, d) : y; write(); const t = performance.now(); samples.current.push({ x: s.x, y: s.y, t }); while (samples.current.length > 2 && t - samples.current[0].t > 90) samples.current.shift(); }; const onPointerDown = (e: React.PointerEvent<HTMLElement>) => { if (!opts.current.image || (e.pointerType === "mouse" && e.button !== 0)) return; e.currentTarget.setPointerCapture(e.pointerId); pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); // Catch it mid-flight: whatever was moving stops under the finger. stop(); vel.current = { x: 0, y: 0, z: 0 }; goal.current = null; begin(); promote(true); }; const onPointerMove = (e: React.PointerEvent<HTMLElement>) => { if (!pointers.current.has(e.pointerId)) return; pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); follow(); }; const onPointerUp = (e: React.PointerEvent<HTMLElement>) => { if (!pointers.current.delete(e.pointerId)) return; if (pointers.current.size) return begin(); const ss = samples.current; const first = ss[0]; const last = ss[ss.length - 1]; const fresh = last && performance.now() - last.t < 80 && last.t > first.t; vel.current = fresh ? { x: ((last.x - first.x) / (last.t - first.t)) * 1000, y: ((last.y - first.y) / (last.t - first.t)) * 1000, z: 0 } : { x: 0, y: 0, z: 0 }; hold.current = null; run(); }; // Wheel and trackpad pinch zoom about the pointer. Not passive: the page shouldn't scroll under the photo. React.useEffect(() => { const el = stage.current; if (!el) return; const onWheel = (e: WheelEvent) => { if (!opts.current.image) return; e.preventDefault(); stop(); const s = state.current; const { maxZoom: max } = opts.current; const z0 = s.z; const z1 = clamp(z0 * Math.exp(-e.deltaY * (e.ctrlKey ? 0.012 : 0.0016)), 1, max); const p = centre({ x: e.clientX, y: e.clientY }); const lim = limits(z1, s.r); s.x = clamp(p.x - (p.x - s.x) * (z1 / z0), -lim.x, lim.x); s.y = clamp(p.y - (p.y - s.y) * (z1 / z0), -lim.y, lim.y); s.z = z1; promote(true); write(); window.clearTimeout(wheelTimer.current); wheelTimer.current = window.setTimeout(settle, 160); }; el.addEventListener("wheel", onWheel, { passive: false }); return () => { el.removeEventListener("wheel", onWheel); window.clearTimeout(wheelTimer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [image]); // ── Commands ─────────────────────────────────────────── /** Zoom to z about the centre at once (the slider). Call settle() yourself when the slider lets go. */ const zoomTo = (z: number) => { stop(); const s = state.current; const z1 = clamp(z, 1, opts.current.maxZoom); const lim = limits(z1, s.r); s.x = clamp(s.x * (z1 / s.z), -lim.x, lim.x); s.y = clamp(s.y * (z1 / s.z), -lim.y, lim.y); s.z = z1; promote(true); write(); }; /** Glide by a step (keys, buttons): the spring takes it there and it settles. */ const nudge = (dx: number, dy: number) => { const s = state.current; goal.current = { x: (goal.current?.x ?? s.x) + dx, y: (goal.current?.y ?? s.y) + dy, z: goal.current?.z ?? s.z }; run(); }; const zoomBy = (factor: number) => { const s = state.current; const z0 = goal.current?.z ?? s.z; const z1 = clamp(z0 * factor, 1, opts.current.maxZoom); goal.current = { x: (goal.current?.x ?? s.x) * (z1 / z0), y: (goal.current?.y ?? s.y) * (z1 / z0), z: z1 }; run(); }; /** A quarter turn clockwise, thrown with a slight overshoot; the photo then springs inside the new bounds. */ const rotate = () => { const s = state.current; const from = s.r * 90; s.r += 1; const el = rot.current; if (el) { el.style.transform = `rotate(${s.r * 90}deg)`; if (!opts.current.reduced) el.animate([{ transform: `rotate(${from}deg)` }, { transform: `rotate(${s.r * 90}deg)` }], { duration: 560, easing: THROW }); } goal.current = { x: s.x, y: s.y, z: s.z }; run(); }; /** Back to the fit for a new photo. */ const reset = () => { stop(); state.current = { x: 0, y: 0, z: 1, r: 0 }; vel.current = { x: 0, y: 0, z: 0 }; goal.current = null; if (rot.current) rot.current.style.transform = "rotate(0deg)"; write(); }; /** Where the crop sits in the photo's own pixels. */ const area = (): CropArea | null => { const { image: img, size: d } = opts.current; if (!img) return null; const s = state.current; const k = (d / Math.min(img.w, img.h)) * s.z; const turn = ((s.r % 4) + 4) % 4; const a = (-turn * Math.PI) / 2; // The crop's centre relative to the photo's centre, on screen, turned back into the photo's own frame. const dx = -s.x; const dy = -s.y; const ux = (dx * Math.cos(a) - dy * Math.sin(a)) / k; const uy = (dx * Math.sin(a) + dy * Math.cos(a)) / k; return { centerX: img.w / 2 + ux, centerY: img.h / 2 + uy, size: d / k, rotation: turn * 90 }; }; /** Draws the crop into a square canvas of `output` px and encodes it. */ const toBlob = async (img: HTMLImageElement, output: number, type: string, quality: number): Promise<Blob> => { const { image: dims, size: d } = opts.current; if (!dims) throw new Error("No photo"); const s = state.current; const canvas = document.createElement("canvas"); canvas.width = output; canvas.height = output; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("No canvas"); const k = (d / Math.min(dims.w, dims.h)) * s.z; ctx.imageSmoothingQuality = "high"; ctx.translate(output / 2, output / 2); ctx.scale(output / d, output / d); ctx.translate(s.x, s.y); ctx.rotate((s.r * Math.PI) / 2); ctx.scale(k, k); ctx.drawImage(img, -dims.w / 2, -dims.h / 2, dims.w, dims.h); return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("Couldn't encode the photo"))), type, quality)); }; return { refs: { pan, zoom, rot, slider, grid, stage }, handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp }, state: () => ({ ...state.current }), zoomTo, settle, nudge, zoomBy, rotate, reset, area, toBlob, };}Registry item: /r/avatar-cropper.json