-
마크다운 에디터 구현하기(+preview)React/Editor 2025. 1. 9. 18:21
이 글은 mate프로젝트 중 작성되었습니다.
2025.01.09 - [Mate프로젝트] - #4 마크다운 에디터 구현
서론
마크다운 에디터와 미리보기를 구현하려한다. 사실 하나도 할 줄 모르지만, 차근차근 해내면 결코 어려운 일이 아니라고 생각한다.
(AI가 함께할테니까)일단 먼저 만들고싶은 목표를 구체화해보자.
이것이 최종 목표다.
일단 먼저 어떠한 기능이 되야하는지 먼저 생각하고 출발하자.
반드시 동작되어야 하는기능
1. 기본적인 Edit 기능.
- 툴바 선택 시 기대되는 기능(H를 누르면 h1태그의 제목처럼 표시되어야 하는기능
- 작성된 부분을 드래그하고 툴바를 클릭하면 그 기능이 동작되어야함.
2. 우측의 preView
- 좌측에 작성된 마크다운이 우측에 그대로 보여져야한다.
- 또한 등록하게 된다면, 우측의 본문이 그대로 보여져야한다. (문단도, 줄당 글자수도)
3. 이미지 삽입
- 이미지업로드 프로세스
4. 태그 생성
- # 또는 , 를 기준으로 하나의 태그 생성
부가적인 기능
1. 이미지 Drag&Drop
- 이미지를 단순히 툴바를 통해 업로드하는 것이 아닌 Drag와 Drop으로 삽입
- 또한 Drag&Drop을 통해 원하는 위치에 이미지를 삽입. (마우스 포인터 위치에 따른 이미지 삽입함수 구현)
2. 코드블럭 스타일
- 사실 필수라 생각할 수 있지만, 부가적인 기능으로 뺀 이유는 이 글이 업로드 될 공간은 외주를 위한 Product를 설명하는 글이기에 코드 자체는 필수적이라고 생각하지 않기 때문
공부해야되는 점
마크다운에 렌더링되는 태그를 p태그로 해야하는지 or pre태그로 해야하는지 고민. (들여쓰기나 스페이스바 그리고 사용자들에게 공백의 자유를 주지 못하지만 효율적으로 관리 가능.)
비속어 필터링이나 태그관련 사항들을 프론트에서 관리할지 or 서버통신으로 다룰지 고민.
설치 라이브러리
마크다운 에디터 관련
npm install @uiw/react-codemirror @codemirror/lang-markdown @codemirror/language-data
마크다운 렌더링 관련
npm install react-markdown remark-gfm rehype-raw
이미지 업로드 관련
npm install react-dropzone
아이콘 관련
npm install lucide-react react-icons
테일윈드 플러그인 - 마크다운 콘텐츠를 렌더링 할 때 필요한 스타일을 정의함.
npm install -D @tailwindcss/typography
- 플러그인은 Tailwind CSS의 기능을 확장하는 도구
- 기본 Tailwind CSS에는 없는 추가적인 스타일이나 기능을 제공
- 마치 브라우저의 확장 프로그램처럼, 필요한 기능을 "끼워넣는" 개념
Typography 플러그인 사용 시, prose클래스가 제공하는 것
<!-- prose 클래스를 추가하면 자동으로 예쁜 타이포그래피 적용 --> <article class="prose"> <h1>제목입니다</h1> <p>본문 텍스트입니다.</p> <code>코드 블록입니다</code> </article> /** * <div className="prose prose-invert"> - prose-invert는 다크 모드용 */
https://github.com/tailwindlabs/tailwindcss-typography
- 제목(h1~h6)의 크기와 여백 조정
- 단락(p) 간의 적절한 간격
- 목록(ul, ol)의 들여쓰기와 기호 스타일
- 코드 블록의 배경색과 폰트
- 인용문의 스타일
- 표의 테두리와 패딩
개발환경에서만 설치(-D or --save-dev)로 설치하는 이유 :
개발/빌드 단계:
- Typography 플러그인은 Tailwind CSS의 빌드 프로세스 중에 실행
- 이 때 prose 관련 클래스들의 CSS를 생성
- 생성된 CSS는 최종 빌드 파일에 포함
프로덕션 단계:
- 실제 운영 환경에서는 이미 생성된 CSS 파일만 사용
- 플러그인 자체는 더 이상 필요X
- 따라서 프로덕션 의존성에 포함될 필요X
타이포그래피 플러그인은 마치 '케이크 틀' - 실제서빙 될 때에는 케이크만 필요하기 때문.
어디에다가 어떻게 만들까
현재 이 마크다운 에디터가 사용될 곳은 생성 or 생성된 것 수정이다. 추후 성능이 추가되거나 수정이 잦을것으로 예상되어 관심사에 맞게 분리할 예정이다.
products/ ├── [id]/ │ └── edit/ │ ├── page.tsx (서버 액션, 상태 관리) │ └── ui/ │ └── EditForm.tsx (UI 렌더링) ├── new/ │ ├── page.tsx (서버 액션, 상태 관리) │ └── ui/ │ └── CreateForm.tsx (UI 렌더링) └── components/ └── common/ ├── Editor/ ├── Editor.tsx ├── EditorToolbar.tsx ├── TagInput.tsx └── Preview.tsx
따라 다음과 같은 폴더구조를 통해 구현하려 한다.
최상위 page컴포넌트에선 유저 로그인 여부 및 관련 서버액션을 다룰 것이고, 수정을 위한 defaultValue만 Editor로 넘겨줄 것이다. (관심사 분리)
그리고 Editor 관련 모든 상태와 그 상태를 변경하는 함수들은 Editor/indext.tsx 에서 다루고 하위 컴포넌트들은 Ui를 담당하게 될 것이다.
(그러나 쓸모없는 2중 드릴링이 발생될 것 같다...)page.tsx에서 서버액션으로 ui컴포넌트에 prop 1차 전달.
Ui컴포넌트에서 Editor컴포넌트로 prop 2차 전달...
구현
red Editor.tsx purple Preview.tsx blue TagInput.tsx yellow Toolbar.tsx
에디터 - Editor.tsx
(핸들러 및 타입은 hook으로 자유롭게 분리)
'use client'; import { useState, useRef } from 'react'; import CodeMirror from '@uiw/react-codemirror'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { languages } from '@codemirror/language-data'; import { EditorView } from '@codemirror/view'; import { EditorState } from '@codemirror/state'; import Toolbar from './Toolbar'; import Preview from './Preview'; import DragDrop from '../dragDrop/DragDrop'; import TagInput from './TagInput'; interface EditorProps { initialValue?: string; } interface PublishData { title: string; content: string; tags: string[]; createdAt: string; updatedAt: string; } export default function Editor({ initialValue = '# 제목을 입력하세요' }: EditorProps) { const [content, setContent] = useState(initialValue); const fileInputRef = useRef<HTMLInputElement>(null); const [title, setTitle] = useState(''); const [tags, setTags] = useState<string[]>([]); const [isSubmitting, setIsSubmitting] = useState(false); const editorRef = useRef<{ view: EditorView; state: EditorState; } | null>(null); // 선택된 텍스트 가져오기 const getSelectedText = () => { if (!editorRef.current?.view) return ''; const state = editorRef.current.view.state; const selection = state.selection.main; return state.sliceDoc(selection.from, selection.to); }; // 선택된 텍스트 변경하기 const replaceSelectedText = (newText: string) => { if (!editorRef.current?.view) return; const view = editorRef.current.view; const selection = view.state.selection.main; const from = selection.from; const to = selection.from === selection.to ? selection.from : selection.to; view.dispatch({ changes: { from, to, insert: newText }, }); }; // 이미지를 마크다운 형식으로 변환하여 에디터에 추가 - 미구현 const addImageToEditor = () => {}; // 이미지 업로드 핸들러 (드래그 드롭용) - 미구현 const handleImageUpload = () => {}; // 파일 input change 핸들러 (클릭 업로드용)- 미구현 const handleFileChange = () => {}; // 태그 추가 const handleTagAdd = (newTag: string) => { setTags((prev) => [...prev, newTag]); }; // 태그 삭제 const handleTagRemove = (tagToRemove: string) => { setTags((prev) => prev.filter((tag) => tag !== tagToRemove)); }; // 등록 하기 프로세스 const handlePublish = async () => { if (!title.trim()) { alert('제목을 입력해주세요.'); return; } if (!content.trim()) { alert('내용을 입력해주세요.'); return; } if (tags.length === 0) { alert('최소 하나의 태그를 입력해주세요.'); return; } try { setIsSubmitting(true); const publishData: PublishData = { title: title.trim(), content, tags, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; // 추후 실제 API 호출로 대체 console.log('Publishing data:', publishData); // 성공 시 처리 alert('성공적으로 등록되었습니다.'); } catch (error) { console.error('Failed to publish:', error); alert('등록 중 오류가 발생했습니다. 다시 시도해주세요.'); } finally { setIsSubmitting(false); } }; return ( <div className="bg-[#15181E] min-h-screen text-white"> <main className="max-w-7xl mx-auto p-4"> <div className="flex gap-4"> <div className="w-1/2"> <DragDrop onImageUpload={handleImageUpload}> <div className="bg-[#1E2227] rounded-lg p-4"> <input type="text" placeholder="제목을 입력하세요" value={title} onChange={(e) => setTitle(e.target.value)} className="w-full mb-4 px-3 py-2 bg-[#2A2E35] border border-gray-700 rounded text-sm text-white focus:outline-none focus:border-blue-500" /> <Toolbar onReplace={replaceSelectedText} getSelectedText={getSelectedText} onImageUpload={} /> <div className="relative"> <CodeMirror value={content} height="600px" theme="dark" basicSetup={{ lineNumbers: false, highlightActiveLineGutter: false, highlightActiveLine: false, }} extensions={[ markdown({ base: markdownLanguage, codeLanguages: languages }), EditorView.lineWrapping, ]} onChange={(value) => { setContent(value); }} className="border border-gray-700 rounded" onCreateEditor={(view) => { editorRef.current = { view, state: view.state, }; }} /> </div> <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} /> </div> </DragDrop> </div> <div className="w-1/2"> <Preview content={content} className="h-[744px]" /> </div> </div> <div className="flex items-center gap-4 mt-4"> <button className="shrink-0 flex items-center gap-2 px-4 py-2 hover:bg-gray-700 rounded">나가기</button> <TagInput tags={tags} onTagAdd={handleTagAdd} onTagRemove={handleTagRemove} /> <div className="shrink-0 flex gap-2"> <button className="px-4 py-2 hover:bg-gray-700 rounded">임시저장</button> <button className="px-4 py-2 bg-blue-500 hover:bg-blue-600 rounded disabled:opacity-50 disabled:cursor-not-allowed" onClick={handlePublish} disabled={isSubmitting} > 등록하기 </button> </div> </div> </main> </div> ); }
CodeMirror 주요 모듈
코드미러는 이미 내부적으로 변경사항을 추적하고 있음.
import { EditorView } from '@codemirror/view' // 에디터 UI/렌더링 import { EditorState } from '@codemirror/state' // 상태 관리 import { commands } from '@codemirror/commands' // 편집 명령어 import { history } from '@codemirror/history' // 실행취소/다시실행 import { keymap } from '@codemirror/keymap' // 키보드 바인딩 import { language } from '@codemirror/language' // 언어 지원
CodeMirror 컴포넌트 주요 props
<CodeMirror // 필수 props value={string} // 에디터 내용 onChange={(value) => void} // 내용 변경 핸들러 // 중요 선택 props height={string} // 높이 theme="dark | light" // 테마 extensions={[]} // 기능 확장 basicSetup={{ // 기본 설정 lineNumbers: boolean, highlightActiveLine: boolean, ... }} onCreateEditor={(view) => void} // 에디터 인스턴스 생성 콜백 />
onCreateEditor는 CodeMirror 에디터 인스턴스가 생성될 때 호출되는 콜백. 현재 코드에서는 이를 통해 editorRef에 에디터의 view와 state를 저장
extensions은 마크다운 확장 기능 설정으로, 기본 마크다운 문법과 코드 블록 내 언어 지원을 설정
onCreateEditor={(view) => { editorRef.current = { view, // 에디터의 UI/렌더링 관련 기능 state: view.state // 현재 에디터의 상태 }; }}
역할 정리
- content: 전체 텍스트 내용 상태 관리
- EditorState: 드래그, 선택 등 에디터 조작을 위한 상태
- EditorView: 상태 변경을 실제 UI에 반영하는 역할
EditorView는 dispatch() 메소드를 통해 변경을 반영 - (초기구조에선 x 아래의 최종 코드에 포함)
view.dispatch({ changes: { from: selection.from, // 변경 시작 위치 to: selection.to, // 변경 끝 위치 insert: newText // 새로 삽입할 텍스트 } });
dispatch는 트랜잭션(transaction)을 생성하여 변경사항을 에디터에 적용하고, 이 변경이 UI에 반영. 마치 React의 setState처럼, 상태 변경을 선언적으로 처리.
이를 통해서
getSelectedText 함수
const getSelectedText = () => { if (!editorRef.current?.view) return ''; // 에디터 뷰가 없으면 빈 문자열 반환 const state = editorRef.current.view.state; // 현재 에디터 상태 가져오기 const selection = state.selection.main; // 현재 선택 영역 정보 가져오기 // selection.from: 선택 시작 위치 // selection.to: 선택 끝 위치 return state.sliceDoc(selection.from, selection.to); // 선택된 텍스트 추출 }
replaceSelectedText 함수
const replaceSelectedText = (newText: string) => { if (!editorRef.current?.view) return; const view = editorRef.current.view; const selection = view.state.selection.main; // 선택 영역 또는 커서 위치 결정 const from = selection.from; const to = selection.from === selection.to ? selection.from : selection.to; // 변경사항을 에디터에 적용 view.dispatch({ changes: { from, to, insert: newText } // 변경 작업 명세 }); };
툴바 생성 - Toolbar.tsx
'use client'; import { ImageIcon } from 'lucide-react'; interface ToolbarProps { onReplace: (text: string) => void; getSelectedText: () => string; onImageUpload?: () => void; } type ToolbarItem = { type: string; label: string | React.ReactNode; className?: string; }; const toolbarItems: ToolbarItem[] = [ { type: 'H1', label: 'H1' }, { type: 'H2', label: 'H2' }, { type: 'H3', label: 'H3' }, { type: 'H4', label: 'H4' }, { type: 'B', label: 'B' }, { type: 'I', label: 'I' }, { type: 'U', label: 'U' }, { type: 'Q', label: '""' }, { type: 'CODE', label: '</>' }, { type: 'HR', label: '─' }, { type: 'TABLE', label: '⊞' }, { type: 'LINK', label: '[]' }, { type: 'IMAGE', label: <ImageIcon size={20} /> }, ]; export default function Toolbar({ onReplace, getSelectedText, onImageUpload }: ToolbarProps) { const defaultTexts: { [key: string]: string } = { H1: '제목 1', H2: '제목 2', H3: '제목 3', H4: '제목 4', B: '굵은 텍스트', I: '기울임 텍스트', U: '밑줄 텍스트', Q: '인용문', CODE: '코드', LINK: '링크텍스트', HR: '구분선', TABLE: '표', }; const transformations: { [key: string]: (text: string) => string } = { H1: (text) => `# ${text}`, H2: (text) => `## ${text}`, H3: (text) => `### ${text}`, H4: (text) => `#### ${text}`, B: (text) => `**${text}**`, I: (text) => `*${text}*`, U: (text) => `<u>${text}</u>`, Q: (text) => `> ${text}`, CODE: (text) => `\`\`\`\n${text}\n\`\`\``, LINK: (text) => `[${text}](URL)`, HR: () => '\n---\n', TABLE: () => `/| 제목1 | 제목2 ---|---|--- 내용 | 내용 | 내용 내용 | 내용 | 내용`, }; const handleToolbarClick = (type: string) => { if (type === 'IMAGE') { onImageUpload?.(); return; } if (transformations[type]) { const selectedText = getSelectedText(); const textToTransform = selectedText || defaultTexts[type]; const transformedText = transformations[type](textToTransform); onReplace(transformedText); } }; return ( <div className="flex gap-2 mb-4"> {/* 제목 관련 버튼 그룹 */} <div className="flex gap-1 border-r border-gray-700 pr-2 mr-2"> {toolbarItems.slice(0, 4).map((item) => ( <button key={item.type} className="p-2 hover:bg-gray-700 rounded" onClick={() => handleToolbarClick(item.type)} > {item.label} </button> ))} </div> {/* 나머지 버튼들 */} {toolbarItems.slice(4).map((item) => ( <button key={item.type} className="p-2 hover:bg-gray-700 rounded" onClick={() => handleToolbarClick(item.type)}> {item.label} </button> ))} </div> ); }
에디터 상단에 표시될 툴바
툴바아이템은 객체로서 하나의 배열에서 관리.
이곳에서 다룰 것은 prop으로 드래그된 텍스트(없으면 default)와 변경함수(Editor에서 내려준)를 받아서 내부로직으로 텍스트 교체 후 변경함수(onReplace)적용 - 상향식 데이터 흐름(bottom-up)
defaultTexts는 선택된 텍스트가 없을 때 사용될 기본 값이다.
++) underline - 밑줄은
U: (text) => `<u>${text}</u>`,
다음과 같이 작성되었는데 __text__ 가 동작하지 않기 때문.
preview 생성(미리보기 컴포넌트) - Preview.tsx
import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; interface PreviewProps { content: string; className?: string; } export default function Preview({ content, className }: PreviewProps) { return ( <div className={`bg-[#1E2227] rounded-lg p-4 ${className}`}> <div className="prose prose-invert max-w-none h-full overflow-y-auto"> <ReactMarkdown remarkPlugins={[[remarkGfm, { breaks: true }]]} rehypePlugins={[rehypeRaw]} components={{ h1: ({ children, ...props }) => ( <h1 className="text-3xl font-bold mt-6 mb-4" {...props}> {children} </h1> ), h2: ({ children, ...props }) => ( <h2 className="text-2xl font-bold mt-5 mb-3" {...props}> {children} </h2> ), h3: ({ children, ...props }) => ( <h3 className="text-xl font-bold mt-4 mb-2" {...props}> {children} </h3> ), h4: ({ children, ...props }) => ( <h4 className="text-lg font-bold mt-3 mb-2" {...props}> {children} </h4> ), p: ({ children, ...props }) => ( <p className="my-2 leading-relaxed whitespace-pre-line" {...props}> {children} </p> ), code({ inline, className, children, ...props }) { const match = /language-(\w+)/.exec(className || ''); const language = match ? match[1] : ''; return !inline ? ( <SyntaxHighlighter style={vscDarkPlus} language={language || 'typescript'} PreTag="div" className="rounded-md" showLineNumbers={true} wrapLines={true} customStyle={{ margin: '1em 0', padding: '1em', backgroundColor: '#1E1E1E', }} {...props} > {String(children).replace(/\n$/, '')} </SyntaxHighlighter> ) : ( <code className="bg-gray-800 px-1 py-0.5 rounded text-sm" {...props}> {children} </code> ); }, blockquote: ({ children }) => ( <blockquote className="border-l-4 border-gray-500 pl-4 my-4 italic">{children}</blockquote> ), table: ({ children }) => ( <div className="overflow-x-auto my-4"> <table className="w-full border-collapse">{children}</table> </div> ), th: ({ children }) => <th className="border border-gray-600 px-4 py-2 bg-gray-800">{children}</th>, td: ({ children }) => <td className="border border-gray-600 px-4 py-2">{children}</td>, u: ({ children }) => <span className="underline decoration-2">{children}</span>, }} > {content} </ReactMarkdown> </div> </div> ); }
preview는 markdown으로 작성된 Editor 컴포넌트의 content 상태에 의존하여 파싱과 처리한다.
remarkGfm - GitHub Flavored Markdown breaks: true: 줄바꿈을 <br/> 태그로 변환 rehypeRaw HTML 태그를 직접 파싱하고 렌더링 허용 <ReactMarkdown remarkPlugins={[]} // 마크다운 처리 플러그인 rehypePlugins={[]} // HTML 처리 플러그인 components={{}} // 요소별 커스텀 렌더링 children={string} // 마크다운 텍스트 className={string} // 스타일링 skipHtml={boolean} // HTML 태그 처리 여부 sourcePos={boolean} // 소스 위치 정보 />
Tag - TagInput.tsx
에디터 하단에서 추가될 태그
'use client'; import { useState } from 'react'; interface TagInputProps { tags: string[]; onTagAdd: (tag: string) => void; onTagRemove: (tag: string) => void; maxTags?: number; } export default function TagInput({ tags, onTagAdd, onTagRemove, maxTags = 10 }: TagInputProps) { const [tagInput, setTagInput] = useState(''); const bannedWords = ['비속어1', '비속어2', '욕설', 'ㅅㅂ', 'ㅆㅂ', '시발', '씨발']; const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if ((e.key === ' ' || e.key === 'Enter') && tagInput.trim()) { e.preventDefault(); const newTag = tagInput.trim().toLowerCase(); // 유효성 검사 if (tags.length >= maxTags) { alert(`태그는 최대 ${maxTags}개까지만 추가할 수 있습니다.`); return; } if (tags.includes(newTag)) { alert('이미 존재하는 태그입니다.'); setTagInput(''); return; } if (bannedWords.some((word) => newTag.includes(word))) { alert('부적절한 단어가 포함되어 있습니다.'); return; } onTagAdd(newTag); setTagInput(''); } // Backspace 키로 마지막 태그 삭제 if (e.key === 'Backspace' && tagInput === '' && tags.length > 0) { e.preventDefault(); onTagRemove(tags[tags.length - 1]); } }; return ( <div className="flex-1 min-w-0"> <div className="flex flex-wrap gap-2 items-center"> {tags.map((tag, index) => ( <span key={index} onClick={() => onTagRemove(tag)} className="relative inline-block px-3 py-1 bg-blue-500 text-white rounded-md text-sm cursor-pointer hover:bg-blue-600 transition-colors" > {tag} </span> ))} {tags.length < maxTags && ( <input type="text" value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={handleKeyDown} placeholder="태그 입력 후 스페이스바 또는 엔터" className="min-w-[150px] flex-1 bg-transparent border-none outline-none text-white placeholder-gray-500" maxLength={20} /> )} </div> <div className="text-xs text-gray-500 mt-1">{`${tags.length}/${maxTags}개의 태그가 추가됨`}</div> </div> ); }
해당 태그 컴포넌트는 Ui담당. 관련 함수나 상태는 상위 Editor컴포넌트에서 총 관리.
- 현재 비속어 필터링 관련 코드를 클라이언트에 직접 작성해 놓았지만, 이는 효율적이지 않은 코드라 생각. 만약 추가적인 비속어 단어를 추가하려면 재배포가 이루어 져야하기 때문, 이 과정은 유저에게 자유도를 줄지 또는 서버와 통신하는 프로세스를 추가하는 방향으로 고민
- 유효성 검사 역시 임시 alert, 추 후 토스트로 변경 예정
추가해야 되는 것 - 아직 구현안된 것
- 이미지업로드 프로세스
- preview 이미지렌더링 코드
- 드래그된 텍스트를 툴바아이템을 통해 변경하면 마크다운으로 변경되지만 재클릭 하면 취소되는 기능 미구현
- ex) text 에서 U 클릭하면 *ㅎㅇ* 하고 재클릭하면 취소되어야하지만 재클릭 하면 **ㅎㅇ** 한번 더 중첩됨.
- content길이가 길어질 수록 preview bar와 스크롤 위치 동기화
오류
현재 content가 길어짐에 따라 타자를 빨리치면 content 제일 상단으로 커서가 점프되는 현상존재
ex) 42번째 줄을 작성하다가 갑자기 0번째 줄로 커서가 점프되어서 텍스트작성에 불편