{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "image-cropper",
  "title": "Image Cropper",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/image-cropper/image-cropper.component.ts",
      "content": "import {\n  Component,\n  ElementRef,\n  EventEmitter,\n  Input,\n  Output,\n  ViewChild,\n  booleanAttribute,\n  effect,\n  numberAttribute,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\n\nfunction coverScale(vw: number, vh: number, nw: number, nh: number): number {\n  if (!nw || !nh) return 1\n  return Math.max(vw / nw, vh / nh)\n}\n\nfunction clamp(n: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, n))\n}\n\ninterface Point {\n  x: number\n  y: number\n}\n\n/**\n * Angular port of the React ImageCropper: the image covers a fixed-aspect viewport and is\n * panned by dragging (pointer capture) or Arrow keys (8px), zoomed by the wheel, + / - keys\n * or the optional range; panning is clamped so the image always covers the viewport.\n * `zoom` / `defaultZoom` / `zoomChange` (`[(zoom)]`). React's imperative handle is the\n * component's public API: `getCroppedCanvas()` / `getCroppedBlob(type, quality)` (grab the\n * component with a template ref or viewChild).\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-image-cropper, [ui-image-cropper]',\n  standalone: true,\n  host: {\n    'data-uipkge': '',\n    'data-slot': 'image-cropper',\n    '[class]': 'hostClass',\n  },\n  template: `\n    <div\n      #viewport\n      data-slot=\"image-cropper-viewport\"\n      role=\"application\"\n      aria-label=\"Image crop viewport\"\n      tabindex=\"0\"\n      [attr.data-disabled]=\"disabled ? '' : null\"\n      [class]=\"viewportClass\"\n      [style.aspect-ratio]=\"aspectRatioValue\"\n      (pointerdown)=\"onPointerDown($event)\"\n      (pointermove)=\"onPointerMove($event)\"\n      (pointerup)=\"onPointerUp($event)\"\n      (wheel)=\"onWheel($event)\"\n      (keydown)=\"onKeyDown($event)\"\n    >\n      <img\n        #img\n        data-slot=\"image-cropper-image\"\n        [src]=\"src\"\n        [alt]=\"alt\"\n        draggable=\"false\"\n        class=\"pointer-events-none absolute top-1/2 left-1/2 max-w-none\"\n        [style.width]=\"imgStyle.width\"\n        [style.height]=\"imgStyle.height\"\n        [style.transform]=\"imgStyle.transform\"\n        (load)=\"onImageLoad()\"\n      />\n    </div>\n    @if (showZoom) {\n      <label data-slot=\"image-cropper-zoom\" class=\"text-muted-foreground flex items-center gap-3 text-xs\">\n        <span class=\"w-10\">Zoom</span>\n        <input\n          type=\"range\"\n          [min]=\"minZoom\"\n          [max]=\"maxZoom\"\n          step=\"0.05\"\n          [value]=\"currentZoom\"\n          class=\"accent-primary h-1.5 w-full cursor-pointer\"\n          aria-label=\"Zoom\"\n          (input)=\"setZoom(+$any($event.target).value)\"\n        />\n        <span class=\"w-10 tabular-nums\">{{ currentZoom.toFixed(1) }}×</span>\n      </label>\n    }\n  `,\n})\nexport class UiImageCropperComponent {\n  private readonly _zoom = signal<number | undefined>(undefined)\n  private readonly _uncontrolledZoom = signal<number | null>(null)\n  private readonly pan = signal<Point>({ x: 0, y: 0 })\n  private readonly natural = signal({ w: 0, h: 0 })\n  private dragging = false\n  private lastPointer: Point = { x: 0, y: 0 }\n\n  @Input({ required: true }) src!: string\n  @Input() alt = ''\n  @Input({ transform: numberAttribute }) aspectRatio = 1\n  /** Controlled zoom. */\n  @Input()\n  set zoom(v: number | null | undefined) {\n    this._zoom.set(v ?? undefined)\n  }\n  get zoom(): number | undefined {\n    return this._zoom()\n  }\n  /** Uncontrolled initial zoom. */\n  @Input({ transform: numberAttribute }) defaultZoom = 1\n  @Output() zoomChange = new EventEmitter<number>()\n  @Input({ transform: numberAttribute }) minZoom = 1\n  @Input({ transform: numberAttribute }) maxZoom = 4\n  @Input({ transform: booleanAttribute }) disabled = false\n  @Input({ transform: booleanAttribute }) showZoom = false\n  @Input() rounded: 'lg' | 'full' = 'lg'\n  @Input('class') className?: string\n\n  @ViewChild('viewport', { static: true }) viewportRef!: ElementRef<HTMLDivElement>\n  @ViewChild('img', { static: true }) imgRef!: ElementRef<HTMLImageElement>\n\n  constructor() {\n    // React: re-clamp the pan whenever the zoom (or the loaded image) changes.\n    effect(() => {\n      void this.currentZoom\n      this.natural()\n      const p = this.pan()\n      const next = this.clampPan(p)\n      if (next.x !== p.x || next.y !== p.y) this.pan.set(next)\n    })\n  }\n\n  get currentZoom(): number {\n    return this._zoom() ?? this._uncontrolledZoom() ?? this.defaultZoom\n  }\n\n  get aspectRatioValue(): string {\n    return String(this.aspectRatio)\n  }\n\n  get hostClass(): string {\n    return cn('flex w-full max-w-md flex-col gap-3', this.className)\n  }\n\n  get viewportClass(): string {\n    return cn(\n      'bg-muted relative w-full overflow-hidden select-none',\n      this.rounded === 'full' ? 'rounded-full' : 'rounded-lg',\n      this.disabled ? 'pointer-events-none opacity-60' : 'cursor-grab active:cursor-grabbing',\n    )\n  }\n\n  get imgStyle(): { width: string | null; height: string | null; transform: string } {\n    const el = this.viewportRef?.nativeElement\n    const { w, h } = this.natural()\n    if (!el || !w) return { width: null, height: null, transform: 'translate(-50%, -50%)' }\n    const scale = coverScale(el.clientWidth, el.clientHeight, w, h) * this.currentZoom\n    const { x, y } = this.pan()\n    return {\n      width: `${w * scale}px`,\n      height: `${h * scale}px`,\n      transform: `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))`,\n    }\n  }\n\n  setZoom(value: number): void {\n    const next = clamp(value, this.minZoom, this.maxZoom)\n    this.zoomChange.emit(next)\n    if (this._zoom() === undefined) this._uncontrolledZoom.set(next)\n  }\n\n  private clampPan(next: Point): Point {\n    const el = this.viewportRef?.nativeElement\n    const { w, h } = this.natural()\n    if (!el || !w) return next\n    const vw = el.clientWidth\n    const vh = el.clientHeight\n    const scale = coverScale(vw, vh, w, h) * this.currentZoom\n    const maxX = Math.abs(vw - w * scale) / 2\n    const maxY = Math.abs(vh - h * scale) / 2\n    return { x: clamp(next.x, -maxX, maxX), y: clamp(next.y, -maxY, maxY) }\n  }\n\n  onImageLoad(): void {\n    const img = this.imgRef.nativeElement\n    this.natural.set({ w: img.naturalWidth, h: img.naturalHeight })\n    this.pan.set({ x: 0, y: 0 })\n  }\n\n  onPointerDown(e: PointerEvent): void {\n    if (this.disabled) return\n    this.dragging = true\n    this.lastPointer = { x: e.clientX, y: e.clientY }\n    ;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)\n  }\n\n  onPointerMove(e: PointerEvent): void {\n    if (!this.dragging) return\n    const p = this.pan()\n    const next = { x: p.x + (e.clientX - this.lastPointer.x), y: p.y + (e.clientY - this.lastPointer.y) }\n    this.lastPointer = { x: e.clientX, y: e.clientY }\n    this.pan.set(this.clampPan(next))\n  }\n\n  onPointerUp(e: PointerEvent): void {\n    this.dragging = false\n    try {\n      ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)\n    } catch {\n      /* already released */\n    }\n  }\n\n  onWheel(e: WheelEvent): void {\n    if (this.disabled) return\n    e.preventDefault()\n    this.setZoom(this.currentZoom + (e.deltaY > 0 ? -0.12 : 0.12))\n  }\n\n  onKeyDown(e: KeyboardEvent): void {\n    if (this.disabled) return\n    const step = 8\n    const p = this.pan()\n    if (e.key === 'ArrowLeft') this.pan.set(this.clampPan({ ...p, x: p.x - step }))\n    if (e.key === 'ArrowRight') this.pan.set(this.clampPan({ ...p, x: p.x + step }))\n    if (e.key === 'ArrowUp') this.pan.set(this.clampPan({ ...p, y: p.y - step }))\n    if (e.key === 'ArrowDown') this.pan.set(this.clampPan({ ...p, y: p.y + step }))\n    if (e.key === '+' || e.key === '=') this.setZoom(this.currentZoom + 0.2)\n    if (e.key === '-' || e.key === '_') this.setZoom(this.currentZoom - 0.2)\n  }\n\n  /** The visible crop as a canvas at the source image's resolution (null before the image loads). */\n  getCroppedCanvas(): HTMLCanvasElement | null {\n    const el = this.viewportRef?.nativeElement\n    const img = this.imgRef?.nativeElement\n    const { w, h } = this.natural()\n    if (!el || !img || !w) return null\n    const vw = el.clientWidth\n    const vh = el.clientHeight\n    const scale = coverScale(vw, vh, w, h) * this.currentZoom\n    const { x, y } = this.pan()\n    const left = (vw - w * scale) / 2 + x\n    const top = (vh - h * scale) / 2 + y\n    const sw = vw / scale\n    const sh = vh / scale\n    const canvas = document.createElement('canvas')\n    canvas.width = Math.max(1, Math.round(sw))\n    canvas.height = Math.max(1, Math.round(sh))\n    const ctx = canvas.getContext('2d')\n    if (!ctx) return null\n    ctx.drawImage(img, -left / scale, -top / scale, sw, sh, 0, 0, canvas.width, canvas.height)\n    return canvas\n  }\n\n  /** The visible crop encoded as a Blob (null before the image loads). */\n  getCroppedBlob(type = 'image/png', quality?: number): Promise<Blob | null> {\n    return new Promise((resolve) => {\n      const canvas = this.getCroppedCanvas()\n      if (!canvas) {\n        resolve(null)\n        return\n      }\n      canvas.toBlob((blob) => resolve(blob), type, quality)\n    })\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/image-cropper/image-cropper.component.ts"
    },
    {
      "path": "packages/registry-angular/components/image-cropper/index.ts",
      "content": "export { UiImageCropperComponent } from './image-cropper.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/image-cropper/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Single image cropper: drag / arrow keys to pan, wheel / + / - / range to zoom, cover-scaled with clamped panning. Drive look with aspectRatio, showZoom, rounded, minZoom, maxZoom, disabled; export with getCroppedCanvas() / getCroppedBlob().",
  "categories": [
    "form"
  ]
}