React-image-crop 사용법

Imnottired·2023년 5월 18일
4

최근 이상형 월드컵이라는 프로젝트를 시작하면서, 이미지 이슈가 생겼다.
크롤링해서 이미지를 받아오는데, 가로형과 세로형이 무작위로 오기 때문에 얼굴이 제대로 나오지 않고 깨지고, 비율이 계속 바뀌어서 보기가 좋지않았다.

이러한 이슈를 해결하기위해 사진을 자르고 저장하는 방법을 찾게 되었다.

유저가 해당 사이즈에 맞게 사진을 자르고 저장해서 일관된 이미지를 가져오고
카카오톡 프로필처럼 설정할 수 있게 만드는 것이다.

그래서 그러한 이슈를 해결하기위해 React-image-crop에 대해 알아보았다.




먼저 crop이란 이미지의 일부분을 잘라내는 것을 의미한다.

React-image-crop은 말처럼 이미지를 잘라내는 라이브러리이다.

실제 예제를 보자.

파일을 선택하면


위처럼 나오는데, 첫번째는 풀화면, 2번째는 자른 화면이다.

위 화면을 통해 사진을 자르면 아래에서 확인을 하고 crop을 다운로드 받는 형식이다.

그리고 그 외에도 사진의 스케일을 키우거나, 돌릴수도 있다.

Toggle aspect on/off의 의미는 비율을 바꿀 것인지 안바꿀것인지에 대해 묻는다.
해당 버튼이 on이면 8방위로

크기 변경이 가능한 버튼이 생기는데, off가 되면 aspect를 유지한 상태에서만 크기 변경이 가능하다.

aspect: crop 영역의 가로 대 세로 비율


목표

이 라이브러리 수정해야하는 이유는 사진을 로컬에서 받아오고, 잘라서 로컬에 저장하는 방식을 사용하였지만, 추가해야하는 방식은 크롤링해서 받아온 사진을 자르고 사용할 수 있게 추가해야한다.

코드 분석

https://codesandbox.io/s/react-image-crop-demo-with-react-hooks-y831o?file=/src/App.tsx:423-709
위 코드 펜을 들어가면 예제를 볼 수 있는데,
이 예제들을 살펴보면서 알아보겠다.

 function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
    if (e.target.files && e.target.files.length > 0) {
      setCrop(undefined); //이미지 간 자르기 미리보기를 업데이트합니다
      const reader = new FileReader();
      reader.addEventListener("load", () =>
        setImgSrc(reader.result?.toString() || "")
      );
      reader.readAsDataURL(e.target.files[0]);
    }
  }
  
  
  <input type="file" accept="image/*" onChange={onSelectFile} />

먼저 input type file에 대해 보면

파일 선택창을 의미한다.

옆을보면 22222.jpeg가 보이는데
여기서 accept="image/*"는 파일 유형을 의미한다.

설정을 하면

image 파일만 선택할 수 있다.


하지만 이를 제거하고 보면 파일 유형이 다양해진 것을 볼 수 있다.
(zip 추가됨)

onChage의 onSelectFile은 선택한 이미지를 받아오는 역할을 한다.

onSelctFile


 function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
    if (e.target.files && e.target.files.length > 0) {
      setCrop(undefined); //이미지 간 자르기 미리보기를 업데이트합니다
      const reader = new FileReader();
      reader.addEventListener("load", () =>
        setImgSrc(reader.result?.toString() || "")
      );
      reader.readAsDataURL(e.target.files[0]);
    }
  }
  

먼저 받아온 파일이 있는지 확인을 한다.

e.target.files을 콘솔로그에 찍으면

위와 같은 화면을 보여주는데,
사진의 이름과 이미지 크기까지 알려준다.

이렇게 사진이 들어오면
setCrop을 초기화 하여 새롭게 자를 공간을 만들고,
fileReader 객체를 선언하여서 읽어줄 준비를 한다.
그리고 여기에 load 이벤트를 추가하는데 load는 파일 업로드가 완료되었을 때를 의미한다.

reader.readAsDataURL(e.target.files[0])는 파일을 DataURL로 읽고 reader.result에 문자열로 값을 넘겨준다.

reader.result.toString()은 파일의 내용을 Data URL로 변환하여 브라우저에 저장해주고 이를 setImgSrc에 저장한다.


(DataURL 콘솔로그를 찍어보았다.)

reader.addEventListener("load", () =>
        setImgSrc(reader.result?.toString() || "")
      );
      reader.readAsDataURL(e.target.files[0]);

그런데 현재는 이벤트 등록 -> 데이터 읽기 여서 이벤트를 등록하는 것보다 먼저 읽는 것이 우선시하면 좋을거 같아서 순서를 바꾸었다.

거기다가 문자열이어서 toString도 할 필요 없어서 제거하였다.
(전과 후도 비교해보았는데 차이가 없었다)

가능성은 없지만 내맘대로 바꾼 것은 나중에 문제가 될 수도 있을 것 같아서, bold처리하였다.

onImageLoad


function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
    if (aspect) {
      const { width, height } = e.currentTarget;
      setCrop(centerAspectCrop(width, height, aspect));
    }
  }
  
        <Image
            ref={imgRef}
            alt="Crop me"
            src={imgSrc}
            width={500}
            style={{ transform: `scale(${scale}) rotate(${rotate}deg)` }}
            height={500}
            onLoad={onImageLoad}
          />
          

onLoad란 이미지가 로드 완료 될 떄 호출된다.

이미지를 받으면, aspect가 존재할 경우 자른다.
aspect는 가로 세로 비율을 나타낸다.
이후 centerAspectCrop 맞추어서 사진을 자른다.

centerAspectCrop

function centerAspectCrop(
  mediaWidth: number,
  mediaHeight: number,
  aspect: number
) {
  //자르기 도구
  return centerCrop(
    //중앙에 배치함
    makeAspectCrop(
      {
        unit: "%",
        width: 50, //초기값 드래그 범위
        height: 50,
      },
      aspect,
      mediaWidth,
      mediaHeight
    ),
    mediaWidth,
    mediaHeight
  );
}

1단계

makeAspectCrop 함수는 초기 드래그 범위를 나타낸다.

makeAspectCrop의 첫번째 인자는 범위를 나타내고,

mediaWidth와 mediaHeight는 이미지의 원래 크기를 나타낸다.

makeAspectCrop 함수는 이 정보들을 기반으로 크롭 정보(자르는 범위)를 생성하고 반환합니다.

2단계

centerCrop 함수는 첫 번째 인자(makeAspectCrop)로 이전 단계에서 생성된 크롭 정보를 전달합니다.
두 번째와 세 번째 인자로는 이미지의 너비(mediaWidth)와 높이(mediaHeight)를 전달합니다.
centerCrop 함수는 이 정보들을 기반으로 크롭 정보(자르는 영역)를 가운데 정렬하여 반환합니다.

요약하면 centerAspectCrop은
초기 드래그 범위와 목표 비율을 기반으로 크롭 정보를 생성하고, 그 정보를 가운데 정렬하여 크롭 정보를 반환하는 동작을 수행합니다.

onDownloadCropClick

  function onDownloadCropClick() {
    if (!previewCanvasRef.current) {
      throw new Error("Crop canvas does not exist");
    }

    previewCanvasRef.current.toBlob((blob) => {
      if (!blob) {
        throw new Error("Failed to create blob");
      }
      if (blobUrlRef.current) {
        URL.revokeObjectURL(blobUrlRef.current);
      }
      blobUrlRef.current = URL.createObjectURL(blob);
      hiddenAnchorRef.current!.href = blobUrlRef.current;
      hiddenAnchorRef.current!.click();
    });
  }
  
    <div>
            <canvas
              ref={previewCanvasRef}
              style={{
                border: "1px solid black",
                objectFit: "contain",
                width: completedCrop.width,
                height: completedCrop.height,
              }}
            />
          </div>
          

사진을 자른 부분(crop)이 canvas 부분인데 자른화면의 미리보기(previewCanvasRef) 존재여부를 확인한다.

toBlob 메서드는 캔버스의 현재 상태를 Blob 형식으로 반환하는 메서드입니다.

Blob은 바이너리 데이터를 나타내는 객체로, 이미지와 같은 멀티미디어 데이터를 다룰 때 주로 사용됩니다.

만약에 blobUrlRef에 값이 있다면, URL.revokeObjectURL를 이용하여 이전 값을 해제합니다.(값이 있을 경우 지워줍니다)

blobUrlRef.current: string = URL.createObjectURL(blob);

URL.createObjectURL을 사용하여서 Blob 객체를 매개변수로 받아 해당 Blob를 나타내는 고유한 URL을 생성합니다.

      hiddenAnchorRef.current!.href = blobUrlRef.current;
    hiddenAnchorRef.current!.click();

a태그인 useRef를 활용하여서 다운로드 링크를 담아주고,
클릭 메서드를 실행하여서 다운로드를 도와줍니다.

요약하면 onDownloadCropClick은
Blob 객체를 생성한 후, 이전 Blob URL을 해제하고 새로운 Blob URL을 생성하여 다운로드 링크를 설정하고, 다운로드를 실행하는 동작을 수행합니다.

디바운스

export function useDebounceEffect(
  fn: () => void,
  waitTime: number,
  deps: DependencyList | undefined = []
) {
  useEffect(() => {
    const t = setTimeout(() => {
      fn();
    }, waitTime);

    return () => {
      clearTimeout(t);
    };
  }, [fn, waitTime, deps]); // 이 부분에서 빈 배열을 기본값으로 설정합니다.
}


  useDebounceEffect(
    async () => {
      if (
        completedCrop?.width &&
        completedCrop?.height &&
        imgRef.current &&
        previewCanvasRef.current
      ) {
        // We use canvasPreview as it's much faster than imgPreview.
        canvasPreview(
          imgRef.current,
          previewCanvasRef.current,
          completedCrop,
          scale,
          rotate
        );
      }
    },
    100,
    [completedCrop, scale, rotate]
  );
  
  
  

첫번째 함수를 받아서 debounce 시켜준다.
디바운스를 하는 이유는 이미지 자르기 칸을 수정할 경우에 시켜주는 것이다.

위 그림처럼 자르기를 늘렸다가 줄였다가 멈추어야 아래 이미지가 업데이트 된다.
만약에 디바운스를 걸어주지 않았으면, 조금만 움직여도 이벤트가 계속해서 발생해 렉이 걸렸을 것이다.

useDebounceEffect라는 함수를 보면 최근에 디바운스를 구현한 적이 있는데, 이보다 더 세련되고 let 사용없이 구현한 점이 인상깊다.
React는 이렇게 구현해야하는데 저번에 직접 구현한 것보다 더 세련되었다.
보물창고에 넣어서 나중에 사용하겠다.

이어서 코드의 if문을 설명하면

completedCrop?.width &&
completedCrop?.height &&
imgRef.current &&
previewCanvasRef.current

차례대로
이미지 크롭 작업외 완료된후
이미지가 존재하는 경우,
미리보기가 존재하는 경우다.

디바운스 안에 있는 함수인 canvasPreview를 보면

canvasPreview(이미지 자르기)

import { PixelCrop } from "react-image-crop";

const TO_RADIANS = Math.PI / 180;

export async function canvasPreview(
  image: HTMLImageElement,//이미지
  canvas: HTMLCanvasElement,//미리보기
  crop: PixelCrop,//자른 화면
  scale = 1,
  rotate = 0
) {
  const ctx = canvas.getContext("2d");

  if (!ctx) {
    throw new Error("No 2d context");
  }

  const scaleX = image.naturalWidth / image.width;
  const scaleY = image.naturalHeight / image.height;
  
  // devicePixelRatio slightly increases sharpness on retina devices
  // at the expense of slightly slower render times and needing to
  // size the image back down if you want to download/upload and be
  // true to the images natural size.
  const pixelRatio = window.devicePixelRatio;
  // const pixelRatio = 1

// 기타 부분 생략
}

canvasPreview는 말 그대로 이미지 잘라주는 역할을 한다.
간단히 요약하면 2d를 그리기 위해 context를 가져오고

naturalwidth와 width가 있는데 natural은 실제 너비를 의미하고
width는 그릴 너비를 의미한다.
이를 scaleX로 계산한다

scaleX, scaleY, image.naturalWidth, image.naturalHeight, image.width, image.height를 찍어보면
4.266666666666667 4.266666666666667 2048 2048 480 480
이렇게 나온다.

window.devicePixelRatio는

고해상도(Retina) 디스플레이에서 이미지의 선명도를 약간 향상시키는 동시에, 그리기 시간이 약간 더 소요되고, 다운로드/업로드 시 원본 크기를 유지하려면 이미지 크기를 다시 조정해야 한다는 설명이 주석으로 달려있다.

이후 부분을 보면

  canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
  canvas.height = Math.floor(crop.height * scaleY * pixelRatio);

canvas.width와 canvas.height는 crop은 자른 값이고, scale은 얼만큼 확대할지에 관한 이야기이다. pixelRatio는 밀도를 의미하는데 실제로 곱하는데 의미가 없다.
왜냐하면 값이 1이다.
그리고 2로 키워봤더니 scale같은 효과가 나왔다.
width 값을 5로 height 같은 경우에는 2를 곱했더니 아래 그림처럼 나왔다.


소수를 곱했더니 오히려 커진다.

소수를 곱해도 차이가 없는 것을 보고, 이것은 이미지 크기를 조절할 수 있는 역할이고,
큰수를 곱할수록 작아진다.

pixelRatio에 대해 호기심이 생겨서 control 이용해 확대해보니 확대나 축소를 하면 값이 변동되지만,
그때 잠시 뿐이다. 변경값도 들어오지 않기때문에, 크게 의미가 없다고 느꼈다.
하지만 확대 축소 값을 참조하니 내가 모르는 변수가 있을 것이라 생각하여 지우지 않았다.

  ctx.scale(pixelRatio, pixelRatio);
  ctx.imageSmoothingQuality = "high";

  const cropX = crop.x * scaleX;
  const cropY = crop.y * scaleY;

  const rotateRads = rotate * TO_RADIANS;
  const centerX = image.naturalWidth / 2;
  const centerY = image.naturalHeight / 2;

순서대로 확대비율,
이미지 부드러운 품질을 의미한다.

그리고 자른 값을 scale 만큼 곱해준다.

crop은 자른 값이 캔버스 중앙에 오도록 도와주는 역할을 한다.

rotateRads는 회전 비율이다

center는 그림의 꼭지점이 원점으로 오게하여서 rotate를 도와주는 역할을 한다.
(rotate를 하려면 꼭지점을 중심으로 돌린다.)

ctx.save();

  // 5) Move the crop origin to the canvas origin (0,0)
  ctx.translate(-cropX, -cropY);
  // 4) Move the origin to the center of the original position
  ctx.translate(centerX, centerY);
  // 3) Rotate around the origin
  ctx.rotate(rotateRads);
  // 2) Scale the image
  ctx.scale(scale, scale);
  // 1) Move the center of the image to the origin (0,0)
  ctx.translate(-centerX, -centerY);
  ctx.drawImage(
    image,
    0,
    0,
    image.naturalWidth,
    image.naturalHeight,
    0,
    0,
    image.naturalWidth,
    image.naturalHeight
  );

  ctx.restore();
}

ctx.save(): 현재 그래픽 상태를 저장합니다.

이제부터 이미지를 이동시키는데 차근 차근 하나씩 보자

먼저 아래 내용을 적용 시키지 않으면

이 그림이다.

ctx.translate(-cropX, -cropY): 이미지를 원하는 위치로 이동시킵니다.
crop을 사용하여서 이미지를 중점으로 데리고 온다.

ctx.translate(centerX, centerY): 이미지를 원래의 중심으로 이동시킵니다.
center를 통해 이미지의 끝을 원점에 맞춘다. 이는 이미지를 rotate하기 전에 준비 동작이다.

ctx.rotate(rotateRads): 회전을 의미합니다.

ctx.scale(scale, scale): 확대/축소를 의미합니다.

ctx.translate(-centerX, -centerY);
회전 확대 동작을 실시하고 나서 원위치 시킨다.

ctx.drawImage(...): 변환된 이미지를 캔버스에 그립니다.

ctx.restore(): 이전의 그래픽 상태를 복원합니다.
일반적으로 변환 행렬이나 그래픽 속성들을 임시로 변경한 후에는 ctx.restore()를 호출하여 이전 상태로 돌아가는 것이 좋다. 이를 통해 변환 및 그리기 작업 간의 독립성을 유지하고 예상치 못한 결과를 방지할 수 있다.

위 내용을 이해하기 위해서 수 많은 반복이 이루어졌다.. 어렵다




마무리

코드펜을 통해 예제를 분석하고 커스텀할 예정이었다.
커스텀하려면 시간이 걸릴 것같아서 미뤄두고 이제서야 하게되었다.

파일을 직접 받아오고 작업한 적이 없어서 하나 하나 이해하는데 오래 걸렸고 고생했지만 커스텀할 자신이 생겨서 만족스럽다.

이제 여기에 파일을 직접 넣는 선택지와 크롤링을 통해 넣는 선택지를 제공하고
이후 편집을 통해 올바르게 사진을 넣는 작업을 추가할 것이다.

profile
새로운 것을 배우는 것보다 정리하는 것이 중요하다.

4개의 댓글

comment-user-thumbnail
2023년 5월 21일

전 프로필 사진 수정할 때 깨지는대로 내버려뒀는데 나중에 적용해보고싶네요! 고생하셨습니다 ㅎㅎ

답글 달기
comment-user-thumbnail
2023년 5월 21일

오우... 고생하셨습니다 !! 오늘도 유용한 라이브러리 감사합니당

답글 달기
comment-user-thumbnail
2023년 5월 21일

와우 라이브러리를 수정해서 사용하신거였군요.. 엄청 고생하셨네요 👏

답글 달기
comment-user-thumbnail
2023년 5월 21일

저번에 얘기하신 문제를 해결하기 위한 라이브러리군요 기다리고 있었습니다. 활용방법도 보고 저도 배워야겠습니다. 잘보고 갑니당 ㅎㅎ

답글 달기