{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-editor-export",
  "title": "Export Dialog",
  "description": "Format, quality and output size; downloads or saves the result.",
  "dependencies": [
    "lucide-react",
    "photocn@^0.1.1"
  ],
  "registryDependencies": [
    "alert",
    "button",
    "dialog",
    "drawer",
    "field",
    "https://photocn.dev/r/image-editor-layout.json",
    "input",
    "select",
    "slider"
  ],
  "files": [
    {
      "path": "src/registry/image-editor/export-dialog.tsx",
      "content": "\"use client\";\n\nimport { useId, useState, type ReactElement, type ReactNode } from \"react\";\nimport { DownloadIcon, Loader2Icon, UploadIcon } from \"lucide-react\";\nimport {\n  useImageEditor,\n  type ImageEditorExportFormat,\n  type ImageEditorExportResult,\n} from \"photocn/react\";\n\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport {\n  Field,\n  FieldContent,\n  FieldGroup,\n  FieldLabel,\n} from \"@/components/ui/field\";\nimport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerDescription,\n  DrawerFooter,\n  DrawerHeader,\n  DrawerTitle,\n  DrawerTrigger,\n} from \"@/components/ui/drawer\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { cn } from \"@/lib/utils\";\n\nimport { MOBILE_QUERY, useMediaQuery } from \"./layout\";\n\nexport const exportFormats = [\n  { value: \"png\", label: \"PNG\", lossy: false, defaultQuality: 1 },\n  { value: \"jpeg\", label: \"JPEG\", lossy: true, defaultQuality: 0.92 },\n  { value: \"webp\", label: \"WebP\", lossy: true, defaultQuality: 0.9 },\n] as const satisfies readonly {\n  value: ImageEditorExportFormat;\n  label: string;\n  lossy: boolean;\n  defaultQuality: number;\n}[];\n\nexport interface ImageEditorExportDialogProps {\n  /** Element that opens the dialog. Defaults to an \"Export\" button. */\n  trigger?: ReactElement;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  defaultFormat?: ImageEditorExportFormat;\n  /**\n   * Save the result somewhere (e.g. upload it). When set, a primary \"Save\"\n   * button appears next to \"Download\".\n   */\n  onSave?: (result: ImageEditorExportResult) => void | Promise<void>;\n  saveLabel?: string;\n  /** Hide the Download button (e.g. when you only want `onSave`). */\n  hideDownload?: boolean;\n  className?: string;\n}\n\n/** Format + quality picker that downloads (or saves) the edited image. */\nexport function ImageEditorExportDialog({\n  trigger,\n  open,\n  onOpenChange,\n  defaultFormat = \"png\",\n  onSave,\n  saveLabel = \"Save\",\n  hideDownload = false,\n  className,\n}: ImageEditorExportDialogProps) {\n  const editor = useImageEditor();\n  const id = useId();\n  const [format, setFormat] = useState<ImageEditorExportFormat>(defaultFormat);\n  const selected =\n    exportFormats.find((option) => option.value === format) ?? exportFormats[0];\n  const [quality, setQuality] = useState<number>(selected.defaultQuality);\n  const [pending, setPending] = useState<\"download\" | \"save\" | null>(null);\n  // Output width in px; height follows the crop's ratio. null = full size.\n  const [width, setWidth] = useState<number | null>(null);\n  const full = editor.geometry.outputSize;\n  const output = full\n    ? width\n      ? { width, height: Math.max(1, Math.round((width * full.height) / full.width)) }\n      : full\n    : null;\n  const [error, setError] = useState<unknown>(null);\n\n  const canExport = editor.isReady && !editor.disabled;\n  const isMobile = useMediaQuery(MOBILE_QUERY);\n\n  const run = async (kind: \"download\" | \"save\") => {\n    setError(null);\n    setPending(kind);\n    try {\n      const options = {\n        format,\n        quality: selected.lossy ? quality : undefined,\n        width: output && width ? output.width : undefined,\n        height: output && width ? output.height : undefined,\n      };\n      if (kind === \"download\") {\n        await editor.download(options);\n      } else {\n        await onSave?.(await editor.exportImage(options));\n      }\n    } catch (err) {\n      setError(err);\n    } finally {\n      setPending(null);\n    }\n  };\n\n  const title = \"Export image\";\n  const description = \"Pick a format and size. Rendering always starts from the original.\";\n  const triggerNode = trigger ?? (\n    <Button disabled={!editor.hasImage || editor.disabled} size=\"sm\" type=\"button\">\n      <DownloadIcon aria-hidden=\"true\" data-icon=\"inline-start\" />\n      Export\n    </Button>\n  );\n  const form = (\n        <FieldGroup className=\"gap-4\">\n          <Field orientation=\"horizontal\">\n            <FieldLabel id={`${id}-format`}>Format</FieldLabel>\n            <FieldContent className=\"items-end\">\n              <Select\n                items={exportFormats}\n                onValueChange={(value) => {\n                  const next = exportFormats.find((option) => option.value === value);\n                  if (!next) return;\n                  setFormat(next.value);\n                  setQuality(next.defaultQuality);\n                }}\n                value={format}\n              >\n                <SelectTrigger\n                  aria-labelledby={`${id}-format`}\n                  className=\"w-32\"\n                  size=\"sm\"\n                >\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectGroup>\n                    {exportFormats.map((option) => (\n                      <SelectItem key={option.value} value={option.value}>\n                        {option.label}\n                      </SelectItem>\n                    ))}\n                  </SelectGroup>\n                </SelectContent>\n              </Select>\n            </FieldContent>\n          </Field>\n\n          {selected.lossy ? (\n            <Field>\n              <div className=\"flex items-center justify-between gap-3\">\n                <FieldLabel id={`${id}-quality`}>Quality</FieldLabel>\n                <output className=\"text-xs tabular-nums text-muted-foreground\">\n                  {Math.round(quality * 100)}%\n                </output>\n              </div>\n              <Slider\n                aria-labelledby={`${id}-quality`}\n                max={100}\n                min={10}\n                onValueChange={(value) => setQuality(((typeof value === \"number\" ? value : value[0]) ?? 90) / 100)}\n                step={1}\n                value={[Math.round(quality * 100)]}\n              />\n            </Field>\n          ) : null}\n\n          {full && output ? (\n            <Field>\n              <div className=\"flex items-center justify-between gap-3\">\n                <FieldLabel htmlFor={`${id}-width`}>Size</FieldLabel>\n                {width ? (\n                  <button\n                    className=\"text-xs text-muted-foreground hover:text-foreground\"\n                    onClick={() => setWidth(null)}\n                    type=\"button\"\n                  >\n                    Full size\n                  </button>\n                ) : null}\n              </div>\n              <div className=\"flex items-center gap-2\">\n                <Input\n                  aria-label=\"Width in pixels\"\n                  className=\"h-8 w-24 tabular-nums\"\n                  id={`${id}-width`}\n                  inputMode=\"numeric\"\n                  max={16384}\n                  min={1}\n                  onChange={(event) => {\n                    const next = Number(event.target.value);\n                    setWidth(next > 0 ? Math.min(16384, Math.round(next)) : null);\n                  }}\n                  type=\"number\"\n                  value={output.width}\n                />\n                <span className=\"text-muted-foreground\">×</span>\n                <Input\n                  aria-label=\"Height in pixels\"\n                  className=\"h-8 w-24 tabular-nums\"\n                  inputMode=\"numeric\"\n                  max={16384}\n                  min={1}\n                  onChange={(event) => {\n                    const next = Number(event.target.value);\n                    setWidth(\n                      next > 0\n                        ? Math.min(16384, Math.max(1, Math.round((next * full.width) / full.height)))\n                        : null,\n                    );\n                  }}\n                  type=\"number\"\n                  value={output.height}\n                />\n                <span className=\"text-xs text-muted-foreground\">px</span>\n              </div>\n              <p className=\"text-xs text-muted-foreground tabular-nums\">\n                {width ? `${Math.round((output.width / full.width) * 100)}% of the crop` : \"Full resolution\"}\n                {format === \"jpeg\" ? \" · EXIF preserved\" : null}\n              </p>\n            </Field>\n          ) : null}\n\n          {error ? (\n            <Alert role=\"alert\" variant=\"destructive\">\n              <AlertDescription>\n                {error instanceof Error ? error.message : \"Export failed.\"}\n              </AlertDescription>\n            </Alert>\n          ) : null}\n        </FieldGroup>\n  );\n  const actions = (\n    <>\n          {hideDownload ? null : (\n            <Button\n              disabled={!canExport || pending !== null}\n              onClick={() => void run(\"download\")}\n              type=\"button\"\n              variant={onSave ? \"outline\" : \"default\"}\n            >\n              {pending === \"download\" ? (\n                <Loader2Icon\n                  aria-hidden=\"true\"\n                  className=\"animate-spin\"\n                  data-icon=\"inline-start\"\n                />\n              ) : (\n                <DownloadIcon aria-hidden=\"true\" data-icon=\"inline-start\" />\n              )}\n              Download\n            </Button>\n          )}\n          {onSave ? (\n            <Button\n              disabled={!canExport || pending !== null}\n              onClick={() => void run(\"save\")}\n              type=\"button\"\n            >\n              {pending === \"save\" ? (\n                <Loader2Icon\n                  aria-hidden=\"true\"\n                  className=\"animate-spin\"\n                  data-icon=\"inline-start\"\n                />\n              ) : (\n                <UploadIcon aria-hidden=\"true\" data-icon=\"inline-start\" />\n              )}\n              {saveLabel}\n            </Button>\n          ) : null}\n    </>\n  );\n\n  // Phones get a bottom drawer; everything else a dialog (shadcn's\n  // responsive dialog pattern).\n  if (isMobile) {\n    return (\n      <Drawer onOpenChange={onOpenChange} open={open}>\n        <DrawerTrigger render={triggerNode} />\n        <DrawerContent className={className} data-slot=\"image-editor-export-dialog\">\n          <DrawerHeader className=\"text-left\">\n            <DrawerTitle>{title}</DrawerTitle>\n            <DrawerDescription>{description}</DrawerDescription>\n          </DrawerHeader>\n          <div className=\"px-4\">{form}</div>\n          <DrawerFooter className=\"pb-[max(1rem,env(safe-area-inset-bottom))]\">{actions}</DrawerFooter>\n        </DrawerContent>\n      </Drawer>\n    );\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogTrigger render={triggerNode} />\n      <DialogContent\n        className={cn(\"sm:max-w-md\", className)}\n        data-slot=\"image-editor-export-dialog\"\n      >\n        <DialogHeader>\n          <DialogTitle>{title}</DialogTitle>\n          <DialogDescription>{description}</DialogDescription>\n        </DialogHeader>\n        {form}\n        <DialogFooter>{actions}</DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/image-editor/export-dialog.tsx"
    }
  ],
  "categories": [
    "image-editor"
  ],
  "type": "registry:component"
}