Reactでアップロードした画像を切り取る:コンポーネントとカスタムフックの実装ガイド
はじめに
株式会社メンヘラテクノロジーでエンジニアをしているandmohikoです。
SNSなどでプロフィール画像を設定する際、選択した画像をそのままのサイズで設定すると見づらくなることがあります。そのため、画像を設定したいサイズに事前に加工してからアップロードした経験がある方も多いでしょう。また、画像を事前に加工する手間を嫌い、サイトから離れたこともあるかもしれません。このような問題を解決するために、Reactで画像のクロップ機能を実装しました。
この記事では、選択した画像のトリミングをしてアップロードする機能の実装方法を解説していきます。具体的なコンポーネントとカスタムフックの実装手順を説明し、実際のPull Requestのリンクも載せてています。
今回実装したもの
今回の実装のプルリクエストはこちらです。
ソースコードの全体はこちらから見れます。他にも便利なコンポーネントを作成しているのでよかったら見てみてください。
使用する技術
Reactで画像をクロップするライブラリとして、今回はreact-image-cropを使用します。
ファイルのアップロードはMantineのDropzoneコンポーネントを使用します。
ライブラリのversionはReact v18.2.0, @mantine/dropzone v7.10.1, react-image-crop v11.0.5を使用しています。
ライブラリのインストール
ベースのプロジェクトのセットアップは済んでいるものとして進めます。
Mantineの導入については公式のGetting startedに従って進めてください。
必要なライブラリをインストールします。
$ yarn add @mantine/dropzone react-image-crop
@mantine/dropzone
のスタイルを_app.tsx
でimportしておきます。
+ import '@mantine/dropzone/styles.css'
ファイルアップロードのコンポーネントの実装
まずはフォームのスキーマを定義します。input type fileでは画像を複数選択することもできまずが、選択した画像をクロップするため、今回はファイルを1つだけ受け付けるようにします。こちらではzodを使用しています。
const FileUploadSchema = z.object({
file: z.string().min(1, 'ファイルを選択してください'),
})
type FileUploadInputType = z.infer<typeof FileUploadSchema>
次に、ファイルをアップロードするコンポーネントを実装します。こちらはMantineのDropzoneコンポーネントをそのまま使用しています。
inputなコンポーネントなのでpropsはvalue
, onChange
, error
にすることで使い勝手を他のコンポーネントと揃えています。
type Props = {
value: FileObject
onChange: (file: FileObject | undefined) => void
error: string | undefined
}
export const FileInputWithCropper = ({
value,
onChange,
error,
}: Props): React.ReactNode => {
return (
<Dropzone
onDrop={() => {
console.log('onDrop')
}}
onReject={() => {
console.log('onReject')
}}
maxSize={100 * 1024 ** 2}
accept={IMAGE_MIME_TYPE}
className={styles.dropzone}
disabled={isDisabled || isLoading}
>
<FlexBox gap={16} justify="center">
<Dropzone.Accept>
<AiOutlineUpload color="#777" size={50} />
</Dropzone.Accept>
<Dropzone.Reject>
<RxCross1 color="#777" size={50} />
</Dropzone.Reject>
<Dropzone.Idle>
<MdOutlineAddPhotoAlternate color="#777" size={50} />
</Dropzone.Idle>
{isLoading && <LoadingOverlay visible />}
</FlexBox>
{isDisabled && <Overlay color="#fff" opacity={0.7} />}
</Dropzone>
)
}
さきほど定義したスキーマと、こちらのコンポーネントを組み合わせてフォームを作成します。フォームバリデーションライブラリにはReact Hook Formを使用しています。
export const ImageCropForm = (): React.ReactNode => {
const {
handleSubmit,
control,
formState: { errors, isSubmitting, isValid },
} = useForm<FileUploadInputType>({
resolver: zodResolver(FileUploadSchema),
mode: 'all',
defaultValues: {
file: undefined,
},
})
const onSubmit = (data: FileUploadInputType) => {
console.log('submit', {
...data,
})
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FlexBox gap={40}>
<Controller
name="file"
control={control}
render={({ field }) => (
<FileInputWithCropper
value={field.value}
onChange={field.onChange}
error={errors.file?.message}
/>
)}
/>
<BasicButton type="submit" loading={isSubmitting} disabled={!isValid}>
保存する
</BasicButton>
</FlexBox>
</form>
)
}
カスタムフックの実装
コンポーネントのI/Oが固まったので、次はファイルアップロードとクロップをするカスタムフックを実装していきます。
カスタムフック内でファイルを取り回す型はFileObjectと定義していますが、他にも必要な情報があれば拡張してください。
useCropImageInput
というカスタムフックを作成します。フックの引数にはfileとsetFileを受け取ります。こちらはフォームから受け取るvalue
とonChange
をそのまま渡すイメージです。
フックの返り値が多いため、[values, handlers, states]
という形にまとめました。
type FileUrl = string
export type FileObject = FileUrl
export const useCropImageInput = (
file: FileObject | undefined,
setFile: (file: FileObject | undefined) => void,
): [
// values
{
file: FileObject | undefined
uncroppedImageUrl: string | undefined
crop: Crop
},
// handlers
{
onSelectImage: (files: Array<File>) => void
remove: () => void
onChangeCrop: (crop: Crop) => void
onCrop: () => void
closeCropper: () => void
},
// states
{
isOpenCropper: boolean
isDisabled: boolean
isLoading: boolean
},
] => {
それではカスタムフックの中身を実装していきます。
まずはカスタムフック内のステートです。今後の実装でそれぞれの役割を説明します。
const [isLoading, setIsLoading] = useState<boolean>(false)
const isDisabled = Boolean(file)
const [isOpen, handlers] = useDisclosure()
const [fileData, setFileData] = useState<File | undefined>()
const [uncroppedImageUrl, setUncroppedImageUrl] = useState<
string | undefined
>()
const [crop, setCrop] = useState<Crop>({
unit: 'px',
x: 0,
y: 0,
width: 200,
height: 200,
})
次に、ファイルの選択とクロップ画面を開くところを実装します。
UI側に渡す関数を用意し、UI側で操作が行われたことを起点に発火するuseEffect
を用意します。
// dropzoneのonDropに渡す関数
const onSelectImage = useCallback((inputFiles: Array<FileWithPath>): void => {
if (!inputFiles || inputFiles.length === 0) {
return
}
const file = inputFiles[0]
setFileData(file)
}, [])
// onDropでfileが選択されると発火し、クロップするためのモーダルを開きます
useEffect(() => {
if (fileData instanceof File) {
uncroppedImageUrl && URL.revokeObjectURL(uncroppedImageUrl)
setUncroppedImageUrl(URL.createObjectURL(fileData))
handlers.open()
} else {
setUncroppedImageUrl(undefined)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileData])
続いて、クロップ時に使用するメソッドの実装です。実際に画像をクロップする処理はcreateCroppedImageUrl
内で行っています。こちらの処理はcanvas上で行うため、canvasで扱えるように選択した画像のimgのHTMLElementを作成しています。
// canvasで画像を扱うため、アップロードした画像のuncroppedImageUrlをもとに、imgのHTMLElementを作る
const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve) => {
const img: HTMLImageElement = document.createElement('img')
img.src = src
img.onload = () => resolve(img)
})
}
// 切り取った画像のObjectUrlを作成し、フォームに保存する
const createCroppedImageUrl = async () => {
if (uncroppedImageUrl) {
const img = await loadImage(uncroppedImageUrl)
const scaleX = img.naturalWidth / img.width
const scaleY = img.naturalHeight / img.height
const canvas = document.createElement('canvas')
canvas.width = crop.width
canvas.height = crop.height
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
ctx.beginPath()
ctx.arc(
canvas.width / 2,
canvas.height / 2,
canvas.width / 2,
0,
2 * Math.PI,
false,
)
ctx.clip()
ctx.drawImage(
img,
crop.x * scaleX,
crop.y * scaleY,
crop.width * scaleX,
crop.height * scaleY,
0,
0,
crop.width,
crop.height,
)
canvas.toBlob((result) => {
if (result instanceof Blob) {
setFile(URL.createObjectURL(result))
}
})
}
}
最後に、こちらのcreateCroppedImageUrl
をUI側で呼べるようにラップしたメソッドを用意します。ローディングを入れている理由は、クロップした画像をオブジェクトストレージなどに保存することが考えられるためです。
// UI側でクロップ範囲を確定したときに発火する関数
const onCrop = () => {
setIsLoading(true)
createCroppedImageUrl()
handlers.close()
setIsLoading(false)
}
画像のクロップのコンポーネントの実装
カスタムフックが実装できたので、こちらのフックを使用して画像をクロップするUIを作成します。
react-image-cropが提供しているReactCrop
コンポーネントを使用しています。
画像のクロップはモーダル上で行うため、ReactCrop
をモーダルでラップしています。
{uncroppedImageUrl && (
<ActionModal
isOpen={isOpenCropper}
onClose={closeCropper}
onSave={onCrop}
title="画像を編集"
>
<ReactCrop
crop={crop}
onChange={(c) => onChangeCrop(c)}
aspect={1}
circularCrop={true}
keepSelection={true}
>
<img
src={uncroppedImageUrl}
alt=""
style={{
height: '100%',
width: '100%',
maxHeight: 400,
}}
/>
</ReactCrop>
</ActionModal>
)}
これでメインの実装は完了です。
最後にクロップした画像の表示などを実装すると使いやすいかもしれません。
おわりに
今回の記事では、Reactを用いて画像クロップ機能を実装する方法について解説しました。コンポーネントとカスタムフックを用意することで拡張性も高く作れたと思います。
さらに深く理解するために、実際のPull RequestやGitHubリポジトリを載せているので、実装の詳細についてはそちらをご確認ください。
画像クロップ機能は、ユーザーが画像を自由に調整できるようにするための重要な要素です。この機能を実装することで、ユーザー体験を向上させられるように皆様のプロジェクトに役立ててください。
Discussion