Skip to content

Commit c694224

Browse files
Merge pull request #74 from ShipFriend0516/feature/clipboard-image-paste
feat: 글 에디터에서 이미지 붙여넣기시 즉시 업로드하는 기능 추가
2 parents f545fa0 + 07ace4d commit c694224

4 files changed

Lines changed: 191 additions & 42 deletions

File tree

app/entities/post/write/BlogForm.tsx

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import useOverlay from '@/app/hooks/common/useOverlay';
1616
import useAutoSync from '@/app/hooks/post/useAutoSync';
1717
import useCloudDraft from '@/app/hooks/post/useCloudDraft';
1818
import useDraft from '@/app/hooks/post/useDraft';
19+
import { usePasteImageUpload } from '@/app/hooks/post/usePasteImageUpload';
1920
import usePost from '@/app/hooks/post/usePost';
2021
import useTheme from '@/app/hooks/useTheme';
2122
import useToast from '@/app/hooks/useToast';
@@ -78,6 +79,13 @@ const BlogForm = () => {
7879
);
7980
const { isOpen: openImageBox, setIsOpen: setOpenImageBox } = useOverlay();
8081

82+
const { containerRef } = usePasteImageUpload({
83+
content: formData.content || '',
84+
setFormData,
85+
setUploadedImages,
86+
toast,
87+
});
88+
8189
// 클라우드 임시저장본 조회
8290
useEffect(() => {
8391
fetchCloudDrafts().catch((error) => {
@@ -269,29 +277,35 @@ const BlogForm = () => {
269277
/>
270278
</Overlay>
271279

272-
<MDEditor
273-
value={formData.content}
274-
onChange={(value) => setFormData({ content: value })}
275-
height={500}
276-
minHeight={500}
277-
visibleDragbar={false}
278-
data-color-mode={theme}
279-
previewOptions={{
280-
wrapperElement: {
281-
'data-color-mode': theme,
282-
},
283-
rehypeRewrite: (node, index?, parent?) => {
284-
asideStyleRewrite(node);
285-
renderYoutubeEmbed(node, index || 0, parent as Element | undefined);
286-
addImageClickHandler(node);
287-
addDescriptionUnderImage(
288-
node,
289-
index,
290-
parent as Element | undefined
291-
);
292-
},
293-
}}
294-
/>
280+
<div ref={containerRef}>
281+
<MDEditor
282+
value={formData.content}
283+
onChange={(value) => setFormData({ content: value })}
284+
height={500}
285+
minHeight={500}
286+
visibleDragbar={false}
287+
data-color-mode={theme}
288+
previewOptions={{
289+
wrapperElement: {
290+
'data-color-mode': theme,
291+
},
292+
rehypeRewrite: (node, index?, parent?) => {
293+
asideStyleRewrite(node);
294+
renderYoutubeEmbed(
295+
node,
296+
index || 0,
297+
parent as Element | undefined
298+
);
299+
addImageClickHandler(node);
300+
addDescriptionUnderImage(
301+
node,
302+
index,
303+
parent as Element | undefined
304+
);
305+
},
306+
}}
307+
/>
308+
</div>
295309
<UploadImageContainer
296310
uploadedImages={uploadedImages}
297311
setUploadedImages={setUploadedImages}

app/entities/post/write/UploadImageContainer.tsx

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from 'react';
99
import { FaImage } from 'react-icons/fa';
1010
import UploadedImage from '@/app/entities/post/write/UploadedImage';
11+
import { uploadImageFile } from '@/app/lib/utils/imageUpload';
1112

1213
interface UploadImageContainerProps {
1314
onClick: (link: string) => void;
@@ -43,24 +44,8 @@ const UploadImageContainer = ({
4344

4445
setUploadProgress({ current: i + 1, total: files.length });
4546

46-
const formData = new FormData();
47-
formData.append('file', file);
48-
49-
const response = await fetch('/api/upload', {
50-
method: 'POST',
51-
body: formData,
52-
});
53-
54-
if (!response.ok) {
55-
const error = await response.json();
56-
throw new Error(error.error || '업로드 실패');
57-
}
58-
59-
const data = await response.json();
60-
61-
if (data.success && data.url) {
62-
setUploadedImages((prev) => [...prev, data.url]);
63-
}
47+
const url = await uploadImageFile(file);
48+
setUploadedImages((prev) => [...prev, url]);
6449
}
6550

6651
return;
@@ -130,7 +115,9 @@ const UploadImageContainer = ({
130115
업로드 중... ({uploadProgress.current}/{uploadProgress.total})
131116
</p>
132117
) : (
133-
<p className={'text-gray-600 dark:text-gray-400'}>클릭하여 링크 복사</p>
118+
<p className={'text-gray-600 dark:text-gray-400'}>
119+
클릭하여 링크 복사
120+
</p>
134121
)}
135122
</div>
136123
<div
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { uploadImageFile } from '@/app/lib/utils/imageUpload';
3+
4+
interface UsePasteImageUploadProps {
5+
content: string;
6+
setFormData: (updates: { content?: string }) => void;
7+
setUploadedImages: React.Dispatch<React.SetStateAction<string[]>>;
8+
toast: {
9+
success: (message: string) => void;
10+
error: (message: string) => void;
11+
};
12+
}
13+
14+
interface UploadProgress {
15+
current: number;
16+
total: number;
17+
}
18+
19+
export function usePasteImageUpload({
20+
content,
21+
setFormData,
22+
setUploadedImages,
23+
toast,
24+
}: UsePasteImageUploadProps) {
25+
const containerRef = useRef<HTMLDivElement>(null);
26+
const [isUploading, setIsUploading] = useState(false);
27+
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({
28+
current: 0,
29+
total: 0,
30+
});
31+
32+
useEffect(() => {
33+
const container = containerRef.current;
34+
if (!container) return;
35+
36+
const handlePaste = async (e: ClipboardEvent) => {
37+
const items = e.clipboardData?.items;
38+
if (!items) return;
39+
40+
// 이미지가 복사되었는지 확인
41+
const imageItems = Array.from(items).filter((item) =>
42+
item.type.startsWith('image/')
43+
);
44+
45+
if (imageItems.length === 0) return;
46+
47+
e.preventDefault();
48+
49+
const textarea = container.querySelector('textarea');
50+
const cursorPosition = textarea?.selectionStart ?? content.length;
51+
52+
setIsUploading(true);
53+
setUploadProgress({ current: 0, total: imageItems.length });
54+
55+
const uploadedUrls: string[] = [];
56+
57+
// 이미지 업로드
58+
for (let i = 0; i < imageItems.length; i++) {
59+
try {
60+
const file = imageItems[i].getAsFile();
61+
if (!file) continue;
62+
63+
toast.success(
64+
`이미지를 업로드하는 중입니다... (${i + 1}/${imageItems.length})`
65+
);
66+
67+
const url = await uploadImageFile(file);
68+
uploadedUrls.push(url);
69+
70+
setUploadProgress({ current: i + 1, total: imageItems.length });
71+
} catch (error) {
72+
const errorMessage =
73+
error instanceof Error ? error.message : '알 수 없는 오류';
74+
toast.error(`이미지 업로드 실패: ${errorMessage}`);
75+
}
76+
}
77+
78+
// 업로드된 이미지 마크다운에 추가
79+
if (uploadedUrls.length > 0) {
80+
const markdownImages = uploadedUrls
81+
.map((url) => `![이미지](${url})`)
82+
.join('\n');
83+
84+
const beforeCursor = content.slice(0, cursorPosition);
85+
const afterCursor = content.slice(cursorPosition);
86+
const newContent = beforeCursor + markdownImages + afterCursor;
87+
88+
setFormData({ content: newContent });
89+
setUploadedImages((prev) => [...prev, ...uploadedUrls]);
90+
91+
toast.success('이미지가 성공적으로 삽입되었습니다.');
92+
}
93+
94+
setIsUploading(false);
95+
setUploadProgress({ current: 0, total: 0 });
96+
};
97+
98+
container.addEventListener('paste', handlePaste);
99+
100+
return () => {
101+
container.removeEventListener('paste', handlePaste);
102+
};
103+
}, [content, setFormData, setUploadedImages, toast]);
104+
105+
return {
106+
containerRef,
107+
isUploading,
108+
uploadProgress,
109+
};
110+
}

app/lib/utils/imageUpload.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* 이미지를 서버에 업로드합니다
3+
* @param file - 업로드할 이미지 파일
4+
* @returns 업로드된 이미지 URL을 반환하는 Promise
5+
* @throws 파일이 이미지가 아니거나 업로드 실패, 응답이 유효하지 않은 경우 에러 발생
6+
*/
7+
export async function uploadImageFile(file: File): Promise<string> {
8+
// 검증
9+
if (!file.type.startsWith('image/')) {
10+
throw new Error('이미지 파일만 업로드할 수 있습니다.');
11+
}
12+
13+
// formData 생성
14+
const formData = new FormData();
15+
formData.append('file', file);
16+
17+
// 이미지 업로드 요청
18+
const response = await fetch('/api/upload', {
19+
method: 'POST',
20+
body: formData,
21+
});
22+
23+
// 응답 검증
24+
if (!response.ok) {
25+
const error = await response.json();
26+
throw new Error(error.error || '업로드 실패');
27+
}
28+
29+
// 응답 파싱
30+
const data = await response.json();
31+
32+
// 응답 데이터 검증
33+
if (!data.success || !data.url) {
34+
throw new Error('업로드 URL을 받지 못했습니다.');
35+
}
36+
37+
return data.url;
38+
}

0 commit comments

Comments
 (0)