【React】React Image Cropでトリミング画像の登録機能を実装する

#はじめに
プロフィール入力フォームをReactで実装するにあたって`react-image-crop`を利用しました。
「NoImageの写真をクリックすることで登録する写真を選択できるようにする」という部分に少々詰まったので、あわせて記事にまとめることにしました。

#実装
どのような実装を行ったのか、登録ステップごとに記載します。

写真選択
<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/f1ef65c3-d5bb-ec8c-aea9-01fe2dc19591.png">
<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/13734534-a1a9-ee16-9fd6-de0b012149b3.png">
トリミング・登録
<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/6935cebc-dd9d-3c21-3543-59b42872c863.png">
<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/667857b4-ae15-57c8-2f93-3e655080278b.png">

##写真選択
NoImageの三列表示についてはReact Bootstrapを使用しました。
Reactで置き換える前の画面にBootstrapを使っていたので、デザインを近づけるために今回も使用しました。

https://react-bootstrap.github.io/

```tsx:InputProfilePicture.tsx
export const InputProfilePicture = () => {
  const types = [1, 2, 3];

  return (
    <Container>
      <Row>
        {types.map((type) => {
          return (
            <Col xs={4} md={4} key={type}>
              <SelectPicture type={type} />
            </Col>
          );
        })}
      </Row>
    </Container>
  );
};
```

NoImageをクリックして写真を選択できるようにするために、`img(Image)`要素と`input`要素を`label`要素でラップします。
この際、`input`が表示されないように、styleに`display: 'none'`を設定します。

また、マウスホバーで写真がホップして表示されるように、styleに`opacity` `boxShadow` `transform`を追加します。
ホバーの有無の状態を管理するために`const [isHover, setIsHover] = useState(false)`を作成し、`onMouseEnter()`と`onMouseLeave()`内で状態を切り替えるようにします。

```tsx:SelectPicture.tsx
export const SelectPicture = ({ type }: { type: number }) => {
  const [isHover, setIsHover] = useState(false);

  return (
    <>
      <label>
        <Image
          src={result}
          roundedCircle
          style={{
            maxWidth: 350,
            border: 'solid 1px',
            cursor: 'pointer',
            opacity: isHover ? 0.8 : 1,
            boxShadow: isHover ? '1px 1px 5px 1px #000' : 'none',
            transform: isHover ? 'translateY(-2px)' : 'none',
          }}
          onMouseEnter={() => setIsHover(true)}
          onMouseLeave={() => setIsHover(false)}
        />
        <input
          type="file"
          accept="image/jpeg,image/png"
          onChange={handleFileChange}
          style={{ display: 'none' }}
        />
      </label>
  );
};
```

##トリミング・登録
写真のトリミングと登録については`react-image-crop`というライブラリを使用しました。

https://github.com/DominicTobias/react-image-crop

写真を選択するとモーダル`TrimmingModal`が出現し、`ReactCrop`で写真のトリミングができるようになります。

```tsx:SelectPicture.tsx
export const SelectPicture = ({ type }: { type: number }) => {
  const [show, setShow] = useState(false);
  const [src, setSrc] = useState<string>('');
  const [result, setResult] = useState<any>(NoImage);
  const [imageInput, setImageInput] = useState<any>({ type });
  const [isHover, setIsHover] = useState(false);

  const [crop, setCrop, setImage] = useImageCrop(
    type,
    imageInput,
    setImageInput,
    setResult
  );
  const [saveCroppedImg] = useImageSave(setShow, imageInput);

  const handleFileChange = (e: any) => {
    setSrc(URL.createObjectURL(e.target.files[0]));
    setShow(true);
  };

  return (
    <>
      <label>
        <Image
          src={result}
          roundedCircle
          style={{
            maxWidth: 350,
            border: 'solid 1px',
            cursor: 'pointer',
            opacity: isHover ? 0.8 : 1,
            boxShadow: isHover ? '1px 1px 5px 1px #000' : 'none',
            transform: isHover ? 'translateY(-2px)' : 'none',
          }}
          onMouseEnter={() => setIsHover(true)}
          onMouseLeave={() => setIsHover(false)}
        />
        <input
          type="file"
          accept="image/jpeg,image/png"
          onChange={handleFileChange}
          style={{ display: 'none' }}
        />
      </label>

      <TrimmingModal
        show={show}
        setShow={setShow}
        saveCroppedImg={saveCroppedImg}
      >
        <ReactCrop
          src={src}
          onImageLoaded={setImage}
          crop={crop}
          onChange={setCrop}
        />
      </TrimmingModal>
    </>
  );
};
```

```tsx:TrimmingModal.tsx
export const TrimmingModal = ({
  show,
  setShow,
  saveCroppedImg,
  children,
}: any) => {
  const handleClose = () => setShow(false);

  return (
    <Modal
      show={show}
      onHide={handleClose}
      backdrop="static"
      keyboard={false}
      size="lg"
      aria-labelledby="contained-modal-title-vcenter"
      centered
    >
      <Modal.Header closeButton>
        <Modal.Title>トリミング</Modal.Title>
      </Modal.Header>
      <Modal.Body className="text-center">{children}</Modal.Body>
      <Modal.Footer>
        <Button variant="secondary" onClick={handleClose}>
          キャンセル
        </Button>
        <Button variant="primary" onClick={saveCroppedImg}>
          登録
        </Button>
      </Modal.Footer>
    </Modal>
  );
};
```
トリミングおよび登録のロジック部分については、カスタムフック`useImageCrop`と`useImageSave`に切りわけました。

https://qiita.com/suzuki0430/items/49c88a6facad0b3aefd1

トリミングする写真のアスペクト比`aspect`、幅初期値`width`、高さ初期値`height`については`crop`の`useState`の初期値で設定します。
トリミングサイズや場所が変更されるごとに`result`と`imageInput`の値が更新されます。

```ts:userImageCrop.ts
export const useImageCrop = (
  type: number,
  imageInput: any,
  setImageInput: Dispatch<SetStateAction<any>>,
  setResult: Dispatch<SetStateAction<any>>
) => {
  const [crop, setCrop] = useState<any>({
    aspect: 1 / 1,
    width: 300,
    height: 300,
  });
  const [image, setImage] = useState<any>(null);

  useEffect(() => {
    if (image) {
      const canvas = document.createElement('canvas');
      const scaleX = image.naturalWidth / image.width;
      const scaleY = image.naturalHeight / image.height;
      canvas.width = crop.width;
      canvas.height = crop.height;
      const ctx: any = canvas.getContext('2d');

      ctx.drawImage(
        image,
        crop.x * scaleX,
        crop.y * scaleY,
        crop.width * scaleX,
        crop.height * scaleY,
        0,
        0,
        crop.width,
        crop.height
      );

      const base64Image = canvas.toDataURL('image/jpeg');
      const base64 = base64Image.split(',')[1];

      setResult(base64Image);
      setImageInput({ ...imageInput, type, base64 });
    }
  }, [crop]);

  return [crop, setCrop, setImage];
};
```
`useImageSave`では、`useImageCrop`が返した`imageInput`とモーダル表示を更新する`setShow`を受け取り、写真を登録してモーダルを閉じる関数`saveCroppedImg`を返すようにします。

モーダルの登録ボタンを押すと`saveCroppedImg`が発火し、登録が完了します。

```ts:useImageSave.ts
export const useImageSave = (
  setShow: Dispatch<SetStateAction<boolean>>,
  imageInput: any
) => {
  const saveCroppedImg = async () => {
    try {
      console.log('imageInputを登録');
    } catch (err) {
      console.log(err);
    }

    setShow(false);
  };

  return [saveCroppedImg];
};
```

#参考資料

https://qiita.com/ksrnnb/items/81d34faf4abc47ea4182