프로젝트를 진행하던 중 사용자가 직접 이미지를 잘라서 넣어줘야 할 상황이 생겼다.
그렇게 이것저것 찾아보던 중 DALL-E 와 이미지 편집기를 사용하는 것을 보게 되었다.
https://reflowhq.com/learn/image-editor-dall-e-next/ 를 참고하여 배웠고 작성 해본다.
next.js는 기본적으로 세팅 되어있다고 보고 들어가보면
1. 이미지 편집기 만들기
우선 이미지 편집기를 만들어 줘야 한다.
// src/components/ImageEditor.tsx
import { useState } from "react";
export default function ImageEditor() {
const [src, setSrc] = useState("");
return <div>{src && <img src={src} />}</div>;
이후에 ImageEditor를 app/page.tsx에 가져온다.
import ImageEditor from "@/components/ImageEditor";
export default function Home() {
return <ImageEditor />;
}
편집 도구를 보관할 컴포넌트도 필요하다.
여기서 업로드, 다운로드 버튼은 react-icons를 사용해서 구현했다.
따라해보면 우선
$ npm install react-icons
다운로드 해준 후
// src/components/Navigation.tsx
"use client";
import { useRef } from "react";
import { FiUpload, FiDownload } from "react-icons/fi";
import IconButton from "@/components/icons/IconButton";
interface Props {
onUpload?: (blob: string) => void;
onDownload?: () => void;
}
export default function Navigation({ onUpload, onDownload }: Props) {
const inputRef = useRef < HTMLInputElement > null;
const onUploadButtonClick = () => {
inputRef.current?.click();
};
const onLoadImage = (event: React.ChangeEvent<HTMLInputElement>) => {
const { files } = event.target;
if (files && files[0]) {
if (onUpload) {
onUpload(URL.createObjectURL(files[0]));
}
}
event.target.value = "";
};
return (
<div className="flex justify-between bg-slate-900 p-5">
<IconButton title="Upload image" onClick={onUploadButtonClick}>
<FiUpload />
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={onLoadImage}
className="hidden"
/>
</IconButton>
<IconButton title="Download image" onClick={onDownload}>
<FiDownload />
</IconButton>
</div>
);
}
// src/components/icons/IconButton.tsx
import { ReactNode } from "react";
interface Props {
onClick?: () => void;
active?: boolean;
disabled?: boolean;
title?: string;
children: ReactNode;
}
export default function IconButton({
onClick,
active,
disabled,
title,
children,
}: Props) {
return (
<button
className={`w-[46px] h-[46px] flex items-center justify-center hover:bg-slate-300/10 rounded-full ${
active ? "text-sky-300 bg-slate-300/10" : "text-slate-300"
}`}
title={title}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
두 컴포넌트를 만들고 Navigation을 ImageEditor에 추가하고 기본 업로드 및 다운로드 기능을 추가한다.
// src/components/ImageEditor.tsx
export default function ImageEditor() {
const [src, setSrc] = useState('');
const onUpload = (objectUrl: string) => {
setSrc(objectUrl);
};
const onDownload = async () => {
if (src) {
downloadImage(src);
}
};
const downloadImage = (objectUrl: string) => {
const linkElement = document.createElement("a");
linkElement.download = "image.png";
linkElement.href = objectUrl;
linkElement.click();
};
return (
<div>
{src && <img src={src}>}
<Navigation
onUpload={onUpload}
onDownload={onDownload}
/>
</div>
);
}
이정도만 해도 우선 이미지를 올리고 다시 다운받기까지 할 수 있다.
이제 편집하는 기능을 만들거다.
2. 이미지 자르기
이미지 마스크를 만들어 볼 것이다.
$ npm install react-advanced-cropper
이를 다운받아 자르기 모드를 만들 준비를 한다.
// src/components/ImageEditor.tsx
"use client";
import { useState, useRef } from "react";
import {
FixedCropperRef,
FixedCropper,
ImageRestriction,
} from "react-advanced-cropper";
import "react-advanced-cropper/dist/style.css";
import Navigation from "@/components/Navigation";
export default function ImageEditor() {
const cropperRef = useRef<FixedCropperRef>(null);
const [src, setSrc] = useState("");
const [mode, setMode] = useState("crop");
const isGenerating = mode === "generate";
const crop = async () => {
const imageSrc = await getCroppedImageSrc();
if (imageSrc) {
setSrc(imageSrc);
setMode("generate");
}
};
const onUpload = (imageSrc: string) => {
setSrc(imageSrc);
setMode("crop");
};
const onDownload = async () => {
if (isGenerating) {
downloadImage(src);
return;
}
const imageSrc = await getCroppedImageSrc();
if (imageSrc) {
downloadImage(imageSrc);
}
};
const downloadImage = (objectUrl: string) => {
const linkElement = document.createElement("a");
linkElement.download = "image.png";
linkElement.href = objectUrl;
linkElement.click();
};
const getCroppedImageSrc = async () => {
if (!cropperRef.current) return;
const canvas = cropperRef.current.getCanvas({
height: 1024,
width: 1024,
});
if (!canvas) return;
const blob = (await getCanvasData(canvas)) as Blob;
return blob ? URL.createObjectURL(blob) : null;
};
const getCanvasData = async (canvas: HTMLCanvasElement | null) => {
return new Promise((resolve, reject) => {
canvas?.toBlob(resolve);
});
};
return (
<div className="w-full bg-slate-950 rounded-lg overflow-hidden">
{isGenerating ? (
<img src={src} />
) : (
<FixedCropper
src={src}
ref={cropperRef}
className={"h-[600px]"}
stencilProps={{
movable: false,
resizable: false,
lines: false,
handlers: false,
}}
stencilSize={{
width: 600,
height: 600,
}}
imageRestriction={ImageRestriction.stencil}
/>
)}
<Navigation
mode={mode}
onUpload={onUpload}
onDownload={onDownload}
onCrop={crop}
/>
</div>
);
}
react-fixed-cropper가 제공해주는 FixedCropper로 이미지를 자를 수 있다.
잘린 이미지가 포함된 HTML 캔버스 요소를 반환하는 getCanvas() 함수에 접근하기 위해 cropperRef를 활용할 수 있다.
이제 Navigation에 Crop버튼을 추가해 사용자가 확인 후 버튼을 눌러 자를 수 있게 한다.
// src/components/Navigation.tsx
return (
<div className="flex justify-between bg-slate-900 p-5">
<IconButton title="Upload image" onClick={onUploadButtonClick}>
<FiUpload />
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={onLoadImage}
className="hidden"
/>
</IconButton>
<div className="flex grow items-center justify-center gap-2 mx-20">
{mode === "crop" && <Button onClick={onCrop}>Crop</Button>}
</div>
<IconButton title="Download image" onClick={onDownload}>
<FiDownload />
</IconButton>
</div>
);
난 우선 정해진 사이즈로 사용자가 자르게 하려고 했어서 여기까지만 구현하니 끝이 났었다.
만약 이미지 마스킹 시키려면
// src/components/ImageSelector.tsx
"use client";
import {
Cropper,
CropperRef,
Coordinates,
ImageSize,
} from "react-advanced-cropper";
interface Props {
src: string;
selectionRect?: Coordinates | null;
onSelectionChange: (cropper: CropperRef) => void;
}
export default function ImageSelector({
src,
selectionRect,
onSelectionChange,
}: Props) {
const defaultCoordinates = ({ imageSize }: { imageSize: ImageSize }) => {
return (
selectionRect || {
top: imageSize.width * 0.1,
left: imageSize.width * 0.1,
width: imageSize.width * 0.8,
height: imageSize.height * 0.8,
}
);
};
return (
<Cropper
src={src}
className={"h-[600px]"}
stencilProps={{
overlayClassName: "cropper-overlay",
}}
backgroundWrapperProps={{
scaleImage: false,
moveImage: false,
}}
defaultCoordinates={defaultCoordinates}
onChange={onSelectionChange}
/>
);
}
이미지 url, 좌표 를 저장할 함수인 onSelectionChange와 좌표를 저장할 selectionRect를 받는다. 이 좌표를 이용해 이미지 마스크를 생성하는 것 이다.
ImageEditor의 <img/>를 새로 생성한 ImageSelector로 바꾸고 selectionRect와 onSelectionChange를 구현을 한다.
// src/components/ImageEditor.tsx
const [selectionRect, setSelectionRect] = useState<Coordinates | null>();
const onSelectionChange = (cropper: CropperRef) => {
setSelectionRect(cropper.getCoordinates());
};
...
return (
...
{isGenerating ? (
<ImageSelector
src={src}
selectionRect={selectionRect}
onSelectionChange={onSelectionChange}
/>
) : (
<FixedCropper ... />
)}
...
)
이제 이미지의 일부를 쉽게 선택할 수 있다.
'이것저것' 카테고리의 다른 글
| 카카오 주소 검색 API (4) | 2024.10.06 |
|---|---|
| git push 에러! (6) | 2024.09.21 |
| 다음 우편번호 api 사용해보기 (0) | 2024.07.24 |
| Framer Motion과 Intersection Observer (0) | 2024.06.22 |
| PWA (1) | 2024.06.13 |