diff --git a/package.json b/package.json index 9c147d0f..d0fe6671 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/commons/imageEditor/ImageEditor.styled.ts b/src/components/commons/imageEditor/ImageEditor.styled.ts new file mode 100644 index 00000000..41b1fbda --- /dev/null +++ b/src/components/commons/imageEditor/ImageEditor.styled.ts @@ -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: ""; + } +`; diff --git a/src/components/commons/imageEditor/ImageEditor.tsx b/src/components/commons/imageEditor/ImageEditor.tsx new file mode 100644 index 00000000..2904534d --- /dev/null +++ b/src/components/commons/imageEditor/ImageEditor.tsx @@ -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({ + 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(null); + const imageRef = useRef(null); + + const onImageLoad = (e: React.SyntheticEvent) => { + // 이미지의 원래 너비와 높이 가져오기 + 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 => { + // 캔버스에 이미지 생성 + 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 ( + + + + + + + ); +}; + +export default ImageEditor; diff --git a/src/pages/register/components/PosterThumbnail.tsx b/src/pages/register/components/PosterThumbnail.tsx index e627747b..404fc3c1 100644 --- a/src/pages/register/components/PosterThumbnail.tsx +++ b/src/pages/register/components/PosterThumbnail.tsx @@ -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; @@ -12,6 +13,7 @@ const PosterThumbnail = ({ value, onImageUpload }: PosterThumbnailProps) => { const [postImg, setPostImg] = useState(null); const [previewImg, setPreviewImg] = useState(value || null); const [inputKey, setInputKey] = useState(Date.now()); + const [openImageModal, setOpenImageModal] = useState(false); useEffect(() => { setPreviewImg(value || null); @@ -26,6 +28,7 @@ const PosterThumbnail = ({ value, onImageUpload }: PosterThumbnailProps) => { setPostImg(file); setPreviewImg(imageUrl); onImageUpload(imageUrl); + setOpenImageModal(true); }; fileReader.readAsDataURL(file); @@ -39,25 +42,43 @@ const PosterThumbnail = ({ value, onImageUpload }: PosterThumbnailProps) => { onImageUpload(""); }; + // ImageEditor에서 크롭된 이미지 URL을 받아서 상태 업데이트 + const handleCroppedImage = (croppedImageUrl: string) => { + setPreviewImg(croppedImageUrl); + setOpenImageModal(false); // 모달 닫기 + onImageUpload(croppedImageUrl); // 부모 컴포넌트로 전달 + }; + return ( - - 포스터 썸네일 - 한 장만 등록 가능합니다. - *포스터 썸네일은 수정불가합니다. - - - - - - - {previewImg && ( - - - - - )} - - + <> + + 포스터 썸네일 + 한 장만 등록 가능합니다. + *포스터 썸네일은 수정불가합니다. + + + + + + + {previewImg && ( + + + + + )} + + + {openImageModal && ( + + )} + ); }; diff --git a/src/pages/register/components/RoleWrapper.tsx b/src/pages/register/components/RoleWrapper.tsx index 5b12f4b4..98d4c6dc 100644 --- a/src/pages/register/components/RoleWrapper.tsx +++ b/src/pages/register/components/RoleWrapper.tsx @@ -4,6 +4,7 @@ import TextField from "@components/commons/input/textField/TextField"; import { IconCamera } from "@assets/svgs"; import { ChangeEvent, useState } from "react"; import { nameFilter } from "@utils/useInputFilter"; +import ImageEditor from "@components/commons/imageEditor/ImageEditor"; interface Role { id: number; @@ -22,6 +23,7 @@ const RoleWrapper = ({ id, role, removeRole, onUpdateRole }: RoleWrapperProps) = const { makerName, makerRole, makerPhoto } = role; const [postImg, setPostImg] = useState(null); const [previewImg, setPreviewImg] = useState(makerPhoto || null); + const [openImageModal, setOpenImageModal] = useState(false); const uploadFile = (e: ChangeEvent) => { const file = e.target.files?.[0]; @@ -33,6 +35,7 @@ const RoleWrapper = ({ id, role, removeRole, onUpdateRole }: RoleWrapperProps) = const imageUrl = event.target?.result as string; setPreviewImg(imageUrl); onUpdateRole(id, "makerPhoto", imageUrl); + setOpenImageModal(true); }; fileReader.readAsDataURL(file); @@ -44,45 +47,56 @@ const RoleWrapper = ({ id, role, removeRole, onUpdateRole }: RoleWrapperProps) = onUpdateRole(id, name, value); }; + // ImageEditor에서 크롭된 이미지 URL을 받아서 상태 업데이트 + const handleCroppedImage = (croppedImageUrl: string) => { + setPreviewImg(croppedImageUrl); // 프리뷰 이미지 업데이트 + onUpdateRole(id, "makerPhoto", croppedImageUrl); // makerPhoto 업데이트 + setOpenImageModal(false); // 모달 닫기 + }; return ( - - - {previewImg ? ( - - - removeRole(id)} /> - - ) : ( - <> - - - + <> + + + {previewImg ? ( + + removeRole(id)} /> - - - )} - - - - - - - + + ) : ( + <> + + + + removeRole(id)} /> + + + )} + + + + + + + + {openImageModal && ( + + )} + ); }; diff --git a/yarn.lock b/yarn.lock index 828d5aa8..544d1166 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5248,6 +5248,7 @@ __metadata: react-components: "npm:^0.5.1" react-dom: "npm:^18.3.1" react-helmet-async: "npm:^2.0.5" + react-image-crop: "npm:^11.0.6" react-lottie-player: "npm:^2.0.0" react-router-dom: "npm:^6.24.0" shelljs: "npm:^0.8.5" @@ -11241,6 +11242,15 @@ __metadata: languageName: node linkType: hard +"react-image-crop@npm:^11.0.6": + version: 11.0.6 + resolution: "react-image-crop@npm:11.0.6" + peerDependencies: + react: ">=16.13.1" + checksum: 10c0/8b3bfbfdb34938e16f224090194ad42a72a6265d01980c2525cfdaf1afee0b711e6f457caa854938ef8c68940f15a8f32cad1c2e6440dd75625611f867b385d7 + languageName: node + linkType: hard + "react-is@npm:18.1.0": version: 18.1.0 resolution: "react-is@npm:18.1.0"