펫모리 프로젝트에서 이미지업로드, 미리보기, 필터를 적용해 퍼블리싱을 한 코드 정리
사용자가 앨범을 수정하려고 버튼을 눌렀을 때, Link을 통해 state를 전달해 이미 존재하고 있던 데이터를 보여준 기능 정리
배경이미지를 등록했을 때, blur처리되도록 css filter를 이용해 필터기능을 적용하였다.
하지만, filter기능을 사용했을 때, 배경 모서리부분이 하얀색이 되는 문제가 발생하였다.
이 모서리 부분을 제거하기 위해 이미지 컨테이너를 생성하고, position absolute를 통해 위치를 조정하고, padding을 통해 컨테이너보다 본래의 이미지 사이즈를 키웠다.
컨테이너에 overflow hidden 속성을 적용해 바깥모서리 부분을 감추는 형식으로 구현하였다.
이미지 업로드 기능은 input type="file" 태그와 FileReader를 통해 구현하였다.
FileReader는 웹에서 비동기적으로 데이터를 읽기 위하여 파일이나 Blob객체를 이용해 파일의 내용을 읽고 사용자의 컴퓨터에 저장하는 것을 가능하게 해준다.
FileReader에서 제공하는 이벤트핸들러와 메소드는 다음과 같다.
이벤트 | 설명 |
---|---|
onload | 이 이벤트는 읽기 동작이 성공적으로 완료 되었을 때마다 발생합니다. |
onError | 이 이벤트는 읽기 동작에 에러가 생길 때마다 발생합니다. |
onbort | 이 이벤트는 읽기 동작이 중단 될 때마다 발생합니다. |
메소드 | 설명 |
---|---|
readAsDataURL() | loadend이벤트 트리거, 바이너리 파일을 base64로 인코딩 된 스트링 데이터로 변환해 result 속성에 반환함. |
readAsText() | 텍스트 파일을 읽어 들임. |
readAsArrayBuffer() | Array Buffer 객체를 반환합니다. buffer를 서버에 보내서 stream으로 처리함. 영상, 오디오등의 데이터를 다룸. |
upload 되었는지 판단하는 상태는 여러 컴포넌트에서 사용하였기 때문에 atom으로 관리하였다.
upload가 안된 상태라면, 사진을 추가해달라는 컴포넌트를 보여주고, 업로드가 되었다면, 업로드된 이미지를 보여준다.
이와 관련된 조금 더 자세한 설명은 코드와 함께 진행하겠다.
먼저 이미지 필터 관련 구현설명이다. ImageContainer안에 먼저 투명도가 0.5인 검정색 필터를 씌웠다.
img_filter라는 검정색 필터아래에 ImageBackground라는 배경화면이 있는데, position을 absolute로, top, left를 -20px, padding을 20px 넣어 원래 컨테이너 사이즈보다 배경화면을 키우고 filter: blur(16px) 속성을 적용하였다.
컨테이너에서 overflow : hidden 속성을 통해 하얀색 모서리부분을 제거하는 과정을 통해 배경흐리게 + 검정색 필터기능을 구현하였다.
ImageUploadBox 라는 button 안에 input type=file 태그를 hidden으로 처리한다.
button으로 처리한 이유는, ref를 통해 만약 ImageUploadBox가 눌리면, 숨겨진 input태그를 눌리기 위해 버튼태그를 사용했다.
upload된 상태라면 upload된 이미지를, 아니면 사진추가 아이콘을 보여준다. upload를 prop으로 받아 같은 마크업내에서 동적 스타일링을 구현하였다.
사용자가 이미지를 한번 업로드한 이후에도 다른 이미지로 변경 할 수 있도록 구현하였다.
//ImageUpload.tsx
const ImageUpload = ({ uploadImage, setUploadImage, setImageUpload }: ImageUploadProps) => {
return (
<S.ImageContainer>
<span className="img_filter" />
<S.ImageBackground backgroundUrl={uploadImage ? uploadImage : '/img/writeAlbumBg.jpg'} />
<S.ImageUploadBox onClick={handleImageUploadBtn} isUpload={isUpload}>
<input type="file" accept="image/*" ref={inputRef} hidden onChange={handleImageUpload} />
{isUpload ? (
<img src={uploadImage} alt="uploadImage" />
) : (
<>
<AlbumIcon width="30%" height="30%" />
<div className="imageCaption">사진을 추가해주세요</div>
</>
)}
</S.ImageUploadBox>
</S.ImageContainer>
);
};
export default ImageUpload;
//ImageUploadStyle.ts
import styled from 'styled-components';
export const ImageContainer = styled.div`
position: relative;
width: 100%;
height: 835px;
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25));
overflow: hidden;
.img_filter {
position: absolute;
display: inline;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
z-index: 1;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
`;
type ImageBackgroundProps = {
backgroundUrl: string;
};
export const ImageBackground = styled.div<ImageBackgroundProps>`
position: absolute;
top: -20px;
left: -20px;
background-image: url(${(props) => props.backgroundUrl});
background-position: center;
background-repeat: no-repeat;
background-size: cover;
width: 100%;
height: 100%;
padding: 20px;
filter: blur(16px);
`;
type ImageUploadBoxProps = {
isUpload: boolean;
};
export const ImageUploadBox = styled.button<ImageUploadBoxProps>`
all: unset;
position: absolute;
z-index: 2;
top: ${(props) => (props.isUpload ? '70px' : '50%')};
left: 50%;
width: ${(props) => (props.isUpload ? '765px' : '30%')};
cursor: pointer;
aspect-ratio: 1;
transform: ${(props) => (props.isUpload ? 'translate(-50%, 0)' : 'translate(-50%, -50%)')};
background-color: rgba(255, 255, 255, 0.12);
border: ${(props) => (props.isUpload ? 'none' : '3px dashed rgba(255, 255, 255, 0.56)')};
box-sizing: border-box;
color: ${(props) => props.theme.color.grayScale.white};
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.imageAlbum {
width: 30%;
height: 30%;
}
.imageCaption {
font-size: 26px;
font-family: ${(props) => props.theme.font.family.pretendard_bold};
margin-top: 35px;
}
.img {
width: 100%;
height: 100%;
object-fit: cover;
}
`;
먼저 위에 ImageUploadBox가 클릭되면, input 태그를 click하도록 useRef를 통해 직접 돔을 조작한다.
여기서 ImageFile은 서버에 post 요청을 보낼 File형태의 이미지이고, UploadImage는 파일형태의 이미지를 string 형태로 바꾼 값이다.
UploadImage를 통해 사용자가 FileReader를 통해 저장한 이미지를 화면에서 확인 할 수 있다.
파일이 성공적으로 업로드되었다면, onload 이벤트 핸들러를 통해 ImageFile과 UploadImage State 값을 변경하고, upload상태를 true로 바꾼다.
// ImageUpload.tsx
const ImageUpload = ({ uploadImage, setUploadImage, setImageFile }: ImageUploadProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isUpload, setIsUpload] = useRecoilState(isUploadAtom);
const reader = new FileReader();
const handleImageUploadBtn = (e: React.MouseEvent<HTMLButtonElement>) => {
inputRef.current?.click();
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
reader.readAsDataURL(e.target.files[0]);
setImageFile(e.target.files[0]);
reader.onload = () => {
setUploadImage(reader.result as string);
setIsUpload(true);
};
};
...
수정기능은 앨범 상세페이지에서 버튼을 눌렀을 때 작동한다. 이때 사용자는 수정을 하기 위해 원래 존재했던 데이터 값이 필요하다.
이는 요청을 통해 구현하지 않고, 라우터의 Link를 통해 상세페이지의 state를 전달하는 방식으로 구현하였다.
먼저 상세페이지에서 모달을 통해 수정버튼을 누르게 되면, detailInfo라는 state를 앨범 수정페이지로 전달한다.
secondBtnHandler는 삭제와 수정을 처리하는 모달의 버튼 이벤트핸들러이다.
//MemoryDetailContainer.tsx
const deleteModalText = {
text: '삭제하시겠습니까?',
btnText1: '취소',
btnText2: '삭제',
};
const reviseModalText = {
text: '수정하시겠습니까?',
btnText1: '취소',
btnText2: '수정',
};
const MemoryDetailContainer = () => {
const albumId = useParams().id;
const [isModal, setModal] = useState<boolean>(false);
const [isRevise, setIsRevise] = useState<boolean>(false);
const [isloading, setLoading] = useState<boolean>(true);
const [commentList, setCommentList] = useState<CommentType[]>([]);
const { detailInfo, setDetailInfo } = useDetailInfo(albumId);
const navigate = useNavigate();
...
const firstBtnHandler = () => {
setModal(false);
setIsRevise(false);
setCommentDelete(false);
};
const secondBtnHandler = async () => {
if (isRevise) {
navigate(`/writeAlbum/${albumId}`, { state: { detailInfo } });
} else if (isCommentDelete) {
const res = await deleteComment(albumId, targetCommentId);
if (!res) {
fetchDetailComments();
}
} else {
const res = await deleteAlbum(albumId);
if (!res) {
navigate('/memory/myAlbum');
}
}
setModal(false);
setIsRevise(false);
setCommentDelete(false);
};
...
return (
<S.DetailBox>
<Modal
ModalText={isRevise ? reviseModalText : deleteModalText}
isModal={isModal}
firstBtnHandler={firstBtnHandler}
SecondBtnHandler={secondBtnHandler}
/>
...
</S.DetailBox>
);
};
export default MemoryDetailContainer;
만약 Link를 통해 detailInfo를 전달받은 경우라면, 상세페이지에서 수정버튼을 눌러 앨범수정을 한 경우이다.
이 경우 컴포넌트가 마운트될때 각 state들의 값을 detailInfo의 값으로 설정해준다.
//WriteAlbumContainer.tsx
const WriteAlbumContainer = () => {
const [title, setTitle] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [visible, setVisible] = useState<boolean>(true);
const [uploadImage, setUploadImage] = useState<string>(''); // 업로드한 이미지(백그라운드로사용)
const setIsUpload = useSetRecoilState(isUploadAtom);
const [albumImages, setAlbumImages] = useState<File | null | string>(null); //사용자가 업로드한 이미지파일, 요청보냄
const [emotionTagList, setEmotionTagList] = useRecoilState(activeTagAtom);
const [imageUrlList, setImgUrlList] = useState<string>('');
const navigate = useNavigate();
const location = useLocation();
const [isRevise, setIsRevise] = useState<boolean>(false);
const detailInfo = { ...location?.state?.detailInfo };
const albumId = useParams().id;
...
const setImageFile = (file: File) => {
if (!file) {
alert('파일을 찾을 수 없습니다.');
return;
}
setAlbumImages(file);
};
const handleUpload = async (e: React.MouseEvent<HTMLButtonElement>) => {
const sendData = {
title,
description,
visible,
emotionTagList,
};
if (!isRevise) {
const res = await postAlbum(sendData, albumImages);
if (isAlbumDetail(res)) {
alert('앨범을 업로드했습니다.');
navigate('/memory/myAlbum');
}
} else {
const res = await putAlbum(sendData, imageUrlList, albumId, albumImages);
if (!res) {
alert('앨범을 수정했습니다.');
navigate('/memory/myAlbum');
}
}
};
useEffect(() => {
if (detailInfo?.imageUrlList) {
setTitle(detailInfo?.title);
setDescription(detailInfo?.description);
setVisible(detailInfo?.visible === 'PUBLIC' ? true : false);
setUploadImage(detailInfo?.imageUrlList[0]); // 현재 화면에 보이는 이미지
setImgUrlList(detailInfo?.imageUrlList[0]); // 수정할때 이전 이미지
setIsRevise(true);
}
return () => setIsUpload(false);
}, []);
return (
<WriteAlbumWrapper>
<ImageUpload
uploadImage={uploadImage}
setUploadImage={setUploadImage}
setImageFile={setImageFile}
/>
<WriteBox>
<TitleForm title={title} handleTitleChange={handleTitleChange} />
<ContentForm description={description} handleContentChange={handleContentChange} />
<EmotionForm emotionTagList={detailInfo?.emotionTagList} />
<RadioForm visible={visible} handleIsOpen={handleIsOpen} />
</WriteBox>
<IconButton width="5vw" height="30px" maxWidth="74px" minWidth="50px" onClick={handleUpload}>
업로드
</IconButton>
</WriteAlbumWrapper>
);
};
export default WriteAlbumContainer;
ref)
fileReader MDN : https://developer.mozilla.org/ko/docs/Web/API/FileReader