Next.js14 이미지 크롭 적용

dobby·2024년 9월 23일
0

이미지 크롭

사용자가 이미지를 업로드할 때 기본적으론 컴포넌트의 크기대로 이미지가 알아서 잘린다.
즉, 어느 영역을 사용하고 싶은지와는 상관없이 중앙을 기준으로 컴포넌트 크기대로 잘린다.

사용자가 알아서 이미지 크기를 알맞게 수정해서 업로드하는 것도 좋지만, 그것보단 원하는 이미지에서 영역을 지정하도록 한다면 더 좋은 서비스를 제공할 수 있지 않을까?

Athens 서비스는 이미지 업로드 기능을 MVP1에 두지 않고 색상으로 채팅방을 구분하도록 했다.
여기에 MVP2로 넘어간 현재, 이미지를 사용해 채팅방 프로필을 지정해주려고 한다.

그렇기 위해선 이미지 업로드 기능을 구현하고, 위에서 언급한 크롭 기능을 추가로 넣어주고자 한다.

결과 미리보기

(항상 느끼는거지만, 영상을 gif로 변환해놓으면 너무 느려져)

이미지 업로드

먼저,<input> 태그를 사용해서 이미지를 업로드하도록 해주자.

<input
  type="file"
  accept=".png, .jpeg, .jpg"
  onChange={onUpload}
  hidden
/>

input 태그의 type을 file로 주게되면, file 선택 창이 뜬다.
여기에 accept를 이미지 확장자로 선언해주면 이미지만 선택할 수 있게 된다.

나는 input 엘리먼트를 클릭하면 창이 뜨도록 하고 싶은게 아니고, 버튼을 따로 둘 것이기 때문에 ref로 연결시켜준다.

const imageRef = useRef<HTMLInputElement>(null);
const [uploadImage, setUploadImage] = useState<
  Array<{ dataUrl: string; file: File }>

const onUpload: ChangeEventHandler<HTMLInputElement> = (e) => {
  e.preventDefault();

  if (e.target.files) {
    Array.from(e.target.files).forEach((file, idx) => {
      const reader = new FileReader();
      reader.onloadend = () => {
        setUploadImage((prevView) => {
          const prev = [...prevView];
          prev[idx] = {
            dataUrl: reader.result as string,
            file,
          };
          return prev;
        });
      };
      reader.readAsDataURL(file);
    });
    // 업로드 후 value 값을 비워줘야 
    // 같은 파일에서 다른 크롭 이미지를 적용하고자 할 때 정상 작동한다.
    e.target.value = '';
  }
};

<input
  type="file"
  accept=".png, .jpeg, .jpg"
  ref={imageRef}
  onChange={onUpload}
  hidden
/>

그리고 이미지를 선택한 후에는 삭제도 가능하게 해야하므로 이미지 선택/삭제 popup 창을 둘 것이다.

const [viewPopup, setViewPopup] = useState(false);

const clickFileInput = () => {
  imageRef.current?.click();
  setViewPopup(false);
};

const removeImage = () => {
  setUploadImage([]);
  setViewPopup(false);
};

<div
  role="menu"
  aria-label="이미지 선택 및 제거 선택 팝업메뉴"
  className={`z-25 transform transition-opacity duration-300 ease-out ${viewPopup ? 'opacity-100' : 'opacity-0 pointer-events-none'} cursor-pointer rounded-md gap-20 flex flex-col absolute ${popupPosition === 'top' ? 'bottom-10' : 'top-1/2'} left-50 p-10 dark:bg-dark-light-200 bg-dark-light-500 text-white text-xs`}
>
  <button
    role="menuitem"
    type="button"
    className="break-keep"
    onClick={clickFileInput}
  >
   이미지 선택
  </button>
  <button
    role="menuitem"
    type="button"
    className="break-keep"
    onClick={removeImage}
  >
   이미지 제거
  </button>
</div>

이미지 미리보기

사용자가 선택한 이미지를 볼 수 있도록 미리보기를 제공하려고 한다.

  • 이미지를 선택하지 않았을 경우, 제거했을 경우엔 기본 스타일을 제공한다.
  • 이미지를 선택한 경우 해당 이미지를 보여준다.
const [cropedPreview, setCropedPreview] = useState<
  Array<{ dataUrl: string; file: File }>
>([]);

{uploadImage[0].dataUrl ? (
   <Image
     src={uploadImage[0].dataUrl}
     alt="아고라 프로필"
     layout="fill"
     objectFit="cover"
     className="rounded-3xl under-mobile:rounded-2xl"
   />
  ) : (
    <div
      aria-hidden
      className="flex justify-center items-center h-full w-full dark:bg-dark-light-500 bg-gray-200 rounded-3xl under-mobile:rounded-2xl"
    >
      <ImageIcon className="w-22 h-22" />
    </div>
)}

이미지 선택/삭제 popup 창은 스크롤 위치에 따라 보여주는 방향이 달라야 한다.
그렇지 않으면 한쪽이 잘려서 보이지 않는 문제가 발생할 수도 있다.

예를 들어,

  • 이미지 컴포넌트가 화면의 최상단에 위치하고 있다면, popup 창을 아래 방향으로 노출시켜주어야 한다.

  • 이미지 컴포넌트가 화면의 최하단에 위치하고 있다면, popup 창을 위 방향으로 노출시켜주어야 한다.

그렇지 않으면 popup 창이 위/아래로 잘리게 된다.
즉, 이미지 컴포넌트의 위치에 따라 동적으로 popup 창의 노출 방향을 조정시켜주여야 하는 것이다.

그리고, popup 창 밖을 클릭하면 창이 닫혀야 하므로, 이벤트도 추가해준다.

const [viewPopup, setViewPopup] = useState(false);
const [popupPosition, setPopupPosition] = useState('top');
const popupRef = useRef<HTMLDivElement>(null);

// popup 창의 출력 방향을 계산한다.
// 설명은 코드 아래에
const calculatePopupPosition = () => {
  if (popupRef.current) {
    const popupTop = popupRef.current.getBoundingClientRect().top;
    const popupBottom =
        window.innerHeight - popupRef.current.getBoundingClientRect().bottom;

    // 상단과 하단 범위를 비교해서 출력시킬 방향을 저장
    if (popupTop < popupBottom) {
      setPopupPosition('bottom');
    } else {
      setPopupPosition('top');
    }
  }
};

const viewPopupHandler = () => {
  calculatePopupPosition(); // 출력시킬 popup 방향 계산
  setViewPopup(true); // popup 출력
};

// popup창의 바깥을 클릭한다면 닫는다.
const handleClickOutside = (event: MouseEvent) => {
  if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
    setViewPopup(false);
  }
};

// 키보드 이벤트
const handlleKeyDown = (e: KeyboardEvent) => {
  if (e.key === 'Enter') {
    setViewPopup(false);
  }
};

// 키보드 이벤트
const handleKeyDownPopupHandler: KeyboardEventHandler<HTMLDivElement> = (
    e,
) => {
  if (e.key === 'Enter') {
    viewPopupHandler();
  }
};

// popup 상태 값이 달라질 때마다 이벤트를 다르게 해준다.
// 보여줄지/보여주지 말지
useEffect(() => {
  if (viewPopup) {
    document.addEventListener('mousedown', handleClickOutside);
    document.addEventListener('keydown', handlleKeyDown);
  } else {
    document.removeEventListener('mousedown', handleClickOutside);
    document.removeEventListener('keydown', handlleKeyDown);
  }

  return () => {
    document.removeEventListener('mousedown', handleClickOutside);
    document.removeEventListener('keydown', handlleKeyDown);
  };
}, [viewPopup]);

// 부모 엘리먼트
 <div
   role="button"
   tabIndex={0}
   aria-label="클릭하여 아고라 프로필 설정"
   ref={popupRef}
   className="relative w-60 h-60 rounded-3xl under-mobile:rounded-2xl cursor-pointer"
   onClick={viewPopupHandler}
   onKeyDown={handleKeyDownPopupHandler}
>
     ...
</div>

MDN의 Element.getBoundingClientRect() 글을 확인하면,
Element.getBoundingClientRect() 메서드는 엘리먼트의 크기와 뷰포트에 상대적인 위치 정보를 제공하는 DOMRect 객체를 반환한다고 한다.

위 사진을 보면, top과 bottom으로 뷰포트상에서 상/하 중에 어느것이 더 범위가 넓은지 알아낼 수 있다.

top은 그대로 getBoundingClientRect().top 을 통해 알아올 수 있다.

bottom은 window 높이 - bottom을 해주면 element의 하단 범위를 알아낼 수 있다.
즉, 코드처럼

 window.innerHeight - popupRef.current.getBoundingClientRect().bottom;

을 해주면 된다.

이미지 크롭 적용

이미지 업로드 기능은 구현했으니, 여기에 크롭 기능을 추가로 적용하자.
나는 크롭 이벤트를 실행할 모달창을 추가로 만들어주었다.

먼저, 이미지 업로드 코드를 수정해야 한다.

const [cropedPreview, setCropedPreview] = useState<
  Array<{ dataUrl: string; file: File }>
>([]);
const [viewCropModal, setViewCropModal] = useState(false);

const onCancelCrop = () => {
  setViewCropModal(false);
};

const removeImage = () => {
  setUploadImage([]);
  setCropedPreview([]); // 추가
  setViewPopup(false);
};

{cropedPreview[0]?.dataUrl && (
  <Image
    src={cropedPreview[0]?.dataUrl ?? image}
    alt="아고라 프로필"
    layout="fill"
    objectFit="cover"
    className="rounded-3xl under-mobile:rounded-2xl"
 />
 ) : (
  <div
    aria-hidden
    className="flex justify-center items-center h-full w-full dark:bg-dark-light-500 bg-gray-200 rounded-3xl under-mobile:rounded-2xl"
  >
    <ImageIcon className="w-22 h-22" />
 </div>
)}

선택한 이미지를 크롭할 수 있도록 따로 관리해주어야 한다.
그래서 기존의 uploadImage와 cropedPreview를 두어 크롭된 이미지와 업로드된 이미지를 따로 관리해준다.

여기에서 전체 코드를 보면 다음과 같다.

'use client';

import CameraIcon from '@/assets/icons/CameraIcon';
import ImageIcon from '@/assets/icons/ImageIcon';
import Image from 'next/image';
import React, {
  ChangeEventHandler,
  KeyboardEventHandler,
  useEffect,
  useRef,
  useState,
} from 'react';
import ImageCropper from '../atoms/ImageCropper';

type Props = {
  image?: string;
};

export default function AgoraImageUpload({ image }: Props) {
  const [uploadImage, setUploadImage] = useState<
    Array<{ dataUrl: string; file: File }>
  >(image ? [{ dataUrl: image, file: new File([], 'image') }] : []);
  const [cropedPreview, setCropedPreview] = useState<
    Array<{ dataUrl: string; file: File }>
  >([]);
  const [viewPopup, setViewPopup] = useState(false);
  const [viewCropModal, setViewCropModal] = useState(false);
  const [popupPosition, setPopupPosition] = useState('top');
  const imageRef = useRef<HTMLInputElement>(null);
  const popupRef = useRef<HTMLDivElement>(null);

  const onUpload: ChangeEventHandler<HTMLInputElement> = (e) => {
    e.preventDefault();

    if (e.target.files) {
      Array.from(e.target.files).forEach((file, idx) => {
        const reader = new FileReader();
        reader.onloadend = () => {
          setUploadImage((prevView) => {
            const prev = [...prevView];
            prev[idx] = {
              dataUrl: reader.result as string,
              file,
            };
            return prev;
          });
          setViewCropModal(true);
        };
        reader.readAsDataURL(file);
      });
      e.target.value = '';
    }
  };

  const clickFileInput = () => {
    imageRef.current?.click();
    setViewPopup(false);
  };

  const onCancelCrop = () => {
    setViewCropModal(false);
  };

  const removeImage = () => {
    setUploadImage([]);
    setCropedPreview([]);
    setViewPopup(false);
  };

  const calculatePopupPosition = () => {
    if (popupRef.current) {
      const popupTop = popupRef.current.getBoundingClientRect().top;
      const popupBottom =
        window.innerHeight - popupRef.current.getBoundingClientRect().bottom;

      if (popupTop < popupBottom) {
        setPopupPosition('bottom');
      } else {
        setPopupPosition('top');
      }
    }
  };

  const viewPopupHandler = () => {
    calculatePopupPosition();
    setViewPopup(true);
  };

  const handleClickOutside = (event: MouseEvent) => {
    if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
      setViewPopup(false);
    }
  };

  const handlleKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Enter') {
      setViewPopup(false);
    }
  };

  useEffect(() => {
    if (viewPopup) {
      document.addEventListener('mousedown', handleClickOutside);
      document.addEventListener('keydown', handlleKeyDown);
    } else {
      document.removeEventListener('mousedown', handleClickOutside);
      document.removeEventListener('keydown', handlleKeyDown);
    }

    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
      document.removeEventListener('keydown', handlleKeyDown);
    };
  }, [viewPopup]);

  const handleKeyDownPopupHandler: KeyboardEventHandler<HTMLDivElement> = (
    e,
  ) => {
    if (e.key === 'Enter') {
      viewPopupHandler();
    }
  };

  return (
    <>
      <div className="relative">
        <div
          role="button"
          tabIndex={0}
          aria-label="클릭하여 아고라 프로필 설정"
          ref={popupRef}
          className="relative w-60 h-60 rounded-3xl under-mobile:rounded-2xl cursor-pointer"
          onClick={viewPopupHandler}
          onKeyDown={handleKeyDownPopupHandler}
        >
          {cropedPreview[0]?.dataUrl || image ? (
            <Image
              src={cropedPreview[0]?.dataUrl ?? image}
              alt="아고라 프로필"
              layout="fill"
              objectFit="cover"
              className="rounded-3xl under-mobile:rounded-2xl"
            />
          ) : (
            <div
              aria-hidden
              className="flex justify-center items-center h-full w-full dark:bg-dark-light-500 bg-gray-200 rounded-3xl under-mobile:rounded-2xl"
            >
              <ImageIcon className="w-22 h-22" />
            </div>
          )}
          <input
            type="file"
            accept=".png, .jpeg, .jpg"
            ref={imageRef}
            onChange={onUpload}
            hidden
          />
          <div
            aria-hidden
            className="flex justify-center items-center absolute top-40 left-40 w-22 h-22 rounded-full bg-dark-line-semilight"
          >
            <CameraIcon className="w-14 h-14" fill="#fffff" />
          </div>
        </div>
        <div
          role="menu"
          aria-label="이미지 선택 및 제거 선택 팝업메뉴"
          ref={popupRef}
          className={`z-25 transform transition-opacity duration-300 ease-out ${viewPopup ? 'opacity-100' : 'opacity-0 pointer-events-none'} cursor-pointer rounded-md gap-20 flex flex-col absolute ${popupPosition === 'top' ? 'bottom-10' : 'top-1/2'} left-50 p-10 dark:bg-dark-light-200 bg-dark-light-500 text-white text-xs`}
        >
          <button
            role="menuitem"
            type="button"
            className="break-keep"
            onClick={clickFileInput}
          >
            이미지 선택
          </button>
          <button
            role="menuitem"
            type="button"
            className="break-keep"
            onClick={removeImage}
          >
            이미지 제거
          </button>
        </div>
      </div>
      {viewCropModal && (
        <ImageCropper
          uploadImage={uploadImage}
          setCropedPreview={setCropedPreview}
          onCancelCrop={onCancelCrop}
        />
      )}
    </>
  );
}

밑의 <ImageCropper> 컴포넌트는 이미지 크롭 기능을 수행하는 모달창을 따로 만들어서 호출해준 것이다.
viewCropModaltrue이면 모달창이 출력되고, false라면 출력되지 않는다.

이미지 크롭 모달

이제 이미지 크롭 기능을 수행하는 모달을 봐보자.
먼저, 나는 react-image-crop 라이브러리를 사용했다.

다른 이미지 크롭 라이브러리가 많았지만,

  • typescript 지원
  • 크롭 영역의 크기를 동적으로 조절할 수 있어야 함
  • 크롭하고자 하는 영역을 이미지 내에서 옮겨다닐 수 있어야 함
  • 크롭 영역이 원, 사각형 모두 가능해야 함

이 조건에 부합하는 라이브러리가 react-image-crop 이었다.

참고로, 나는 유튜브 영상을 많이 참고했다.
react-image-crop 사용 유튜브 영상


먼저, 크롭 모달에 대한 코드를 살펴보면 다음과 같다.

const modalContent = (
    <div className="flex flex-col z-50 fixed top-0 left-0 w-screen h-screen bg-dark-bg-dark">
      <div className="text-dark-line-light font-semibold flex justify-between items-center p-10">
        <button
          type="button"
          aria-label="이미지 변경 취소하기"
          className="font-normal flex items-center gap-x-5"
          onClick={onCancelCrop}
          onKeyDown={handleKeyDownCancelCrop}
        >
          <BackIcon className="w-20 h-20" />
          취소
        </button>
        자르기
        <button
          aria-label="이미지 변경 완료"
          onClick={handleImgCrop}
          onKeyDown={handleKeyDownImgCrop}
          type="button"
        >
          <CheckIcon className="w-25 h-25" fill="#fffff" />
        </button>
      </div>
      <div className="w-full h-full flex flex-1 justify-center items-center p-32">
        <ReactCrop // 이미지 크롭 기능을 제공
          crop={crop}
          aspect={1}
          keepSelection
          onChange={(newCrop) => setCrop(newCrop)}
          className="w-fit h-fit"
        >
          <Image // 사용자가 선택한 이미지 (배경이 된다.)
            aria-label="선택한 이미지"
            ref={imgRef}
            onLoad={({ currentTarget }) => onImageLoaded(currentTarget)}
            src={uploadImage[0].dataUrl}
            alt="preview"
            width={500}
            height={500}
            style={{ height: '100%', width: '100%' }}
          />
        </ReactCrop>
      </div>
      {crop && ( // 미리보기 영역
        <canvas
          aria-hidden
          ref={canvasRef}
          className="mt-12"
          style={{
            display: 'none',
            border: '1px solid #000',
            objectFit: 'contain',
            width: Math.round(crop.width ?? 0),
            height: Math.round(crop.height ?? 0),
          }}
        />
      )}
    </div>
  );

크롭에서의 미리보기는 사실 없어도 돼서 제거했었는데, 제거하니 기능이 제대로 동작하지 않아서 aria-hiddendisplay: none으로 가려주었다.

이제 함수 및 상태들을 살펴보자.

  const [crop, setCrop] = useState<Crop>();
  const imgRef = useRef<HTMLImageElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  • crop은 크롭시 적용할 설정들을 저장한다.
  • imgRef는 사용자가 선택했던 이미지에 대한 ref이다.
  • canvasRef는 미리보기에 대한 ref이다.
  const onImageLoaded = (e: HTMLImageElement) => {
    const { width, height } = e;
    const maxSize = Math.min(width, height);

    const initialCrop: Crop = centerCrop(
      makeAspectCrop(
        {
          unit: 'px', // 픽셀 단위로 설정
          width: maxSize,
          height: maxSize,
        },
        1, // 1:1 비율 (정사각형)
        width,
        height,
      ),
      width,
      height,
    );

    setCrop(initialCrop);
    return false;
  };

사용자가 선택한 이미지가 로드되면, 위의 함수를 실행시켜준다.
이미지에 대한 너비와 높이를 받아와 crop 설정에 추가해준다.

  const handleImgCrop = () => {
    if (!imgRef.current || !canvasRef.current || !crop) {
      return;
    }

    getCroppedImg(
      imgRef.current,
      canvasRef.current,
      convertToPixelCrop(crop, imgRef.current?.width, imgRef.current?.height),
    );
    const dataUrl = canvasRef.current.toDataURL();
    const file = new File([dataUrl], 'image');
    setCropedPreview([{ dataUrl, file }]);
    onCancelCrop();
  };

사용자 지정 이미지나 크롭에 대한 Ref, crop이 없다면 return 해주도록 한다.
getCroppedImg는 다른 파일에서 불러오는 함수이다.
크롭된 이미지를 얻어온다.

이후엔 canvasRef에 크롭된 이미지가 저장되고, 이것으로 이미지를 관리해준다.

모달창이 닫힌 후에 사용자에게 크롭된 이미지를 제공해주어야 하므로 state를 사용해 크롭된 이미지를 저장해준다.

  const handleKeyDownImgCrop = (e: KeyboardEvent<HTMLButtonElement>) => {
    if (e.key === 'Enter') {
      handleImgCrop();
    }
  };

  const handleKeyDownCancelCrop = (e: KeyboardEvent<HTMLButtonElement>) => {
    if (e.key === 'Enter') {
      onCancelCrop();
    }
  };

모두 키보드에 대한 이벤트 함수이다.

전체 코드를 보면 다음과 같다.

import 'client-only';

import BackIcon from '@/assets/icons/BackIcon';
import CheckIcon from '@/assets/icons/CheckIcon';
import Image from 'next/image';
import React, { KeyboardEvent, useRef, useState } from 'react';
import ReactCrop, {
  Crop,
  centerCrop,
  convertToPixelCrop,
  makeAspectCrop,
} from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
import getCroppedImg from '@/utils/getCroppedImg';
import { createPortal } from 'react-dom';

type Props = {
  uploadImage: Array<{ dataUrl: string; file: File }>;
  setCropedPreview: React.Dispatch<
    React.SetStateAction<Array<{ dataUrl: string; file: File }>>
  >;
  onCancelCrop: () => void;
};

export default function ImageCropper({
  uploadImage,
  setCropedPreview,
  onCancelCrop,
}: Props) {
  const [crop, setCrop] = useState<Crop>();
  const imgRef = useRef<HTMLImageElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const onImageLoaded = (e: HTMLImageElement) => {
    const { width, height } = e;
    const maxSize = Math.min(width, height);

    const initialCrop: Crop = centerCrop(
      makeAspectCrop(
        {
          unit: 'px', // 픽셀 단위로 설정
          width: maxSize,
          height: maxSize,
        },
        1, // 1:1 비율 (정사각형)
        width,
        height,
      ),
      width,
      height,
    );

    setCrop(initialCrop);
    return false;
  };

  const handleImgCrop = () => {
    if (!imgRef.current || !canvasRef.current || !crop) {
      return;
    }

    getCroppedImg(
      imgRef.current,
      canvasRef.current,
      convertToPixelCrop(crop, imgRef.current?.width, imgRef.current?.height),
    );
    const dataUrl = canvasRef.current.toDataURL();
    const file = new File([dataUrl], 'image');
    setCropedPreview([{ dataUrl, file }]);
    onCancelCrop();
  };

  const handleKeyDownImgCrop = (e: KeyboardEvent<HTMLButtonElement>) => {
    if (e.key === 'Enter') {
      handleImgCrop();
    }
  };

  const handleKeyDownCancelCrop = (e: KeyboardEvent<HTMLButtonElement>) => {
    if (e.key === 'Enter') {
      onCancelCrop();
    }
  };

  const modalContent = (
    <div className="flex flex-col z-50 fixed top-0 left-0 w-screen h-screen bg-dark-bg-dark">
      <div className="text-dark-line-light font-semibold flex justify-between items-center p-10">
        <button
          type="button"
          aria-label="이미지 변경 취소하기"
          className="font-normal flex items-center gap-x-5"
          onClick={onCancelCrop}
          onKeyDown={handleKeyDownCancelCrop}
        >
          <BackIcon className="w-20 h-20" />
          취소
        </button>
        자르기
        <button
          aria-label="이미지 변경 완료"
          onClick={handleImgCrop}
          onKeyDown={handleKeyDownImgCrop}
          type="button"
        >
          <CheckIcon className="w-25 h-25" fill="#fffff" />
        </button>
      </div>
      <div className="w-full h-full flex flex-1 justify-center items-center p-32">
        <ReactCrop
          crop={crop}
          aspect={1}
          keepSelection
          onChange={(newCrop) => setCrop(newCrop)}
          className="w-fit h-fit"
        >
          <Image
            aria-label="선택한 이미지"
            ref={imgRef}
            onLoad={({ currentTarget }) => onImageLoaded(currentTarget)}
            src={uploadImage[0].dataUrl}
            alt="preview"
            width={500}
            height={500}
            style={{ height: '100%', width: '100%' }}
          />
        </ReactCrop>
      </div>
      {crop && (
        <canvas
          aria-hidden
          ref={canvasRef}
          className="mt-12"
          style={{
            display: 'none',
            border: '1px solid #000',
            objectFit: 'contain',
            width: Math.round(crop.width ?? 0),
            height: Math.round(crop.height ?? 0),
          }}
        />
      )}
    </div>
  );

  return createPortal(modalContent, document.body); // 포탈로 모달을 body로 이동
}

이제 얼마 안남았다!!!!

크롭된 이미지 저장하기

import { PixelCrop } from 'react-image-crop';

const getCroppedImg = (
  image: HTMLImageElement,
  originalCanvas: HTMLCanvasElement,
  crop: PixelCrop,
) => {
  const canvas = originalCanvas; // canvas의 복사본을 생성
  const ctx = canvas.getContext('2d');

  if (!ctx) {
    throw new Error('No 2d context');
  }

  const pixelRatio = window.devicePixelRatio;
  const scaleX = image.naturalWidth / image.width;
  const scaleY = image.naturalHeight / image.height;

  canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
  canvas.height = Math.floor(crop.height * scaleY * pixelRatio);

  ctx.scale(pixelRatio, pixelRatio);
  ctx.imageSmoothingQuality = 'high';
  ctx.save();

  const cropX = crop.x * scaleX;
  const cropY = crop.y * scaleY;

  // 5) Move the crop origin to the canvas origin (0,0)
  ctx.translate(-cropX, -cropY);
  ctx.drawImage(
    image,
    0,
    0,
    image.naturalWidth,
    image.naturalHeight,
    0,
    0,
    image.naturalWidth,
    image.naturalHeight,
  );

  ctx.restore();
};

export default getCroppedImg;

위 코드는 라이브러리를 제공하는 깃에서 함께 제공한다.
거기서 필요한 부분만 남겨놓는다.
이는 모두 유튜브 영상을 보면서 수정했다.

정리

생각보다 코드가 많다.
크롭 기능은 사실 제공해도 되고, 안해도 되는 부분이다.

그래도 사용자 편의성과 UX를 위해서는 제공하는게 더 좋을 것 같았다.
내가 써본 다른 서비스에서도 내가 직접 이미지를 조정하는게 좋았기 때문이다.

그래도 잘 동작하는 것을 보면, 시간을 쓴 보람이 있는 것 같다.
유튜브 영상을 보면서 따라하면, 생각보다 그리 어렵지는 않았다.

profile
성장통을 겪고 있습니다.

0개의 댓글