Skip to content

Commit

Permalink
Merge pull request #383 from TEAM-BEAT/feat/#369/ImageEditor
Browse files Browse the repository at this point in the history
[Feat/#369] 이미지 크롭 기능 추가
  • Loading branch information
imddoy authored Aug 31, 2024
2 parents 49da59d + a87e38d commit a2080fb
Show file tree
Hide file tree
Showing 6 changed files with 317 additions and 55 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"react-components": "^0.5.1",
"react-dom": "^18.3.1",
"react-helmet-async": "^2.0.5",
"react-image-crop": "^11.0.6",
"react-lottie-player": "^2.0.0",
"react-router-dom": "^6.24.0",
"shelljs": "^0.8.5",
Expand Down
47 changes: 47 additions & 0 deletions src/components/commons/imageEditor/ImageEditor.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import styled from "styled-components";
import { Generators } from "@styles/generator";
import ReactCrop from "react-image-crop";

export const ModalContainer = styled.div`
${Generators.flexGenerator("column", "center", "center")}
position: fixed;
z-index: 100;
gap: 2rem;
background-color: rgb(15 15 15 / 70%);
inset: 0;
`;

export const OriginImage = styled.img`
width: 32rem;
`;

export const CustomReactCrop = styled(ReactCrop)<{
aspectRatio: number;
calculatedSize: { width: number; height: number };
}>`
position: relative;
.ReactCrop__drag-handle {
width: 1rem;
height: 1rem;
background: ${({ theme }) => theme.colors.main_pink_400};
border: none;
}
.ReactCrop__crop-selection::before {
position: absolute;
top: 50%;
left: 50%;
box-sizing: border-box;
width: ${({ calculatedSize }) => calculatedSize.width}rem;
height: ${({ calculatedSize }) => calculatedSize.height}rem;
transform: translate(-50%, -50%);
border: 0.2rem solid ${({ theme }) => theme.colors.white};
border-radius: 0.6rem;
content: "";
}
`;
169 changes: 169 additions & 0 deletions src/components/commons/imageEditor/ImageEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import React, { useState, useRef, useEffect } from "react";
import * as S from "./ImageEditor.styled";
import { Crop, PixelCrop, centerCrop, makeAspectCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import Button from "@components/commons/button/Button";

interface ImageEditorProps {
file: string;
aspectRatio: number;
onCropped: (croppedImageUrl: string) => void;
}

const ImageEditor = ({ file, aspectRatio, onCropped }: ImageEditorProps) => {
const [crop, setCrop] = useState<Crop>({
unit: "%",
x: 0,
y: 0,
width: 100,
height: 100,
});
const [imageSize, setImageSize] = useState<{ width: number; height: number }>({
width: 0,
height: 0,
});
const [calculatedSize, setCalculatedSize] = useState<{ width: number; height: number }>({
width: 0,
height: 0,
});
const [croppedImageUrl, setCroppedImageUrl] = useState<string | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);

const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
// 이미지의 원래 너비와 높이 가져오기
const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
if (e.currentTarget) {
setImageSize({ width, height });
}

// 이미지의 중앙에 크롭 영역 설정
const centerCropped = centerCrop(
makeAspectCrop(
// 크롭 영역 설정
{
unit: "%", // 크롭 단위
width: 100, // 크롭 영역 너비
height: 100,
},
width / height,
width,
height
),
width,
height
);

setCrop(centerCropped); // 중앙에 설정된 크롭 영역을 상태에 반영
};

useEffect(() => {
if (imageSize.width > 0 && imageSize.height > 0) {
// 렌더링된 이미지 크기 계산
const renderedWidth = 32;
const renderedHeight = (imageSize.height / imageSize.width) * renderedWidth;

// 100으로 초기화했던 크롭 영역의 최대 크기 계산
const maxWidth = (crop.width / 100) * renderedWidth;
const maxHeight = (crop.height / 100) * renderedHeight;

// 최적의 width, height 계산
let width, height;
if (maxWidth / aspectRatio > maxHeight) {
height = maxHeight;
width = height * aspectRatio;
} else {
width = maxWidth;
height = width / aspectRatio;
}

setCalculatedSize({ width, height });
}
}, [imageSize, crop]);

// 이미지 크롭 업데이트
const onCropChange = (crop: Crop, percentCrop: Crop) => {
setCrop(percentCrop);
};

const onCropComplete = (crop: PixelCrop, percentCrop: Crop) => {
makeClientCrop(crop);
};

const makeClientCrop = async (crop: PixelCrop) => {
if (imageRef.current && crop.width && crop.height) {
const croppedImage = await getCroppedImg(imageRef.current, crop, "newFile.jpeg");
setCroppedImageUrl(croppedImage);
}
};

// 크롭된 이미지를 생성
const getCroppedImg = (
image: HTMLImageElement,
crop: PixelCrop,
fileName: string
): Promise<string> => {
// 캔버스에 이미지 생성
const canvas = document.createElement("canvas");
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
// 캔버스 크기를 원본 해상도 기준으로 설정
const pixelRatio = window.devicePixelRatio; // 실제 픽셀 크기와 CSS 픽셀 크기 간의 비율
canvas.width = crop.width * scaleX * pixelRatio;
canvas.height = crop.height * scaleY * pixelRatio;
const ctx = canvas.getContext("2d");

// 고해상도 이미지를 유지하기 위해 canvas에 스케일 적용
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.imageSmoothingQuality = "high";

ctx.drawImage(
// 원본 이미지 영역
image,
crop.x * scaleX, // 크롭 시작 x 좌표
crop.y * scaleY, // 크롭 시작 y 좌표
crop.width * scaleX, // 크롭할 이미지의 가로 길이
crop.height * scaleY, // 크롭할 이미지의 세로 길이
// 캔버스 영역
0, // 캔버스에서 이미지 시작 x 좌표
0, // 캔버스에서 이미지 시작 y 좌표
crop.width * scaleX, // 캔버스에서 이미지의 가로 길이
crop.height * scaleY // 캔버스에서 이미지의 세로 길이
);

return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
console.error("이미지 없음");
return reject(new Error("이미지 없음"));
}
const fileUrl = URL.createObjectURL(blob);
resolve(fileUrl);
}, "image/jpeg");
});
};

const handleComplete = () => {
if (croppedImageUrl) {
onCropped(croppedImageUrl); // 크롭된 이미지 URL을 부모 컴포넌트로 전달
} else {
onCropped(file); // 크롭하지 않으면 원래 URL 전달
}
};

return (
<S.ModalContainer>
<S.CustomReactCrop
crop={crop}
onChange={onCropChange}
onComplete={onCropComplete}
ruleOfThirds={true} // 삼분법선
calculatedSize={calculatedSize}
>
<S.OriginImage src={file} alt="Original" onLoad={onImageLoad} ref={imageRef} />
</S.CustomReactCrop>
<Button onClick={handleComplete}>완료하기</Button>
</S.ModalContainer>
);
};

export default ImageEditor;
57 changes: 39 additions & 18 deletions src/pages/register/components/PosterThumbnail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ChangeEvent, useEffect, useState } from "react";
import * as S from "../Register.styled";
import { IconCamera } from "@assets/svgs";
import Spacing from "@components/commons/spacing/Spacing";
import ImageEditor from "@components/commons/imageEditor/ImageEditor";

interface PosterThumbnailProps {
value?: string | undefined;
Expand All @@ -12,6 +13,7 @@ const PosterThumbnail = ({ value, onImageUpload }: PosterThumbnailProps) => {
const [postImg, setPostImg] = useState<File | null>(null);
const [previewImg, setPreviewImg] = useState<string | null>(value || null);
const [inputKey, setInputKey] = useState<number>(Date.now());
const [openImageModal, setOpenImageModal] = useState(false);

useEffect(() => {
setPreviewImg(value || null);
Expand All @@ -26,6 +28,7 @@ const PosterThumbnail = ({ value, onImageUpload }: PosterThumbnailProps) => {
setPostImg(file);
setPreviewImg(imageUrl);
onImageUpload(imageUrl);
setOpenImageModal(true);
};

fileReader.readAsDataURL(file);
Expand All @@ -39,25 +42,43 @@ const PosterThumbnail = ({ value, onImageUpload }: PosterThumbnailProps) => {
onImageUpload("");
};

// ImageEditor에서 크롭된 이미지 URL을 받아서 상태 업데이트
const handleCroppedImage = (croppedImageUrl: string) => {
setPreviewImg(croppedImageUrl);
setOpenImageModal(false); // 모달 닫기
onImageUpload(croppedImageUrl); // 부모 컴포넌트로 전달
};

return (
<S.InputRegisterBox $marginBottom={2.8}>
<S.InputTitle>포스터 썸네일</S.InputTitle>
<S.InputDescription>한 장만 등록 가능합니다.</S.InputDescription>
<S.InputDescription $warning={true}>*포스터 썸네일은 수정불가합니다.</S.InputDescription>
<Spacing marginBottom="1.4" />
<S.FileInputWrapper>
<S.HiddenFileInput key={inputKey} type="file" id="file" onChange={uploadFile} />
<S.CustomFileInput htmlFor="file">
<IconCamera width={"3.2rem"} />
</S.CustomFileInput>
{previewImg && (
<S.PreviewImageWrapper>
<S.PreviewImage src={previewImg} alt="Preview" />
<S.RemoveImageButton onClick={removeImage} />
</S.PreviewImageWrapper>
)}
</S.FileInputWrapper>
</S.InputRegisterBox>
<>
<S.InputRegisterBox $marginBottom={2.8}>
<S.InputTitle>포스터 썸네일</S.InputTitle>
<S.InputDescription>한 장만 등록 가능합니다.</S.InputDescription>
<S.InputDescription $warning={true}>*포스터 썸네일은 수정불가합니다.</S.InputDescription>
<Spacing marginBottom="1.4" />
<S.FileInputWrapper>
<S.HiddenFileInput
key={inputKey}
type="file"
id="file"
accept="images/*"
onChange={uploadFile}
/>
<S.CustomFileInput htmlFor="file">
<IconCamera width={"3.2rem"} />
</S.CustomFileInput>
{previewImg && (
<S.PreviewImageWrapper>
<S.PreviewImage src={previewImg} alt="Preview" />
<S.RemoveImageButton onClick={removeImage} />
</S.PreviewImageWrapper>
)}
</S.FileInputWrapper>
</S.InputRegisterBox>
{openImageModal && (
<ImageEditor file={previewImg} aspectRatio={3 / 4} onCropped={handleCroppedImage} />
)}
</>
);
};

Expand Down
Loading

0 comments on commit a2080fb

Please sign in to comment.