파일 Drag and Drop 기능을 구현하자!

김채은·2022년 10월 20일
31
post-thumbnail

사용자 친화적인 사이트로

깜지 팀엔 디자이너가 있다. 처음에는 프론트엔드 파트(나)에서 디자인을 포함한 화면의 모든 것을 담당했었는데 UX/UI 역량의 한계를 느끼고 디자이너 분을 모시게 되었다.

UX/UI 디자이너가 있어 좋은 점은 개발자가 기능적으로 돌아가게만 만들어두었던 부분들을 좀 더 사용자에게 편의한 방면으로 개선할 수 있다는 것이다.

바로 예시를 들어보자면 이번 스프린트에서 개선한 부분인 문제 제출 페이지이다. 사용자들 중 기존 문제 제출 페이지에서 챌린지 정보를 볼 수 없어 문제를 잘못 제출하는 경우가 생겼다. 문제 제출 화면에서 챌린지 정보를 보여주는 기능을 추가하기로 했고, 하는 김에 전체적으로 문제 제출 화면을 개선하기로 했다.

  • 기존 문제 제출 화면

  • 새로 디자인된 문제 제출 화면

화면 상에서 예외처리도 한층 강화되었고, 사용자가 현재 글자수를 볼 수 있는 등 편의성이 높아졌다.

이미지 Drag and Drop 기능

이미지 DnD 기능에 대해서는 디자이너 님께서 짧게 카톡으로 의견을 물어봐주셨다. 사실 한 번도 구현해보지 않는 부분이라 걱정은 됐지만... 이번 기회에 해보는 게 좋을 것 같아 바로 괜찮다고 말씀드렸다.

기존 이미지 첨부 기능은 버튼 하나로 돼있고 이미지 파일이 첨부됐을 때 파일의 제목이 나타난다. 물론 이미지 업로드에는 전혀 문제가 없지만 이미지를 제대로 업로드 했는지 미리보기로 확인할 수 없다는 점 등의 불편함이 예상된다.

구현 내용

제안해주신 이미지 첨부 기능을 살펴보면 다음과 같다.

  1. 이미지를 Drag and drop 또는 버튼 클릭으로 첨부할 수 있다. 드래그 중일 때는 점선과 버튼 디자인이 변경된다.

  2. 첨부된 이미지는 미리보기 할 수 있고, 삭제할 수 있다.

자...

그럼 이제 본격적으로 구현해볼까!!!!

Drag Event

드래그한 이미지 파일을 받을 컴포넌트(드롭 대상)를 생성하고 Drag event listener를 등록한다.

  • onDragEnter
    - 드래그된 요소가 드롭 대상에 들어갈 때 발생
  • onDragOver
    - 드래그된 요소가 드롭 대상 위에 있을 때 발생
  • onDragLeave
    - 드래그된 요소가 드롭 대상을 벗어날 때 발생
  • onDrop
    - 드래그한 요소를 드롭 대상에 놓을 때 발생
e.preventDefault();
e.stopPropagation();

위 코드를 모든 이벤트 핸들러에 작성해주어야 한다. 브라우저에 기본적으로 등록된 이벤트 동작이 있기 때문이다. 지금 사용 중인 벨로그 에디터를 예로 들자면, 글 작성 시 에디터 위로 이미지를 드래그 앤 드롭 하면 새 탭에서 이미지가 열린다.

깜지는 정책 상 한 문제 당 하나의 이미지만 첨부할 수 있으므로 contentImage 라는 File 형식의 state로 첨부된 이미지를 관리해준다.

구현 내용 1번을 보면 dragging 중일 때 디자인을 변경해주어야 하므로 isDragging state도 선언한다. 이 부분은 Styled Component의 props 기능을 통해 state에 따라 디자인을 변경해준다.

interface Props {
  contentImageState: {
    contentImage: File | null;
    setContentImage: Function;
  };
}

function ImageInputBlock({ contentImageState }: Props) {
  const [isDragging, setIsDragging] = useState(false);

  // 부모 컴포넌트에서 내려준 contentImage state
  const { contentImage, setContentImage } = contentImageState;

  const onDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(true);
  };
  const onDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(false);
  };
  const onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    if (e.dataTransfer.files) {
      setIsDragging(true);
    }
  };
  const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    setContentImage(e.dataTransfer.files[0]);
    setIsDragging(false);
  };

  return (
      <DndBox
          onDragEnter={onDragEnter}
          onDragLeave={onDragLeave}
          onDragOver={onDragOver}
          onDrop={onDrop}

		  // styled components props
          isDragging={isDragging}
      >
      </DndBox>
	);
}

input type=file

드래그 앤 드롭 기능만 있으면 될게 아니라, 이미지 가져오기 버튼을 통해서도 이미지를 첨부할 수 있어야 한다. input 요소와 label 요소를 id-htmlFor로 연결해주면 label을 눌렀을 때 input을 누른 것처럼 동작한다. 디자인을 위해 input은 화면에서 보이지 않도록 display: none을 적용한다.

input에서 Change Event가 발생했을 때 Drop Event 상황과 같이 contentImage state를 업데이트한다.

  const onContentImageChange = (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setContentImage(e.target.files[0]);
    }
  };

  return (
  		<input
            type="file"
            accept=".png,.jpg,.jpeg"
            id="input-file"
            style={{ display: "none" }}
  			aria-hidden
            onChange={onContentImageChange}
          />
         <DndBox
            onDragEnter={onDragEnter}
            onDragLeave={onDragLeave}
            onDragOver={onDragOver}
            onDrop={onDrop}
            isDragging={isDragging}
          >
             <label htmlFor="input-file" role="button">
                이미지 가져오기
             </label>
         </DndBox>
  )

이미지 미리보기

이제 구현 내용 2번의 이미지 미리보기 기능을 만들어보자.

자바스크립트 FileReader를 통해 contentImage를 Url로 변환하면 img 태그로 렌더링할 수 있다. readImage 함수는 onDrop과 onContentImageChange에서 호출하며, 인자로는 File 형식의 이미지를 받는다.

이미지 클릭 시 원본 이미지 모달을 띄우는 기능은 간단하므로 생략. 이미지 삭제 기능은 contentImage, contentImageUrl state만 null로 바꿔주면 된다.

  const [contentImageUrl, setContentImageUrl] = useState<string | null>(null);

  const readImage = (image: File) => {
    const reader = new FileReader();
    reader.onload = function (e) {
      setContentImageUrl(String(e.target?.result));
    };
    reader.readAsDataURL(image);
  };

  const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
	// ...
    readImage(e.dataTransfer.files[0]);
  };

  const onContentImageChange = (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setContentImage(e.target.files[0]);
      readImage(e.target.files[0]);
    }
  };

  return (
   	 <ImageBox>
        {contentImageUrl && (
            <img
              alt="문제 이미지 미리보기"
              src={contentImageUrl}
              onClick={() => onModalStateChange({ state: true })}
            />
        )}
     </ImageBox>
  )

결과물

짠~ 막상 해보니 크게 어려운 기능은 아니었다. 역시 해보기 전까진 모르는 것...

그래도 열심히 개발한 나. 멋지다.

profile
배워서 남주는 개발자 김채은입니다 ( •̀ .̫ •́ )✧

5개의 댓글

comment-user-thumbnail
2022년 10월 21일

좋은 글 너무 잘 봤어요^^

1개의 답글
comment-user-thumbnail
2022년 10월 21일

좋은 글 잘 보고갑니다^^

1개의 답글
comment-user-thumbnail
2023년 10월 24일

좋은 글 남겨주셔서 감사합니다~ 도움 많이 되었습니다!

답글 달기