프로젝트
react-md-editor 이미지 업로드 구현
2026년 3월 9일
@uiw/react-md-editor 이미지 업로드 구현
@uiw/react-md-editor는 기본 이미지 업로드 기능이 없다. 라이브러리 자체는 텍스트/마크다운 문법 편집에 집중하며, 파일 처리는 직접 구현해야 한다.
공식 GitHub 이슈 답변: "This needs to interact with the backend. You need to implement this yourself." — 메인테이너
구현 방법 3가지
| 방법 | 트리거 | 난이도 |
|---|---|---|
| 붙여넣기(Clipboard) | Ctrl+V로 이미지 붙여넣기 | 낮음 |
| 드래그 앤 드롭 | 파일을 에디터에 드래그 | 낮음 |
| 툴바 버튼 | 커스텀 버튼 클릭 → 파일 선택 | 중간 |
공통 유틸 — 이미지 업로드 함수
ts
const uploadImage = async (file: File): Promise<string> => {
const formData = new FormData();
formData.append('image', file);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const { url } = await res.json();
return url;
};
서버 없이 테스트할 경우 Base64 인라인으로 대체 가능:
ts
const toBase64 = (file: File): Promise<string> =>
new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
방법 1 — 붙여넣기(Paste)
클립보드에서 이미지를 붙여넣을 때 감지하는 방법.
.w-md-editor-text-input은 에디터 내부 <textarea>의 고정 CSS 클래스셀렉터이다.
tsx
useEffect(() => {
const onPaste = async (e: ClipboardEvent) => {
for (const item of Array.from(e.clipboardData?.items ?? [])) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (!file) continue;
const url = await uploadImage(file);
document.execCommand('insertText', false, `\n`);
}
}
};
const textarea = document.querySelector('.w-md-editor-text-input');
textarea?.addEventListener('paste', onPaste);
return () => textarea?.removeEventListener('paste', onPaste);
}, []);
document.execCommand('insertText')는 deprecated지만 textarea에서 커서 위치를 유지하며 텍스트를 삽입하는 현실적인 방법이다. 대안으로textarea.setRangeText()+input이벤트 dispatch를 사용할 수도 있다.
방법 2 — 드래그 앤 드롭
<MDEditor>의 onDrop / onDragOver prop을 직접 활용한다.
tsx
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
const file = e.dataTransfer.files[0];
if (!file?.type.startsWith('image/')) return;
e.preventDefault();
const url = await uploadImage(file);
const textarea = document.querySelector<HTMLTextAreaElement>('.w-md-editor-text-input');
textarea?.focus();
document.execCommand('insertText', false, `\n`);
};
<MDEditor
value={value}
onChange={setValue}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()} // 이 줄 없으면 drop 이벤트 발생 안 함
/>
react-dropzone을 활용하면 드래그 오버레이 등 UI를 더 세밀하게 제어할 수 있다.
방법 3 — 툴바 커스텀 버튼
ICommand 인터페이스로 커스텀 툴바 버튼을 추가한다.
execute 콜백에서 제공되는 api.replaceSelection()이 가장 깔끔한 삽입 방법이다.
tsx
import MDEditor, { commands, ICommand, TextAreaTextApi } from '@uiw/react-md-editor';
export default function Editor() {
const [value, setValue] = useState('');
const apiRef = useRef<TextAreaTextApi | null>(null);
const imageCommand: ICommand = {
name: 'upload-image',
keyCommand: 'upload-image',
buttonProps: { title: '이미지 업로드', 'aria-label': '이미지 업로드' },
icon: (
<svg width="12" height="12" viewBox="0 0 20 20">
<path fill="currentColor" d="M15 9c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4-7H1c-.55 0-1 .45-1 1v14c0 .55.45 1 1 1h18c.55 0 1-.45 1-1V3c0-.55-.45-1-1-1zm-1 13l-6-5-2 2-4-5-4 8V4h16v11z" />
</svg>
),
execute: (_state, api) => {
apiRef.current = api;
document.getElementById('md-img-input')?.click();
},
};
return (
<>
<input
id="md-img-input"
type="file"
accept="image/*"
hidden
onChange={async (e) => {
const file = e.target.files?.[0];
if (file && apiRef.current) {
const url = await uploadImage(file);
apiRef.current.replaceSelection(``);
e.target.value = ''; // 동일 파일 재선택 가능하게 초기화
}
}}
/>
<MDEditor
value={value}
onChange={setValue}
commands={[
commands.bold,
commands.italic,
commands.divider,
imageCommand,
]}
/>
</>
);
}
세 방법 통합 예시
tsx
import MDEditor, { commands, ICommand, TextAreaTextApi } from '@uiw/react-md-editor';
import { useEffect, useRef, useState } from 'react';
const uploadImage = async (file: File): Promise<string> => {
const formData = new FormData();
formData.append('image', file);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const { url } = await res.json();
return url;
};
const insertAtCursor = (url: string) => {
document.querySelector<HTMLTextAreaElement>('.w-md-editor-text-input')?.focus();
document.execCommand('insertText', false, `\n`);
};
export default function MarkdownEditor() {
const [value, setValue] = useState('');
const apiRef = useRef<TextAreaTextApi | null>(null);
// 붙여넣기
useEffect(() => {
const onPaste = async (e: ClipboardEvent) => {
for (const item of Array.from(e.clipboardData?.items ?? [])) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) insertAtCursor(await uploadImage(file));
}
}
};
const el = document.querySelector('.w-md-editor-text-input');
el?.addEventListener('paste', onPaste);
return () => el?.removeEventListener('paste', onPaste);
}, []);
const imageCommand: ICommand = {
name: 'upload-image',
keyCommand: 'upload-image',
buttonProps: { title: '이미지 업로드' },
icon: <span>IMG</span>,
execute: (_s, api) => {
apiRef.current = api;
document.getElementById('img-input')?.click();
},
};
return (
<>
<input
id="img-input"
type="file"
accept="image/*"
hidden
onChange={async (e) => {
const file = e.target.files?.[0];
if (file && apiRef.current) {
apiRef.current.replaceSelection(`})`);
e.target.value = '';
}
}}
/>
<MDEditor
value={value}
onChange={setValue}
onDrop={async (e) => {
const file = e.dataTransfer.files[0];
if (file?.type.startsWith('image/')) {
e.preventDefault();
insertAtCursor(await uploadImage(file));
}
}}
onDragOver={(e) => e.preventDefault()}
commands={[commands.bold, commands.italic, commands.divider, imageCommand]}
/>
</>
);
}
주요 주의사항
document.execCommanddeprecated: 현재까지는 작동하나 장기적으로textarea.setRangeText()+input이벤트 dispatch로 교체 권장.w-md-editor-text-input셀렉터: 라이브러리 내부 클래스로 버전에 따라 변경될 수 있음 — 업그레이드 시 확인 필요api.replaceSelection(): 툴바execute콜백에서만 사용 가능. 이 방법이 가장 공식적이고 안전함onDragOver={(e) => e.preventDefault()}: 반드시 추가해야onDrop이 정상 동작함- Next.js
dynamic import: 에디터 컴포넌트 전체를dynamic(() => import(...), { ssr: false })로 감싸야 SSR 에러 없음