개발일기/Web

[React] 리액트에서 Drag&Drop으로 파일 업로드하기

DongKeun2 2023. 6. 27. 15:21

파일 업로드를 할 때, Input태그를 활용하여 기존의 선택 방식이 아닌
영역을 지정해 drag & drop로 업로드하는 방법을 알아보겠습니다.

 

Input과 Label 연결시키기

일단 파일을 업로드시킬 영역을 지정합니다.

input 대신 사용할 커스텀 버튼을 만들 때 처럼 label을 활용하여 영역을 설정합니다.

// Upload.tsx
const Upload = () => {
  return (
    <div className="UploadContainer">
      <div className={`${isDragging ? 'UploadWrapperActive' : 'UploadWrapper'}`}>
        <label className="UploadArea" htmlFor="fileUpload">
          <div className="PlaceHolderText">Drag&Drop File Here</div>
        </label>
      </div>

      <div className="FileContainer">
        // 첨부된 파일 리스트
      </div>

      <input
          type="file"
          id="fileUpload"
          style={{ display: 'none' }}
          multiple={true}
      />
    </div>
  );
};

export default Upload;

inputidlabelhtmlfor을 연결시켜 주고 입맛에 맞게 css처리합니다.

 

이벤트 등록

useEffect를 활용해 이벤트 처리를 합니다.

  const handleDragIn = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDragOut = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDragOver = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDrop = useCallback(
    (e: DragEvent): void => {
      e.preventDefault();
      e.stopPropagation();

      onChangeFiles(e);
    },
    [onChangeFiles]
  );

  const addDragEvents = useCallback((): void => {
    if (dragRef.current !== null) {
      dragRef.current.addEventListener('dragenter', handleDragIn);
      dragRef.current.addEventListener('dragleave', handleDragOut);
      dragRef.current.addEventListener('dragover', handleDragOver);
      dragRef.current.addEventListener('drop', handleDrop);
    }
  }, [handleDragIn, handleDragOut, handleDragOver, handleDrop]);

  const removeDragEvents = useCallback((): void => {
    if (dragRef.current !== null) {
      dragRef.current.removeEventListener('dragenter', handleDragIn);
      dragRef.current.removeEventListener('dragleave', handleDragOut);
      dragRef.current.removeEventListener('dragover', handleDragOver);
      dragRef.current.removeEventListener('drop', handleDrop);
    }
  }, [handleDragIn, handleDragOut, handleDragOver, handleDrop]);

  useEffect(() => {
    addDragEvents();
    return () => removeDragEvents();
  }, [addDragEvents, removeDragEvents]);

모든 이벤트에는 preventDefault() stopPropagation() 메서드 처리를 해줘야 합니다.

해주지 않으면 파일 드래그 드롭 시 브라우저 기본 동작에 의해 브라우저에서 열리게 됩니다.

 

 

파일 업로드 이벤트 처리

이제 드래그 이벤트를 등록했으니, 원하는 영역에 파일을 드래그 했을 때,

State에 원하는 형태로 저장하는 단계입니다.

 

드래그 이벤트를 동작시킬 영역에 useRef를 활용합니다.

const dragRef = useRef<HTMLLabelElement | null>(null);

const handleLabelClick = (e: any) => {
    e.preventDefault();
  };

 return (
    <div className="UploadContainer">
      <div className={`${isDragging ? 'UploadWrapperActive' : 'UploadWrapper'}`}>

      // label에 dragRef 연결, onClick 방지
        <label className="UploadArea" htmlFor="fileUpload" ref={dragRef} onClick={handleLabelClick}>
          <UploadFileIcon></UploadFileIcon>
          <div className="PlaceHolderText">Drag&Drop File Here</div>
        </label>
      </div>

      <input
          type="file"
          id="fileUpload"
          style={{ display: 'none' }}
          multiple={true}
      />
    </div>
  );
};

또한 드래그로 업로드만 가능하게 하기 위해 저는 Click 이벤트를 막았습니다.

 

 

먼저 file의 구분을 위해 interface를 설정합니다.

import { ChangeEvent, useCallback, useRef, useState, useEffect } from 'react';
...

// 관리하고 싶은 포맷 설정
interface FileFormat {
  id: number;
  content: File;
}

const Upload = () => {
    ...
}

이제 드롭한 데이터를 저장할 State를 만들어 줍니다.

이전에 작성한 handleDrop을 타고 onChangeFiles 함수를 실행시켜 파일을 files에 담아줍니다.

const [files, setFiles] = useState<FileFormat[]>([]);

const onChangeFiles = useCallback(
    (e: ChangeEvent<HTMLInputElement> | any): void => {
      let selectFiles: File[] = [];

      // 드래그로 올린 파일 구분
      e.type === 'drop' ? (selectFiles = e.dataTransfer.files) : (selectFiles = e.target.files);

      //원하는 형태로 files에 담기 위한 반복문
      let tempFiles: FileFormat[] = files;
      for (const file of selectFiles) {
        tempFiles = [
          ...tempFiles,
          {
            id: fileId.current++,
            content: file,
          },
        ];
      }
      setFiles(tempFiles);
    },
    [files]
  );

🔥한 가지 주의할 점

드래그로 받아오는 파일은 e.target.files로 읽지 않습니다.

드래그에 잡혀있는 파일을 e.dataTransfer.files에서 읽어옵니다.

 

DataTransfer: files property - Web APIs | MDN

The files property of DataTransfer objects is a list of the files in the drag operation. If the operation includes no files, the list is empty.

developer.mozilla.org

 

물론 지금은 클릭으로 파일을 첨부하는 것을 막아두었지만, 기존 코드와 비교해서 무엇이 다른지 알아보기 위해

onChangeFiles 콜백 함수에서 두 가지 방식을 모두 다루었습니다.

 

그리고 e.dataTransfer.files 이나 e.target.files는 모두 FileList로 받아집니다.

그렇기 때문에 임의로 만든 FileFormat에 맞춰 파일들을 담아주기 위해

for문을 돌리려면 FileList를 배열로 바꾸어야합니다.

그렇기 때문에 위 코드와 같이

selectFiles: File[] = []; 처럼 File을 담을 빈 배열을 생성해 담거나,

FileList를 Array()에 담아 배열로 바꾸어 활용해야 합니다.

 

파일 보여주기 및 삭제

state에 저장한 값을 보여주기만 하면 됩니다.

  <div className="FileContainer">
    {files.length > 0 &&
      files.map((file: FileFormat) => {
        const {
          id,
          content: { name },
        } = file;

        return (
          <div className="UploadFile" key={id}>
            <div className="Filename">{name} </div>
            <div className="DeleteBtn" onClick={() => handleDeleteFile(id)}>
              <DeleteOutlineIcon></DeleteOutlineIcon>
            </div>
          </div>
        );
      })}
  </div>

삭제는 Icon을 클릭했을 때 처리했습니다.

  const handleDeleteFile = useCallback(
    (id: number): void => {
      setFiles(files.filter((file: FileFormat) => file.id !== id));
    },
    [files]
  );

완성

전체 코드

// Upload.tsx
import { ChangeEvent, useCallback, useRef, useState, useEffect } from 'react';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import './Upload.css';

interface FileFormat {
  id: number;
  content: File;
}

const Upload = () => {
  const [files, setFiles] = useState<FileFormat[]>([]);
  const [isDragging, setIsDragging] = useState<boolean>(false);
  const dragRef = useRef<HTMLLabelElement | null>(null);
  const fileId = useRef<number>(0);

  const onChangeFiles = useCallback(
    (e: ChangeEvent<HTMLInputElement> | any): void => {
      let selectFiles: File[] = [];
      let tempFiles: FileFormat[] = files;

      e.type === 'drop' ? (selectFiles = e.dataTransfer.files) : (selectFiles = e.target.files);

      for (const file of selectFiles) {
        tempFiles = [
          ...tempFiles,
          {
            id: fileId.current++,
            content: file,
          },
        ];
      }

      setFiles(tempFiles);
    },
    [files]
  );

  const handleLabelClick = (e: any) => {
    e.preventDefault();
  };

  const handleDeleteFile = useCallback(
    (id: number): void => {
      setFiles(files.filter((file: FileFormat) => file.id !== id));
    },
    [files]
  );

  const handleDragIn = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDragOut = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();

    setIsDragging(false);
  }, []);

  const handleDragOver = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();

    if (e.dataTransfer!.files) {
      setIsDragging(true);
    }
  }, []);

  const handleDrop = useCallback(
    (e: DragEvent): void => {
      e.preventDefault();
      e.stopPropagation();

      onChangeFiles(e);
      setIsDragging(false);
    },
    [onChangeFiles]
  );

  const initDragEvents = useCallback((): void => {
    if (dragRef.current !== null) {
      dragRef.current.addEventListener('dragenter', handleDragIn);
      dragRef.current.addEventListener('dragleave', handleDragOut);
      dragRef.current.addEventListener('dragover', handleDragOver);
      dragRef.current.addEventListener('drop', handleDrop);
    }
  }, [handleDragIn, handleDragOut, handleDragOver, handleDrop]);

  const resetDragEvents = useCallback((): void => {
    if (dragRef.current !== null) {
      dragRef.current.removeEventListener('dragenter', handleDragIn);
      dragRef.current.removeEventListener('dragleave', handleDragOut);
      dragRef.current.removeEventListener('dragover', handleDragOver);
      dragRef.current.removeEventListener('drop', handleDrop);
    }
  }, [handleDragIn, handleDragOut, handleDragOver, handleDrop]);

  useEffect(() => {
    initDragEvents();
    return () => resetDragEvents();
  }, [initDragEvents, resetDragEvents]);

  return (
    <div className="UploadContainer">
      <div className={`${isDragging ? 'UploadWrapperActive' : 'UploadWrapper'}`}>
        <label className="UploadArea" htmlFor="fileUpload" ref={dragRef} onClick={handleLabelClick}>
          <UploadFileIcon></UploadFileIcon>
          <div className="PlaceHolderText">Drag&Drop File Here</div>
        </label>
      </div>

      <div className="FileContainer">
        {files.length > 0 &&
          files.map((file: FileFormat) => {
            const {
              id,
              content: { name },
            } = file;

            return (
              <div className="UploadFile" key={id}>
                <div className="Filename">{name} </div>
                <div className="DeleteBtn" onClick={() => handleDeleteFile(id)}>
                  <DeleteOutlineIcon></DeleteOutlineIcon>
                </div>
              </div>
            );
          })}
      </div>
      <input type="file" id="fileUpload" style={{ display: 'none' }} multiple={true} onChange={onChangeFiles} />
    </div>
  );
};

export default Upload;
// Upload.css
.UploadWrapper {
  width: 400px;
  height: 200px;
  border: 2px dotted black;
}

.UploadWrapperActive {
  background: #0000000f;
  width: 400px;
  height: 200px;
  border: 2px dotted black;
  opacity: 0.7;
}

.UploadArea {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
}

.FileContainer {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: start;
  margin-top: 10px;
  gap: 2px;
}

.UploadFile {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  padding: 5px;
  background-color: #0000000f;
  border-radius: 5px;
}

.Filename {
  display: flex;
  flex-direction: row;
  justify-content: center;
  align-items: center;
}

.DeleteBtn {
  display: flex;
  flex-direction: row;
  justify-content: center;
  align-items: center;
  text-align: center;
  vertical-align: center;
}

.DeleteBtn:hover {
  cursor: pointer;
}

전체 코드에서 아이콘은 mui에서 가져왔고 isDragging State를 활용해서 Drag시 hoverEffect를 나타냈습니다.

 

 

만약 업로드 시킨 이미지를 미리보기 하고 싶다면?

 

[React] 파일 업로드 및 미리보기 구현 + input태그 버튼연결하기

이번 프로젝트에서 이미지 파일을 업로드하여 그것과 비슷한 그림체의 웹툰을 추천해주는 부가기능이 있었습니다. 이 페이지의 기능 구현을 담당하여 필요한 기술들을 학습하면서 배운 내용을

dongkeun2.tistory.com

 

 

Reference

 

DragEvent::JavaScript 레퍼런스

대상 요소가 다음과 같이 드래그가 가능한 상태이어야 한다. Drag me

www.devdic.com

 

코딩교육 티씨피스쿨

4차산업혁명, 코딩교육, 소프트웨어교육, 코딩기초, SW코딩, 기초코딩부터 자바 파이썬 등

tcpschool.com

 

React에서 Drag & Drop을 이용한 파일 업로드 하기 📃

안녕하세요! 오늘은 React.js에서 드래그 앤 드롭을 이용한 파일 업로드 하는법을 알아보겠습니다.

velog.io