{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"snap-crop","type":"registry:ui","title":"Snap Crop","description":"Crop frame with springy corner handles that magnetically snap to edges, thirds and center, with snap guides and a live pixel-size readout.","dependencies":["motion"],"registryDependencies":[],"files":[{"path":"registry/ui/snap-crop.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useRef, useState, type KeyboardEvent, type PointerEvent } from \"react\";\nimport { AnimatePresence, MotionConfig, motion } from \"motion/react\";\n\nexport interface CropRect {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\nexport interface SnapCropProps {\n  /** Image to crop. */\n  src?: string;\n  alt?: string;\n  /** Real pixel size of the image; the readout and onChange use these units. */\n  naturalWidth?: number;\n  naturalHeight?: number;\n  /** Snap edges to the image edges, thirds and center. */\n  snap?: boolean;\n  /** Snap distance in screen px. */\n  snapDistance?: number;\n  /** Smallest crop, as a fraction of the image side (0-1). */\n  minSize?: number;\n  /** Guide and handle accent color. */\n  accent?: string;\n  /** Called with the crop in image pixels. */\n  onChange?: (crop: CropRect) => void;\n  className?: string;\n}\n\ntype Box = { l: number; t: number; r: number; b: number };\ntype Mode = \"move\" | \"nw\" | \"ne\" | \"sw\" | \"se\";\n\nconst TARGETS = [0, 1 / 3, 0.5, 2 / 3, 1];\nconst corners: { mode: Mode; name: string; pos: string; edge: string }[] = [\n  { mode: \"nw\", name: \"top-left\", pos: \"-left-3.5 -top-3.5\", edge: \"left-2.5 top-2.5 border-l-[3px] border-t-[3px] rounded-tl-sm\" },\n  { mode: \"ne\", name: \"top-right\", pos: \"-right-3.5 -top-3.5\", edge: \"right-2.5 top-2.5 border-r-[3px] border-t-[3px] rounded-tr-sm\" },\n  { mode: \"sw\", name: \"bottom-left\", pos: \"-bottom-3.5 -left-3.5\", edge: \"bottom-2.5 left-2.5 border-b-[3px] border-l-[3px] rounded-bl-sm\" },\n  { mode: \"se\", name: \"bottom-right\", pos: \"-bottom-3.5 -right-3.5\", edge: \"bottom-2.5 right-2.5 border-b-[3px] border-r-[3px] rounded-br-sm\" },\n];\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\nexport function SnapCrop({\n  src,\n  alt = \"\",\n  naturalWidth = 1600,\n  naturalHeight = 1000,\n  snap = true,\n  snapDistance = 10,\n  minSize = 0.15,\n  accent = \"#a3e635\",\n  onChange,\n  className,\n}: SnapCropProps) {\n  const stage = useRef<HTMLDivElement>(null);\n  const drag = useRef<{ mode: Mode; x: number; y: number; start: Box; w: number; h: number } | null>(null);\n  const [box, setBox] = useState<Box>({ l: 0.12, t: 0.14, r: 0.88, b: 0.86 });\n  const [active, setActive] = useState<Mode | null>(null);\n  const [guides, setGuides] = useState<{ v: number[]; h: number[] }>({ v: [], h: [] });\n\n  // Move edges by (dx, dy) in 0-1 units from `start`, clamped, then magnetically snapped.\n  const apply = (mode: Mode, dx: number, dy: number, start: Box, w: number, h: number) => {\n    const sx = snap ? snapDistance / w : 0;\n    const sy = snap ? snapDistance / h : 0;\n    const near = (v: number, s: number) => TARGETS.find((t) => Math.abs(t - v) < s);\n    const v: number[] = [];\n    const hz: number[] = [];\n    const n = { ...start };\n\n    if (mode === \"move\") {\n      const bw = start.r - start.l;\n      const bh = start.b - start.t;\n      n.l = clamp(start.l + dx, 0, 1 - bw);\n      n.t = clamp(start.t + dy, 0, 1 - bh);\n      // Try snapping the left edge, the right edge, then the center, per axis.\n      for (const [off, s, axis] of [\n        [0, sx, \"x\"], [bw, sx, \"x\"], [bw / 2, sx, \"x\"],\n        [0, sy, \"y\"], [bh, sy, \"y\"], [bh / 2, sy, \"y\"],\n      ] as const) {\n        const cur = axis === \"x\" ? n.l : n.t;\n        if ((axis === \"x\" ? v : hz).length) continue;\n        const hit = near(cur + off, s);\n        if (hit === undefined) continue;\n        if (axis === \"x\") { n.l = clamp(hit - off, 0, 1 - bw); v.push(hit); }\n        else { n.t = clamp(hit - off, 0, 1 - bh); hz.push(hit); }\n      }\n      n.r = n.l + bw;\n      n.b = n.t + bh;\n    } else {\n      const edge = (val: number, lo: number, hi: number, s: number, out: number[]) => {\n        const c = clamp(val, lo, hi);\n        const hit = near(c, s);\n        if (hit !== undefined && hit >= lo && hit <= hi) { out.push(hit); return hit; }\n        return c;\n      };\n      if (mode[1] === \"w\") n.l = edge(start.l + dx, 0, start.r - minSize, sx, v);\n      else n.r = edge(start.r + dx, start.l + minSize, 1, sx, v);\n      if (mode[0] === \"n\") n.t = edge(start.t + dy, 0, start.b - minSize, sy, hz);\n      else n.b = edge(start.b + dy, start.t + minSize, 1, sy, hz);\n    }\n\n    setBox(n);\n    setGuides({ v, h: hz });\n    onChange?.({\n      x: Math.round(n.l * naturalWidth),\n      y: Math.round(n.t * naturalHeight),\n      width: Math.round((n.r - n.l) * naturalWidth),\n      height: Math.round((n.b - n.t) * naturalHeight),\n    });\n  };\n\n  const down = (mode: Mode, e: PointerEvent<HTMLElement>) => {\n    const rect = stage.current?.getBoundingClientRect();\n    if (!rect || e.button !== 0) return;\n    e.stopPropagation();\n    e.currentTarget.setPointerCapture(e.pointerId);\n    drag.current = { mode, x: e.clientX, y: e.clientY, start: box, w: rect.width, h: rect.height };\n    setActive(mode);\n  };\n  const move = (e: PointerEvent<HTMLElement>) => {\n    const d = drag.current;\n    if (d) apply(d.mode, (e.clientX - d.x) / d.w, (e.clientY - d.y) / d.h, d.start, d.w, d.h);\n  };\n  const up = () => {\n    drag.current = null;\n    setActive(null);\n    setGuides({ v: [], h: [] });\n  };\n  const key = (mode: Mode) => (e: KeyboardEvent<HTMLElement>) => {\n    const dirs: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };\n    const d = dirs[e.key];\n    if (!d) return;\n    e.preventDefault();\n    e.stopPropagation();\n    const step = e.shiftKey ? 0.05 : 0.01;\n    // Infinite size = zero snap distance: snapping would swallow single key steps near a target.\n    apply(mode, d[0] * step, d[1] * step, box, Infinity, Infinity);\n    setGuides({ v: [], h: [] });\n  };\n\n  const pct = (n: number) => `${n * 100}%`;\n  const w = Math.round((box.r - box.l) * naturalWidth);\n  const h = Math.round((box.b - box.t) * naturalHeight);\n  const track = { type: \"spring\", stiffness: 900, damping: 55 } as const;\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div\n        ref={stage}\n        className={`relative w-full max-w-xl touch-none select-none overflow-hidden rounded-2xl bg-muted shadow-2xl ${className ?? \"\"}`}\n        style={{ aspectRatio: `${naturalWidth} / ${naturalHeight}` }}\n      >\n        {src ? (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img src={src} alt={alt} draggable={false} className=\"absolute inset-0 size-full object-cover\" />\n        ) : (\n          <div className=\"absolute inset-0 bg-gradient-to-br from-sky-400 via-violet-500 to-orange-400\" />\n        )}\n\n        <AnimatePresence>\n          {guides.v.map((g) => (\n            <motion.span key={`v${g}`} aria-hidden className=\"pointer-events-none absolute inset-y-0 z-20 w-px\" style={{ left: pct(g), background: accent }}\n              initial={{ opacity: 0, scaleY: 0 }} animate={{ opacity: 1, scaleY: 1 }} exit={{ opacity: 0 }} />\n          ))}\n          {guides.h.map((g) => (\n            <motion.span key={`h${g}`} aria-hidden className=\"pointer-events-none absolute inset-x-0 z-20 h-px\" style={{ top: pct(g), background: accent }}\n              initial={{ opacity: 0, scaleX: 0 }} animate={{ opacity: 1, scaleX: 1 }} exit={{ opacity: 0 }} />\n          ))}\n        </AnimatePresence>\n\n        <motion.div\n          role=\"group\"\n          tabIndex={0}\n          aria-label={`Crop area, ${w} by ${h} pixels. Arrow keys move it, Shift for bigger steps.`}\n          onPointerDown={(e) => down(\"move\", e)}\n          onPointerMove={move}\n          onPointerUp={up}\n          onPointerCancel={up}\n          onKeyDown={key(\"move\")}\n          initial={false}\n          animate={{ left: pct(box.l), top: pct(box.t), width: pct(box.r - box.l), height: pct(box.b - box.t) }}\n          transition={track}\n          className={`absolute z-10 border border-white/80 shadow-[0_0_0_9999px_rgb(0_0_0/.55)] outline-none focus-visible:border-2 ${active === \"move\" ? \"cursor-grabbing\" : \"cursor-grab\"}`}\n          style={{ borderColor: guides.v.length || guides.h.length ? accent : undefined }}\n        >\n          {/* Rule-of-thirds grid while dragging. */}\n          <div aria-hidden className={`pointer-events-none absolute inset-0 transition-opacity duration-200 ${active ? \"opacity-100\" : \"opacity-0\"}`}>\n            {[1, 2].map((i) => (\n              <span key={`c${i}`} className=\"absolute inset-y-0 w-px bg-white/40\" style={{ left: `${(i * 100) / 3}%` }} />\n            ))}\n            {[1, 2].map((i) => (\n              <span key={`r${i}`} className=\"absolute inset-x-0 h-px bg-white/40\" style={{ top: `${(i * 100) / 3}%` }} />\n            ))}\n          </div>\n\n          {corners.map((c) => (\n            <motion.button\n              key={c.mode}\n              type=\"button\"\n              aria-label={`Resize from ${c.name} corner`}\n              onPointerDown={(e) => down(c.mode, e)}\n              onKeyDown={key(c.mode)}\n              animate={{ scale: active === c.mode ? 1.3 : 1 }}\n              whileHover={{ scale: active === c.mode ? 1.3 : 1.15 }}\n              transition={{ type: \"spring\", stiffness: 600, damping: 15 }}\n              className={`group absolute ${c.pos} size-8 rounded-full outline-none ${c.mode === \"nw\" || c.mode === \"se\" ? \"cursor-nwse-resize\" : \"cursor-nesw-resize\"}`}\n            >\n              <span\n                aria-hidden\n                className={`absolute size-4 border-white drop-shadow group-focus-visible:border-[var(--rf-accent)] ${c.edge}`}\n                style={{ [\"--rf-accent\" as string]: accent }}\n              />\n            </motion.button>\n          ))}\n\n          <motion.span\n            aria-hidden\n            className=\"pointer-events-none absolute bottom-2 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full bg-black/70 px-2.5 py-1 font-mono text-[11px] font-medium tabular-nums text-white backdrop-blur\"\n            animate={{ scale: active ? 1.08 : 1, y: active ? -2 : 0 }}\n            transition={{ type: \"spring\", stiffness: 500, damping: 18 }}\n          >\n            {w} × {h}\n          </motion.span>\n        </motion.div>\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {active ? \"\" : `Crop ${w} by ${h} pixels`}\n        </span>\n      </div>\n    </MotionConfig>\n  );\n}\n"}]}