{
  "name": "lit-ui-image-view",
  "title": "lit-ui-image-view",
  "type": "registry:component",
  "description": "",
  "registryDependencies": [],
  "dependencies": [
    "@egoist/tailwindcss-icons",
    "@iconify-json/lucide",
    "prosekit@^0.22.0"
  ],
  "files": [
    {
      "path": "registry/src/lit/ui/image-view/image-view.ts",
      "type": "registry:component",
      "target": "components/editor/ui/image-view/image-view.ts",
      "content": "import { html, render } from 'lit'\nimport type { Extension } from 'prosekit/core'\nimport { defineNodeView } from 'prosekit/core'\nimport { UploadTask } from 'prosekit/extensions/file'\nimport type { ImageAttrs } from 'prosekit/extensions/image'\nimport {\n  registerResizableHandleElement,\n  registerResizableRootElement,\n  type ResizeEndEvent,\n} from 'prosekit/lit/resizable'\nimport type { ProseMirrorNode } from 'prosekit/pm/model'\nimport type { Decoration, EditorView } from 'prosekit/pm/view'\n\ninterface ResizeEndEventDetail {\n  width: number\n  height: number\n}\n\nclass ImageNodeView {\n  dom: HTMLElement\n  private node: ProseMirrorNode\n  private view: EditorView\n  private getPos: () => number | undefined\n  private selected = false\n\n  private aspectRatio: number | undefined\n  private error: string | undefined\n  private progress = 0\n  private unsubscribeProgress?: VoidFunction\n  private canceled = false\n  private lastUrl = ''\n\n  constructor(node: ProseMirrorNode, view: EditorView, getPos: () => number | undefined) {\n    this.node = node\n    this.view = view\n    this.getPos = getPos\n\n    this.dom = document.createElement('div')\n    this.dom.setAttribute('data-node-view-root', 'true')\n\n    registerResizableRootElement()\n    registerResizableHandleElement()\n\n    this.sync()\n  }\n\n  private setAttrs(attrs: Partial<ImageAttrs>) {\n    const pos = this.getPos()\n    if (typeof pos !== 'number') return\n    const next: ImageAttrs = { ...(this.node.attrs as ImageAttrs), ...attrs }\n    this.view.dispatch(this.view.state.tr.setNodeMarkup(pos, undefined, next))\n  }\n\n  private handleResizeEnd = (event: Event) => {\n    const detail = (event as ResizeEndEvent).detail as ResizeEndEventDetail\n    this.setAttrs({ width: detail.width, height: detail.height })\n  }\n\n  private handleImageLoad = (event: Event) => {\n    const img = event.target as HTMLImageElement\n    const { naturalWidth, naturalHeight } = img\n    const ratio = naturalWidth / naturalHeight\n    if (ratio && Number.isFinite(ratio)) {\n      this.aspectRatio = ratio\n    }\n    const attrs = this.node.attrs as ImageAttrs\n    if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) {\n      this.setAttrs({ width: naturalWidth, height: naturalHeight })\n    } else {\n      this.sync()\n    }\n  }\n\n  private subscribeUpload(url: string) {\n    this.unsubscribeUpload()\n    this.canceled = false\n\n    const uploadTask = UploadTask.get<string>(url)\n    if (!uploadTask) return\n\n    uploadTask.finished.catch((err) => {\n      if (this.canceled) return\n      this.error = String(err)\n      this.sync()\n    })\n    this.unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {\n      if (this.canceled) return\n      this.progress = total ? loaded / total : 0\n      this.sync()\n    })\n  }\n\n  private unsubscribeUpload() {\n    this.canceled = true\n    this.unsubscribeProgress?.()\n    this.unsubscribeProgress = undefined\n  }\n\n  private sync() {\n    const attrs = this.node.attrs as ImageAttrs\n    const url = attrs.src || ''\n    const uploading = url.startsWith('blob:')\n\n    if (url !== this.lastUrl) {\n      this.lastUrl = url\n      this.error = undefined\n      this.progress = 0\n      if (uploading) {\n        this.subscribeUpload(url)\n      } else {\n        this.unsubscribeUpload()\n      }\n    }\n\n    render(\n      html`\n        <prosekit-resizable-root\n          class=\"relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid\"\n          .width=${attrs.width ?? null}\n          .height=${attrs.height ?? null}\n          .aspectRatio=${this.aspectRatio ?? null}\n          ?data-selected=${this.selected}\n          @resizeEnd=${this.handleResizeEnd}\n        >\n          ${\n            url && !this.error\n              ? html`\n                  <img\n                    src=${url}\n                    alt=\"upload preview\"\n                    class=\"h-full w-full max-w-full max-h-full object-contain\"\n                    @load=${this.handleImageLoad}\n                  />\n                `\n              : ''\n          }\n          ${\n            uploading && !this.error\n              ? html`\n                  <div class=\"absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition\">\n                    <div class=\"i-lucide-loader-circle size-4 animate-spin block\"></div>\n                    <div>${Math.round(this.progress * 100)}%</div>\n                  </div>\n                `\n              : ''\n          }\n          ${\n            this.error\n              ? html`\n                  <div class=\"absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container\">\n                    <div class=\"i-lucide-image-off size-8 block\"></div>\n                    <div class=\"hidden opacity-80 @xs:block\">Failed to upload image</div>\n                  </div>\n                `\n              : ''\n          }\n          <prosekit-resizable-handle class=\"absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100\"\n            ><div class=\"i-lucide-arrow-down-right size-4 block\"></div\n          ></prosekit-resizable-handle>\n        </prosekit-resizable-root>\n      `,\n      this.dom,\n    )\n  }\n\n  update(node: ProseMirrorNode, _decorations: readonly Decoration[]) {\n    if (node.type !== this.node.type) return false\n    this.node = node\n    this.sync()\n    return true\n  }\n\n  selectNode() {\n    this.selected = true\n    this.sync()\n  }\n\n  deselectNode() {\n    this.selected = false\n    this.sync()\n  }\n\n  destroy() {\n    this.unsubscribeUpload()\n    render(null, this.dom)\n  }\n}\n\nexport function defineImageView(): Extension {\n  return defineNodeView({\n    name: 'image',\n    constructor: (node, view, getPos) => new ImageNodeView(node, view, getPos),\n  })\n}\n"
    },
    {
      "path": "registry/src/lit/ui/image-view/index.ts",
      "type": "registry:component",
      "target": "components/editor/ui/image-view/index.ts",
      "content": "export { defineImageView } from './image-view.ts'\n"
    }
  ],
  "meta": {
    "hasIcons": true,
    "hidden": false,
    "story": "",
    "framework": "lit",
    "accumulatedFiles": [
      "registry/src/lit/ui/image-view/image-view.ts",
      "registry/src/lit/ui/image-view/index.ts"
    ],
    "internalDependencies": []
  },
  "css": {
    "@plugin @egoist/tailwindcss-icons": {}
  },
  "$schema": "https://ui.shadcn.com/schema/registry-item.json"
}
