프로젝트

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, `![image](${url})\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, `![image](${url})\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(`![image](${url})`);
            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, `![image](${url})\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(`![image](${await uploadImage(file)})`);
            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.execCommand deprecated: 현재까지는 작동하나 장기적으로 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 에러 없음