본문 바로가기
React.js

커버 이미지 업로드 구현하기 ( react-dropzone + react-image-crop )

by Zih0 2022. 2. 18.

커버이미지

1. 노션

2. ko-fi

 

개인 토이프로젝트를 진행하면서 유저 페이지에 커버 이미지를 넣으면 좋겠다고 생각했습니다.

그래서 커버 이미지 업로드하는 걸 구현해봤습니다!

 

ko-fi의 커버 이미지 업로드 창입니다.

 

이 형태를 보고, 이전에 사용했던 react-dropzone 라이브러리를 쓰면 좋겠다고 생각했습니다.

 

사진 업로드

저도 ko-fi처럼 모달을 만들어서 이미지를 업로드하도록 했습니다.

 

1. 모달 만들기

React Portal을 이용해서 모달을 만들었습니다.

해당 내용은 이번 포스팅의 목적이 아니니까 스킵하도록 하겠습니다.

 

모달에 이미지를 업로드 시킬 Dropzone 컴포넌트와

사용자가 이미지를 크롭할 수 있는 ImageCrop 컴포넌트를 만들어서 import 시켰습니다.

import React, { useState } from "react";
import styled from "styled-components";
import Dropzone from "../Dropzone";
import ImageCrop from "../ImageCrop";

const Container = styled.div`
  width: 600px;
  padding: 3rem;
  background-color: #fff;
  z-index: 11;
  display: flex;
  flex-direction: column;
  gap: 2rem;
  border-radius: 16px;
`;

interface IImageUploadModalProps {
  closeModal: () => void;
}

function ImageUploadModal({ closeModal }: IImageUploadModalProps) {
  const [image, setImage] = useState("");

  const onChangeImage = (uploadedImage: File) => {
    setImage(URL.createObjectURL(uploadedImage));
  };

  return (
    <Container>
      {image ? (
        <ImageCrop image={image} closeModal={closeModal} />
      ) : (
        <Dropzone onChangeImage={onChangeImage} />
      )}
    </Container>
  );
}

export default ImageUploadModal;

해당 모달에서 image state를 만들었고, Dropzone에는 onChangeImage 함수를 props로 전달해주었습니다.

 

2. Dropzone

react-dropzone 공식문서 예제를 바탕으로 작성했습니다.

import React, { useCallback } from "react";
import styled from "styled-components";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useDropzone } from "react-dropzone";

interface IDropzoneProps {
  onChangeImage: (file: File) => void;
}

function Dropzone({ onChangeImage }: IDropzoneProps) {
  const onDrop = useCallback(
    (files: File[]) => {
      onChangeImage(files[0]);
    },
    [onChangeImage]
  );

  const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });

  return (
    <Container {...getRootProps()}>
      <input {...getInputProps()} />
      {isDragActive ? (
        <FontAwesomeIcon icon={faSpinner} spin={true} />
      ) : (
        <p>이미지를 드래그하거나 클릭하여 첨부해주세요.</p>
      )}
    </Container>
  );
}

const Container = styled.div`
  width: 100%;
  height: 10rem;
  border: 3px dashed ${({ theme }) => theme.color.gray};
  display: flex;
  justify-content: center;
  align-items: center;

  svg {
    font-size: 3rem;
  }
`;

export default Dropzone;

border dashed를 이용해서 나름대로 스타일링을 해봤습니다.

 

그리고 useDropzone를 이용해 가져온 isDragActive는 현재 Dropzone에 파일을 드래그한 상태인지 알려줍니다.

드래그 중이면 spin 이미지를 돌아가도록 스타일링했습니다.

 

그리고 onDrop 함수에는 이미지를 업로드하고 나서의 콜백함수를 지정해주면 됩니다.

저는 모달로부터 전달받은 onChangeImage 함수를 이용해 image state에 업로드한 사진을 넣어주었습니다.

 

 

3. ImageCrop

이미지 크롭을 하는 이유는 대부분의 커버이미지는 가로가 세로에 비해 긴 편입니다. 

그렇기 때문에, 사용자가 원하는 영역을 커버에 보이기 위해 넣고자 했습니다.

 

react-image-crop 라이브러리를 사용했고 기본 사용 방식은 아래와 같습니다.

import React, { useState } from "react";
import ReactCrop, { Crop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";

interface IImageCropProps {
  image: string;
}

function ImageCrop({ image }: IImageCropProps) {

  const [crop, setCrop] = useState<Crop>({
    aspect: 1280 / 300,
    x: 0,
    y: 0,
    width: 504,
    height: 118,
    unit: "px",
  });
  const [completedCrop, setCompletedCrop] = useState<Crop>({
    aspect: 1280 / 300,
    x: 0,
    y: 0,
    width: 504,
    height: 118,
    unit: "px",
  });

  return (
    <>
      <ReactCrop
        src={image}
        crop={crop}
        onChange={(crop) => setCrop(crop)}
        onComplete={(crop) => setCompletedCrop(crop)}
      />
    </>
  );
}

export default ImageCrop;

crop에서 초기 크롭 영역 위치와 비율, 사이즈를 지정해줄 수 있습니다.

그리고 onChange는 크롭 영역을 움직이거나 지정할 때 계속 실행되고,

onComplete는 크롭 영역을 지정하여 멈췄을 때 실행되게 됩니다.

 

 

저는 가로 1280 세로 300 비율로 초기 설정을 해주었고, 아래와 같이 뜨는걸 확인할 수 있습니다. 

 

 

해당 부분만 서버에 업로드하기 위해, 해당 부분을 canvas에 옮기고, canvas를 blob으로 만들어 저장시키는 방식을 사용했습니다!

image-crop -> canvas -> blob -> server

 

image -> canvas

먼저 crop된 이미지를 canvas에 넣어보겠습니다.

우선 canvas와 img의 ref를 만들어줍니다.

img의 ref도 만드는 이유는 canvas의 width와 height을 지정해주기위함입니다.

... 생략

function ImageCrop({ image, closeModal }: IImageCropProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const imgRef = useRef<HTMLImageElement | null>(null);
  
  const [crop, setCrop] = useState<Crop>({
    aspect: 1280 / 300,
    x: 0,
    y: 0,
    width: 504,
    height: 118,
    unit: "px",
  });
  const [completedCrop, setCompletedCrop] = useState<Crop>({
    aspect: 1280 / 300,
    x: 0,
    y: 0,
    width: 504,
    height: 118,
    unit: "px",
  });
  
  const onLoad = useCallback((img) => {
    imgRef.current = img;
  }, []);
  
  // 크롭 영역 canvas에 넣기
  const createCanvas = () => {
    if (!completedCrop || !canvasRef.current || !imgRef.current) {
      return;
    }
    const ctx = canvasRef.current.getContext("2d");
    if (!ctx) return;

    const crop = completedCrop;

    const scaleX = imgRef.current.naturalWidth / imgRef.current.width;
    const scaleY = imgRef.current.naturalHeight / imgRef.current.height;
    const pixelRatio = window.devicePixelRatio;

    canvasRef.current.width = crop.width * pixelRatio * scaleX;
    canvasRef.current.height = crop?.height * pixelRatio * scaleY;

    ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
    ctx.imageSmoothingQuality = "high";

    ctx.drawImage(
      imgRef.current,
      crop.x * scaleX,
      crop.y * scaleY,
      crop.width * scaleX,
      crop.height * scaleY,
      0,
      0,
      crop.width * scaleX,
      crop.height * scaleY
    );
  };
  
  return (
    <>
      <ReactCrop
        src={image}
        crop={crop}
        onImageLoaded={onLoad}
        onChange={(newCrop) => setCrop(newCrop)}
        onComplete={(crop) => setCompletedCrop(crop)}
      />

      <Button onClick={onChangeCoverImage}>저장하기</Button>
      <Canvas ref={canvasRef}></Canvas>
    </>
  );
}

// canvas 안보이도록 width,heigh 0 지정해주었습니다.
const Canvas = styled.canvas`
  width: 0;
  height: 0;
`;

export default ImageCrop;

 

canvas -> blob

다음으로는 canvas를 blob으로 만드는 방법입니다.

toBlob()이라는 메서드를 사용합니다.

toBlob() 메서드의 파라미터는 아래와 같습니다.

toBlob(callback, type, quality)

콜백함수를 필수로 입력받고, type과 quality를 optional로 입력받습니다.

저장하기 버튼을 클릭하면 해당 함수가 실행되어 blob으로 만들고, 서버에 저장시키도록 했습니다.

... 생략

  const onChangeCoverImage = () => {
  	createCanvas();
    
    if (!canvasRef.current) return;
    
    // canvas를 blob 형태로 만들어서 이미지 업로드하기
    canvasRef.current.toBlob(
      (blob: Blob | null) => uploadCoverImage(blob),
      "image/jpeg",
      0.95
    );
  };
  
... 중략

  return (
    <>
      ...
      <Button onClick={onChangeCoverImage}>저장하기</Button>
      ...
    </>
  );
}

전체 코드

import React, {
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";
import ReactCrop, { Crop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import styled from "styled-components";
import { AuthContext } from "../../contexts/AuthContext";
import { API } from "../../firebase/api";
import Button from "../Button";

interface IImageCropProps {
  image: string;
  closeModal: () => void;
}

function ImageCrop({ image, closeModal }: IImageCropProps) {
  const { user, setUser } = useContext(AuthContext);

  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const imgRef = useRef<HTMLImageElement | null>(null);
  const [crop, setCrop] = useState<Crop>({
    aspect: 1280 / 300,
    x: 0,
    y: 0,
    width: 504,
    height: 118,
    unit: "px",
  });
  const [completedCrop, setCompletedCrop] = useState<Crop>({
    aspect: 1280 / 300,
    x: 0,
    y: 0,
    width: 504,
    height: 118,
    unit: "px",
  });

  const uploadCoverImage = async (blob: Blob | null) => {
    if (!blob) return;

    const url = await saveImage(blob);
    const updatedUserData = Object.assign(
      { ...user },
      {
        coverImgUrl: url,
      }
    );
    await API.setUserCover(user.creatorId, url);
    setUser(updatedUserData);
    closeModal();
    alert("커버 사진이 변경되었습니다.");
  };

  const onChangeCoverImage = () => {
    createCanvas();

    if (!canvasRef.current) return;

    canvasRef.current.toBlob(
      (blob: Blob | null) => uploadCoverImage(blob),
      "image/jpeg",
      0.95
    );
  };

  const saveImage = async (blob: Blob | null) => {
    if (!blob) return;

    const url = await API.uploadUserCover(blob);
    return url;
  };

  const onLoad = useCallback((img) => {
    imgRef.current = img;
  }, []);

  const createCanvas = () => {
    if (!completedCrop || !canvasRef.current || !imgRef.current) {
      return;
    }
    const ctx = canvasRef.current.getContext("2d");
    if (!ctx) return;

    const crop = completedCrop;

    const scaleX = imgRef.current.naturalWidth / imgRef.current.width;
    const scaleY = imgRef.current.naturalHeight / imgRef.current.height;
    const pixelRatio = window.devicePixelRatio;

    canvasRef.current.width = crop.width * pixelRatio * scaleX;
    canvasRef.current.height = crop?.height * pixelRatio * scaleY;

    ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
    ctx.imageSmoothingQuality = "high";

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

  return (
    <>
      <ReactCrop
        src={image}
        crop={crop}
        onImageLoaded={onLoad}
        onChange={(newCrop) => setCrop(newCrop)}
        onComplete={(crop) => setCompletedCrop(crop)}
      />

      <Button onClick={onChangeCoverImage}>저장하기</Button>
      <Canvas ref={canvasRef}></Canvas>
    </>
  );
}

const Canvas = styled.canvas`
  width: 0;
  height: 0;
`;

export default ImageCrop;

 

 

결론

사진이 잘 크롭되어 저장되었습니다 :)

편리한 두 라이브러리를 이용해 금방 원하는 기능들을 만들었네요

react-image-crop 라이브러리를 사용하면서 canvas를 다루는 방법까지 알게되서 스스로 얻어가는게 좀 많다고 느꼈습니다.

 

 

ref :

https://react-dropzone.js.org/https://github.com/DominicTobias/react-image-crop#readme

https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob

댓글