[Next.js]ユーザーが画像をアップロードするときにトリミングしてほしい。ついでにサイズを縮小したい。
やること
- ユーザーが画像をinputしたときにダイアログが表示される
- ダイアログ上で画像をトリミングする
- サイズを縮小する
- OKボタンを押す
環境
Next.js:13.4
react-cropper:2.3.3
browser-image-compression:2.0.2
また、状態管理をしたほうがコードの見通しが良いのでjotai
を使用しています。
jotai:2.1.0
ダイアログにshadcnを使用しています。
Dialog - shadcn/ui
npx shadcn-ui add dialog
トリミングするためのダイアログを実装する
中身はシンプルです。
Dialog - shadcn/uiを使用しています。
inputタグがトリガーとなっており、画像が入力されるとsetIsOpen(true)
となり、ダイアログが開きます。
<input
type="file"
onInput={handleInput}
</input
// ファイルを選択したときの動作
const handleInput = async (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
if (!e.target.files) return;
setImage(e.target.files[0]);
setIsOpen(true);
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
{/* ダイアログ用のボタン */}
<DialogTrigger asChild>
(省略)
現時点のCropperDialogは以下の通りです。
export const CropperDialog = () => {
const [isOpen, setIsOpen] = React.useState(false);
const [image, setImage] = React.useState<File>();
// ダイアログを閉じたときの動作
const handleClose = () => {
setIsOpen(false);
setImage(undefined);
};
// ダイアログを開いたときの動作
const handleOpenChange = (bool: Boolean) => {
if (!bool) handleClose();
if (bool && !image) setIsOpen(false);
if (bool && image) setIsOpen(true);
};
// ファイルを選択したときの動作
const handleInput = async (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
if (!e.target.files) return;
setImage(e.target.files[0]);
setIsOpen(true);
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
{/* ダイアログ用のボタン */}
<DialogTrigger asChild>
<label className="block">
<span className="sr-only">Choose profile photo</span>
<input
type="file"
className="block w-full text-sm text-slate-500 file:mr-4 file:rounded-full file:border-0 file:bg-violet-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-violet-700 hover:file:bg-violet-100"
id="image"
accept="image/*"
placeholder="画像"
onInput={handleInput}
/>
</label>
</DialogTrigger>
{/* ダイアログ本体 */}
<DialogContent className="sm:max-w-[425px]">
{/* ダイアログのヘッダー */}
<DialogHeader>
<DialogTitle>画像をトリミングする</DialogTitle>
<DialogDescription>
枠線に合わせて画像をトリミングしてください。
</DialogDescription>
</DialogHeader>
{/* ダイアログのコンテンツ */}
<div>
ここにダイアログを表示する
</div>
{/* ダイアログのフッター */}
<DialogFooter className="gap-y-4">
<Button
type="button"
variant="outline"
onClick={async () => handleClose()}
>
キャンセル
</Button>
<Button
type="button"
onClick={async () => {handleClose()}}
>
決定する
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
cropperを実装する
react-cropper - npmを実装します。
importに以下を追加します。
必須です。ないとレイアウトが崩れます。
import "cropperjs/dist/cropper.css";
refを用意します。
const cropperRef = createRef<ReactCropperElement>();
以下のようにcropperを用意します。
src
にはURLを用意します。
inputで入力されたimageをURL.createObjectURL(image)
でurlにしています。
aspectRatio
で比率を調整します。
aspectRatio={16 / 9}
{/* ダイアログのコンテンツ */}
<div className="">
{image && (
<Cropper
src={image && URL.createObjectURL(image)}
ref={cropperRef}
aspectRatio={16 / 9}
guides={true}
viewMode={1}
minCropBoxHeight={10}
minCropBoxWidth={10}
background={false}
responsive={true}
checkOrientation={false} // https://github.com/fengyuanchen/cropperjs/issues/671
autoCropArea={1}
></Cropper>
)}
</div>
決定ボタンを押したときに、トリミングした画像を取得します。
await getResizeAndTrimmedImage();
を追加します。
<Button
type="button"
onClick={async () => {
await getResizeAndTrimmedImage();
handleClose();
}}
>
決定する
</Button>
getResizeAndTrimmedImage()
の実装は以下の通りです。
cropperRef
をcanvas -> dataURL -> blob -> File型に変換しています。
同時に圧縮しています。
maxSizeMB:1
で最大サイズを1MBに設定できます。
でもこの設定だけだと圧縮されないことがあるのでmaxWidthOrHeight
で値を指定しています。
const getTrimmedImage = async () => {
// トリミング
if (!cropperRef.current) return;
const canvas = cropperRef.current.cropper.getCroppedCanvas();
const dataURL = canvas.toDataURL();
const file = await convertDataUrlToFile(dataURL, "image.png", "image/png");
// リサイズ
const resizedImage = await imageCompression(file, {
maxSizeMB: 1,
maxWidthOrHeight: 2028,
});
setFile(resizedImage);
};
convertDataUrlToFile.tsは以下の通りです。
そのまんまです。dataUrlを引き取ってFile型をかえしています。
export const convertDataUrlToFile = async (
dataURL: string,
filename: string,
type: "image/png" | "image/jpeg"
): Promise<File> => {
const blob = await (await fetch(dataURL)).blob();
return new File([blob], filename, { type: type });
};
GCSにアップロードしています。
1MB以下に収まってますね。
注意点として、この圧縮ライブラリは結構時間かかります。利用者の端末スペックが低ければなおのことです。ですので、環境によっては圧縮する場所を工夫したり、圧縮中はローディングスピナーを出すなどの工夫が必要になります。
トリミングダイアログ完成時点でのコード
export const CropperDialog = () => {
const [isOpen, setIsOpen] = React.useState(false);
const [image, setImage] = React.useState<File>();
const cropperRef = createRef<ReactCropperElement>();
const setFile = useSetAtom(cropperImageAtom);
// ダイアログを閉じたときの動作
const handleClose = () => {
setIsOpen(false);
setImage(undefined);
};
// ダイアログを開いたときの動作
const handleOpenChange = (bool: Boolean) => {
if (!bool) handleClose();
if (bool && !image) setIsOpen(false);
if (bool && image) setIsOpen(true);
};
// ファイルを選択したときの動作
const handleInput = async (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
if (!e.target.files) return;
setImage(e.target.files[0]);
setIsOpen(true);
};
// トリミングした画像を取得する
const getTrimmedImage = async () => {
if (!cropperRef.current) return;
const canvas = cropperRef.current.cropper.getCroppedCanvas();
const dataURL = canvas.toDataURL();
const file = await convertDataUrlToFile(dataURL, "image.png", "image/png");
setFile(file);
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
{/* ダイアログ用のボタン */}
<DialogTrigger asChild>
<label className="block">
<span className="sr-only">Choose profile photo</span>
<input
type="file"
className="block w-full text-sm text-slate-500 file:mr-4 file:rounded-full file:border-0 file:bg-violet-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-violet-700 hover:file:bg-violet-100"
id="image"
accept="image/*"
placeholder="画像"
onInput={handleInput}
/>
</label>
</DialogTrigger>
{/* ダイアログ本体 */}
<DialogContent className="sm:max-w-[425px]">
{/* ダイアログのヘッダー */}
<DialogHeader>
<DialogTitle>画像をトリミングする</DialogTitle>
<DialogDescription>
枠線に合わせて画像をトリミングしてください。
</DialogDescription>
</DialogHeader>
{/* ダイアログのコンテンツ */}
<div className="">
{image && (
<Cropper
src={image && URL.createObjectURL(image)}
ref={cropperRef}
aspectRatio={16 / 9}
guides={true}
viewMode={1}
minCropBoxHeight={10}
minCropBoxWidth={10}
background={false}
responsive={true}
checkOrientation={false} // https://github.com/fengyuanchen/cropperjs/issues/671
autoCropArea={1}
></Cropper>
)}
</div>
{/* ダイアログのフッター */}
<DialogFooter className="gap-y-4">
<Button
type="button"
variant="outline"
onClick={async () => handleClose()}
>
キャンセル
</Button>
<Button
type="button"
onClick={async () => {
await getTrimmedImage();
handleClose();
}}
>
決定する
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
ページの実装
ダイアログを使用したページを作成します。
export const cropperImageAtom = atom<File | undefined>(undefined);
export const Page = () => {
const cropperImage = useAtomValue(cropperImageAtom);
const handleClick = async () => {
// 略
}
return (
<div className="space-y-8">
<CropperDialog />
{cropperImage && (
<>
<Image
src={URL.createObjectURL(cropperImage)}
alt={"preview"}
width={1600}
height={900}
/>
<div className={"flex justify-end"}>
<Button type="button" onClick={handleClick}>
投稿する
</Button>
</div>
</>
)}
</div>
);
};
Discussion