---
format: typebulb/v1
name: Crop & Resize
---

**code.tsx**

```tsx
import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"
import { createRoot } from "react-dom/client"

// Output = crop × scale, never a stretch. A set ratio (the link toggle, a preset, or both sizes typed
// while free) fixes the frame's shape and makes a typed size scale; with none, the frame drags free
// and one typed size scales. Every coordinate is in source pixels.

interface Rect { x: number; y: number; w: number; h: number }
interface Pin { dim: "w" | "h"; v: number; free?: boolean }   // the one typed output dimension; `free` = typed with no ratio set
interface Source { name: string; type: string; w: number; h: number; full: HTMLCanvasElement; preview: HTMLCanvasElement; note?: string }
type Grip = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "move" | "new"

const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
const round = Math.round
const MAX_OUT = 16384
const PREVIEW_MAX = 2048
const MAX_AREA = 16e6

// ---------- Geometry ----------

function snap(r: Rect, W: number, H: number): Rect {
  const w = clamp(round(r.w), 1, W), h = clamp(round(r.h), 1, H)
  return { x: clamp(round(r.x), 0, W - w), y: clamp(round(r.y), 0, H - h), w, h }
}
// A ratio change keeps the frame's centre and area (so a swap is a transpose and a round trip),
// shrinking around the centre only where the image's edge forces it.
function fitRatio(c: Rect, ratio: number, W: number, H: number): Rect {
  let w = Math.sqrt(c.w * c.h * ratio), h = w / ratio
  if (w > W) { w = W; h = w / ratio }
  if (h > H) { h = H; w = h * ratio }
  return snap({ x: c.x + (c.w - w) / 2, y: c.y + (c.h - h) / 2, w, h }, W, H)
}
function sizeOf(crop: Rect, ratio: number | null, pin: Pin | null): { w: number; h: number } {
  if (!pin) return { w: crop.w, h: crop.h }
  const r = ratio ?? crop.w / crop.h
  return pin.dim === "w" ? { w: pin.v, h: clamp(round(pin.v / r), 1, MAX_OUT) } : { w: clamp(round(pin.v * r), 1, MAX_OUT), h: pin.v }
}
// One drag step: `start` is the crop at pointer-down, (ax, ay) the pointer then, (px, py) now.
function dragTo(start: Rect, grip: Grip, ax: number, ay: number, px: number, py: number, W: number, H: number, ratio: number | null, min: number): Rect {
  if (grip === "move") return snap({ ...start, x: start.x + px - ax, y: start.y + py - ay }, W, H)
  px = clamp(px, 0, W); py = clamp(py, 0, H)
  const horiz = grip === "e" || grip === "w", vert = grip === "n" || grip === "s"
  const fx = grip === "new" ? ax : grip.includes("w") ? start.x + start.w : start.x
  const fy = grip === "new" ? ay : grip.includes("n") ? start.y + start.h : start.y
  const cx = start.x + start.w / 2, cy = start.y + start.h / 2
  const sx = px >= fx ? 1 : -1, sy = py >= fy ? 1 : -1
  let w = vert ? start.w : Math.abs(px - fx), h = horiz ? start.h : Math.abs(py - fy)
  const availW = vert ? (ratio ? 2 * Math.min(cx, W - cx) : start.w) : sx > 0 ? W - fx : fx
  const availH = horiz ? (ratio ? 2 * Math.min(cy, H - cy) : start.h) : sy > 0 ? H - fy : fy
  if (ratio) {
    if (horiz) h = w / ratio
    else if (vert) w = h * ratio
    else if (w / Math.max(h, 1e-6) > ratio) h = w / ratio; else w = h * ratio
    if (w < min) { w = min; h = w / ratio }
    if (h < min) { h = min; w = h * ratio }
    if (w > availW) { w = availW; h = w / ratio }
    if (h > availH) { h = availH; w = h * ratio }
  } else {
    w = clamp(w, min, availW); h = clamp(h, min, availH)
  }
  const x = vert ? (ratio ? cx - w / 2 : start.x) : sx > 0 ? fx : fx - w
  const y = horiz ? (ratio ? cy - h / 2 : start.y) : sy > 0 ? fy : fy - h
  return snap({ x, y, w, h }, W, H)
}

// ---------- Rasters ----------

let bitmapResize: boolean | null = null

function canvasOf(w: number, h: number): HTMLCanvasElement {
  const c = document.createElement("canvas"); c.width = Math.max(1, w); c.height = Math.max(1, h); return c
}
// iOS Safari caps canvas area silently: a too-large canvas draws nothing and reads back zeros.
function canvasOk(w: number, h: number): boolean {
  try {
    const c = canvasOf(w, h), x = c.getContext("2d")!
    x.fillStyle = "#fff"; x.fillRect(w - 1, h - 1, 1, 1)
    return x.getImageData(w - 1, h - 1, 1, 1).data[3] === 255
  } catch { return false }
}
// High-quality reduction of one region: createImageBitmap's resize where honoured (verified by the
// result's size), stepwise halving where it is ignored (Firefox).
async function downscale(src: HTMLCanvasElement, r: Rect, w: number, h: number): Promise<HTMLCanvasElement> {
  const out = canvasOf(w, h), octx = out.getContext("2d")!
  try {
    const bm = await createImageBitmap(src, r.x, r.y, r.w, r.h, { resizeWidth: w, resizeHeight: h, resizeQuality: "high" })
    const ok = bm.width === w && bm.height === h
    if (bitmapResize === null) bitmapResize = ok
    if (ok) { octx.drawImage(bm, 0, 0); bm.close(); return out }
    bm.close()
  } catch { if (bitmapResize === null) bitmapResize = false }
  let cur = src, cx = r.x, cy = r.y, cw = r.w, ch = r.h
  while (cw / 2 >= w && ch / 2 >= h) {
    const c = canvasOf(Math.floor(cw / 2), Math.floor(ch / 2)), x = c.getContext("2d")!
    x.imageSmoothingQuality = "high"; x.drawImage(cur, cx, cy, cw, ch, 0, 0, c.width, c.height)
    cur = c; cx = 0; cy = 0; cw = c.width; ch = c.height
  }
  octx.imageSmoothingQuality = "high"; octx.drawImage(cur, cx, cy, cw, ch, 0, 0, w, h)
  return out
}
function decode(file: File): Promise<HTMLImageElement> {
  const url = URL.createObjectURL(file)
  return new Promise((res, rej) => {
    const im = new Image()
    im.onload = () => { URL.revokeObjectURL(url); res(im) }
    im.onerror = () => { URL.revokeObjectURL(url); rej(new Error("This browser can't decode that image")) }
    im.src = url
  })
}
async function loadSource(file: File): Promise<Source> {
  const img = await decode(file)   // an <img> honours EXIF orientation; drawImage from it is oriented
  let w = img.naturalWidth, h = img.naturalHeight, note: string | undefined
  if (!canvasOk(w, h)) {
    let k = 1, found = false
    for (let i = 0; i < 6 && !found; i++) { k /= Math.SQRT2; found = canvasOk(Math.floor(w * k), Math.floor(h * k)) }
    if (!found) throw new Error("Image too large for this browser")
    const nw = Math.floor(w * k), nh = Math.floor(h * k)
    note = `Downscaled from ${w} × ${h} to ${nw} × ${nh}: this browser's canvas can't hold the full frame.`
    w = nw; h = nh
  }
  const full = canvasOf(w, h)
  full.getContext("2d")!.drawImage(img, 0, 0, w, h)
  const pk = Math.min(1, PREVIEW_MAX / Math.max(w, h))
  const preview = pk < 1 ? await downscale(full, { x: 0, y: 0, w, h }, round(w * pk), round(h * pk)) : full
  return { name: file.name, type: file.type, w, h, full, preview, note }
}
function toBlob(c: HTMLCanvasElement, mime: string, q?: number): Promise<Blob> {
  return new Promise((res, rej) => c.toBlob(b => b ? res(b) : rej(new Error("encode failed")), mime, q))
}
async function render(src: Source, crop: Rect, w: number, h: number): Promise<HTMLCanvasElement> {
  if (!canvasOk(w, h)) throw new Error("That size is too large for this browser")
  if (w * h < crop.w * crop.h) return downscale(src.full, crop, w, h)
  const c = canvasOf(w, h), x = c.getContext("2d")!
  x.imageSmoothingQuality = "high"; x.drawImage(src.full, crop.x, crop.y, crop.w, crop.h, 0, 0, w, h)
  return c
}
// The file keeps its format (JPEG stays JPEG, WebP stays WebP where the browser encodes it), else PNG.
async function encode(src: Source, crop: Rect, w: number, h: number): Promise<Blob> {
  const mime = src.type === "image/jpeg" || src.type === "image/webp" ? src.type : "image/png"
  return toBlob(await render(src, crop, w, h), mime, 0.92)
}
// Four flat quadrants: the self-test crops inside one and checks the output's colour.
function makeTestFile(): Promise<File> {
  const c = canvasOf(640, 480), x = c.getContext("2d")!
  const q: [string, number, number][] = [["#1e3a8a", 0, 0], ["#e11d48", 320, 0], ["#22c55e", 0, 240], ["#f59e0b", 320, 240]]
  for (const [col, px, py] of q) { x.fillStyle = col; x.fillRect(px, py, 320, 240) }
  return new Promise(res => c.toBlob(b => res(new File([b as BlobPart], "selftest.png", { type: "image/png" })), "image/png"))
}

// ---------- UI bits ----------

const ASPECTS: { label: string; r: number }[] = [
  { label: "9:16", r: 9 / 16 }, { label: "2:3", r: 2 / 3 }, { label: "3:4", r: 3 / 4 }, { label: "1:1", r: 1 },
  { label: "4:3", r: 4 / 3 }, { label: "3:2", r: 3 / 2 }, { label: "16:9", r: 16 / 9 },
]
// Continued fraction: exact for a ratio of two integers, which is where every ratio here comes from.
function fractionOf(r: number): string {
  let h0 = 0, h1 = 1, k0 = 1, k1 = 0, x = r
  for (let i = 0; i < 40; i++) {
    const a = Math.floor(x)
    const h2 = a * h1 + h0, k2 = a * k1 + k0
    h0 = h1; h1 = h2; k0 = k1; k1 = k2
    if (x === a || Math.abs(h1 / k1 - r) < 1e-9) break
    x = 1 / (x - a)
  }
  return `${h1}:${k1}`
}
const stem = (n: string) => n.replace(/\.[^/.]+$/, "") || "image"
const isWebView = (() => { const ua = navigator.userAgent; return (/iPhone|iPad|iPod/i.test(ua) && !ua.includes("Safari")) || /; wv\)/.test(ua) })()
const coarse = typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches

const LinkIcon = () => (
  <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
    <path d="M15 7h3a5 5 0 0 1 0 10h-3M9 17H6A5 5 0 0 1 6 7h3M8 12h8" />
  </svg>
)

// A size field commits on Enter, blur, or the native change event (which `tb:set` fires), so typing
// "1200" doesn't re-crop on the "1".
function NumField({ label, value, onCommit }: { label: string; value: number; onCommit: (v: number) => void }) {
  const [text, setText] = useState<string | null>(null)
  const ref = useRef<HTMLInputElement>(null)
  const commitRef = useRef(onCommit); commitRef.current = onCommit
  useEffect(() => {
    const el = ref.current!
    const onChange = () => { const v = parseInt(el.value, 10); if (Number.isFinite(v) && v > 0) commitRef.current(v); setText(null) }
    el.addEventListener("change", onChange)
    return () => el.removeEventListener("change", onChange)
  }, [])
  return (
    <input ref={ref} className="num" type="text" inputMode="numeric" aria-label={label} value={text ?? String(value)}
      onFocus={e => { setText(String(value)); e.target.select() }}
      onChange={e => setText(e.target.value.replace(/\D/g, ""))}
      onBlur={() => setText(null)}
      onKeyDown={e => {
        const el = e.currentTarget
        if (e.key === "Enter") el.blur()
        else if (e.key === "Escape") { setText(null); el.blur() }
        else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
          e.preventDefault()
          onCommit(Math.max(1, value + (e.key === "ArrowUp" ? 1 : -1) * (e.shiftKey ? 10 : 1))); setText(null)
        }
      }} />
  )
}

// ---------- App ----------

function App() {
  const [src, setSrc] = useState<Source | null>(null)
  const [crop, setCrop] = useState<Rect>({ x: 0, y: 0, w: 1, h: 1 })
  const [ratio, setRatio] = useState<number | null>(null)
  const [pin, setPin] = useState<Pin | null>(null)
  const [stage, setStage] = useState({ w: 0, h: 0 })
  const [dragging, setDragging] = useState(false)
  const [hover, setHover] = useState(false)
  const [busy, setBusy] = useState("")
  const [encoding, setEncoding] = useState(false)
  const [toast, setToast] = useState("")

  const stageRef = useRef<HTMLDivElement>(null)
  const frameRef = useRef<HTMLDivElement>(null)
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const fileRef = useRef<HTMLInputElement>(null)
  const dragRef = useRef<{ grip: Grip; ax: number; ay: number; start: Rect; moved: boolean } | null>(null)

  const flash = (m: string) => { setToast(m); window.setTimeout(() => setToast(""), 2200) }

  // --- loading ---
  const load = useCallback(async (file: File | null | undefined): Promise<Source | null> => {
    if (!file) return null
    if (!file.type.startsWith("image/")) { flash("That isn't an image"); return null }
    setBusy("Loading…")
    try {
      const s = await loadSource(file)
      setSrc(s); setCrop({ x: 0, y: 0, w: s.w, h: s.h }); setRatio(null); setPin(null)
      if (s.note) flash(s.note)
      return s
    } catch (e: any) { flash(String(e?.message || e)); return null }
    finally { setBusy("") }
  }, [])

  useEffect(() => {
    const onPaste = (e: ClipboardEvent) => {
      const items = e.clipboardData?.items; if (!items) return
      for (const it of items) if (it.type.startsWith("image/")) { load(it.getAsFile()); break }
    }
    window.addEventListener("paste", onPaste)
    return () => window.removeEventListener("paste", onPaste)
  }, [load])

  useEffect(() => {
    const ro = new ResizeObserver(([en]) => setStage({ w: en.contentRect.width, h: en.contentRect.height }))
    if (stageRef.current) ro.observe(stageRef.current)
    return () => ro.disconnect()
  }, [src])

  // --- view ---
  const fit = src && stage.w && stage.h ? Math.min((stage.w - 40) / src.w, (stage.h - 40) / src.h) : 0
  const minSrc = src && fit ? Math.min(Math.max(1, Math.ceil(24 / fit)), src.w, src.h) : 1
  const size = useMemo(() => sizeOf(crop, ratio, pin), [crop, ratio, pin])

  useEffect(() => {
    const cv = canvasRef.current
    if (!cv || !src || !fit) return
    let s = fit * (window.devicePixelRatio || 1)
    const area = src.w * src.h * s * s
    if (area > MAX_AREA) s *= Math.sqrt(MAX_AREA / area)
    const W = Math.max(1, round(src.w * s)), H = Math.max(1, round(src.h * s))
    cv.width = W; cv.height = H
    const raster = s <= src.preview.width / src.w * 1.02 ? src.preview : src.full
    const x = cv.getContext("2d")!
    x.imageSmoothingQuality = "high"; x.drawImage(raster, 0, 0, W, H)
  }, [src, fit])

  // --- crop gestures ---
  const toSrc = (e: { clientX: number; clientY: number }) => {
    const r = frameRef.current!.getBoundingClientRect()
    return { x: (e.clientX - r.left) / fit, y: (e.clientY - r.top) / fit }
  }
  const onDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!src || !fit || e.button !== 0) return
    const gripEl = (e.target as HTMLElement).closest<HTMLElement>("[data-grip]")
    const p = toSrc(e)
    const inside = p.x >= crop.x && p.x <= crop.x + crop.w && p.y >= crop.y && p.y <= crop.y + crop.h
    const grip: Grip = gripEl ? gripEl.dataset.grip as Grip : inside ? "move" : "new"
    e.preventDefault()
    e.currentTarget.setPointerCapture(e.pointerId)
    dragRef.current = { grip, ax: p.x, ay: p.y, start: grip === "new" ? { x: p.x, y: p.y, w: 0, h: 0 } : crop, moved: false }
    setDragging(true)
  }
  const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
    const d = dragRef.current; if (!d || !src) return
    const p = toSrc(e)
    if (!d.moved && Math.hypot(p.x - d.ax, p.y - d.ay) * fit < 3) return
    d.moved = true
    setCrop(dragTo(d.start, d.grip, d.ax, d.ay, p.x, p.y, src.w, src.h, ratio, minSrc))
  }
  const onUp = () => { if (dragRef.current) { dragRef.current = null; setDragging(false) } }

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (!src) return
      const tag = (e.target as HTMLElement)?.tagName
      if (tag === "INPUT" || tag === "TEXTAREA") return
      const d = e.shiftKey ? 10 : 1
      const dx = e.key === "ArrowLeft" ? -d : e.key === "ArrowRight" ? d : 0
      const dy = e.key === "ArrowUp" ? -d : e.key === "ArrowDown" ? d : 0
      if (!dx && !dy) return
      e.preventDefault()
      setCrop(c => snap({ ...c, x: c.x + dx, y: c.y + dy }, src.w, src.h))
    }
    window.addEventListener("keydown", onKey)
    return () => window.removeEventListener("keydown", onKey)
  }, [src])

  // --- aspect & size ---
  const fixed = ratio !== null
  const aspect = ratio !== null ? ASPECTS.find(a => Math.abs(a.r - ratio) < 1e-6)?.label ?? "custom" : "custom"
  const customLabel = `Custom ${fractionOf(ratio ?? crop.w / crop.h)}`
  const setFixed = (r: number | null) => {
    if (!src) return
    setRatio(r); if (r) setCrop(c => fitRatio(c, r, src.w, src.h))
    if (pin) setPin({ ...pin, free: false })   // a ratio change resets pairing: the next typed size scales
  }
  const pickAspect = (label: string) => { const a = ASPECTS.find(a => a.label === label); if (a) setFixed(a.r) }
  const toggleFixed = () => setFixed(fixed ? null : crop.w / crop.h)
  const typeSize = (dim: "w" | "h", raw: number) => {
    if (!src) return
    const v = clamp(round(raw), 1, MAX_OUT)
    const pair = ratio === null && !!pin?.free && pin.dim !== dim
    if (pair) {
      // Free, and the other field was typed too: the pair is exact, so the frame takes its ratio and locks.
      const w = dim === "w" ? v : pin!.v, h = dim === "h" ? v : pin!.v
      const r = w / h
      setRatio(r); setCrop(c => fitRatio(c, r, src.w, src.h))
    }
    setPin({ dim, v, free: ratio === null && !pair })
  }
  const reset = () => {
    if (!src) return
    setCrop({ x: 0, y: 0, w: src.w, h: src.h }); setRatio(null); setPin(null)
  }
  const dirty = !!src && (pin !== null || ratio !== null || crop.x !== 0 || crop.y !== 0 || crop.w !== src.w || crop.h !== src.h)

  // --- output ---
  const download = async () => {
    if (!src || encoding) return
    if (isWebView) { await tb.copy(await tb.url()); flash("Link copied: open it in your browser to download"); return }
    setEncoding(true)
    try {
      const blob = await encode(src, crop, size.w, size.h)
      const ext = blob.type === "image/jpeg" ? "jpg" : blob.type === "image/webp" ? "webp" : "png"
      const name = `${stem(src.name)}-${size.w}x${size.h}.${ext}`
      const file = new File([blob as BlobPart], name, { type: blob.type })
      if (coarse && navigator.share && navigator.canShare?.({ files: [file] })) {
        try { await navigator.share({ files: [file] }); return } catch { /* fall through to the link */ }
      }
      const url = URL.createObjectURL(blob)
      const a = document.createElement("a"); a.href = url; a.download = name; a.click()
      window.setTimeout(() => URL.revokeObjectURL(url), 2000)
    } catch (e: any) { flash(String(e?.message || e)) }
    finally { setEncoding(false) }
  }
  const copy = async () => {
    if (!src) return
    try {
      // The promise form keeps the user activation alive across the render (Safari insists on it).
      const png = render(src, crop, size.w, size.h).then(c => toBlob(c, "image/png"))
      await navigator.clipboard.write([new ClipboardItem({ "image/png": png })])
      flash("Copied")
    } catch { flash("Copy isn't allowed here. Use Download.") }
  }

  // --- probes: typebulb send <file> '<msg>' --wait ---
  const latest = useRef<any>(null)
  latest.current = { src, crop, ratio, pin, size, load }
  useEffect(() => {
    const set = (v: any, s: Source | null = latest.current.src) => {
      if (!v || typeof v !== "object") return
      if (!s) return
      if (v.crop) setCrop(snap(v.crop, s.w, s.h))
      if ("ratio" in v) setRatio(typeof v.ratio === "number" && v.ratio > 0 ? v.ratio : null)
      if ("pin" in v) setPin(v.pin && (v.pin.dim === "w" || v.pin.dim === "h") ? { dim: v.pin.dim, v: clamp(round(v.pin.v), 1, MAX_OUT) } : null)
    }
    const state = () => {
      const L = latest.current, s: Source = L.src
      return { source: { w: s.w, h: s.h, name: s.name, type: s.type }, crop: L.crop, ratio: L.ratio, pin: L.pin, output: L.size }
    }
    const settle = () => new Promise(r => window.setTimeout(r, 0))
    return tb.onMessage(async (m: any) => {
      const L = latest.current
      if (m === "selftest") {
        const s: Source | null = L.src ?? await L.load(await makeTestFile())
        if (!s) return { error: "no source" }
        const crop = snap({ x: s.w * 0.55, y: s.h * 0.55, w: s.w * 0.4, h: s.h * 0.4 }, s.w, s.h)
        const pin: Pin = { dim: "w", v: 160 }
        set({ crop, ratio: null, pin }, s)
        const expected = sizeOf(crop, null, pin)
        const blob = await encode(s, crop, expected.w, expected.h)
        const bm = await createImageBitmap(blob)
        const c = canvasOf(bm.width, bm.height), x = c.getContext("2d")!
        x.drawImage(bm, 0, 0)
        const p = x.getImageData(bm.width >> 1, bm.height >> 1, 1, 1).data
        const synthetic = s.name === "selftest.png"   // the bottom-right quadrant is #f59e0b
        const colourOk = !synthetic || (Math.abs(p[0] - 0xf5) < 6 && Math.abs(p[1] - 0x9e) < 6 && Math.abs(p[2] - 0x0b) < 6)
        const pass = bm.width === expected.w && bm.height === expected.h && colourOk
        return { w: bm.width, h: bm.height, expected, mime: blob.type, bytes: blob.size, centre: [p[0], p[1], p[2]], synthetic, pass }
      }
      if (m && typeof m === "object") {
        if (Array.isArray(m.drag)) return (dragTo as any)(...m.drag)   // [start, grip, ax, ay, px, py, W, H, ratio, min]
        if (m.load === "synthetic") { const s = await L.load(await makeTestFile()); await settle(); return s ? state() : { error: "load failed" } }
        if (!L.src) return { error: "no image loaded" }
        if (m.get === "state") return state()
        if (m.set === "state") { set(m.value); await settle(); return state() }
        if (m.get === "export") {
          const blob = await encode(L.src, L.crop, L.size.w, L.size.h)
          return { bytes: blob.size, mime: blob.type, w: L.size.w, h: L.size.h }
        }
        if (m.probe === "caps") return { bitmapResize, note: L.src.note ?? null }
      }
    })
  }, [])

  // --- render ---
  const cssW = crop.w * fit, cssH = crop.h * fit
  const grips: Grip[] = ["nw", "ne", "sw", "se", ...(cssW > 64 ? ["n", "s"] as Grip[] : []), ...(cssH > 64 ? ["e", "w"] as Grip[] : [])]

  return (
    <div className={"app" + (hover ? " hover" : "")}
      onDragOver={e => { e.preventDefault(); setHover(true) }}
      onDragLeave={() => setHover(false)}
      onDrop={e => { e.preventDefault(); setHover(false); load(e.dataTransfer.files?.[0]) }}>
      <input ref={fileRef} type="file" accept="image/*" hidden onChange={e => { load(e.target.files?.[0]); e.target.value = "" }} />

      <div ref={stageRef} className="stage">
        {src ? (
          <div ref={frameRef} className={"frame" + (dragging ? " dragging" : "")} style={{ width: src.w * fit, height: src.h * fit }}
            onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}>
            <canvas ref={canvasRef} className="art" aria-label="image" style={{ width: src.w * fit, height: src.h * fit }} />
            <div className="crop" role="group" aria-label="crop"
              style={{ left: crop.x * fit, top: crop.y * fit, width: cssW, height: cssH }}>
              <div className="grid" />
              {grips.map(g => <div key={g} className={"grip " + g} data-grip={g} />)}
            </div>
          </div>
        ) : (
          <button type="button" className="drop" onClick={() => fileRef.current?.click()}>
            Drop an image, paste, or click to open
          </button>
        )}
        {busy && <div className="overlay"><div className="spinner" /></div>}
      </div>

      {src && (
        <div className="bar">
          <select className="aspect" aria-label="Aspect" value={aspect} onChange={e => pickAspect(e.target.value)}>
            {aspect === "custom" && <option value="custom">{customLabel}</option>}
            {ASPECTS.map(a => <option key={a.label} value={a.label}>{a.label}</option>)}
          </select>
          <div className="group" role="group" aria-label="Size">
            <NumField label="Width" value={size.w} onCommit={v => typeSize("w", v)} />
            <button className={"link" + (fixed ? " on" : "")} aria-label="Fix ratio" aria-pressed={fixed}
              title={fixed ? "Ratio fixed: click to free the frame" : "Free: click to fix the frame's shape"} onClick={toggleFixed}>{fixed ? <LinkIcon /> : "×"}</button>
            <NumField label="Height" value={size.h} onCommit={v => typeSize("h", v)} />
            <span className="unit">px</span>
          </div>
          <div className="group">
            <button className="ghost" onClick={() => fileRef.current?.click()}>Open</button>
            <button className="ghost" disabled={!dirty} onClick={reset}>Reset</button>
            <button className="ghost" onClick={copy}>Copy</button>
            <button className="ghost" disabled={encoding} onClick={download}>{encoding ? "Encoding…" : "Download"}</button>
          </div>
        </div>
      )}

      <div className={"toast" + (toast ? " show" : "")}>{toast}</div>
    </div>
  )
}

createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**

```css
* { box-sizing: border-box; }

.app {
  height: 100dvh;
  min-height: 420px;
  display: flex;
  flex-direction: column;
  font: 14px system-ui, -apple-system, sans-serif;
  color: CanvasText;
  --line: color-mix(in srgb, currentColor 16%, transparent);
  --faint: color-mix(in srgb, currentColor 5%, transparent);
  --check-a: #e4e4e4;
  --check-b: #f8f8f8;
}
html[data-theme="dark"] .app { --check-a: #3a3a3a; --check-b: #2c2c2c; }

/* stage */
.stage { flex: 1 1 auto; min-height: 0; position: relative; display: flex; background: var(--faint); overflow: hidden; }
.app.hover .stage::after {
  content: ""; position: absolute; inset: 10px; pointer-events: none; border-radius: 12px;
  border: 2px dashed color-mix(in srgb, currentColor 45%, transparent);
}
.drop {
  flex: 1 1 auto; margin: 0; padding: 24px; font: inherit; color: inherit; cursor: pointer;
  border: 0; background: transparent; opacity: 0.7;
}
.drop:hover { opacity: 1; }

.frame {
  position: relative; flex: none; margin: auto; overflow: hidden; line-height: 0;
  touch-action: none; user-select: none; -webkit-user-select: none; cursor: crosshair;
  background: conic-gradient(var(--check-a) 25%, var(--check-b) 0 50%, var(--check-a) 0 75%, var(--check-b) 0) 0 0 / 16px 16px;
}
.art { display: block; }

/* crop frame: the dim is its shadow, clipped by the frame */
.crop {
  position: absolute; cursor: move;
  border: 1px solid rgba(255, 255, 255, 0.9);
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
}
.grid {
  position: absolute; inset: 0; pointer-events: none; opacity: 0; transition: opacity 0.15s;
  background:
    linear-gradient(to right, transparent calc(33.333% - 0.5px), rgba(255, 255, 255, 0.35) calc(33.333% - 0.5px) calc(33.333% + 0.5px), transparent calc(33.333% + 0.5px) calc(66.667% - 0.5px), rgba(255, 255, 255, 0.35) calc(66.667% - 0.5px) calc(66.667% + 0.5px), transparent calc(66.667% + 0.5px)),
    linear-gradient(to bottom, transparent calc(33.333% - 0.5px), rgba(255, 255, 255, 0.35) calc(33.333% - 0.5px) calc(33.333% + 0.5px), transparent calc(33.333% + 0.5px) calc(66.667% - 0.5px), rgba(255, 255, 255, 0.35) calc(66.667% - 0.5px) calc(66.667% + 0.5px), transparent calc(66.667% + 0.5px));
}
.frame.dragging .grid { opacity: 1; }

/* grips: a 28px hit box centred on the edge, the visual drawn just inside it */
.grip { position: absolute; width: 28px; height: 28px; z-index: 2; }
.grip::after { content: ""; position: absolute; border: 0 solid #fff; filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.7)); }
.grip.nw { left: -14px; top: -14px; cursor: nwse-resize; }
.grip.ne { right: -14px; top: -14px; cursor: nesw-resize; }
.grip.sw { left: -14px; bottom: -14px; cursor: nesw-resize; }
.grip.se { right: -14px; bottom: -14px; cursor: nwse-resize; }
.grip.nw::after { left: 13px; top: 13px; width: 16px; height: 16px; border-width: 3px 0 0 3px; }
.grip.ne::after { right: 13px; top: 13px; width: 16px; height: 16px; border-width: 3px 3px 0 0; }
.grip.sw::after { left: 13px; bottom: 13px; width: 16px; height: 16px; border-width: 0 0 3px 3px; }
.grip.se::after { right: 13px; bottom: 13px; width: 16px; height: 16px; border-width: 0 3px 3px 0; }
.grip.n, .grip.s { left: 50%; margin-left: -20px; width: 40px; cursor: ns-resize; }
.grip.n { top: -14px; } .grip.s { bottom: -14px; }
.grip.n::after, .grip.s::after { left: 8px; width: 24px; height: 3px; background: #fff; border-radius: 2px; }
.grip.n::after { top: 13px; } .grip.s::after { bottom: 13px; }
.grip.e, .grip.w { top: 50%; margin-top: -20px; height: 40px; cursor: ew-resize; }
.grip.w { left: -14px; } .grip.e { right: -14px; }
.grip.e::after, .grip.w::after { top: 8px; height: 24px; width: 3px; background: #fff; border-radius: 2px; }
.grip.w::after { left: 13px; } .grip.e::after { right: 13px; }

/* bar */
.bar {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
  gap: 10px 24px; padding: 10px 16px;
  border-top: 1px solid var(--line);
}
.group { display: inline-flex; align-items: center; gap: 4px; }

button { font: inherit; color: inherit; cursor: pointer; line-height: 1.2; }
button:disabled { cursor: default; }
.aspect {
  font: inherit; padding: 5px 8px; border: 1px solid var(--line); border-radius: 8px;
  background: Canvas; color: CanvasText; cursor: pointer;
}
select, option { background: Canvas; color: CanvasText; }
.num {
  width: 6.5ch; padding: 5px 4px; font: inherit; font-variant-numeric: tabular-nums; text-align: center;
  border: 1px solid var(--line); border-radius: 8px; background: transparent; color: inherit;
}
.num:focus { outline: 0; border-color: color-mix(in srgb, currentColor 60%, transparent); }
.unit { opacity: 0.55; padding: 0 2px; }
.link {
  width: 26px; height: 28px; padding: 0; border: 0; border-radius: 6px; background: transparent;
  display: inline-flex; align-items: center; justify-content: center; font-size: 15px; opacity: 0.55;
}
.link:hover { opacity: 1; background: var(--faint); }
.link.on { opacity: 1; }
.ghost { padding: 6px 12px; border-radius: 8px; border: 0; background: transparent; opacity: 0.8; }
.ghost:hover:not(:disabled) { opacity: 1; background: var(--faint); }
.ghost:disabled { opacity: 0.3; }

/* loading & toast */
.overlay { position: absolute; inset: 0; display: grid; place-items: center; background: color-mix(in srgb, Canvas 55%, transparent); }
.spinner { width: 28px; height: 28px; border-radius: 50%; border: 3px solid var(--line); border-top-color: currentColor; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.toast {
  position: fixed; left: 50%; bottom: 72px; transform: translate(-50%, 12px);
  background: CanvasText; color: Canvas; padding: 8px 16px; border-radius: 999px;
  opacity: 0; pointer-events: none; transition: opacity 0.18s, transform 0.18s; max-width: 90vw;
}
.toast.show { opacity: 0.92; transform: translate(-50%, 0); }
```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "description": "Crop an image with a draggable frame, set its exact pixel size, and download.",
  "dependencies": {
    "react": "^19.2.7",
    "react-dom": "^19.2.7"
  }
}
```