Crop an image with a draggable frame, set its exact pixel size, and download.
---
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"
}
}
```