본문 바로가기
Front-End/작업물

React Image Upload 컴포넌트 제작

by kimik 2023. 6. 4.

1. 기능

  • image upload(drag drop, paste)
  • image Resize (라이브러리 사용)
  • image preview

2. JSX 작성

2-1. Image Upload Component JSX

    <>
        <div
          onDrop={onDrop}
          onDragOver={onDragOver}
          onDragLeave={onDragLeave}
          data-active={isDrop}
          className={style.upload}
        >
          <input
            type="text"
            onPaste={onPaste}
            readOnly
            value="파일을 drag and drop 해도됩니다!"
          />
          <input type="file" onChange={onChange} {...{ id, multiple, ref }} />
          <label htmlFor={id}>업로드</label>
        </div>
        {!!resizeFiles?.length && (
          <ImagePreview {...{ resizeFiles, setResizeFiles }} />
        )}
      </>

2-2. Image Preview Component JSX

import { UploadFiles } from "./ImageUpload";
import style from "./ImageUpload.module.scss";

interface Props {
  resizeFiles: UploadFiles[];
  setResizeFiles: React.Dispatch<React.SetStateAction<UploadFiles[]>>;
}

export default function ImagePreview({ resizeFiles, setResizeFiles }: Props) {
  return (
    <div className={style.preview}>
      {resizeFiles.map((item, index) => (
        <div data-name="preview-img">
          <div data-name="img">
            <img src={item.url} alt={`업로드 이미지 ${index}번째 미리보기`} />
          </div>
          <button
            onClick={() => {
              setResizeFiles(resizeFiles.filter((_, i) => i !== index));
            }}
          >
            X
          </button>
        </div>
      ))}
    </div>
  );
}

2-3. 마크업

dropzone을 위한 div와 그안에 복사 붙여넣기를 위한 input[type="text"] 그리고 파일 업로드 UI 호출을 위한 input[type="file"] 세가지로 업로드에 대한 파일을 처리하며, 업로드된 이미지를 확인하기 위한 ImagePreview 컴포넌트를 별도로 제작하여 하단에 배치했습니다.

2-4. 이벤트

Image upload에 필요한 이벤트는 이미지를 드래그하여 dropzone 위에 마우스를올려두었을때 스타일 변화를 위한 onDragOver,
마우스를 dropzone 밖으로 이동했을때 스타일 변화를 위한 onDragLeave,
dropzone에 파일을 둔채로 마우스에 drag끝내고 drop했을때를 위한 onDrop 이벤트가 drag and Drop을 위한 이벤트이며,
해당 영역을 클릭했을때 브라우저에서 기본적으로 제공하는 파일 선택 UI 호출을 하는 Input type file의 onChange, input 요소에 파일을 복사 붙여넣기 할때를 위한 onPaste 이렇게 총 5개의 이벤트를 구성합니다.

3. 로직 작성

3-1. 3개의 이벤트에 대한 처리

이벤트는 5개로 이루어져있지만, 파일에 대한 처리를 하는것은 onDrop, onChange, onPaste 이고, 나머지 2개의 이벤트는 스타일을 위한 부가적인 이벤트입니다. 이 3개의 이벤트는 이벤트를 처리하는 방식이 전부 다르기 때문에 이부분을 어떻게 처리하느냐가 중요합니다.

const getDataTransfer = (files: FileList | DataTransferItemList | null) => {
 const dataTransfer = new DataTransfer();
 if (files instanceof FileList) {
   for (let i = 0; i < files.length; i++) {
     dataTransfer.items.add(files[i]);
   }
 } else {
   if (files)
     Array.from(files).map((item) => {
       const file = item.getAsFile();
       if (file) dataTransfer.items.add(file);
     });
 }
 return dataTransfer;
};

input[type="file"]로 브라우저의 기본 기능으로 파일을 업로드했을때 해당 파일에대한 타입은 FileList이며, 붙여넣기나 Drop으로 파일을 업로드하면 DataTransferItemList 타입을 가집니다. 이에따라 이것을 공통으로 처리하는 함수로 DataTransfer를 통일하여 return해줍니다.

3-2. 이미지 필터링

drag and drop, 복사 붙여넣기, 파일선택으로 업로드 어떤방식이든 이미지 외에 다른 파일을 업로드할 수 있으므로 파일정보를 이용해 이것을 필터링하는 함수를 작성합니다.


const filterImage = (files: DataTransfer) => {
  const filter = Array.from(files.files).filter((item) => {
    return item.type.match("image.*");
  });
  const diffNum = files.files.length - filter.length;
  !!diffNum &&
    alert(`이미지가 아닌 파일 ${diffNum}개를 제외하고 업로드 되었습니다.`);
  return filter;
};

3-3. 이미지 배열 생성

const setImageFiles = (files: File[]): UploadFiles[] => {
  return files.map((file) => {
    return {
      id: uuid(),
      file: file,
    };
  });
};

이미지 정보는 있지만, 해당 이미지들에 대한 고유한 번호가 없기 때문에 단순히 업로드한 파일만으로 반복문을 이용하거나 기타 처리를 하다보면 문제가 될 수 있기 때문에 uuid 패키지를 이용하여 각 이미지에 고유한 ID 값을 생성해줍니다.

4. 상태 관리

이제 기본적인 로직은 작성하였으므로, 위의 로직들로 React의 핵심인 상태를 관리해줍니다.

const [uploadFiles, setUploadFiles] = React.useState<UploadFiles[]>();
    const [resizeFiles, setResizeFiles] = React.useState<UploadFiles[]>([]);
    const [isDrop, setIsDrop] = React.useState(false);

총 3가지 상태를 사용합니다.

처음 이미지를 업로드했을때 파일들의 상태를 관리하는 uploadFiles.

uploadFiles의 이미지를 리사이징하여 별도로 관리하는 resizeFiles.

그리고 위에서 잠깐 언급한 파일을 drag 했을때 스타일 변경을 위한 isDrop.

그럼 위에서 작성한 함수들을 이용해 상태를 업데이트 하도록 합니다.

const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
  setUploadFiles(
    setImageFiles(filterImage(getDataTransfer(e.dataTransfer.files)))
  );
};
const onPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
  setUploadFiles(
    setImageFiles(filterImage(getDataTransfer(e.clipboardData.items)))
  );
};
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setUploadFiles(
    setImageFiles(filterImage(getDataTransfer(e.target.files)))
  );
};

이제 각 이벤트의 매개변수가 위의 함수들을 거쳐 나온 결과물이 setUploadFiles로 상태변화가 진행될것입니다.

그럼 uploadFiles의 변화를 감지하여 자동으로 리사이징 하는 useEffect와 Resize함수를 작성합니다.

resizing은 react-image-file-resizer 패키지를 사용하였습니다.

React.useEffect(() => {
    if (!!uploadFiles?.length) {
        handleResizing({
          files: uploadFiles,
          resizewidth: resizeWidth,
          resizeheight: resizeHeight,
          }).then((files) => {
            setResizeFiles(resizeFiles?.concat(files));
        });
    }
}, [uploadFiles]);


const handleResizing = async ({
  files,
  resizeheight,
  resizewidth,
}: ResizeFilesType) => {
  const ResizeFileAsyncs = files.map((item) => {
    return {
      id: item.id,
      resize: new Promise(
        (
          resolve: (
            value: string | File | Blob | ProgressEvent<FileReader>
          ) => void
        ) => {
          if (!item.file) return resolve("");
          Resizer.imageFileResizer(
            item.file, //resize file
            resizewidth || 1920, //max width
            resizeheight || 2000, // max height
            item.file.type.replace("image/", ""), // format
            80, // quality
            0, //rotation
            (uri) => {
              resolve(uri);
            },
            "file" //outputType
            // min width
            // min height
          );
        }
      ),
    };
  });
  const resizeFiles = await Promise.all(
    ResizeFileAsyncs.map((item) => {
      return item.resize.then((value) => {
        const file = value as File;
        return {
          id: item.id,
          file: file,
          url: URL.createObjectURL(file),
        };
      });
    })
  );
  return resizeFiles;
};

위와같이 기능까지 작성하고, Image Upload 컴포넌트를 사용하는 부모컴포넌트에서는 images라는 props를 통해 이미 업로드 된 이미지들을 전달하여 prevew에 표시하고, onImageUpload props를 통해 이미지를 업로드/삭제 할때마다 변경되는 상태인 resizeFiles를 알려줍니다.

5. 완성

5-1. ImageUpload.tsx


import * as React from "react";
import uuid from "react-uuid";
import style from "./ImageUpload.module.scss";
import Resizer from "react-image-file-resizer";
import ImagePreview from "./ImagePreview";

export interface ImageUploadProps
  extends React.ComponentPropsWithoutRef<"input"> {
  resizeWidth?: number;
  resizeHeight?: number;
  images: UploadFiles[];
  onImageUpload: (files: UploadFiles[]) => void;
}

export interface UploadFiles {
  id: string;
  file?: File;
  url?: string;
}

const getDataTransfer = (files: FileList | DataTransferItemList | null) => {
  const dataTransfer = new DataTransfer();
  if (files instanceof FileList) {
    Array.from(files).map((item) => {
      dataTransfer.items.add(item);
    });
  } else if (files) {
    Array.from(files).map((item) => {
      const file = item.getAsFile();
      if (file) dataTransfer.items.add(file);
    });
  }
  return dataTransfer;
};

const filterImage = (files: DataTransfer) => {
  const filter = Array.from(files.files).filter((item) => {
    return item.type.match("image.*");
  });
  const diffNum = files.files.length - filter.length;
  !!diffNum &&
    alert(`이미지가 아닌 파일 ${diffNum}개를 제외하고 업로드 되었습니다.`);
  return filter;
};

const setImageFiles = (files: File[]): UploadFiles[] => {
  return files.map((file) => {
    return {
      id: uuid(),
      file: file,
    };
  });
};

interface ResizeFilesType {
  files: UploadFiles[];
  resizewidth?: number;
  resizeheight?: number;
}

const handleResizing = async ({
  files,
  resizeheight,
  resizewidth,
}: ResizeFilesType) => {
  const ResizeFileAsyncs = files.map((item) => {
    return {
      id: item.id,
      resize: new Promise(
        (
          resolve: (
            value: string | File | Blob | ProgressEvent<FileReader>
          ) => void
        ) => {
          if (!item.file) return resolve("");
          Resizer.imageFileResizer(
            item.file, //resize file
            resizewidth || 1920, //max width
            resizeheight || 2000, // max height
            item.file.type.replace("image/", ""), // format
            80, // quality
            0, //rotation
            (uri) => {
              resolve(uri);
            },
            "file" //outputType
            // min width
            // min height
          );
        }
      ),
    };
  });
  const resizeFiles = await Promise.all(
    ResizeFileAsyncs.map((item) => {
      return item.resize.then((value) => {
        const file = value as File;
        return {
          id: item.id,
          file: file,
          url: URL.createObjectURL(file),
        };
      });
    })
  );
  return resizeFiles;
};

export const ImageUpload = React.forwardRef<HTMLInputElement, ImageUploadProps>(
  (
    {
      id,
      multiple,
      resizeWidth = 1920,
      resizeHeight = 2000,
      images,
      onImageUpload,
      ...props
    },
    ref
  ) => {
    const [uploadFiles, setUploadFiles] = React.useState<UploadFiles[]>();
    const [resizeFiles, setResizeFiles] = React.useState<UploadFiles[]>([]);
    const [isDrop, setIsDrop] = React.useState(false);
    React.useEffect(() => {
      onImageUpload(resizeFiles);
    }, [resizeFiles]);
    React.useEffect(() => {
      if (!!uploadFiles?.length) {
        handleResizing({
          files: uploadFiles,
          resizewidth: resizeWidth,
          resizeheight: resizeHeight,
        }).then((files) => {
          setResizeFiles(resizeFiles?.concat(files));
        });
      }
    }, [uploadFiles]);
    React.useEffect(() => {
      if (!!images?.length) {
        setResizeFiles(images);
      }
    }, [images]);

    const onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
      e.preventDefault();
      setIsDrop(true);
    };
    const onDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
      e.preventDefault();
      setIsDrop(false);
    };

    const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
      setUploadFiles(
        setImageFiles(filterImage(getDataTransfer(e.dataTransfer.files)))
      );
    };
    const onPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
      setUploadFiles(
        setImageFiles(filterImage(getDataTransfer(e.clipboardData.items)))
      );
    };
    const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      setUploadFiles(
        setImageFiles(filterImage(getDataTransfer(e.target.files)))
      );
    };
    return (
      <>
        <div
          onDrop={onDrop}
          onDragOver={onDragOver}
          onDragLeave={onDragLeave}
          data-active={isDrop}
          className={style.upload}
        >
          <input
            type="text"
            onPaste={onPaste}
            readOnly
            value="파일을 drag and drop 해도됩니다!"
          />
          <input type="file" onChange={onChange} {...{ id, multiple, ref }} />
          <label htmlFor={id}>업로드</label>
        </div>
        {!!resizeFiles?.length && (
          <ImagePreview {...{ resizeFiles, setResizeFiles }} />
        )}
      </>
    );
  }
);

5-2. ImagePreview.tsx


import { UploadFiles } from "./ImageUpload";
import style from "./ImageUpload.module.scss";

interface Props {
  resizeFiles: UploadFiles[];
  setResizeFiles: React.Dispatch<React.SetStateAction<UploadFiles[]>>;
}

export default function ImagePreview({ resizeFiles, setResizeFiles }: Props) {
  return (
    <div className={style.preview}>
      {resizeFiles.map((item, index) => (
        <div data-name="preview-img">
          <div data-name="img">
            <img src={item.url} alt={`업로드 이미지 ${index}번째 미리보기`} />
          </div>
          <button
            onClick={() => {
              setResizeFiles(resizeFiles.filter((_, i) => i !== index));
            }}
          >
            X
          </button>
        </div>
      ))}
    </div>
  );
}

5.3 style

.upload {
  position: relative;
  border: 2px dashed #cccccc;
  border-radius: 5px;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  &[data-active="true"] {
    border-color: blue;
  }
  input[type="file"] {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    opacity: 0;
    cursor: pointer;
  }
  input[type="text"] {
    width: 100%;
    height: 100%;
    border: 1px solid #ccc;
    border-radius: 4px;
    padding: 6px 12px;
    box-sizing: border-box;
  }
  label {
    position: absolute;
    top: 0;
    right: 0;
    width: 100px;
    height: 100%;
    background-color: #ccc;
    border: 1px solid #ccc;
    border-radius: 4px;
    padding: 6px 12px;
    box-sizing: border-box;
    cursor: pointer;
    text-align: center;
  }
}

.preview {
  display: flex;
  margin-top: 20px;
  gap: 0 12px;

  [data-name="preview-img"] {
    position: relative;
  }
  [data-name="img"] {
    position: relative;
    width: 100px;
    height: 100px;
    border-radius: 8px;
    overflow: hidden;
  }
  img {
    position: absolute;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
  button {
    position: absolute;
    display: flex;
    top: -8px;
    right: -8px;
    background-color: #fff;
    justify-content: center;
    align-items: center;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    font-size: 11px;
    border: 1px solid #ddd;
    z-index: 1;
  }
}

이미 업로드된 이미지를 삭제하는것은 구현에 따라 로직이 달라지기 때문에 적당히 변형해서 쓰시면 됩니다!

'Front-End > 작업물' 카테고리의 다른 글

[React] Todolist(feat.Parcel)  (0) 2019.05.12

댓글