{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-editor-canvas",
  "title": "Canvas",
  "description": "The image stage: crop frame and perspective corners in the crop tool; zoom, blur focus, drag-and-drop and hold-to-compare everywhere else.",
  "dependencies": [
    "lucide-react",
    "photocn@^0.1.1"
  ],
  "registryDependencies": [
    "alert",
    "button"
  ],
  "files": [
    {
      "path": "src/registry/image-editor/canvas.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type DragEvent,\n  type PointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { AlertCircleIcon, ImagePlusIcon, Loader2Icon, Maximize2Icon } from \"lucide-react\";\nimport { errorMessage, useImageEditor } from \"photocn/react\";\n\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nimport { BlurCenterOverlay } from \"./blur-center-overlay\";\nimport {\n  CornerHandlesOverlay,\n  CropFrameOverlay,\n  fitView,\n  rectToStage,\n  type StageSize,\n  type StageView,\n} from \"./crop-overlay\";\n\nexport interface ImageEditorCanvasProps {\n  className?: string;\n  /** Accessible name of the image. */\n  alt?: string;\n  /** Allow dropping an image file onto the canvas. Default `true`. */\n  allowDrop?: boolean;\n  /** Show an \"Open image\" button in the empty state. Default `true`. */\n  showOpenButton?: boolean;\n  /** Replace the empty state (no image loaded yet). */\n  emptyState?: ReactNode;\n  /** Space (px) kept around the image. Default 24. */\n  padding?: number;\n  /** Extra layers rendered above the image, positioned over it. */\n  children?: ReactNode;\n}\n\ntype UserView = { scale: number; x: number; y: number };\nconst IDENTITY_VIEW: UserView = { scale: 1, x: 0, y: 0 };\n\nconst SETTLE_MS = 240;\nconst easeOutCubic = (t: number) => 1 - (1 - t) ** 3;\n\n/**\n * Blend two views so the point under the stage center travels in a straight\n * line and the zoom changes geometrically (no wobble).\n */\nfunction blendViews(from: StageView, to: StageView, stage: StageSize, t: number): StageView {\n  const cx = stage.width / 2;\n  const cy = stage.height / 2;\n  const fromX = (cx - from.ox) / from.k;\n  const fromY = (cy - from.oy) / from.k;\n  const toX = (cx - to.ox) / to.k;\n  const toY = (cy - to.oy) / to.k;\n  const k = from.k * (to.k / from.k) ** t;\n  const x = fromX + (toX - fromX) * t;\n  const y = fromY + (toY - fromY) * t;\n  return { k, ox: cx - x * k, oy: cy - y * k };\n}\n\nfunction usePrefersReducedMotion() {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(query.matches);\n    const onChange = () => setReduced(query.matches);\n    query.addEventListener(\"change\", onChange);\n    return () => query.removeEventListener(\"change\", onChange);\n  }, []);\n  return reduced;\n}\n\n/**\n * The image stage. In the crop tool it shows the whole image dimmed around\n * the crop frame (drag handles, move, zoom, perspective corners); in every\n * other tool it shows the result, with pinch/⌘-scroll zoom, blur focus and\n * press-and-hold to compare.\n */\nexport function ImageEditorCanvas({\n  className,\n  alt = \"Image being edited\",\n  allowDrop = true,\n  showOpenButton = true,\n  emptyState,\n  padding = 24,\n  children,\n}: ImageEditorCanvasProps) {\n  const editor = useImageEditor();\n  const { geometry } = editor;\n  const [stage, setStage] = useState<StageSize | null>(null);\n  const [frozenView, setFrozenView] = useState<StageView | null>(null);\n  // After a drag, ease from the frozen view to the re-fitted one (Photos-style).\n  const [settle, setSettle] = useState<{ from: StageView; start: number } | null>(null);\n  const [, setFrame] = useState(0);\n  const reducedMotion = usePrefersReducedMotion();\n  const freezeView = (next: StageView | null) => {\n    if (next === null && frozenView && !reducedMotion) {\n      setSettle({ from: frozenView, start: performance.now() });\n    } else if (next !== null) {\n      setSettle(null);\n    }\n    setFrozenView(next);\n  };\n  useEffect(() => {\n    if (!settle) return;\n    let raf = 0;\n    const tick = () => {\n      if (performance.now() - settle.start >= SETTLE_MS) {\n        setSettle(null);\n        return;\n      }\n      setFrame((frame) => frame + 1);\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [settle]);\n  const [userView, setUserView] = useState<UserView>(IDENTITY_VIEW);\n  const [isDragOver, setIsDragOver] = useState(false);\n  const stageRef = editor.stageRef;\n\n  const isCropView = geometry.view === \"full\";\n  const isCorners = geometry.editingCorners;\n  const isBlur = editor.tool === \"blur\";\n\n  // Measure the stage.\n  useLayoutEffect(() => {\n    const element = stageRef.current;\n    if (!element) return;\n    const observer = new ResizeObserver(([entry]) => {\n      const { width, height } = entry.contentRect;\n      setStage((prev) =>\n        prev && prev.width === width && prev.height === height ? prev : { width, height },\n      );\n    });\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, [stageRef, editor.imageSrc]);\n\n  // A new image, or switching between crop and result, starts un-zoomed.\n  const viewKey = `${editor.imageSrc}|${geometry.view}`;\n  const [trackedKey, setTrackedKey] = useState(viewKey);\n  if (viewKey !== trackedKey) {\n    setTrackedKey(viewKey);\n    setUserView(IDENTITY_VIEW);\n    setFrozenView(null);\n    setSettle(null);\n  }\n\n  const oriented = geometry.orientedSize;\n  let view: StageView | null = null;\n  if (stage && oriented) {\n    if (frozenView) {\n      view = frozenView;\n    } else if (isCropView) {\n      // The frame stays put: fit the crop (or, while dragging corners, the\n      // whole image) and let the image move and zoom under it.\n      view = fitView(isCorners ? geometry.bounds : geometry.crop, oriented, stage, padding * 1.5);\n      if (settle) {\n        const t = Math.min(1, (performance.now() - settle.start) / SETTLE_MS);\n        view = blendViews(settle.from, view, stage, easeOutCubic(t));\n      }\n    } else {\n      const fit = fitView(geometry.displayRect, oriented, stage, padding);\n      view = {\n        k: fit.k * userView.scale,\n        ox: fit.ox * userView.scale + userView.x,\n        oy: fit.oy * userView.scale + userView.y,\n      };\n    }\n  }\n  const canvasBox = view && oriented ? rectToStage(view, oriented, geometry.displayRect) : null;\n\n  // ⌘/Ctrl-scroll or pinch zooms the result; plain scroll pans once zoomed.\n  const zoomRef = useRef<(event: WheelEvent) => void>(() => {});\n  zoomRef.current = (event: WheelEvent) => {\n    if (isCropView || !stage) return;\n    const zooming = event.ctrlKey || event.metaKey;\n    if (!zooming && userView.scale === 1) return;\n    event.preventDefault();\n    const rect = stageRef.current!.getBoundingClientRect();\n    const cx = event.clientX - rect.left;\n    const cy = event.clientY - rect.top;\n    setUserView((prev) => {\n      if (!zooming) return { ...prev, x: prev.x - event.deltaX, y: prev.y - event.deltaY };\n      const scale = Math.min(8, Math.max(1, prev.scale * Math.exp(-event.deltaY * 0.01)));\n      const f = scale / prev.scale;\n      if (scale === 1) return IDENTITY_VIEW;\n      return { scale, x: cx - (cx - prev.x) * f, y: cy - (cy - prev.y) * f };\n    });\n  };\n  useEffect(() => {\n    const element = stageRef.current;\n    if (!element) return;\n    const onWheel = (event: WheelEvent) => zoomRef.current(event);\n    element.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => element.removeEventListener(\"wheel\", onWheel);\n  }, [stageRef, editor.imageSrc]);\n\n  // Press and hold the result to see the original.\n  const comparePointer = useRef<number | null>(null);\n  const canCompare = !isCropView && !isBlur && !editor.disabled;\n  const onStagePointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (!canCompare || (event.pointerType === \"mouse\" && event.button !== 0)) return;\n    comparePointer.current = event.pointerId;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    editor.compare.setActive(true);\n  };\n  const endCompare = (event: PointerEvent<HTMLDivElement>) => {\n    if (comparePointer.current !== event.pointerId) return;\n    comparePointer.current = null;\n    editor.compare.setActive(false);\n  };\n\n  const dropProps = allowDrop\n    ? {\n        onDragOver: (event: DragEvent) => {\n          if (!Array.from(event.dataTransfer.items).some((item) => item.kind === \"file\")) return;\n          event.preventDefault();\n          setIsDragOver(true);\n        },\n        onDragLeave: () => setIsDragOver(false),\n        onDrop: (event: DragEvent) => {\n          const file = Array.from(event.dataTransfer.files).find((candidate) =>\n            candidate.type.startsWith(\"image/\"),\n          );\n          setIsDragOver(false);\n          if (!file || editor.disabled) return;\n          event.preventDefault();\n          void editor.load(file).catch(() => undefined);\n        },\n      }\n    : {};\n\n  return (\n    <div\n      className={cn(\"relative h-full min-h-0\", className)}\n      data-drag-over={isDragOver || undefined}\n      data-slot=\"image-editor-canvas\"\n      data-settling={settle ? \"\" : undefined}\n      data-view={isCropView ? \"crop\" : \"result\"}\n      {...dropProps}\n    >\n      {editor.imageSrc ? (\n        <div\n          className={cn(\n            \"relative size-full touch-none overflow-hidden rounded-md border bg-muted/40 select-none\",\n            \"bg-[conic-gradient(var(--muted)_25%,transparent_0_50%,var(--muted)_0_75%,transparent_0)] bg-[length:16px_16px]\",\n          )}\n          data-slot=\"image-editor-stage\"\n          onPointerCancel={endCompare}\n          onPointerDown={onStagePointerDown}\n          onPointerUp={endCompare}\n          ref={stageRef}\n        >\n          {/* A canvas handed to the render worker can't be reused; canvasKey mounts a fresh one when needed. */}\n          <canvas\n            aria-label={alt}\n            className=\"pointer-events-none absolute block\"\n            key={editor.canvasKey}\n            ref={editor.canvasRef}\n            role=\"img\"\n            style={\n              canvasBox\n                ? { left: canvasBox.left, top: canvasBox.top, width: canvasBox.width, height: canvasBox.height }\n                : { visibility: \"hidden\" }\n            }\n          />\n          {!editor.isReady && editor.status !== \"error\" ? (\n            <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n              <Loader2Icon className=\"size-5 animate-spin text-muted-foreground\" />\n            </div>\n          ) : null}\n          {view && stage && isCropView && !isCorners ? (\n            <CropFrameOverlay\n              disabled={editor.disabled}\n              geometry={geometry}\n              onFreezeView={freezeView}\n              stage={stage}\n              view={view}\n            />\n          ) : null}\n          {view && stage && isCropView && isCorners ? (\n            <CornerHandlesOverlay\n              disabled={editor.disabled}\n              geometry={geometry}\n              onFreezeView={freezeView}\n              stage={stage}\n              view={view}\n            />\n          ) : null}\n          {canvasBox && !isCropView ? (\n            <div className=\"pointer-events-none absolute\" style={canvasBox}>\n              {isBlur ? (\n                <BlurCenterOverlay\n                  centerX={editor.blur.value.centerX}\n                  centerY={editor.blur.value.centerY}\n                  className=\"pointer-events-auto\"\n                  disabled={editor.disabled}\n                  onCenterChange={editor.blur.setCenter}\n                  onCenterCommit={editor.blur.commitCenter}\n                />\n              ) : null}\n              {children}\n            </div>\n          ) : null}\n          {!isCropView && userView !== IDENTITY_VIEW ? (\n            <Button\n              aria-label=\"Fit to screen\"\n              className=\"absolute right-2 bottom-2\"\n              onClick={() => setUserView(IDENTITY_VIEW)}\n              onPointerDown={(event) => event.stopPropagation()}\n              size=\"icon-sm\"\n              type=\"button\"\n              variant=\"secondary\"\n            >\n              <Maximize2Icon />\n            </Button>\n          ) : null}\n        </div>\n      ) : (\n        (emptyState ?? <ImageEditorEmptyState showOpenButton={showOpenButton} />)\n      )}\n      {isDragOver ? (\n        <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border-2 border-dashed border-primary bg-primary/5 text-sm font-medium text-primary\">\n          Drop to open\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\n/** Shown before an image is loaded, and when loading fails. */\nexport function ImageEditorEmptyState({\n  showOpenButton = true,\n  className,\n}: {\n  showOpenButton?: boolean;\n  className?: string;\n}) {\n  const editor = useImageEditor();\n  const error = editor.status === \"error\" ? editor.error : null;\n  return (\n    <div\n      className={cn(\"flex h-full min-h-72 flex-col items-center justify-center p-6\", className)}\n      data-slot=\"image-editor-empty-state\"\n    >\n      <Alert\n        className={cn(\n          \"flex max-w-md flex-col items-center gap-2 text-center [&>svg]:translate-y-0\",\n          Boolean(error) && \"border-destructive/30 bg-destructive/10\",\n        )}\n        variant={error ? \"destructive\" : \"default\"}\n      >\n        {error ? <AlertCircleIcon aria-hidden=\"true\" /> : null}\n        {error ? <AlertTitle>Couldn&apos;t open this image</AlertTitle> : null}\n        <AlertDescription className=\"items-center text-center\">\n          {error\n            ? errorMessage(error)\n            : editor.isLoading\n              ? \"Loading image…\"\n              : \"Open or drop an image to start editing.\"}\n        </AlertDescription>\n        {showOpenButton ? (\n          <Button\n            disabled={editor.isLoading || editor.disabled}\n            onClick={() => void editor.openFile()}\n            size=\"sm\"\n            type=\"button\"\n            variant=\"outline\"\n          >\n            <ImagePlusIcon aria-hidden=\"true\" data-icon=\"inline-start\" />\n            {error ? \"Open another image\" : \"Open image\"}\n          </Button>\n        ) : null}\n      </Alert>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/image-editor/canvas.tsx"
    },
    {
      "path": "src/registry/image-editor/crop-overlay.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent } from \"react\";\nimport {\n  fitRectInPolygon,\n  rectInsidePolygon,\n  type NormalizedRect,\n  type Quad,\n  type Vec2,\n} from \"photocn\";\nimport type { ImageEditorGeometryApi } from \"photocn/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/** Maps oriented px (X, Y) to stage px: (ox + X·k, oy + Y·k). */\nexport interface StageView {\n  k: number;\n  ox: number;\n  oy: number;\n}\n\nexport interface StageSize {\n  width: number;\n  height: number;\n}\n\ntype Size = { width: number; height: number };\n\n/** View that fits `rect` (normalized oriented) inside the stage with padding. */\nexport function fitView(rect: NormalizedRect, oriented: Size, stage: StageSize, padding: number): StageView {\n  const rw = Math.max(1e-6, rect.width * oriented.width);\n  const rh = Math.max(1e-6, rect.height * oriented.height);\n  const k = Math.max(\n    1e-6,\n    Math.min((stage.width - padding * 2) / rw, (stage.height - padding * 2) / rh),\n  );\n  return {\n    k,\n    ox: stage.width / 2 - (rect.x * oriented.width + rw / 2) * k,\n    oy: stage.height / 2 - (rect.y * oriented.height + rh / 2) * k,\n  };\n}\n\nexport function toStage(view: StageView, oriented: Size, [x, y]: Vec2): Vec2 {\n  return [view.ox + x * oriented.width * view.k, view.oy + y * oriented.height * view.k];\n}\n\nexport function toNormalized(view: StageView, oriented: Size, [x, y]: Vec2): Vec2 {\n  return [(x - view.ox) / (oriented.width * view.k), (y - view.oy) / (oriented.height * view.k)];\n}\n\nexport function rectToStage(view: StageView, oriented: Size, rect: NormalizedRect) {\n  const [left, top] = toStage(view, oriented, [rect.x, rect.y]);\n  return {\n    left,\n    top,\n    width: rect.width * oriented.width * view.k,\n    height: rect.height * oriented.height * view.k,\n  };\n}\n\ntype Handle = \"n\" | \"s\" | \"e\" | \"w\" | \"ne\" | \"nw\" | \"se\" | \"sw\";\n\nconst HANDLES: { id: Handle; label: string; className: string }[] = [\n  { id: \"nw\", label: \"Resize from top left\", className: \"left-0 top-0 cursor-nwse-resize\" },\n  { id: \"n\", label: \"Resize from top\", className: \"left-1/2 top-0 cursor-ns-resize\" },\n  { id: \"ne\", label: \"Resize from top right\", className: \"left-full top-0 cursor-nesw-resize\" },\n  { id: \"e\", label: \"Resize from right\", className: \"left-full top-1/2 cursor-ew-resize\" },\n  { id: \"se\", label: \"Resize from bottom right\", className: \"left-full top-full cursor-nwse-resize\" },\n  { id: \"s\", label: \"Resize from bottom\", className: \"left-1/2 top-full cursor-ns-resize\" },\n  { id: \"sw\", label: \"Resize from bottom left\", className: \"left-0 top-full cursor-nesw-resize\" },\n  { id: \"w\", label: \"Resize from left\", className: \"left-0 top-1/2 cursor-ew-resize\" },\n];\n\n/** Resize a px rect by a pointer delta from one handle, honoring a locked ratio. */\nfunction resizeRect(\n  start: { x: number; y: number; w: number; h: number },\n  handle: Handle,\n  dx: number,\n  dy: number,\n  ratio: number | null,\n  min: number,\n) {\n  let { x, y, w, h } = start;\n  const east = handle.includes(\"e\");\n  const west = handle.includes(\"w\");\n  const north = handle.includes(\"n\");\n  const south = handle.includes(\"s\");\n\n  if (!ratio) {\n    if (east) w = Math.max(min, start.w + dx);\n    if (west) {\n      w = Math.max(min, start.w - dx);\n      x = start.x + start.w - w;\n    }\n    if (south) h = Math.max(min, start.h + dy);\n    if (north) {\n      h = Math.max(min, start.h - dy);\n      y = start.y + start.h - h;\n    }\n    return { x, y, w, h };\n  }\n\n  const isCorner = (east || west) && (north || south);\n  if (isCorner) {\n    // Follow whichever axis moved more, keep the opposite corner anchored.\n    const dw = east ? dx : -dx;\n    const dh = south ? dy : -dy;\n    w = Math.max(min, Math.abs(dw) >= Math.abs(dh * ratio) ? start.w + dw : start.w + dh * ratio);\n    h = w / ratio;\n    if (h < min) {\n      h = min;\n      w = h * ratio;\n    }\n    x = west ? start.x + start.w - w : start.x;\n    y = north ? start.y + start.h - h : start.y;\n  } else if (east || west) {\n    w = Math.max(min, start.w + (east ? dx : -dx));\n    h = w / ratio;\n    x = west ? start.x + start.w - w : start.x;\n    y = start.y + start.h / 2 - h / 2;\n  } else {\n    h = Math.max(min, start.h + (south ? dy : -dy));\n    w = h * ratio;\n    y = north ? start.y + start.h - h : start.y;\n    x = start.x + start.w / 2 - w / 2;\n  }\n  return { x, y, w, h };\n}\n\n/** Largest t in [0, 1] for which `candidate(t)` is valid (binary search). */\nfunction constrain<T>(candidate: (t: number) => T, valid: (value: T) => boolean): T {\n  if (valid(candidate(1))) return candidate(1);\n  let lo = 0;\n  let hi = 1;\n  for (let i = 0; i < 24; i++) {\n    const mid = (lo + hi) / 2;\n    if (valid(candidate(mid))) lo = mid;\n    else hi = mid;\n  }\n  return candidate(lo);\n}\n\ntype Drag =\n  | { kind: \"resize\"; handle: Handle; pointerId: number; start: Vec2; rect: NormalizedRect; view: StageView }\n  | { kind: \"pan\"; pointerId: number; start: Vec2; rect: NormalizedRect; view: StageView }\n  | { kind: \"pinch\"; pointerId: number; distance: number; rect: NormalizedRect; view: StageView };\n\nexport interface CropFrameOverlayProps {\n  geometry: ImageEditorGeometryApi;\n  view: StageView;\n  stage: StageSize;\n  disabled?: boolean;\n  /** Freeze the view during a handle drag (null to release). */\n  onFreezeView: (view: StageView | null) => void;\n}\n\nconst MIN_CROP_PX = 32;\n\n/**\n * The crop frame: dimmed outside, rule-of-thirds inside, 8 handles. Drag a\n * handle to resize, drag inside to move the image under the frame, scroll\n * or pinch to zoom. Everything is limited to the (warped) image.\n */\nexport function CropFrameOverlay({ geometry, view, stage, disabled, onFreezeView }: CropFrameOverlayProps) {\n  const oriented = geometry.orientedSize;\n  const polygon = geometry.polygon;\n  const drag = useRef<Drag | null>(null);\n  // Fingers currently down inside the frame (two = pinch to zoom).\n  const pointers = useRef(new Map<number, Vec2>());\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const escRef = useRef<((event: KeyboardEvent) => void) | null>(null);\n  const wheelRef = useRef<(event: WheelEvent) => void>(() => {});\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    // Native + non-passive so zooming the crop doesn't also scroll the page.\n    const onWheel = (event: WheelEvent) => wheelRef.current(event);\n    root.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => root.removeEventListener(\"wheel\", onWheel);\n  }, [oriented, polygon]);\n  if (!oriented || !polygon) return null;\n\n  const frame = rectToStage(view, oriented, geometry.crop);\n  const ratio = geometry.value.aspectRatio;\n  const inside = (rect: NormalizedRect) => rectInsidePolygon(rect, polygon, oriented);\n\n  const toNorm = (r: { x: number; y: number; w: number; h: number }): NormalizedRect => ({\n    x: r.x / oriented.width,\n    y: r.y / oriented.height,\n    width: r.w / oriented.width,\n    height: r.h / oriented.height,\n  });\n  const toPx = (r: NormalizedRect) => ({\n    x: r.x * oriented.width,\n    y: r.y * oriented.height,\n    w: r.width * oriented.width,\n    h: r.height * oriented.height,\n  });\n\n  const resizeBy = (handle: Handle, rect: NormalizedRect, k: number, dx: number, dy: number) =>\n    constrain(\n      (t) => toNorm(resizeRect(toPx(rect), handle, (dx * t) / k, (dy * t) / k, ratio, MIN_CROP_PX / k)),\n      inside,\n    );\n\n  const panBy = (rect: NormalizedRect, k: number, dx: number, dy: number) =>\n    constrain(\n      (t) => ({\n        ...rect,\n        x: rect.x - (dx * t) / k / oriented.width,\n        y: rect.y - (dy * t) / k / oriented.height,\n      }),\n      inside,\n    );\n\n  /** Scale the crop about its center (factor > 1 = zoom out), limited to the image. */\n  const zoomBy = (rect: NormalizedRect, factor: number, k: number) => {\n    const cx = rect.x + rect.width / 2;\n    const cy = rect.y + rect.height / 2;\n    const minW = MIN_CROP_PX / k / oriented.width;\n    const width = Math.max(minW, rect.width * factor);\n    const height = (rect.height * width) / rect.width;\n    const next: NormalizedRect = { x: cx - width / 2, y: cy - height / 2, width, height };\n    return inside(next) ? next : fitRectInPolygon(next, polygon, oriented);\n  };\n\n  const distance = () => {\n    const [a, b] = [...pointers.current.values()];\n    return a && b ? Math.hypot(a[0] - b[0], a[1] - b[1]) : 0;\n  };\n\n  const begin = (event: PointerEvent<HTMLElement>, handle: Handle | null) => {\n    if (disabled || (event.pointerType === \"mouse\" && event.button !== 0)) return;\n    event.stopPropagation();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    if (!handle) {\n      pointers.current.set(event.pointerId, [event.clientX, event.clientY]);\n      if (pointers.current.size === 2) {\n        // Second finger: switch the move into a pinch.\n        drag.current = {\n          kind: \"pinch\",\n          pointerId: event.pointerId,\n          distance: distance(),\n          rect: geometry.crop,\n          view,\n        };\n        return;\n      }\n    }\n    const common = {\n      pointerId: event.pointerId,\n      start: [event.clientX, event.clientY] as Vec2,\n      rect: geometry.crop,\n      view,\n    };\n    drag.current = handle ? { kind: \"resize\", handle, ...common } : { kind: \"pan\", ...common };\n    if (handle) onFreezeView(view);\n    // Esc puts the crop back where the drag started (no undo step).\n    const onKey = (keyEvent: KeyboardEvent) => {\n      if (keyEvent.key !== \"Escape\" || !drag.current) return;\n      keyEvent.preventDefault();\n      keyEvent.stopPropagation();\n      drag.current = null;\n      geometry.cancel();\n      onFreezeView(null);\n      stopEsc();\n    };\n    stopEsc();\n    escRef.current = onKey;\n    window.addEventListener(\"keydown\", onKey, true);\n  };\n\n  const stopEsc = () => {\n    if (escRef.current) window.removeEventListener(\"keydown\", escRef.current, true);\n    escRef.current = null;\n  };\n\n  const move = (event: PointerEvent<HTMLElement>) => {\n    if (pointers.current.has(event.pointerId)) {\n      pointers.current.set(event.pointerId, [event.clientX, event.clientY]);\n    }\n    const d = drag.current;\n    if (d?.kind === \"pinch\") {\n      const now = distance();\n      if (now > 0) geometry.setCrop(zoomBy(d.rect, d.distance / now, d.view.k), { transient: true });\n      return;\n    }\n    if (!d || d.pointerId !== event.pointerId) return;\n    const dx = event.clientX - d.start[0];\n    const dy = event.clientY - d.start[1];\n    const next =\n      d.kind === \"resize\"\n        ? resizeBy(d.handle, d.rect, d.view.k, dx, dy)\n        : panBy(d.rect, view.k, dx, dy);\n    geometry.setCrop(next, { transient: true });\n  };\n\n  const end = (event: PointerEvent<HTMLElement>) => {\n    const wasPointer = pointers.current.delete(event.pointerId);\n    const d = drag.current;\n    if (d?.kind === \"pinch\" && wasPointer) {\n      // Lifting either finger ends the pinch.\n      drag.current = null;\n      pointers.current.clear();\n      geometry.commit();\n      return;\n    }\n    if (!d || d.pointerId !== event.pointerId) return;\n    drag.current = null;\n    stopEsc();\n    geometry.commit();\n    onFreezeView(null);\n  };\n\n  wheelRef.current = (event: WheelEvent) => {\n    if (disabled) return;\n    event.preventDefault();\n    geometry.setCrop(zoomBy(geometry.crop, Math.exp(event.deltaY * 0.002), view.k), {\n      transient: true,\n    });\n  };\n\n  const onHandleKey = (handle: Handle) => (event: ReactKeyboardEvent<HTMLButtonElement>) => {\n    const step = event.shiftKey ? 20 : 4;\n    const delta: Record<string, Vec2> = {\n      ArrowLeft: [-step, 0],\n      ArrowRight: [step, 0],\n      ArrowUp: [0, -step],\n      ArrowDown: [0, step],\n    };\n    const d = delta[event.key];\n    if (!d || disabled) return;\n    event.preventDefault();\n    geometry.setCrop(resizeBy(handle, geometry.crop, view.k, d[0], d[1]), { transient: true });\n  };\n\n  const maskPath = `M0 0H${stage.width}V${stage.height}H0Z M${frame.left} ${frame.top}v${frame.height}h${frame.width}v${-frame.height}Z`;\n\n  return (\n    <div className=\"absolute inset-0\" data-slot=\"image-editor-crop-overlay\" ref={rootRef}>\n      <svg aria-hidden className=\"pointer-events-none absolute inset-0 size-full\" height={stage.height} width={stage.width}>\n        <path className=\"fill-black/55\" d={maskPath} fillRule=\"evenodd\" />\n      </svg>\n      <div\n        aria-label=\"Crop area. Drag to move the image, scroll to zoom.\"\n        className={cn(\n          \"absolute touch-none outline outline-1 -outline-offset-1 outline-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.35)]\",\n          disabled ? \"pointer-events-none\" : \"cursor-move\",\n        )}\n        onPointerCancel={end}\n        onPointerDown={(event) => begin(event, null)}\n        onPointerMove={move}\n        onPointerUp={end}\n        role=\"group\"\n        style={{ left: frame.left, top: frame.top, width: frame.width, height: frame.height }}\n      >\n        <div aria-hidden className=\"pointer-events-none absolute inset-0\">\n          <div className=\"absolute inset-y-0 left-1/3 w-px bg-white/40\" />\n          <div className=\"absolute inset-y-0 left-2/3 w-px bg-white/40\" />\n          <div className=\"absolute inset-x-0 top-1/3 h-px bg-white/40\" />\n          <div className=\"absolute inset-x-0 top-2/3 h-px bg-white/40\" />\n        </div>\n        {HANDLES.map((handle) => (\n          <button\n            aria-label={handle.label}\n            className={cn(\n              \"absolute z-10 flex size-7 pointer-coarse:size-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full outline-none touch-none focus-visible:ring-2 focus-visible:ring-ring\",\n              handle.className,\n            )}\n            disabled={disabled}\n            key={handle.id}\n            onKeyDown={onHandleKey(handle.id)}\n            onKeyUp={() => geometry.commit()}\n            onPointerCancel={end}\n            onPointerDown={(event) => begin(event, handle.id)}\n            onPointerMove={move}\n            onPointerUp={end}\n            type=\"button\"\n          >\n            <span\n              className={cn(\n                \"block rounded-full border border-black/30 bg-white shadow\",\n                handle.id.length === 2 ? \"size-3.5\" : \"size-2.5\",\n              )}\n            />\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nexport interface CornerHandlesOverlayProps {\n  geometry: ImageEditorGeometryApi;\n  view: StageView;\n  stage: StageSize;\n  disabled?: boolean;\n  onFreezeView: (view: StageView | null) => void;\n}\n\nconst CORNER_LABELS = [\"top left\", \"top right\", \"bottom right\", \"bottom left\"] as const;\n\n/** Advanced perspective: drag each corner of the image. */\nexport function CornerHandlesOverlay({ geometry, view, stage, disabled, onFreezeView }: CornerHandlesOverlayProps) {\n  const oriented = geometry.orientedSize;\n  const polygon = geometry.polygon;\n  const drag = useRef<{ index: 0 | 1 | 2 | 3; pointerId: number; view: StageView; origin: Vec2 } | null>(null);\n  const cleanupRef = useRef<(() => void) | null>(null);\n  if (!oriented || !polygon) return null;\n\n  const points = polygon.map((p) => toStage(view, oriented, p)) as Quad;\n\n  const begin = (index: 0 | 1 | 2 | 3) => (event: PointerEvent<HTMLButtonElement>) => {\n    if (disabled) return;\n    event.stopPropagation();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const rect = event.currentTarget.parentElement!.getBoundingClientRect();\n    drag.current = { index, pointerId: event.pointerId, view, origin: [rect.left, rect.top] };\n    onFreezeView(view);\n    const onKey = (keyEvent: KeyboardEvent) => {\n      if (keyEvent.key !== \"Escape\" || !drag.current) return;\n      keyEvent.preventDefault();\n      keyEvent.stopPropagation();\n      drag.current = null;\n      geometry.cancel();\n      onFreezeView(null);\n      window.removeEventListener(\"keydown\", onKey, true);\n    };\n    window.addEventListener(\"keydown\", onKey, true);\n    cleanupRef.current = () => window.removeEventListener(\"keydown\", onKey, true);\n  };\n  const move = (event: PointerEvent<HTMLButtonElement>) => {\n    const d = drag.current;\n    if (!d || d.pointerId !== event.pointerId) return;\n    const stagePoint: Vec2 = [event.clientX - d.origin[0], event.clientY - d.origin[1]];\n    geometry.setCorner(d.index, toNormalized(d.view, oriented, stagePoint), { transient: true });\n  };\n  const end = (event: PointerEvent<HTMLButtonElement>) => {\n    if (!drag.current || drag.current.pointerId !== event.pointerId) return;\n    drag.current = null;\n    cleanupRef.current?.();\n    geometry.commit();\n    onFreezeView(null);\n  };\n\n  return (\n    <div className=\"absolute inset-0\" data-slot=\"image-editor-corner-overlay\">\n      <svg aria-hidden className=\"pointer-events-none absolute inset-0 size-full\" height={stage.height} width={stage.width}>\n        <polygon\n          className=\"fill-none stroke-white/90\"\n          points={points.map((p) => p.join(\",\")).join(\" \")}\n          strokeWidth={1.5}\n        />\n      </svg>\n      {points.map((point, index) => (\n        <button\n          aria-label={`Move ${CORNER_LABELS[index]} corner`}\n          className=\"absolute flex size-9 pointer-coarse:size-12 -translate-x-1/2 -translate-y-1/2 cursor-grab items-center justify-center rounded-full outline-none touch-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing\"\n          disabled={disabled}\n          key={CORNER_LABELS[index]}\n          onPointerCancel={end}\n          onPointerDown={begin(index as 0 | 1 | 2 | 3)}\n          onPointerMove={move}\n          onPointerUp={end}\n          style={{ left: point[0], top: point[1] }}\n          type=\"button\"\n        >\n          <span className=\"block size-4 rounded-full border-2 border-white bg-primary shadow\" />\n        </button>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/image-editor/crop-overlay.tsx"
    },
    {
      "path": "src/registry/image-editor/blur-center-overlay.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface BlurCenterOverlayProps {\n  /** Normalized 0..1 position of the blur center within the displayed image rect. */\n  centerX: number;\n  centerY: number;\n  disabled?: boolean;\n  className?: string;\n  /** Fires transient on drag and on pointer-up; the parent decides debouncing/commit. */\n  onCenterChange?: (next: { centerX: number; centerY: number }) => void;\n  onCenterCommit?: (next: { centerX: number; centerY: number }) => void;\n}\n\nfunction clamp01(value: number): number {\n  if (value < 0) return 0;\n  if (value > 1) return 1;\n  return value;\n}\n\nexport function BlurCenterOverlay({\n  centerX,\n  centerY,\n  disabled = false,\n  className,\n  onCenterChange,\n  onCenterCommit,\n}: BlurCenterOverlayProps) {\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  const [dragging, setDragging] = useState(false);\n\n  const positionFromEvent = useCallback(\n    (event: React.PointerEvent<HTMLDivElement>) => {\n      const container = containerRef.current;\n      if (!container) return null;\n      const rect = container.getBoundingClientRect();\n      if (rect.width <= 0 || rect.height <= 0) return null;\n      return {\n        centerX: clamp01((event.clientX - rect.left) / rect.width),\n        centerY: clamp01((event.clientY - rect.top) / rect.height),\n      };\n    },\n    [],\n  );\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (disabled) return;\n    if (event.button !== 0 && event.pointerType === \"mouse\") return;\n    const pos = positionFromEvent(event);\n    if (!pos) return;\n    // Don't let pointerdown bubble up to the workspace's compare gesture —\n    // clicking the blur handle should drag, not flash the original image.\n    event.stopPropagation();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setDragging(true);\n    onCenterChange?.(pos);\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (disabled || !dragging) return;\n    const pos = positionFromEvent(event);\n    if (!pos) return;\n    onCenterChange?.(pos);\n  };\n\n  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!dragging) return;\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n    setDragging(false);\n    const pos = positionFromEvent(event);\n    if (pos) onCenterCommit?.(pos);\n  };\n\n  return (\n    <div\n      className={cn(\n        \"absolute inset-0\",\n        disabled ? \"pointer-events-none\" : \"pointer-events-auto cursor-crosshair\",\n        className,\n      )}\n      data-slot=\"blur-center-overlay\"\n      onPointerCancel={endDrag}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={endDrag}\n      ref={containerRef}\n    >\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute size-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white/90 bg-white/10 shadow-[0_0_0_2px_rgba(0,0,0,0.5)] transition-transform\"\n        style={{\n          left: `${centerX * 100}%`,\n          top: `${centerY * 100}%`,\n          transform: dragging\n            ? \"translate(-50%, -50%) scale(1.15)\"\n            : \"translate(-50%, -50%)\",\n        }}\n      >\n        <div className=\"absolute left-1/2 top-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white\" />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/image-editor/blur-center-overlay.tsx"
    }
  ],
  "categories": [
    "image-editor"
  ],
  "type": "registry:component"
}