HextaUI

BêtaConstruit sur l’UI SDK 0.1.0-beta.1 de Vercel. Son API peut changer.

Image cropper

Un recadreur d’image sur l’UI SDK de Vercel avec sélection redimensionnable, formats, cadre d’avatar rond, zoom, rotation et retournement.

  • Composant
  • 10 parties
  • 6 exemples
Dépend de
  • @tabler/icons-react
  • @ui-sdk/core
  • @ui-sdk/react
  • class-variance-authority
A rowing boat on a green mountain lake
"use client"

import * as React from "react"
import { cropImage } from "@ui-sdk/react/image"

import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import {
  ImageCropper,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperViewport,
  type ImageCrop,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80&auto=format&fit=crop"

const ratios = [
  { label: "Free", value: "free", ratio: undefined },
  { label: "Square", value: "square", ratio: 1 },
  { label: "Wide", value: "wide", ratio: 16 / 9 },
]

function UiSdkImageCropperDemo() {
  const [ratio, setRatio] = React.useState("wide")
  const [crop, setCrop] = React.useState<ImageCrop>()
  const [result, setResult] = React.useState<string>()

  React.useEffect(
    () => () => {
      if (result) URL.revokeObjectURL(result)
    },
    [result]
  )

  async function save() {
    if (!crop) return
    const file = await cropImage(photo, crop, { width: 480 })
    setResult(URL.createObjectURL(file))
  }

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <ToggleGroup
        aria-label="Aspect ratio"
        value={[ratio]}
        onValueChange={(next) => {
          if (next[0]) setRatio(next[0])
        }}
        variant="outline"
        size="sm"
      >
        {ratios.map((item) => (
          <ToggleGroupItem key={item.value} value={item.value}>
            {item.label}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <ImageCropper
        aria-label="Photo"
        src={photo}
        aspectRatio={ratios.find((item) => item.value === ratio)?.ratio}
        minWidth={80}
        minHeight={80}
        crop={crop}
        onCropChange={setCrop}
      >
        <ImageCropperViewport>
          <ImageCropperImage alt="A rowing boat on a green mountain lake" />
          <ImageCropperSelection>
            <ImageCropperGrid />
            <ImageCropperHandles />
          </ImageCropperSelection>
        </ImageCropperViewport>
      </ImageCropper>
      <div className="flex items-center gap-3">
        <Button size="sm" onClick={save} disabled={!crop}>
          Crop
        </Button>
        {crop && (
          <span className="text-sm text-muted-foreground tabular-nums">
            {Math.round(crop.width)} × {Math.round(crop.height)}
          </span>
        )}
      </div>
      {result && (
        <img
          src={result}
          alt="The cropped photo"
          className="w-full rounded-lg"
        />
      )}
    </div>
  )
}

export { UiSdkImageCropperDemo }
pnpm dlx shadcn@latest add https://hextaui.com/r/ui-sdk-image-cropper.json

Ajoute le composant, les tokens de thème HextaUI et les composants HextaUI dont il dépend.

UI SDK ajoute un peu de CSS dans une couche de cascade nommée ui-sdk. Déclarez cette couche en haut de votre feuille de style globale, au-dessus de l’import Tailwind, pour que vos classes l’emportent.

@layer ui-sdk;

@import "tailwindcss";
import {
  ImageCropper,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperViewport,
} from "@/components/ui/ui-sdk-image-cropper"
<ImageCropper aria-label="Cover photo" src={src} aspectRatio={3}>
  <ImageCropperViewport>
    <ImageCropperImage alt="Your cover photo" />
    <ImageCropperSelection>
      <ImageCropperGrid />
      <ImageCropperHandles />
    </ImageCropperSelection>
  </ImageCropperViewport>
</ImageCropper>

Le recadrage est un rectangle dans les pixels de l’image, avec sa rotation et son retournement. Passez-le à cropImage pour obtenir un fichier à envoyer. Une image d’un autre site a besoin de CORS, sinon le navigateur empêche la page de lire ses pixels.

import { cropImage } from "@ui-sdk/react/image"

const file = await cropImage(src, crop, { width: 512 })
ImageCropper
├── ImageCropperViewport
│   ├── ImageCropperImage
│   └── ImageCropperSelection
│       ├── ImageCropperGrid
│       └── ImageCropperHandles
├── ImageCropperZoom
├── ImageCropperRotate
└── ImageCropperFlip

Avec mode="frame", la sélection reste fixe et vous déplacez et zoomez l’image dessous. shape="circle" l’arrondit : passez aussi shape: "circle" à cropImage.

"use client"

import * as React from "react"
import { IconUser } from "@tabler/icons-react"
import { cropImage } from "@ui-sdk/react/image"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  ImageCropper,
  ImageCropperImage,
  ImageCropperRotate,
  ImageCropperSelection,
  ImageCropperViewport,
  ImageCropperZoom,
  type ImageCrop,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200&q=80&auto=format&fit=crop"

function UiSdkImageCropperAvatar() {
  const [crop, setCrop] = React.useState<ImageCrop>()
  const [avatar, setAvatar] = React.useState<string>()

  React.useEffect(
    () => () => {
      if (avatar) URL.revokeObjectURL(avatar)
    },
    [avatar]
  )

  async function save() {
    if (!crop) return
    const file = await cropImage(photo, crop, { width: 128, shape: "circle" })
    setAvatar(URL.createObjectURL(file))
  }

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <ImageCropper
        aria-label="Profile photo"
        src={photo}
        mode="frame"
        aspectRatio={1}
        shape="circle"
        maxZoom={4}
        crop={crop}
        onCropChange={setCrop}
      >
        <ImageCropperViewport>
          <ImageCropperImage alt="Snowy peaks above a sea of clouds" />
          <ImageCropperSelection />
        </ImageCropperViewport>
        <div className="flex items-center gap-2">
          <ImageCropperZoom />
          <ImageCropperRotate />
        </div>
      </ImageCropper>
      <div className="flex items-center gap-3">
        <Button size="sm" onClick={save} disabled={!crop}>
          Save avatar
        </Button>
        <Avatar>
          {avatar && <AvatarImage src={avatar} alt="Your new avatar" />}
          <AvatarFallback>
            <IconUser />
          </AvatarFallback>
        </Avatar>
      </div>
    </div>
  )
}

export { UiSdkImageCropperAvatar }

ImageCropperRotate tourne l’image d’un quart dans le sens horaire et ImageCropperFlip la retourne. Sans enfants, ce sont des boutons icône aux libellés anglais.

import {
  ImageCropper,
  ImageCropperFlip,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperRotate,
  ImageCropperSelection,
  ImageCropperViewport,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80&auto=format&fit=crop"

function UiSdkImageCropperRotateFlip() {
  return (
    <ImageCropper
      aria-label="Photo"
      src={photo}
      aspectRatio={4 / 5}
      className="max-w-sm"
    >
      <ImageCropperViewport>
        <ImageCropperImage alt="A rowing boat on a green mountain lake" />
        <ImageCropperSelection>
          <ImageCropperGrid />
          <ImageCropperHandles />
        </ImageCropperSelection>
      </ImageCropperViewport>
      <div className="flex gap-2">
        <ImageCropperRotate />
        <ImageCropperFlip />
        <ImageCropperFlip direction="vertical" />
      </div>
    </ImageCropper>
  )
}

export { UiSdkImageCropperRotateFlip }

Recadrez une photo issue d’un input de fichier avec une URL d’objet. Donnez une key au recadreur pour qu’une nouvelle photo reparte de zéro.

"use client"

import * as React from "react"

import { Input } from "@/components/ui/input"
import {
  ImageCropper,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperViewport,
} from "@/components/ui/ui-sdk-image-cropper"

function UiSdkImageCropperUpload() {
  const [src, setSrc] = React.useState<string>()

  React.useEffect(
    () => () => {
      if (src) URL.revokeObjectURL(src)
    },
    [src]
  )

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <Input
        type="file"
        accept="image/*"
        aria-label="Choose a photo"
        onChange={(event) => {
          const file = event.target.files?.[0]
          if (file) setSrc(URL.createObjectURL(file))
        }}
      />
      {src ? (
        <ImageCropper key={src} aria-label="Photo" src={src} aspectRatio={1}>
          <ImageCropperViewport>
            <ImageCropperImage alt="The photo you chose" />
            <ImageCropperSelection>
              <ImageCropperGrid />
              <ImageCropperHandles />
            </ImageCropperSelection>
          </ImageCropperViewport>
        </ImageCropper>
      ) : (
        <p className="text-sm text-muted-foreground">
          Choose a photo to crop it.
        </p>
      )}
    </div>
  )
}

export { UiSdkImageCropperUpload }

onCropCommit s’exécute à la fin d’un glissement ou d’une touche : enregistrez-y. Un contrôleur issu de createImageCropperController() définit le recadrage depuis n’importe où.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  createImageCropperController,
  ImageCropper,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperViewport,
  type ImageCrop,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80&auto=format&fit=crop"

const start = { x: 120, y: 80, width: 480, height: 480 }

function UiSdkImageCropperControlled() {
  const [cropper] = React.useState(createImageCropperController)
  const [saved, setSaved] = React.useState<ImageCrop | null>(null)

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <ImageCropper
        aria-label="Photo"
        src={photo}
        aspectRatio={1}
        defaultCrop={start}
        controller={cropper}
        onCropCommit={setSaved}
      >
        <ImageCropperViewport>
          <ImageCropperImage alt="A rowing boat on a green mountain lake" />
          <ImageCropperSelection>
            <ImageCropperGrid />
            <ImageCropperHandles />
          </ImageCropperSelection>
        </ImageCropperViewport>
      </ImageCropper>
      <div className="flex items-center gap-3">
        <Button
          variant="outline"
          size="sm"
          onClick={() => cropper.setCrop(start)}
        >
          Reset
        </Button>
        <span className="text-sm text-muted-foreground tabular-nums">
          {saved
            ? `Saved ${Math.round(saved.width)} × ${Math.round(saved.height)} at ${Math.round(saved.x)}, ${Math.round(saved.y)}`
            : "Move the crop to save it."}
        </span>
      </div>
    </div>
  )
}

export { UiSdkImageCropperControlled }

Un recadreur désactivé ne prend pas le focus et ne change pas, et son zoom et ses boutons sont désactivés.

import {
  ImageCropper,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperRotate,
  ImageCropperSelection,
  ImageCropperViewport,
  ImageCropperZoom,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80&auto=format&fit=crop"

function UiSdkImageCropperDisabled() {
  return (
    <ImageCropper
      aria-label="Photo"
      src={photo}
      aspectRatio={16 / 9}
      disabled
      className="max-w-md"
    >
      <ImageCropperViewport>
        <ImageCropperImage alt="A rowing boat on a green mountain lake" />
        <ImageCropperSelection>
          <ImageCropperHandles />
        </ImageCropperSelection>
      </ImageCropperViewport>
      <div className="flex items-center gap-2">
        <ImageCropperZoom />
        <ImageCropperRotate />
      </div>
    </ImageCropper>
  )
}

export { UiSdkImageCropperDisabled }
ToucheAction
←→↑↓Déplace la sélection, ou sur une poignée, déplace ses bords.
Shift←Déplace dix fois plus loin.
+-Zoome ou dézoome autour du centre du recadrage.
RTourne l’image d’un quart dans le sens horaire, ou en arrière avec Maj.
EscPendant un glissement, remet le recadrage là où il a commencé.

Le recadreur est un groupe nommé par aria-label. La sélection et chaque poignée sont des sliders qui énoncent le recadrage, comme « 300 by 300 pixels, at 50, 0 », et le zoom son niveau, comme « 125% ». Ces noms sont en anglais : passez un aria-label à chaque partie dans la langue de votre page.

La grille n’apparaît que pendant un glissement, et jamais si le mouvement est réduit. Les poignées ont une zone tactile plus grande sur écran tactile, la page ne défile pas pendant un glissement, et en mode couleurs forcées la sélection reçoit une bordure aux couleurs du système.

Chaque partie enveloppe la partie UI SDK du même nom et accepte toutes ses props, y compris render et un className qui peut être une fonction de son état.

PropTypePar défaut
src
string–
cropLe recadrage en pixels de l’image, avec rotation, flipX et flipY.
ImageCrop–
defaultCrop
ImageCrop–
onCropChange
(crop, details) => void–
onCropCommitS’exécute à la fin d’un glissement, d’une touche ou de la molette.
(crop, details) => void–
mode
"box" | "frame""box"
aspectRatio
number–
shape
"rectangle" | "circle""rectangle"
minWidth
number–
minHeight
number–
maxZoom
number10
disabled
booleanfalse
controllerIssu de createImageCropperController().
ImageCropperController–
AttributDescription
data-slot="image-cropper"Cible le recadreur en CSS.
data-modebox ou frame.
data-shaperectangle ou circle.
data-loadingPrésent jusqu’au chargement de l’image.
data-errorPrésent lorsque l’image n’a pas pu se charger.
data-draggingPrésent pendant un glissement du pointeur.
PropTypePar défaut
render
ReactElement | (props, state) => ReactElement–
AttributDescription
data-slot="image-cropper-viewport"Cible le viewport en CSS.
--image-cropper-aspect-ratioLa largeur de l’image sur sa hauteur une fois chargée. Le viewport la prend comme format.
PropTypePar défaut
altRequis.
string–
render
ReactElement | (props, state) => ReactElement–
PropTypePar défaut
aria-label
string"Crop"
render
ReactElement | (props, state) => ReactElement–
AttributDescription
data-slot="image-cropper-selection"Cible la sélection en CSS.
PropTypePar défaut
childrenPar défaut, des lignes en tiers.
ReactNode–
PropTypePar défaut
positionLe bord ou le coin qu’elle redimensionne.
"n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw"–

ImageCropperHandles affiche les huit. Les poignées n’apparaissent pas en mode frame.

Un input range natif de 1, le plus grand recadrage, à maxZoom.

PropTypePar défaut
childrenPar défaut, une icône avec le libellé Rotate. Avec des enfants, c’est un petit bouton texte.
ReactNode–
PropTypePar défaut
direction
"horizontal" | "vertical""horizontal"
childrenPar défaut, une icône avec un libellé anglais.
ReactNode–