{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-editor-crop",
  "title": "Crop",
  "description": "Quarter turns, flips, aspect ratios, straighten and perspective. Non-destructive: reopen and widen the crop any time.",
  "dependencies": [
    "lucide-react",
    "photocn@^0.1.1"
  ],
  "registryDependencies": [
    "button",
    "field",
    "https://photocn.dev/r/image-editor-panel-header.json",
    "select",
    "separator",
    "slider",
    "toggle",
    "tooltip"
  ],
  "files": [
    {
      "path": "src/registry/image-editor/crop-panel.tsx",
      "content": "\"use client\";\n\nimport { useId, type ReactNode } from \"react\";\nimport {\n  FlipHorizontal2Icon,\n  FlipVertical2Icon,\n  RectangleHorizontalIcon,\n  RectangleVerticalIcon,\n  RotateCcwIcon,\n  RotateCcwSquareIcon,\n  RotateCwSquareIcon,\n} from \"lucide-react\";\nimport {\n  aspectRatioOptions as defaultAspectRatioOptions,\n  useImageEditor,\n  type AspectRatioOption,\n} from \"photocn/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Field,\n  FieldGroup,\n  FieldLabel,\n  FieldLegend,\n  FieldSet,\n} from \"@/components/ui/field\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { Toggle } from \"@/components/ui/toggle\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\n\nimport { SkewIcon } from \"./icon-skew\";\nimport { PanelResetHeader } from \"./panel-header\";\n\nexport interface CropPanelValue {\n  /** Degrees, -45..45. */\n  straighten: number;\n  /** -1..1 */\n  perspectiveX: number;\n  /** -1..1 */\n  perspectiveY: number;\n  /** Aspect preset value (\"free\", \"original\", \"1:1\", …). */\n  aspectRatio: string;\n  /** Whether the crop is portrait. */\n  portrait: boolean;\n  /** Whether the advanced corner handles are showing. */\n  editingCorners: boolean;\n  /** Whether corners were moved (so \"Reset corners\" means something). */\n  hasCorners: boolean;\n}\n\nexport interface CropPanelProps {\n  value: CropPanelValue;\n  aspectRatioOptions?: readonly AspectRatioOption[];\n  disabled?: boolean;\n  className?: string;\n  /** Hide the whole perspective section. */\n  hidePerspective?: boolean;\n  onRotate?: (direction: 1 | -1) => void;\n  onFlip?: (axis: \"horizontal\" | \"vertical\") => void;\n  onAspectRatioChange?: (value: string) => void;\n  onToggleOrientation?: () => void;\n  /** Live while dragging; `onCommit` fires on release. */\n  onStraightenChange?: (degrees: number) => void;\n  onPerspectiveChange?: (value: { x?: number; y?: number }) => void;\n  onCommit?: () => void;\n  onEditingCornersChange?: (editing: boolean) => void;\n  onResetCorners?: () => void;\n  onReset?: () => void;\n  /** Leave the crop tool. Shows a \"Done\" button when set. */\n  onDone?: () => void;\n  /** Show the keyboard shortcut list. Default `true`. */\n  showShortcuts?: boolean;\n}\n\n/** Keys handled by the editor while the crop tool is active. */\nexport const cropShortcuts: readonly [keys: string, action: string][] = [\n  [\"R / ⇧R\", \"Rotate right / left\"],\n  [\"H · V\", \"Flip horizontal · vertical\"],\n  [\"X\", \"Portrait ⇄ landscape\"],\n  [\"[ ]\", \"Straighten ±0.5° (⇧ ±5°)\"],\n  [\"← → ↑ ↓\", \"Move the image (⇧ faster)\"],\n  [\"Scroll\", \"Zoom inside the frame\"],\n  [\"Esc\", \"Cancel a drag / leave corners\"],\n  [\"⌫\", \"Reset crop\"],\n  [\"↵\", \"Done\"],\n];\n\n/**\n * The crop tool's controls: quarter turns, flips, ratio, straighten and\n * perspective. Cropping itself happens on the canvas. See docs/compose.md.\n */\nexport function CropPanel({\n  value,\n  aspectRatioOptions = defaultAspectRatioOptions,\n  disabled = false,\n  className,\n  hidePerspective = false,\n  onRotate,\n  onFlip,\n  onAspectRatioChange,\n  onToggleOrientation,\n  onStraightenChange,\n  onPerspectiveChange,\n  onCommit,\n  onEditingCornersChange,\n  onResetCorners,\n  onReset,\n  onDone,\n  showShortcuts = true,\n}: CropPanelProps) {\n  const id = useId();\n  const canToggleOrientation = value.aspectRatio !== \"1:1\";\n\n  return (\n    <TooltipProvider>\n      <section\n        aria-label=\"Crop\"\n        className={cn(\"flex h-full min-w-0 flex-col bg-background text-foreground\", className)}\n        data-slot=\"crop-panel\"\n      >\n        <PanelResetHeader\n          description=\"Drag the frame on the image to crop\"\n          onReset={onReset}\n          resetDisabled={disabled}\n          title=\"Crop\"\n        />\n\n        <FieldGroup className=\"gap-5 px-4 py-4\">\n          <div className=\"flex items-center gap-1\" role=\"group\" aria-label=\"Rotate and flip\">\n            <IconAction disabled={disabled || !onRotate} label=\"Rotate left\" onClick={() => onRotate?.(-1)}>\n              <RotateCcwSquareIcon />\n            </IconAction>\n            <IconAction disabled={disabled || !onRotate} label=\"Rotate right\" onClick={() => onRotate?.(1)}>\n              <RotateCwSquareIcon />\n            </IconAction>\n            <Separator className=\"mx-1 h-5\" orientation=\"vertical\" />\n            <IconAction disabled={disabled || !onFlip} label=\"Flip horizontal\" onClick={() => onFlip?.(\"horizontal\")}>\n              <FlipHorizontal2Icon />\n            </IconAction>\n            <IconAction disabled={disabled || !onFlip} label=\"Flip vertical\" onClick={() => onFlip?.(\"vertical\")}>\n              <FlipVertical2Icon />\n            </IconAction>\n          </div>\n\n          <Field>\n            <FieldLabel id={`${id}-ratio`}>Aspect ratio</FieldLabel>\n            <div className=\"flex items-center gap-2\">\n              <Select\n                disabled={disabled || !onAspectRatioChange}\n                items={aspectRatioOptions}\n                onValueChange={(next) => {\n                  if (next) onAspectRatioChange?.(next);\n                }}\n                value={value.aspectRatio}\n              >\n                <SelectTrigger aria-labelledby={`${id}-ratio`} className=\"flex-1\" size=\"sm\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  {aspectRatioOptions.map((option) => (\n                    <SelectItem key={option.value} value={option.value}>\n                      {option.label}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n              <IconAction\n                disabled={disabled || !onToggleOrientation || !canToggleOrientation}\n                label={value.portrait ? \"Switch to landscape\" : \"Switch to portrait\"}\n                onClick={() => onToggleOrientation?.()}\n                variant=\"outline\"\n              >\n                {value.portrait ? <RectangleVerticalIcon /> : <RectangleHorizontalIcon />}\n              </IconAction>\n            </div>\n          </Field>\n\n          <CenteredSlider\n            disabled={disabled || !onStraightenChange}\n            format={(v) => `${v > 0 ? \"+\" : \"\"}${v.toFixed(1)}°`}\n            label=\"Straighten\"\n            max={45}\n            min={-45}\n            onChange={(v) => onStraightenChange?.(v)}\n            onCommit={onCommit}\n            step={0.1}\n            value={value.straighten}\n          />\n        </FieldGroup>\n\n        {hidePerspective ? null : (\n          <>\n            <Separator />\n            <FieldSet className=\"gap-5 px-4 py-4\">\n              <div className=\"flex items-center justify-between gap-2\">\n                <FieldLegend className=\"mb-0 text-sm\" variant=\"label\">\n                  Perspective\n                </FieldLegend>\n                <div className=\"flex items-center gap-1\">\n                  {value.hasCorners ? (\n                    <Button\n                      disabled={disabled || !onResetCorners}\n                      onClick={onResetCorners}\n                      size=\"xs\"\n                      type=\"button\"\n                      variant=\"ghost\"\n                    >\n                      <RotateCcwIcon data-icon=\"inline-start\" />\n                      Corners\n                    </Button>\n                  ) : null}\n                  <Tooltip>\n                    <TooltipTrigger\n                      render={\n                        <Toggle\n                          aria-label=\"Adjust corners\"\n                          disabled={disabled || !onEditingCornersChange}\n                          onPressedChange={(pressed) => onEditingCornersChange?.(pressed)}\n                          pressed={value.editingCorners}\n                          size=\"sm\"\n                          variant=\"outline\"\n                        >\n                          <SkewIcon />\n                        </Toggle>\n                      }\n                    />\n                    <TooltipContent>Adjust corners</TooltipContent>\n                  </Tooltip>\n                </div>\n              </div>\n              <CenteredSlider\n                disabled={disabled || !onPerspectiveChange}\n                format={percent}\n                label=\"Vertical\"\n                max={1}\n                min={-1}\n                onChange={(y) => onPerspectiveChange?.({ y })}\n                onCommit={onCommit}\n                step={0.01}\n                value={value.perspectiveY}\n              />\n              <CenteredSlider\n                disabled={disabled || !onPerspectiveChange}\n                format={percent}\n                label=\"Horizontal\"\n                max={1}\n                min={-1}\n                onChange={(x) => onPerspectiveChange?.({ x })}\n                onCommit={onCommit}\n                step={0.01}\n                value={value.perspectiveX}\n              />\n            </FieldSet>\n          </>\n        )}\n\n        {onDone || showShortcuts ? (\n          <>\n            <Separator />\n            <div className=\"flex flex-col gap-3 px-4 py-4\">\n              {onDone ? (\n                <Button disabled={disabled} onClick={onDone} size=\"sm\" type=\"button\">\n                  Done\n                </Button>\n              ) : null}\n              {showShortcuts ? (\n                <details className=\"group text-xs text-muted-foreground\">\n                  <summary className=\"cursor-pointer select-none rounded-sm outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\">\n                    Keyboard shortcuts\n                  </summary>\n                  <dl className=\"mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1\">\n                    {cropShortcuts.map(([keys, action]) => (\n                      <div className=\"contents\" key={keys}>\n                        <dt>\n                          <kbd className=\"rounded border bg-muted px-1 font-mono text-[11px] text-foreground\">\n                            {keys}\n                          </kbd>\n                        </dt>\n                        <dd>{action}</dd>\n                      </div>\n                    ))}\n                  </dl>\n                </details>\n              ) : null}\n            </div>\n          </>\n        ) : null}\n      </section>\n    </TooltipProvider>\n  );\n}\n\nconst percent = (v: number) => `${v > 0 ? \"+\" : \"\"}${Math.round(v * 100)}`;\n\nfunction CenteredSlider({\n  label,\n  value,\n  min,\n  max,\n  step,\n  disabled,\n  format,\n  onChange,\n  onCommit,\n}: {\n  label: string;\n  value: number;\n  min: number;\n  max: number;\n  step: number;\n  disabled?: boolean;\n  format: (value: number) => string;\n  onChange: (value: number) => void;\n  onCommit?: () => void;\n}) {\n  const id = useId();\n  return (\n    <Field data-disabled={disabled}>\n      <div className=\"flex items-center justify-between gap-3\">\n        <FieldLabel id={id}>{label}</FieldLabel>\n        <button\n          className=\"rounded px-1 text-xs tabular-nums text-muted-foreground hover:text-foreground disabled:pointer-events-none\"\n          disabled={disabled || value === 0}\n          onClick={() => {\n            onChange(0);\n            onCommit?.();\n          }}\n          title=\"Reset\"\n          type=\"button\"\n        >\n          {format(value)}\n        </button>\n      </div>\n      <Slider\n        aria-labelledby={id}\n        disabled={disabled}\n        max={max}\n        min={min}\n        onValueChange={(next) => onChange((typeof next === \"number\" ? next : next[0]) ?? 0)}\n        onValueCommitted={() => onCommit?.()}\n        step={step}\n        value={[value]}\n      />\n    </Field>\n  );\n}\n\nfunction IconAction({\n  label,\n  children,\n  variant = \"ghost\",\n  ...props\n}: {\n  label: string;\n  children: ReactNode;\n  variant?: \"ghost\" | \"outline\";\n  disabled?: boolean;\n  onClick?: () => void;\n}) {\n  const button = (\n    <Button aria-label={label} size=\"icon-sm\" type=\"button\" variant={variant} {...props}>\n      {children}\n    </Button>\n  );\n  return (\n    <Tooltip>\n      <TooltipTrigger render={props.disabled ? <span className=\"inline-flex\">{button}</span> : button} />\n      <TooltipContent>{label}</TooltipContent>\n    </Tooltip>\n  );\n}\n\nexport type ImageEditorCropProps = Partial<CropPanelProps>;\n\n/** Crop tool controls wired to the nearest `<ImageEditorProvider>`. */\nexport function ImageEditorCrop(props: ImageEditorCropProps) {\n  const editor = useImageEditor();\n  const g = editor.geometry;\n  return (\n    <CropPanel\n      aspectRatioOptions={g.aspectRatioOptions}\n      disabled={editor.disabled || !editor.hasImage}\n      onAspectRatioChange={g.setAspectRatio}\n      onCommit={g.commit}\n      onDone={g.done}\n      onEditingCornersChange={g.setEditingCorners}\n      onFlip={g.flip}\n      onPerspectiveChange={(value) => g.setPerspective(value, { transient: true })}\n      onReset={g.reset}\n      onResetCorners={g.resetCorners}\n      onRotate={g.rotate}\n      onStraightenChange={(degrees) => g.setStraighten(degrees, { transient: true })}\n      onToggleOrientation={g.toggleOrientation}\n      value={{\n        straighten: g.value.straighten,\n        perspectiveX: g.value.perspectiveX,\n        perspectiveY: g.value.perspectiveY,\n        aspectRatio: g.aspectRatio,\n        portrait: g.portrait,\n        editingCorners: g.editingCorners,\n        hasCorners: Boolean(g.value.corners),\n      }}\n      {...props}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/image-editor/crop-panel.tsx"
    },
    {
      "path": "src/registry/image-editor/icon-skew.tsx",
      "content": "import type { SVGProps } from \"react\";\n\n/**\n * Skew/perspective icon. Path data lifted from xdadda/mini-photo-editor's\n * `icon_skew.svg` so this app's perspective tool reads as a continuation of\n * the original (parallelogram shape, suggests \"skew\" / \"perspective warp\").\n * Re-rendered as a stroke-only icon to match lucide-react's visual language.\n */\nexport function SkewIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      fill=\"none\"\n      height=\"24\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"2\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      {/* parallelogram outline */}\n      <path d=\"M7 5 L21 8 L17 19 L3 16 Z\" />\n      {/* corner dots — hint of the 4-handle perspective UI */}\n      <circle cx=\"7\" cy=\"5\" r=\"0.7\" fill=\"currentColor\" />\n      <circle cx=\"21\" cy=\"8\" r=\"0.7\" fill=\"currentColor\" />\n      <circle cx=\"17\" cy=\"19\" r=\"0.7\" fill=\"currentColor\" />\n      <circle cx=\"3\" cy=\"16\" r=\"0.7\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/image-editor/icon-skew.tsx"
    }
  ],
  "categories": [
    "image-editor"
  ],
  "type": "registry:component"
}