{
  "name": "photo-straighten",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Photo Straighten\n * @version: 1.0.0\n * @date: 2026-10-16\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { RotateCcw, Wand2 } from \"lucide-react\";\nimport {\n  type AnimationPlaybackControls,\n  animate,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport Image from \"next/image\";\nimport { type KeyboardEvent, type PointerEvent, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst MAX = 45;\n// Ruler spacing: px per degree.\nconst PX = 9;\n// A throw keeps going for this long, in seconds of its release speed.\nconst THROW = 0.18;\n// Releases this close to level land on 0.\nconst SNAP = 0.6;\nconst SETTLE = { type: \"spring\", duration: 0.45, bounce: 0.1 } as const;\nconst TICKS = Array.from({ length: MAX * 2 + 1 }, (_, i) => i - MAX);\n\nexport interface PhotoStraightenProps {\n  src?: string;\n  alt?: string;\n  /** Width over height of the crop. */\n  aspectRatio?: number;\n  /**\n   * How crooked the photo is, in degrees; what Auto corrects. The demo\n   * tilts its photo by this much so there is something to fix.\n   */\n  tilt?: number;\n  defaultAngle?: number;\n  onAngleChange?: (degrees: number) => void;\n  className?: string;\n}\n\nconst PHOTO =\n  \"https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1600&h=1200&fit=crop&auto=format&q=80\";\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, v));\n\n// Past the end of the ruler it still moves, with growing resistance.\nfunction rubber(a: number) {\n  if (Math.abs(a) <= MAX) {\n    return a;\n  }\n  const over = Math.abs(a) - MAX;\n  return Math.sign(a) * (MAX + 3 * (1 - Math.exp(-over / 6)));\n}\n\nconst formatAngle = (a: number) => {\n  const v = Math.round(a * 10) / 10;\n  if (v === 0) {\n    return \"0°\";\n  }\n  return `${v > 0 ? \"+\" : \"−\"}${Math.abs(v).toFixed(1)}°`;\n};\n\nexport default function PhotoStraighten({\n  src = PHOTO,\n  alt = \"A lake below mountains\",\n  aspectRatio = 4 / 3,\n  tilt = 4.2,\n  defaultAngle = 0,\n  onAngleChange,\n  className,\n}: PhotoStraightenProps) {\n  const reduce = useReducedMotion();\n  const angle = useMotionValue(defaultAngle);\n  const settle = useRef<AnimationPlaybackControls | null>(null);\n  const drag = useRef<{\n    x: number;\n    from: number;\n    samples: { x: number; t: number }[];\n  } | null>(null);\n  const [value, setValue] = useState(Math.round(defaultAngle * 10) / 10);\n  const [adjusting, setAdjusting] = useState(false);\n\n  // The photo turns by its own tilt plus the correction, and grows just\n  // enough that no corner of the crop ever shows a gap.\n  const ratio = Math.max(aspectRatio, 1 / aspectRatio);\n  const transform = useTransform(angle, (a) => {\n    const turn = tilt + a;\n    const rad = (Math.abs(turn) * Math.PI) / 180;\n    const scale = Math.cos(rad) + ratio * Math.sin(rad);\n    return `rotate(${turn}deg) scale(${scale})`;\n  });\n  const rulerX = useTransform(angle, (a) => -(a + MAX) * PX);\n\n  useMotionValueEvent(angle, \"change\", (a) => {\n    const v = Math.round(clamp(a, -MAX, MAX) * 10) / 10;\n    setValue((prev) => (prev === v ? prev : v));\n    onAngleChange?.(v);\n  });\n\n  const settleTo = (to: number) => {\n    settle.current?.stop();\n    if (reduce) {\n      angle.set(to);\n      setAdjusting(false);\n      return;\n    }\n    setAdjusting(true);\n    settle.current = animate(angle, to, {\n      ...SETTLE,\n      onComplete: () => {\n        if (!drag.current) {\n          setAdjusting(false);\n        }\n      },\n    });\n  };\n\n  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (!event.isPrimary || event.button !== 0 || drag.current) {\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    settle.current?.stop();\n    drag.current = {\n      x: event.clientX,\n      from: angle.get(),\n      samples: [{ x: event.clientX, t: event.timeStamp }],\n    };\n    setAdjusting(true);\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d) {\n      return;\n    }\n    // Dragging the ruler left turns the photo clockwise, like a dial.\n    angle.set(rubber(d.from - (event.clientX - d.x) / PX));\n    d.samples.push({ x: event.clientX, t: event.timeStamp });\n    if (d.samples.length > 5) {\n      d.samples.shift();\n    }\n  };\n\n  const endDrag = (event: PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d) {\n      return;\n    }\n    drag.current = null;\n    const first = d.samples[0];\n    const dt = (event.timeStamp - first.t) / 1000;\n    // Degrees per second at release, from the last few moves.\n    const velocity =\n      dt > 0 && dt < 0.15 ? -(event.clientX - first.x) / PX / dt : 0;\n    let to = clamp(angle.get() + velocity * THROW, -MAX, MAX);\n    if (Math.abs(to) < SNAP) {\n      to = 0;\n    } else {\n      to = Math.round(to * 10) / 10;\n    }\n    settleTo(to);\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const step = event.shiftKey ? 1 : 0.1;\n    const moves: Record<string, number> = {\n      ArrowLeft: -step,\n      ArrowDown: -step,\n      ArrowRight: step,\n      ArrowUp: step,\n    };\n    if (event.key === \"Home\" || event.key === \"0\") {\n      event.preventDefault();\n      settleTo(0);\n      return;\n    }\n    const move = moves[event.key];\n    if (move === undefined) {\n      return;\n    }\n    event.preventDefault();\n    settle.current?.stop();\n    angle.set(clamp(Math.round((angle.get() + move) * 10) / 10, -MAX, MAX));\n  };\n\n  const level = Math.abs(tilt + value) < 0.05;\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-lg select-none rounded-3xl border border-zinc-200 bg-white p-4 text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100\",\n        className\n      )}\n    >\n      <div\n        className=\"relative overflow-hidden rounded-2xl bg-zinc-100 dark:bg-zinc-900\"\n        style={{ aspectRatio }}\n      >\n        <motion.div className=\"absolute inset-0\" style={{ transform }}>\n          <Image\n            alt={alt}\n            className=\"pointer-events-none object-cover\"\n            draggable={false}\n            fill\n            sizes=\"(max-width: 512px) 100vw, 512px\"\n            src={src}\n            unoptimized\n          />\n        </motion.div>\n\n        {/* A grid to line the horizon up against, only while adjusting. */}\n        <div\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute inset-0 transition-opacity duration-200 ease-out\",\n            adjusting ? \"opacity-100\" : \"opacity-0\"\n          )}\n        >\n          {[1, 2, 3, 4, 5].map((i) => (\n            <span\n              className={cn(\n                \"absolute inset-x-0 h-px\",\n                i % 2 === 0 ? \"bg-white/70\" : \"bg-white/25\"\n              )}\n              key={`h${i}`}\n              style={{ top: `${(i / 6) * 100}%` }}\n            />\n          ))}\n          {[1, 2, 3, 4, 5].map((i) => (\n            <span\n              className={cn(\n                \"absolute inset-y-0 w-px\",\n                i % 2 === 0 ? \"bg-white/70\" : \"bg-white/25\"\n              )}\n              key={`v${i}`}\n              style={{ left: `${(i / 6) * 100}%` }}\n            />\n          ))}\n        </div>\n\n        <span\n          className={cn(\n            \"absolute top-3 left-3 rounded-full px-2.5 py-1 font-medium text-[11px] text-white transition-colors duration-200 ease-out\",\n            level ? \"bg-emerald-600/90\" : \"bg-black/55\"\n          )}\n        >\n          {level ? \"Level\" : \"Crooked\"}\n        </span>\n      </div>\n\n      <div className=\"mt-4 flex items-center justify-between px-1\">\n        <button\n          className=\"flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium text-xs text-zinc-600 outline-none transition-colors duration-150 ease-out hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100\"\n          onClick={() => settleTo(-tilt)}\n          type=\"button\"\n        >\n          <Wand2 aria-hidden className=\"size-3.5\" />\n          Auto\n        </button>\n        <span\n          className={cn(\n            \"font-medium text-sm tabular-nums transition-colors duration-200 ease-out\",\n            level && \"text-emerald-700 dark:text-emerald-400\"\n          )}\n        >\n          {formatAngle(value)}\n        </span>\n        <button\n          className=\"flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium text-xs text-zinc-600 outline-none transition-colors duration-150 ease-out hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100\"\n          disabled={value === 0}\n          onClick={() => settleTo(0)}\n          type=\"button\"\n        >\n          <RotateCcw aria-hidden className=\"size-3.5\" />\n          Reset\n        </button>\n      </div>\n\n      {/* The ruler slides under a fixed centre mark. */}\n      <div\n        aria-label=\"Straighten\"\n        aria-valuemax={MAX}\n        aria-valuemin={-MAX}\n        aria-valuenow={value}\n        aria-valuetext={formatAngle(value)}\n        className=\"relative mt-2 h-12 cursor-ew-resize touch-none overflow-hidden rounded-lg outline-none [mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)] focus-visible:ring-2 focus-visible:ring-zinc-400\"\n        onDoubleClick={() => settleTo(0)}\n        onKeyDown={handleKeyDown}\n        onLostPointerCapture={endDrag}\n        onPointerCancel={endDrag}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={endDrag}\n        role=\"slider\"\n        tabIndex={0}\n      >\n        <motion.div\n          className=\"absolute top-0 left-1/2 h-full\"\n          style={{ x: rulerX, width: MAX * 2 * PX }}\n        >\n          {TICKS.map((deg) => {\n            const major = deg % 5 === 0;\n            return (\n              <span\n                className=\"absolute top-2 flex -translate-x-1/2 flex-col items-center\"\n                key={deg}\n                style={{ left: (deg + MAX) * PX }}\n              >\n                <span\n                  className={cn(\n                    \"w-px\",\n                    // Zero is the reference mark, in the level colour.\n                    deg === 0 && \"h-3 bg-emerald-600 dark:bg-emerald-400\",\n                    deg !== 0 &&\n                      (major\n                        ? \"h-3 bg-zinc-400 dark:bg-zinc-500\"\n                        : \"h-2 bg-zinc-300 dark:bg-zinc-700\")\n                  )}\n                />\n                {deg % 15 === 0 && (\n                  <span\n                    className={cn(\n                      \"mt-1 text-[10px] tabular-nums\",\n                      deg === 0\n                        ? \"text-emerald-700 dark:text-emerald-400\"\n                        : \"text-zinc-400 dark:text-zinc-500\"\n                    )}\n                  >\n                    {deg}\n                  </span>\n                )}\n              </span>\n            );\n          })}\n        </motion.div>\n        <span\n          aria-hidden\n          className=\"absolute top-1 left-1/2 h-5 w-0.5 -translate-x-1/2 rounded-full bg-zinc-900 dark:bg-zinc-100\"\n        />\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/photo-straighten.tsx",
      "target": "components/kokonutui/photo-straighten.tsx"
    }
  ]
}