😽

react-easy-cropでTwitter風の画像トリミングを実装してみた

2022/07/13に公開1

概要

Twitterのヘッダー画像トリミング のようなものを実装しました。

画像ファイルをアップロードしてモーダル上で画像のトリミング領域を指定し、「OK」ボタンを押下すると切り取った結果が画面に表示されます。

トリミングのルール

  • トリミングサイズは固定
  • 画像側を動かしてトリミング箇所を指定
    • 画像のサイズ変更可
    • 画像の回転は不可
  • 画像の範囲内でのみトリミングが可能

ライブラリ

今回は画像側を動かしてトリミングを行うためreact-easy-cropを採用しました。

実装結果

画像ファイルをアップロードしてモーダルに表示

App.tsx
  const onFileChange = useCallback(
    async (e: React.ChangeEvent<HTMLInputElement>) => {
      if (e.target.files && e.target.files.length > 0) {
        const reader = new FileReader();
        reader.addEventListener("load", () => {
          if (reader.result) {
            setImgSrc(reader.result.toString() || "");
            setIsOpen(true);
          }
        });
        reader.readAsDataURL(e.target.files[0]);
      }
    },
    []
  );

onFileChangeinputタグでファイルをアップロードしたときに実行されます。
FileReaderで画像の読み込みを行い、読み込みが完了した後にファイルのURLをCropperコンポーネントへ渡します。

画像のZoomデフォルト値を設定

画像の範囲内でのみトリミングを可能としているため、アップロードした画像のサイズがトリミング領域より小さかったときにトリミング対象の画像サイズを大きくしてあげる必要があります。

アップロードされた画像のアスペクト比が切り取り領域のアスペクト比より大きいときに画像の縦幅を切り取り領域の高さにあわせ、小さいときは画像の横幅を切り取り領域の横幅にあわせてZoomのデフォルト値を指定します。(このデフォルト値をZoomの最小値としてこれ以上小さくできなくします。)

App.tsx
  const onMediaLoaded = useCallback((mediaSize: MediaSize) => {
    const { width, height } = mediaSize;
    const mediaAspectRadio = width / height;
    if (mediaAspectRadio > ASPECT_RATIO) {
      // 縦幅に合わせてZoomを指定
      const result = CROP_WIDTH / ASPECT_RATIO / height;
      setZoom(result);
      setMinZoom(result);
      return;
    }
    // 横幅に合わせてZoomを指定
    const result = CROP_WIDTH / width;
    setZoom(result);
    setMinZoom(result);
  }, []);

onMediaLoadedはCropper側でファイルの読み込みが完了した後に呼ばれます。

CropperコンポーネントのPropsを指定

Modalの中でCropperコンポーネントを呼び出します。
Cropperはreact-easy-cropで用意されているコンポーネントです。

CropperModal.tsx
<Cropper
   image={imgSrc}
   crop={crop}
   zoom={zoom}
   minZoom={minZoom}
   maxZoom={minZoom + 3}
   aspect={ASPECT_RATIO}
   onCropChange={setCrop}
   onCropComplete={onCropComplete}
   onZoomChange={setZoom}
   cropSize={{
      width: CROP_WIDTH,
      height: CROP_WIDTH / ASPECT_RATIO
   }}
   classes={{
      containerClassName: "container",
      cropAreaClassName: "crop-area",
      mediaClassName: "media"
   }}
   onMediaLoaded={onMediaLoaded}
   showGrid={false}
/>

各Propsについて

  • image
    • トリミング対象の画像URL
  • crop
    • クロッパーの位置の指定。
    • 画像をクロッパーのどの位置に配置させるか設定する。
  • onCropChange
    • cropが更新されるたびに毎回呼ばれる。
  • onCropComplete
    • ユーザーが画像の移動やZoomを停止したときに呼ばれる。
    • 画像のトリミング箇所の情報が取得できる。
    • 最終的に画像をトリミングする際はこの値をもとに位置を指定する。
  • cropSize
    • トリミング領域のサイズ(px)。
    • 指定しない場合は、アスペクト比と画像サイズを使用して自動的に計算される。
  • zoom
    • 画像の倍率。
  • onZoomChange
    • Zoomが変更されるたびに毎回呼ばれる。
  • minZoom
    • 画像倍率の最小値。
  • maxZoom
    • 画像倍率の最大値。
  • aspect
    • 切り取り領域のアスペクト比。
  • onMediaLoaded
    • ファイルが読み込まれたときに呼ばれる。
    • 画像の横幅や高さなど、サイズ情報が取得できる
  • showGrid
    • グリッドの表示非表示。

https://github.com/ValentinH/react-easy-crop#props

画像の切り取り情報を更新

onCropCompleteで画像切り取り場所の位置情報を更新します。
onCropCompleteはユーザーが画像の移動やZoomの操作をやめたときに呼ばれ、切り取り箇所の位置情報を取得できます。

App.tsx
  const onCropComplete = useCallback(
    (croppedArea: Area, croppedAreaPixels: Area) => {
      setCroppedAreaPixels(croppedAreaPixels);
    },
    []
  );

切り取った画像のプレビューを表示

最後にトリミングした結果を画面に表示させます。

トリミングモーダルの「OK」ボタンを押下すると以下のshowCroppedImageが実行されます。
onCropCompleteで設定した画像切り取り場所の位置情報から、アップロードされた画像データのトリミングを行い新たな画像作成します。

App.tsx
  const showCroppedImage = useCallback(async () => {
    if (!croppedAreaPixels) return;
    try {
      const croppedImage = await getCroppedImg(imgSrc, croppedAreaPixels);
      setCroppedImgSrc(croppedImage);
    } catch (e) {
      console.error(e);
    }
  }, [croppedAreaPixels, imgSrc]);

実際に画像トリミングを行う処理はこちらです。

getCroppedImg.ts
import { Area } from "react-easy-crop";

/**
 * urlをもとにimage要素を作成
 */
export const createImage = (url: string): Promise<HTMLImageElement> =>
  new Promise((resolve, reject) => {
    const image = new Image();
    image.addEventListener("load", () => resolve(image));
    image.addEventListener("error", (error) => reject(error));
    // CodeSandboxでCORSエラーを回避するために必要
    image.setAttribute("crossOrigin", "anonymous");
    image.src = url;
  });

/**
 * 画像トリミングを行い新たな画像urlを作成
 */
export default async function getCroppedImg(
  imageSrc: string,
  pixelCrop: Area
): Promise<string> {
  const image = await createImage(imageSrc);
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");

  if (!ctx) {
    return "";
  }

  // canvasサイズを設定
  canvas.width = image.width;
  canvas.height = image.height;

  // canvas上に画像を描画
  ctx.drawImage(image, 0, 0);

  // トリミング後の画像を抽出
  const data = ctx.getImageData(
    pixelCrop.x,
    pixelCrop.y,
    pixelCrop.width,
    pixelCrop.height
  );

  // canvasのサイズ指定(切り取り後の画像サイズに更新)
  canvas.width = pixelCrop.width;
  canvas.height = pixelCrop.height;

  // 抽出した画像データをcanvasの左隅に貼り付け
  ctx.putImageData(data, 0, 0);

  // canvasを画像に変換
  return new Promise((resolve, reject) => {
    canvas.toBlob((file) => {
      if (file !== null) resolve(URL.createObjectURL(file));
    }, "image/jpeg");
  });
}

アップロードした画像のURLからimgタグを作成し画像をcanvasに上に描画します。
その後、トリミングの位置情報から画像データとして必要な箇所を抽出し、最後にcanvasの内容を画像化してあげます。

Discussion

llc_starhacksllc_starhacks

関数が依存し合っていて非常に難解なコードになっています。
(きっと今のあなたでも、過去のこのコードを読むのも大変なのではないでしょうか。)

責務を分け、オブジェクト指向やDIを取り入れてみてはいかがでしょう。
ずっと可読性があがります。