[React] Drag & Drop 이미지 업로드 박스 만들기

박지인·2021년 5월 10일
3

React

목록 보기
1/1
post-thumbnail

목표

아래와 같은 형태의 Drag & Drop 박스를 만들려고 한다. 물론 태그를 클릭한 것과 같은 방식의 업로드 또한 지원할 것이다.

구현 방법

ImagePreview Component

이미지와 삭제 버튼이 담긴 컴포넌트를 만들어준다.

function ImagePreview({ image, deleteFunc }) {
  return (
    <div className="ImagePreview" draggable>
      <img src={image} alt="preview" />
      <div className="icon_container" onClick={deleteFunc}>
        <i className="fas fa-times"></i>
      </div>
    </div>
  );
}

useState, useRef

먼저 사용자가 업로드한 이미지, 그리고 브라우저에 띄워지는 이미지 preview들을 담을 state를 선언한다. Drag & Drop 박스와 input tag를 가리키는 ref들 역시 선언한다.

function ImageUploadBox({ max = 10 }) {
  const [uploadedImages, setUploadedImages] = useState([]);
  const [previewImages, setPreviewImages] = useState([]);
  const uploadBoxRef = useRef();
  const inputRef = useRef();
}

JSX

function ImageUploadBox({ max = 10 }) {
...
  return (
    <div className="ImageUploadBox">
      <label className="drag_or_click" htmlFor={id} ref={uploadBoxRef}>
        <div className="text_box">
          <h3>드래그 또는 클릭하여 업로드</h3>
          <span>권장사항: oooMB 이하 고화질</span>
        </div>
        <div className="icon_box">
          <i className="fas fa-arrow-circle-up"></i>
        </div>
      </label>
      <input type="file" multiple accept="image/*" id={id} ref={inputRef} />
      <div className="preview_wrapper">
        <div className="preview_container">{previewImages}</div>
      </div>
    </div>
  );
}

이후 간단하게 label이 input tag를 향하게 하고, input에 visibility: hidden; 또는 display: none;을 주어서 스타일링을 할 것이다. 그러나 이 경우 접근성 보조 기술은 파일 입력칸을 상호작용할 수 없는 상태로 인식하게 된다. 그러니 가능하면 opacity: 0;을 쓰는 것이 좋다.

useEffect

ImageUploadBox 컴포넌트에 아래의 hook을 추가한다.

useEffect(() => {
  const uploadBox = uploadBoxRef.current;
  const input = inputRef.current;
  
  const handleFiles = (files) => {
    for (const file of files) {
      if (!file.type.startsWith("image/")) continue;
      const reader = new FileReader();
      reader.onloadend = (e) => {
        const result = e.target.result;
        if (result) {
          setUploadedImages((state) => [...state, result].slice(0, max));
        }
      };
      reader.readAsDataURL(file);
    }
  };
  
  const changeHandler = (event) => {
    const files = event.target.files;
    handleFiles(files);
  };
  
  const dropHandler = (event) => {
    event.preventDefault();
    event.stopPropagation();
    const files = event.dataTransfer.files;
    handleFiles(files);
  };
  
  const dragOverHandler = (event) => {
    event.preventDefault();
    event.stopPropagation();
  };
  
  uploadBox.addEventListener("drop", dropHandler);
  uploadBox.addEventListener("dragover", dragOverHandler);
  input.addEventListener("change", changeHandler);
  
  return () => {
    uploadBox.removeEventListener("drop", dropHandler);
    uploadBox.removeEventListener("dragover", dragOverHandler);
    input.removeEventListener("change", changeHandler);
  };
}, [max]);

useEffect(() => {
  const imageJSXs = uploadedImages.map((image, index) => {
    const isDeleteImage = (element) => {
      return element === image;
    };
    const deleteFunc = () => {
      uploadedImages.splice(uploadedImages.findIndex(isDeleteImage), 1);
      setUploadedImages([...uploadedImages]);
    };
    return <ImagePreview image={image} deleteFunc={deleteFunc} key={index} />;
  });
  setPreviewImages(imageJSXs);
}, [uploadedImages]);

하나하나 짚어가며 설명하겠다.

Element 선언

  const uploadBox = uploadBoxRef.current;
  const input = inputRef.current;

이 부분은 아래서 event listener을 add/remove할 Elememt를 설정한 것이다. 이렇게 하지 않고 아래서 다음과 같이 작성하면

    uploadBoxRef.current.addEventListener("drop", dropHandler);
    uploadBoxRef.current.addEventListener("dragover", dragOverHandler);
    inputRef.current.addEventListener("change", changeHandler);

    return () => {
      uploadBoxRef.current.removeEventListener("drop", dropHandler);
      uploadBoxRef.current.removeEventListener("dragover", dragOverHandler);
      inputRef.current.removeEventListener("change", changeHandler);
    };

return 부분에서 다음과 같은 경고가 뜰 것이다.

The ref value 'uploadBoxRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'uploadBoxRef.current' to a variable inside the effect, and use that variable in the cleanup function

다음과 같이 쓰면 해당 경고를 해결할 수 있다.

  const uploadBox = uploadBoxRef.current;
  const input = inputRef.current;

  (중략)

  uploadBox.addEventListener("drop", dropHandler);
  uploadBox.addEventListener("dragover", dragOverHandler);
  input.addEventListener("change", changeHandler);

  return () => {
    uploadBox.removeEventListener("drop", dropHandler);
    uploadBox.removeEventListener("dragover", dragOverHandler);
    input.removeEventListener("change", changeHandler);
  };

Event Handler

    const changeHandler = (event) => {
      event.preventDefault();
      event.stopPropagation();
      const files = event.target.files;
      handleFiles(files);
    };

    const dropHandler = (event) => {
      event.preventDefault();
      event.stopPropagation();
      const files = event.dataTransfer.files;
      handleFiles(files);
    };

    const dragOverHandler = (event) => {
      event.preventDefault();
      event.stopPropagation();
    };

preventDefault(), stopPropagation()을 통해 input tag의 change event, drag & drop의 기본 event 동작을 방지한다. 그리고 changeHandler와 dropHandler에선 event에서 file 리스트를 가져와 handleFiles에 넘겨준다.

Handle Files

const handleFiles = (files) => {
  for (const file of files) {
    if (!file.type.startsWith("image/")) continue;
    const reader = new FileReader();
    reader.onloadend = (e) => {
      const result = e.target.result;
      if (result) {
        setUploadedImages((state) => [...state, result].slice(0, max));
      }
    };
    reader.readAsDataURL(file);
  }
};

위의 changeHandler와 dropHandler에서 넘겨준 파일 리스트를 처리한다. 리스트 안의 모든 파일들에 대해 반복문을 돌며 처리한다. 파일 리스트의 경우 Array의 형태로 보이지만 실재론 Object이다. 단지 key가 0부터 시작하는 정수인 Object일 뿐이다.

이러한 이유로 Array에 사용 가능한 forEach등의 문법은 사용할 수 없다. 위와 같은 방법으로 접근하거나, 리스트 안의 length 속성을 이용해 접근해야 한다.

입력된 파일이 image인지를 먼저 검사한 후, 새로운 FileReader 객체의 onloaded에 이미지가 로드된 후 실행할 작업을 설정한다. 위의 useState hook을 통해 선언한 uploadedImage에 이미지 갯수가 max를 넘지 않도록 넣어준다. 마지막으론 readAsDataURL(file)을 통해 이미지를 load한다.

setUploadedImages((state) => [...state, result].slice(0, max));

이 부분을 아래와 같이 작성하면 입력한 이미지들 중 일부만 state에 저장된다.

setUploadedImages([...uploadedImages, result].slice(0, max));
setUploadedImages(uploadedImages.concat([result]).slice(0, max));

이는 확실하진 않지만 각각의 onloadend event에 등록한 함수의 uploadedImages state는 모두 이미지 입력 전 uploadedImages state를 기준으로 삼고 있으며, 몇몇 이미지가 load되어 uploadedImages가 변경되어도 그 후 load된 이미지가 먼저 load된 이미지가 먼저 set된 uploadedImages를 덮어씌워서 발생하는 문제로 보인다. 첫번째와 같이 함수의 argument로 함수를 넣어주면, 해당 함수가 실행 될 때의 state를 기준으로 삼기 때문에 이러한 문제가 발생하지 않는다.

Set Preview Images

  useEffect(() => {
    const imageJSXs = uploadedImages.map((image, index) => {
      const isDeleteImage = (element) => {
        return element === image;
      };

      const deleteFunc = () => {
        uploadedImages.splice(uploadedImages.findIndex(isDeleteImage), 1);
        setUploadedImages([...uploadedImages]);
      };

      return <ImagePreview image={image} deleteFunc={deleteFunc} key={index} />;
    });
    setPreviewImages(imageJSXs);
  }, [uploadedImages]);

이 useEffect hook은 uploadedImages state에 의존하고 있다. handleFiles 함수에서 setUploadedImages 함수로 인해 state가 변경될 때 마다 해당 함수가 실행될 것이다. uploadedImages에 저장된 모든 이미지들을 대상으로 ImagePreview 컴포넌트를 생성하여 previewImages에 저장한다. deleteFunc는 사용자가 ImagePreview 컴포넌트의 삭제 버튼을 눌렀을 때 uploadedImages에서 해당 이미지를 삭제하는 역할을 한다.

Styling(SCSS)

기본적인 구현은 끝났다. 스타일링만 하면 된다.

@mixin centerFill($width, $direction: row) {
  width: $width;
  display: flex;
  flex-direction: $direction;
  align-items: center;
  justify-content: center;
}

.ImagePreview {
  $size: 104px;

  @keyframes ImagePreview_construct {
    0% {
      opacity: 0;
    }
    100% {
      opacity: 1;
    }
  }

  position: relative;
  width: $size;
  height: $size;
  overflow: hidden;
  animation: ImagePreview_construct 0.5s;

  img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .icon_container {
    $size: 20px;

    @include centerFill(100%);
    position: absolute;
    top: 0;
    right: 0;
    width: $size;
    height: $size;
    margin: 5px;
    background-color: #c4c4c4;
    border-radius: 50%;

    i {
      color: white;
    }
  }
}

.ImageUploadBox {
  @include centerFill(400px, column);

  > * {
    width: 100%;
    border-radius: 3px;
  }

  input {
    display: none;
  }

  label {
    @include centerFill(100%);
    justify-content: space-between;
    background-color: #dedede;
    padding: 20px 50px;

    &:hover {
      background-color: #c8c8c8;
    }

    .text_box {
      display: flex;
      flex-direction: column;
      align-items: center;

      h3 {
        font-size: 14px;
        margin-bottom: 20px;
      }
      span {
        font-size: 10px;
        color: #6d6d6d;
      }
    }

    .icon_box {
      i {
        font-size: 32px;
      }
    }
  }

  .preview_wrapper {
    margin-top: 20px;
    padding: 10px;
    border: 1px solid #b8b8b8;

    .preview_container {
      $height: 240px;

      width: 100%;
      overflow-y: auto;
      height: $height;
      max-height: $height;
      display: grid;
      grid-template-columns: repeat(auto-fill, 104px);
      column-gap: 20px;
      row-gap: 10px;

      &::-webkit-scrollbar {
        width: 10px;
        height: 10px;
      }
      &::-webkit-scrollbar-thumb {
        background: #7c7c7c;
        border-radius: 10px;
      }
      &::-webkit-scrollbar-thumb:hover {
        background: #a6a6a6;
      }
      &::-webkit-scrollbar-track {
        background: #ededed;
        border-radius: 10px;
      }
    }
  }
}

완성! 그럴싸한 이미지 업로드 박스를 만들었다!

0개의 댓글