{
  "name": "focal-point-picker",
  "type": "registry:component",
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Focal Point Picker\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 Image from \"next/image\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface FocalPoint {\n  /** 0 to 1 from the left. */\n  x: number;\n  /** 0 to 1 from the top. */\n  y: number;\n}\n\nexport interface FocalCrop {\n  label: string;\n  /** Width over height. */\n  ratio: number;\n}\n\nexport interface FocalPointPickerProps {\n  src?: string;\n  alt?: string;\n  /** The image's natural width over height. */\n  imageRatio?: number;\n  defaultValue?: FocalPoint;\n  /** The crops your layouts use; each previews the point live. */\n  crops?: FocalCrop[];\n  onValueChange?: (point: FocalPoint) => void;\n  className?: string;\n}\n\nconst PHOTO =\n  \"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1600&h=1067&fit=crop&auto=format&q=80\";\n\nconst DEFAULT_CROPS: FocalCrop[] = [\n  { label: \"Avatar\", ratio: 1 },\n  { label: \"Banner\", ratio: 16 / 9 },\n  { label: \"Story\", ratio: 9 / 16 },\n];\n\nconst EASE = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, v));\n\n/**\n * The window a cover-fit crop shows, as fractions of the image, centred on\n * the point as far as the edges allow; and the object-position that shows it.\n */\nfunction cropWindow(point: FocalPoint, imageRatio: number, ratio: number) {\n  const w = Math.min(1, ratio / imageRatio);\n  const h = Math.min(1, imageRatio / ratio);\n  const left = clamp(point.x - w / 2, 0, 1 - w);\n  const top = clamp(point.y - h / 2, 0, 1 - h);\n  return {\n    left,\n    top,\n    w,\n    h,\n    position: `${w < 1 ? (left / (1 - w)) * 100 : 50}% ${h < 1 ? (top / (1 - h)) * 100 : 50}%`,\n  };\n}\n\nconst pct = (v: number) => `${Math.round(v * 100)}%`;\n\nconst CORNERS = [\n  \"top-0 left-0 border-t border-l\",\n  \"top-0 right-0 border-t border-r\",\n  \"bottom-0 left-0 border-b border-l\",\n  \"right-0 bottom-0 border-r border-b\",\n];\n\n/** Thin L-shaped crop marks, like a viewfinder, at the corners of the parent. */\nfunction CropCorners({ className }: { className?: string }) {\n  return (\n    <span aria-hidden className={cn(\"pointer-events-none absolute\", className)}>\n      {CORNERS.map((corner) => (\n        <span\n          className={cn(\n            \"absolute size-2.5 border-white drop-shadow-[0_0_1px_rgb(0_0_0/0.6)]\",\n            corner\n          )}\n          key={corner}\n        />\n      ))}\n    </span>\n  );\n}\n\nexport default function FocalPointPicker({\n  src = PHOTO,\n  alt = \"Peaks above a sea of clouds\",\n  imageRatio = 3 / 2,\n  defaultValue = { x: 0.68, y: 0.38 },\n  crops = DEFAULT_CROPS,\n  onValueChange,\n  className,\n}: FocalPointPickerProps) {\n  const [point, setPoint] = useState(defaultValue);\n  const [dragging, setDragging] = useState(false);\n  // The crop whose window is drawn on the source, from hovering its preview.\n  const [shown, setShown] = useState<number | null>(null);\n  // A click glides the point; a drag follows the finger exactly.\n  const [gliding, setGliding] = useState(false);\n  const frameRef = useRef<HTMLDivElement>(null);\n  const pointRef = useRef<HTMLButtonElement>(null);\n\n  useEffect(() => {\n    onValueChange?.(point);\n  }, [point, onValueChange]);\n\n  const pointAt = (clientX: number, clientY: number) => {\n    const rect = frameRef.current?.getBoundingClientRect();\n    if (!rect) {\n      return point;\n    }\n    return {\n      x: clamp((clientX - rect.left) / rect.width, 0, 1),\n      y: clamp((clientY - rect.top) / rect.height, 0, 1),\n    };\n  };\n\n  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (!event.isPrimary || event.button !== 0) {\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const onPoint = pointRef.current?.contains(event.target as Node);\n    setGliding(!onPoint);\n    setDragging(true);\n    setPoint(pointAt(event.clientX, event.clientY));\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (!dragging) {\n      return;\n    }\n    setGliding(false);\n    setPoint(pointAt(event.clientX, event.clientY));\n  };\n\n  const endDrag = () => setDragging(false);\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    const step = event.shiftKey ? 0.1 : 0.01;\n    const moves: Record<string, [number, number]> = {\n      ArrowLeft: [-step, 0],\n      ArrowRight: [step, 0],\n      ArrowUp: [0, -step],\n      ArrowDown: [0, step],\n    };\n    const move = moves[event.key];\n    if (!move) {\n      return;\n    }\n    event.preventDefault();\n    setGliding(false);\n    setPoint((p) => ({\n      x: clamp(Math.round((p.x + move[0]) * 100) / 100, 0, 1),\n      y: clamp(Math.round((p.y + move[1]) * 100) / 100, 0, 1),\n    }));\n  };\n\n  const glide = gliding ? `transform 280ms ${EASE}` : \"none\";\n  const shownCrop =\n    shown === null ? null : cropWindow(point, imageRatio, crops[shown].ratio);\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-xl 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 cursor-crosshair touch-none overflow-hidden rounded-2xl bg-zinc-100 dark:bg-zinc-900\"\n        onLostPointerCapture={endDrag}\n        onPointerCancel={endDrag}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={endDrag}\n        ref={frameRef}\n        style={{ aspectRatio: imageRatio }}\n      >\n        <Image\n          alt={alt}\n          className=\"pointer-events-none object-cover\"\n          draggable={false}\n          fill\n          sizes=\"(max-width: 576px) 100vw, 576px\"\n          src={src}\n          unoptimized\n        />\n\n        {/* The hovered crop's window: everything it cuts is dimmed. */}\n        <div\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute shadow-[0_0_0_9999px_rgb(0_0_0/0.45)] outline outline-1 outline-white/60 transition-[left,top,width,height,opacity] duration-200\",\n            shownCrop ? \"opacity-100\" : \"opacity-0\"\n          )}\n          style={{\n            left: pct(shownCrop?.left ?? 0),\n            top: pct(shownCrop?.top ?? 0),\n            width: pct(shownCrop?.w ?? 1),\n            height: pct(shownCrop?.h ?? 1),\n            transitionTimingFunction: EASE,\n          }}\n        >\n          <CropCorners className=\"inset-1.5\" />\n        </div>\n\n        {/* Full-size layer moved by the point, so dragging skips layout. */}\n        <div\n          className=\"pointer-events-none absolute inset-0\"\n          style={{\n            transform: `translate(${point.x * 100}%, ${point.y * 100}%)`,\n            transition: glide,\n          }}\n        >\n          <button\n            aria-label={`Focal point, ${pct(point.x)} from the left, ${pct(point.y)} from the top. Move with the arrow keys.`}\n            className={cn(\n              \"pointer-events-auto absolute top-0 left-0 size-10 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black/40\",\n              dragging && \"cursor-grabbing\"\n            )}\n            onKeyDown={handleKeyDown}\n            ref={pointRef}\n            type=\"button\"\n          >\n            {/* Hairline crosshair, open at the centre around the dot. */}\n            <span className=\"absolute inset-0 drop-shadow-[0_0_1px_rgb(0_0_0/0.7)]\">\n              <span className=\"absolute top-1/2 left-0 h-px w-[15px] -translate-y-1/2 bg-white\" />\n              <span className=\"absolute top-1/2 right-0 h-px w-[15px] -translate-y-1/2 bg-white\" />\n              <span className=\"absolute top-0 left-1/2 h-[15px] w-px -translate-x-1/2 bg-white\" />\n              <span className=\"absolute bottom-0 left-1/2 h-[15px] w-px -translate-x-1/2 bg-white\" />\n            </span>\n            <span className=\"absolute top-1/2 left-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-orange-600 ring-1 ring-white\" />\n          </button>\n        </div>\n      </div>\n\n      <div className=\"mt-4 flex items-end gap-3\">\n        {crops.map((crop, i) => {\n          const c = cropWindow(point, imageRatio, crop.ratio);\n          return (\n            <button\n              className=\"group min-w-0 text-left outline-none\"\n              key={crop.label}\n              onBlur={() => setShown(null)}\n              onFocus={() => setShown(i)}\n              onPointerEnter={(event) => {\n                if (event.pointerType === \"mouse\") {\n                  setShown(i);\n                }\n              }}\n              onPointerLeave={() => setShown(null)}\n              style={{ flex: `${crop.ratio} 1 0` }}\n              type=\"button\"\n            >\n              <span\n                className={cn(\n                  \"relative block overflow-hidden rounded-lg bg-zinc-100 ring-offset-2 transition-shadow duration-150 ease-out group-focus-visible:ring-2 group-focus-visible:ring-zinc-400 dark:bg-zinc-900 dark:ring-offset-zinc-950\",\n                  shown === i && \"ring-2 ring-zinc-900 dark:ring-zinc-100\"\n                )}\n                style={{ aspectRatio: crop.ratio }}\n              >\n                <Image\n                  alt=\"\"\n                  className=\"pointer-events-none object-cover\"\n                  draggable={false}\n                  fill\n                  sizes=\"200px\"\n                  src={src}\n                  style={{\n                    objectPosition: c.position,\n                    transition: gliding\n                      ? `object-position 280ms ${EASE}`\n                      : \"none\",\n                  }}\n                  unoptimized\n                />\n                <CropCorners className=\"inset-1\" />\n              </span>\n              <span className=\"mt-1.5 block truncate text-xs text-zinc-500 dark:text-zinc-400\">\n                {crop.label}\n              </span>\n            </button>\n          );\n        })}\n      </div>\n\n      <div className=\"mt-3 flex items-center justify-between gap-3 border-zinc-200 border-t pt-3 text-xs dark:border-zinc-800\">\n        <span className=\"text-zinc-500 dark:text-zinc-400\">\n          Drag the point, or hover a crop to see it\n        </span>\n        <code className=\"shrink-0 font-mono text-zinc-900 tabular-nums dark:text-zinc-100\">\n          {pct(point.x)} {pct(point.y)}\n        </code>\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/focal-point-picker.tsx",
      "target": "components/kokonutui/focal-point-picker.tsx"
    }
  ]
}