{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "image-cropper",
  "title": "Image Cropper",
  "type": "registry:ui",
  "description": "Single image cropper. Drive look with props: aspectRatio, showZoom, rounded, minZoom, maxZoom, disabled.",
  "categories": [
    "form"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/image-cropper/image-cropper.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nfunction coverScale(vw: number, vh: number, nw: number, nh: 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) {\n  return Math.min(max, Math.max(min, n))\n}\n\n/**\n * <uip-image-cropper> — Interactive image cropper with pan, zoom, custom aspect ratio,\n * keyboard controls, and canvas/blob export.\n */\nexport class UipImageCropper extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: block;\n        width: 100%;\n      }\n    `,\n  ]\n\n  static properties = {\n    src: { type: String },\n    alt: { type: String },\n    aspectRatio: { type: Number, attribute: 'aspect-ratio' },\n    zoom: { type: Number },\n    defaultZoom: { type: Number, attribute: 'default-zoom' },\n    minZoom: { type: Number, attribute: 'min-zoom' },\n    maxZoom: { type: Number, attribute: 'max-zoom' },\n    disabled: { type: Boolean },\n    showZoom: { type: Boolean, attribute: 'show-zoom' },\n    rounded: { type: String },\n    panX: { state: true },\n    panY: { state: true },\n  }\n\n  src = ''\n  alt = ''\n  aspectRatio = 1\n  zoom = 1\n  defaultZoom = 1\n  minZoom = 1\n  maxZoom = 4\n  disabled = false\n  showZoom = false\n  rounded: 'lg' | 'full' = 'lg'\n\n  panX = 0\n  panY = 0\n\n  private naturalW = 0\n  private naturalH = 0\n  private dragging = false\n  private lastPointer = { x: 0, y: 0 }\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'image-cropper')\n    if (this.defaultZoom && !this.hasAttribute('zoom')) {\n      this.zoom = this.defaultZoom\n    }\n  }\n\n  setZoom(value: number) {\n    const next = clamp(value, this.minZoom, this.maxZoom)\n    this.zoom = next\n    this.clampPan()\n    this.dispatchEvent(\n      new CustomEvent('zoom-change', {\n        detail: { zoom: next },\n        bubbles: true,\n        composed: true,\n      }),\n    )\n    this.requestUpdate()\n  }\n\n  private clampPan() {\n    const viewport = this.renderRoot.querySelector('[part=viewport]') as HTMLElement | null\n    if (!viewport || !this.naturalW) return\n    const vw = viewport.clientWidth\n    const vh = viewport.clientHeight\n    const scale = coverScale(vw, vh, this.naturalW, this.naturalH) * this.zoom\n    const dw = this.naturalW * scale\n    const dh = this.naturalH * scale\n    const maxX = Math.abs(vw - dw) / 2\n    const maxY = Math.abs(vh - dh) / 2\n    this.panX = clamp(this.panX, -maxX, maxX)\n    this.panY = clamp(this.panY, -maxY, maxY)\n  }\n\n  getCroppedCanvas(): HTMLCanvasElement | null {\n    const viewport = this.renderRoot.querySelector('[part=viewport]') as HTMLElement | null\n    const img = this.renderRoot.querySelector('img') as HTMLImageElement | null\n    if (!viewport || !img || !this.naturalW) return null\n\n    const vw = viewport.clientWidth\n    const vh = viewport.clientHeight\n    const scale = coverScale(vw, vh, this.naturalW, this.naturalH) * this.zoom\n    const dw = this.naturalW * scale\n    const dh = this.naturalH * scale\n    const left = (vw - dw) / 2 + this.panX\n    const top = (vh - dh) / 2 + this.panY\n    const sw = vw / scale\n    const sh = vh / scale\n\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  async getCroppedBlob(type = 'image/png', quality?: number): Promise<Blob | null> {\n    const canvas = this.getCroppedCanvas()\n    if (!canvas) return null\n    return new Promise((resolve) => {\n      canvas.toBlob((blob) => resolve(blob), type, quality)\n    })\n  }\n\n  private onPointerDown(e: PointerEvent) {\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  private onPointerMove(e: PointerEvent) {\n    if (!this.dragging) return\n    const dx = e.clientX - this.lastPointer.x\n    const dy = e.clientY - this.lastPointer.y\n    this.lastPointer = { x: e.clientX, y: e.clientY }\n    this.panX += dx\n    this.panY += dy\n    this.clampPan()\n  }\n\n  private onPointerUp(e: PointerEvent) {\n    this.dragging = false\n    try {\n      ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)\n    } catch {\n      /* ignore */\n    }\n  }\n\n  private onWheel(e: WheelEvent) {\n    if (this.disabled) return\n    e.preventDefault()\n    this.setZoom(this.zoom + (e.deltaY > 0 ? -0.12 : 0.12))\n  }\n\n  private onKeyDown(e: KeyboardEvent) {\n    if (this.disabled) return\n    const step = 8\n    if (e.key === 'ArrowLeft') {\n      this.panX -= step\n      this.clampPan()\n    } else if (e.key === 'ArrowRight') {\n      this.panX += step\n      this.clampPan()\n    } else if (e.key === 'ArrowUp') {\n      this.panY -= step\n      this.clampPan()\n    } else if (e.key === 'ArrowDown') {\n      this.panY += step\n      this.clampPan()\n    } else if (e.key === '+' || e.key === '=') {\n      this.setZoom(this.zoom + 0.2)\n    } else if (e.key === '-' || e.key === '_') {\n      this.setZoom(this.zoom - 0.2)\n    }\n  }\n\n  private onImageLoad(e: Event) {\n    const img = e.currentTarget as HTMLImageElement\n    this.naturalW = img.naturalWidth\n    this.naturalH = img.naturalHeight\n    this.panX = 0\n    this.panY = 0\n    this.clampPan()\n    this.requestUpdate()\n  }\n\n  render() {\n    const viewport = this.renderRoot.querySelector('[part=viewport]') as HTMLElement | null\n    let imgStyle = 'transform: translate(-50%, -50%);'\n    if (viewport && this.naturalW) {\n      const scale = coverScale(viewport.clientWidth, viewport.clientHeight, this.naturalW, this.naturalH) * this.zoom\n      imgStyle = `width: ${this.naturalW * scale}px; height: ${this.naturalH * scale}px; transform: translate(calc(-50% + ${this.panX}px), calc(-50% + ${this.panY}px));`\n    }\n\n    return html`\n      <div part=\"base\" class=\"flex w-full max-w-md flex-col gap-3\">\n        <div\n          part=\"viewport\"\n          data-slot=\"image-cropper-viewport\"\n          role=\"application\"\n          aria-label=\"Image crop viewport\"\n          tabindex=\"0\"\n          data-disabled=${this.disabled ? '' : nothing}\n          class=${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          style=\"aspect-ratio: ${this.aspectRatio};\"\n          @pointerdown=${this.onPointerDown}\n          @pointermove=${this.onPointerMove}\n          @pointerup=${this.onPointerUp}\n          @wheel=${this.onWheel}\n          @keydown=${this.onKeyDown}\n        >\n          <img\n            data-slot=\"image-cropper-image\"\n            src=${this.src}\n            alt=${this.alt}\n            draggable=\"false\"\n            class=\"pointer-events-none absolute top-1/2 left-1/2 max-w-none\"\n            style=${imgStyle}\n            @load=${this.onImageLoad}\n          />\n        </div>\n\n        ${this.showZoom\n          ? html`\n              <label\n                data-slot=\"image-cropper-zoom\"\n                class=\"text-muted-foreground flex items-center gap-3 text-xs\"\n              >\n                <span class=\"w-10\">Zoom</span>\n                <input\n                  type=\"range\"\n                  min=${this.minZoom}\n                  max=${this.maxZoom}\n                  step=\"0.05\"\n                  .value=${String(this.zoom)}\n                  class=\"accent-primary h-1.5 w-full cursor-pointer\"\n                  aria-label=\"Zoom\"\n                  @input=${(e: Event) => this.setZoom(Number((e.target as HTMLInputElement).value))}\n                />\n                <span class=\"w-10 tabular-nums\">${this.zoom.toFixed(1)}×</span>\n              </label>\n            `\n          : nothing}\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-image-cropper') || customElements.define('uip-image-cropper', UipImageCropper)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-image-cropper': UipImageCropper\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/image-cropper/image-cropper.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
