마이페이지에서 프로필 이미지를 업로드할 때 사용자가 원하는 부분만 크롭하여 업로드할 수 있도록 구현해보려고 한다. 나는 react-cropper 라이브러리를 사용했다.
💡react-cropper
대표적인 JavaScript 크로핑 라이브러리인 cropper.js를 리액트용으로 포장한 버전이다.
다양한 기능과 설정 옵션을 제공한다.
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);
// 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>
// 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>
);
// 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);
이렇게만 하면된다.
// 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 });
}
Uncaught TypeError: Cannot read properties of null (reading '1')
arr[0].match(/:(.*?);/)
에서 정규 표현식에 매칭되는 부분이 없을 때 match
가 null
을 반환한다. 그래서 [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 });
}
Uncaught Error: Invalid MIME type in data URL
수정했으나 여전히 동일한 오류가 발생하고, mime
타입이 없을 경우 에러를 화면에 보여주는 게 끝이다. 오류의 원인은 data URL
이 유효한 형식이 아니거나, data URL
에 mime
타입이 포함되어 있지 않아서인데, 오류가 발생했을 경우 다른 처리가 필요할 것 같다.
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
타입이 없을 때 크롭을 리셋시켜주었다.
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에서 이미지를 자르고 압축해보자