[항해 실전 프로젝트][React] react-cropper 사용하여 이미지 크롭하기

Carrie·2023년 9월 1일
0

이미지 크롭 구현✂️

마이페이지에서 프로필 이미지를 업로드할 때 사용자가 원하는 부분만 크롭하여 업로드할 수 있도록 구현해보려고 한다. 나는 react-cropper 라이브러리를 사용했다.

💡react-cropper
대표적인 JavaScript 크로핑 라이브러리인 cropper.js를 리액트용으로 포장한 버전이다.
다양한 기능과 설정 옵션을 제공한다.

react-cropper 라이브러리 설치

yarn add react-cropper

프로필 이미지 상태관리 변수 선언

// MyPage.jsx
// 프로필 이미지 화면에 보여주기
const [profileImage, setProfileImage] = useState(null);
// 사용자가 선택한 이미지
const [selectImage, setSelectImage] = useState(null);
// 크롭된 이미지
const [croppedImage, setCroppedImage] = useState(null);
// 이미지 cropper 모달
const [openCropper, setOpenCropper] = useState(false);

프로필 이미지 업로드 버튼 및 input 태그 구현

// Mypage.jsx
          <input
            type='file'
            ref={imageUploadInput}
            accept='image/*'
            onChange={selectImageHandelr} // 이미지 선택 handler 호출
            style={{ display: 'none' }}
          />
          <ProfileImageButton onClick={() => setProfileModal(true)}>
            <img
              className='profileImage'
              src={
                profileImage || // profileImage 표시
                'https://t1.daumcdn.net/cfile/tistory/243FE450575F82662D'
              }
              alt='프로필 사진'
            />
            <img
              className='cameraIcon'
              src={`${process.env.PUBLIC_URL}assets/svgs/camera.svg`}
              alt='프로필 사진'
            />
          </ProfileImageButton>

CropperModal 컴포넌트 구현

// MyPage.jsx
          {openCropper && ( // openCropper가 true일 때 모달을 연다
            <CropperModal
              imageSubmitHandler={imageSubmitHandler}
              selectImage={selectImage}
              croppedImage={croppedImage}
              setCroppedImage={setCroppedImage}
              setOpenCropper={setOpenCropper}
            />
          )}
// cropperModal.jsx
import { Cropper } from 'react-cropper';
import 'cropperjs/dist/cropper.css'; // css도 import해줘야 한다.
.
.
.
  return (
    <Wrapper>
      <button className='backButton'>
        <IconComponents
          iconType='vectorLeft'
          stroke='#FFF'
          onClick={() => setOpenCropper(false)}
        />
      </button>
      <button
        className='applyButton'
        onClick={() => imageSubmitHandler(croppedImage)} // 이미지 submit handler 호출
      >
        적용
      </button>
      <div>
        <Cropper
          src={selectImage} // 사용자가 선택한 사진
          crop={onCrop} // 크롭 함수 호출
          ref={cropperRef}
          aspectRatio={1} // 정사각형
          viewMode={1} // 크롭 영역이 이미지를 벗어나지 않게
          background={false}
          guides={false}
          data={{ width: '100%' }}
        />
      </div>
    </Wrapper>
  );

crop 함수 구현

// cropperModal.jsx
  const cropperRef = useRef(null);

  //Data URL을 Blob으로 변환
  function dataURLtoBlob(dataURL) {
    const arr = dataURL.split(',');
    const matches = arr[0].match(/:(.*?);/);
    const mime = matches && matches.length >= 2 ? matches[1] : null;

    // MIME 타입이 없을 때 크롭 리셋
    if (!mime) {
      const cropperInstance = cropperRef.current.cropper;
      cropperInstance.reset();
      return;
    }

    const bstr = atob(arr[1]);
    let n = bstr.length;
    const u8arr = new Uint8Array(n);

    while (n--) {
      u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], { type: mime });
  }

  const onCrop = () => {
    const imageElement = cropperRef?.current;
    const cropper = imageElement?.cropper;
    // Data URL로 크롭된 이미지 가져오기
    const croppedDataURL = cropper.getCroppedCanvas().toDataURL();
    // Blob으로 변환
    const blob = dataURLtoBlob(croppedDataURL);
    setCroppedImage(blob);
  };

참고로 나는 서버에 이미지를 파일 형식으로 전송해야해서 위와 같이 Data Url을 Blob형태로 변환하는 작업을 했지만, 서버에 Data Url을 보내면 되는 경우는 setCroppedImage(croppedDataURL); 이렇게만 하면된다.

이미지 선택 및 이미지 submit 핸들러 구현

// Mypage.jsx
  const selectImageHandelr = (e) => {
    const file = e.target.files[0];
    if (file) {
      setSelectImage(URL.createObjectURL(file)); // 사용자가 선택한 이미지의 Url selectImage에 저장
      setOpenCropper(true); // Cropper 모달을 연다
    }
  };

  const mutation = useMutation(updateMyPageProfileImage); // api 호출

  const imageSubmitHandler = async () => {
    const formData = new FormData();
    formData.append('profileUrl', croppedImage); // 크롭된 이미지 formData에 추가
    mutation.mutate(formData, {
      onSuccess: (data) => {
        setProfileImage(data); // 서버에서 받아온 Url profileImage에 저장
        setOpenCropper(false);
      },
      onError: (error) => {
        alert('프로필 이미지 등록에 실패했습니다. 잠시 후 다시 시도해주세요.');
        console.error(error);
      },
    });
  };

위와 같이 구현하면 사용자가 원하는 영역을 크롭해서 이미지를 업로드 할 수 있다!

구현 화면😎

트러블슈팅🧨

이렇게만 구현하면 해피엔딩일줄 알았는데, 이미지를 크롭하는 과정에서 계속 오류가 발생했다. 정확히는 Data Url을 파일 형식으로 변환하는 과정에서 오류가 발생했던 것인데 그 오류들을 기록해보려고 한다.

처음 코드

function dataURLtoBlob(dataURL) {
  const arr = dataURL.split(',');
  const mime = arr[0].match(/:(.*?);/)[1];
  const bstr = atob(arr[1]);
  let n = bstr.length;
  const u8arr = new Uint8Array(n);
  
  while (n--) {
    u8arr[n] = bstr.charCodeAt(n);
  }
  return new Blob([u8arr], { type: mime });
}

오류1

Uncaught TypeError: Cannot read properties of null (reading '1')

arr[0].match(/:(.*?);/)에서 정규 표현식에 매칭되는 부분이 없을 때 matchnull을 반환한다. 그래서 [1]로 접근하려니 오류가 발생한다.

1차 수정

function dataURLtoBlob(dataURL) {
    const arr = dataURL.split(',');
    const matches = arr[0].match(/:(.*?);/);
    
    // MIME 타입이 없을 때의 처리 추가
    if (!matches || matches.length < 2) {
        throw new Error("Invalid MIME type in data URL.");
    }

    const mime = matches[1];
    const bstr = atob(arr[1]);
    let n = bstr.length;
    const u8arr = new Uint8Array(n);

    while (n--) {
        u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], { type: mime });
}

오류2

Uncaught Error: Invalid MIME type in data URL
수정했으나 여전히 동일한 오류가 발생하고, mime 타입이 없을 경우 에러를 화면에 보여주는 게 끝이다. 오류의 원인은 data URL이 유효한 형식이 아니거나, data URLmime 타입이 포함되어 있지 않아서인데, 오류가 발생했을 경우 다른 처리가 필요할 것 같다.

2차 수정

function dataURLtoBlob(dataURL) {
    const arr = dataURL.split(',');
    const matches = arr[0].match(/:(.*?);/);
    
    // MIME 타입이 없을 때 크롭 리셋
    if (!matches || matches.length < 2) {
        const cropperInstance = cropperRef.current.cropper;
		cropperInstance.reset();
    }

    const mime = matches[1];
    const bstr = atob(arr[1]);
    let n = bstr.length;
    const u8arr = new Uint8Array(n);

    while (n--) {
        u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], { type: mime });
}

mime 타입이 없을 때 크롭을 리셋시켜주었다.

오류3

Cannot read properties of null (reading '1')
계속해서 동일한 오류가 발생한다. 현재의 코드를 보면 mime 타입이 없을 경우 크롭을 리셋하는데, 리셋 후에도 아래 코드를 계속 실행하게 된다. const mime = matches[1];이 실행되어서 나는 오류인 것이다.

해결!🎉

function dataURLtoBlob(dataURL) {
    const arr = dataURL.split(',');
    const matches = arr[0].match(/:(.*?);/);
    const mime = matches && matches.length >= 2 ? matches[1] : null;
  
    // MIME 타입이 없을 때의 처리 추가
    if (!mime) {
      const cropperInstance = cropperRef.current.cropper;
      cropperInstance.reset();
      return;
    }
  
    const bstr = atob(arr[1]);
    let n = bstr.length;
    const u8arr = new Uint8Array(n);

    while (n--) {
      u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], { type: mime });
  }

삼항 연산자를 사용하여 matches의 유효성을 검사하고, 유효성을 통과했을 경우 mime 타입을 추출하여 mime에 할당하고 그렇지 않을 경우 null을 반환한다. 그 후에 mime 타입이 null이면 크롭을 리셋하고 함수를 return한다.

📌 참고

이미지 크롭에 관해서는 아래 블로그를 참고했다.
[리액트] 상개발자특 내 마음대로 이미지 잘라서 씀
react에서 이미지를 자르고 압축해보자

profile
Markup Developer🧑‍💻

0개의 댓글