react-markdown-editor 기본 사용법 정리
react-markdown-editor 기본 사용법 정리
개인적으로 개발공부도 하고 혹시 모를 수입발생도 염두해서 모하지 라는 사이트를 만들고 있다. 이번에 여기에 블로그 글도 남기기 위해서 블로그 사이트를 붙이게 되었다. 구글의 애드센스를 붙이기 위한 자격을 얻기 위한 방편이기도 하다 ㅎㅎ
처음에는 별도로 마크다운 파일을 만든 후, Front Matter로 변경하고, 빌드타임에 컨텐츠를 정적으로 생성하는 방식으로 컨텐츠를 늘려가려고 했지만, 왠지 컨텐츠가 많아지고 또 수정이 필요할 경우에는 사용이 어렵겠다는 생각이 들었다.
또 언제 어디서든 글을 동적으로 작성해서 업로드하면 좋겠다는 생각이 들어서 에디터를 구현하게 되었다.
개발에 사용한 라이브러리는 react-markdown-editor 이라는 라이브러리다. 무료로 사용가능하고 또 이미지를 삽입하기에 무난한 라이브러리라 판단했다. 다른 그럴듯한 마크다운용 에디터는 아직 찾지 못했다.
개발환경
react-router 환경에서 개발했다.
사용된 라이브러리 버전은 다음과 같다.
1"dependencies": {2 "react-router": "^7.9.1",3 "@supabase/ssr": "^0.7.0",4 "@supabase/supabase-js": "^2.57.4"5},6"devDependencies": {7 "@uiw/react-markdown-editor": "^6.1.4",8}
샘플예제
나는 컨텐츠 작성시 이미지를 편하게 업로드 할 수 있는 기능이 필요했다. 그래서 컨텐츠에 이미지 추가시 supabase를 이용해서 이미지를 업로드하는 기능을 추가했다. 샘플코드는 개발했던 모든 코드를 붙여넣기 너무 귀찮아서 관련 코드만 추린다.
에디터 초기화
에디터는 CSR 환경에서 동작하기 때문에 SSR(Server Side Rendering) 환경에서 에디터를 로딩하려면 아래의 코드와 같이 클라이언트가 초기화 된 이후에 호출되도록 해야 한다.
1const MarkdownEditor = lazy(() => import('@uiw/react-markdown-editor')) // 무거운 라이브러리 이므로 필요할 때만 로드 하도록 함2 3export default function BlogEditorPage({ loaderData }: Route.ComponentProps) {4 5 const {6 isInitialized,7 markdownValue,8 previewWidth,9 postMeta,10 setMarkdownValue,11 handleSave,12 handlePaste,13 handleDrop,14 handleMetaFieldChange,15 handleDraftChange,16 } = useBlogEditor({17 blogPost: loaderData.data,18 })19 20 if (loaderData.error) {21 return <EditorLoading text={loaderData.error} redirectUrl="/" />22 }23 24 if (!isInitialized) {25 return <EditorLoading />26 }27 28 if (editorStatus) {29 return <EditorLoading text={editorStatus} />30 }31 32 return (33 <Suspense fallback={<EditorLoading />}> // lazy()로 로딩된 컴포넌트는 <Suspense fallback={...}> 컴포넌트로 감싸야 함34 <MarkdownEditor35 className="flex-1 pb-30"36 value={markdownValue}37 previewWidth={previewWidth}38 enableScroll={true}39 onChange={setMarkdownValue}40 onPaste={handlePaste} // 뭔가를 복사후 에디터에 붙여넣기 하면 호출된다.41 onDrop={handleDrop} // 이미지를 에디터에 드래그앤 드롭하면 호출된다.42 />43 </Suspense>44 )45}46
이미지 삽입하기
블로그 글 작성시 이미지 이미지를 사용하는 경우가 빈번하기 때문에 이미지를 편리하게 업로드하고 싶었다. 마크다운 에디터기 때문에 편집기에서 내가 입력한 이미지를 직접적으로 편집기에 넣을 수는 없었다. 다음과 같은 문법을 사용해서 이미지를 업로드할 수 있다.
1
문제는 내 로컬이미지를 올리기 위해서는 어딘가에 이미지를 올리고 그 이미지의 url을 복사해서 넣는 과정이 불편하다는 것이다. 현재 나는 supabase 를 사용하고 있는데 storage 를 무료로 사용할 수 있다. 그래서 에디터에 이미지를 붙여넣거나 이미지를 편집기에 드롭하면 드롭한 이미지를 supabase 에 업로드 후, 반환된 public url 을 에디터에 삽입하는 기능을 추가했다. 이 라이브러리의 사용법을 몰라서 몇일 헤맸기 때문에 샘플 코드를 기록해 두게 되었다.
샘플코드는 다음과 같다.
1type UseBlogEditorResult = {2 isInitialized: boolean3 editorStatus: string | null4 markdownValue: string5 previewWidth: string6 setMarkdownValue: (value: string) => void7 handleSave: (event: React.FormEvent<HTMLFormElement>) => void8 handlePaste: (event: React.ClipboardEvent<HTMLDivElement>) => void9 handleDrop: (event: React.DragEvent<HTMLDivElement>) => void10}11 12const PREVIEW_BREAKPOINT = '(max-width: 768px)'13const DEFAULT_MARKDOWN = ''14 15export const useBlogEditor = (): UseBlogEditorResult => {16 const [isInitialized, setIsInitialized] = useState(false) // 에디터 초기화 확인용. false 일 경우 로딩중.. 이라는 문구 표시17 const imageUploaderFetcher = useFetcher() // 이미지 업로드용 fetcher 18 const [markdownValue, setMarkdownValue] = useState(DEFAULT_MARKDOWN)19 const [previewWidth, setPreviewWidth] = useState('50%')20 21 useEffect(() => {22 setIsInitialized(true) // 에디터가 초기화되면 23 }, [])24 25 // 화면이 작으면 미리보기 화면이 화면전체로 표시되게 한다. 26 useEffect(() => {27 if (!isInitialized || typeof window === 'undefined') return28 29 const mediaQuery = window.matchMedia(PREVIEW_BREAKPOINT)30 const updatePreviewWidth = (matches: boolean) => {31 setPreviewWidth(matches ? '100%' : '50%')32 }33 34 updatePreviewWidth(mediaQuery.matches)35 36 const listener = (event: MediaQueryListEvent) => {37 updatePreviewWidth(event.matches)38 }39 40 if (typeof mediaQuery.addEventListener === 'function') {41 mediaQuery.addEventListener('change', listener)42 return () => {43 mediaQuery.removeEventListener('change', listener)44 }45 }46 47 mediaQuery.onchange = listener48 return () => {49 mediaQuery.onchange = null50 }51 }, [isInitialized])52 53 // 에디터 내부의 뷰를 꺼내온다. 이 뷰를 이용해서 컨텐츠를 조작할 수 있다.54 const getCodeMirrorView = useCallback((): EditorView | null => {55 if (typeof document === 'undefined') return null56 57 const cmContent = document.querySelector('.cm-content') as58 | (HTMLElement & { cmView?: { view?: EditorView } })59 | null60 61 return cmContent?.cmView?.view ?? null62 }, [])63 64 65 const createTempImageMarkdown = useCallback((imageName: string) => {66 return ``67 }, [])68 69 // 요청받은 커서의 위치에 전달받은 'placeholder' 를 추가해준다.70 // 이미지가 업로드 되는동안 업로드 중이라는 메시지를 표기하기 위한 용도.71 const insertPlaceholder = useCallback(72 (insertPosition: number, placeholder: string, view: EditorView | null) => {73 if (view) {74 view.dispatch({75 changes: {76 from: insertPosition,77 insert: placeholder,78 },79 selection: {80 anchor: insertPosition + placeholder.length,81 },82 })83 84 setMarkdownValue(view.state.doc.toString())85 return insertPosition + placeholder.length86 }87 88 let nextPosition = insertPosition + placeholder.length89 setMarkdownValue((previous) => {90 const safePosition = Math.min(insertPosition, previous.length)91 nextPosition = safePosition + placeholder.length92 const before = previous.slice(0, safePosition)93 const after = previous.slice(safePosition)94 return `${before}${placeholder}${after}`95 })96 97 return nextPosition98 },99 [],100 )101 102 // 이미지 업로드가 완료되면 실제 이미지 url 로 변경해준다.103 const replacePlaceholderWithImage = useCallback(104 (imageUrl: string, imageName: string, view: EditorView | null) => {105 const tempImageMarkdown = createTempImageMarkdown(imageName)106 const finalImageMarkdown = ``107 108 if (view) {109 const docText = view.state.doc.toString()110 const tempImageIndex = docText.indexOf(tempImageMarkdown)111 112 if (tempImageIndex === -1) return113 114 view.dispatch({115 changes: {116 from: tempImageIndex,117 to: tempImageIndex + tempImageMarkdown.length,118 insert: finalImageMarkdown,119 },120 })121 122 setMarkdownValue(view.state.doc.toString())123 return124 }125 126 setMarkdownValue((previous) => {127 if (!previous.includes(tempImageMarkdown)) {128 return previous129 }130 131 return previous.replace(tempImageMarkdown, finalImageMarkdown)132 })133 },134 [createTempImageMarkdown],135 )136 137 // 이미지 파일을 업로드 한다.138 const uploadImage = useCallback(139 (file: File, insertPosition: number, view?: EditorView | null) => {140 const editorView = view ?? getCodeMirrorView()141 const imageName = crypto.randomUUID()142 const placeholder = createTempImageMarkdown(imageName)143 const nextPosition = insertPlaceholder(144 insertPosition,145 placeholder,146 editorView,147 )148 149 const formData = new FormData()150 formData.append('image', file)151 formData.append('imageName', imageName)152 153 imageUploaderFetcher.submit(formData, {154 method: 'post',155 encType: 'multipart/form-data',156 action: '/blog/upload-image',157 })158 159 return nextPosition160 },161 [162 createTempImageMarkdown,163 getCodeMirrorView,164 imageUploaderFetcher,165 insertPlaceholder,166 ],167 )168 169 // 이미지 업로드를 요청 하면 응답은 imageUploaderFetcher.data 를 통해 받게 된다.170 // 응답으로 받은 url 을 이전에 추가했던 이미지 로딩 문구와 교체한다.171 useEffect(() => {172 if (!imageUploaderFetcher.data) return173 174 const { imageUrl, imageName } = imageUploaderFetcher.data as {175 imageUrl?: string176 imageName?: string177 }178 179 if (!imageUrl || !imageName) return180 181 const view = getCodeMirrorView()182 replacePlaceholderWithImage(imageUrl, imageName, view)183 }, [184 getCodeMirrorView,185 imageUploaderFetcher.data,186 replacePlaceholderWithImage,187 ])188 189 // 에디터의 현재 커서 위치를 반환한다.190 const getCursorPosition = useCallback(191 (view: EditorView | null) => {192 return view?.state.selection.main.from ?? markdownValue.length193 },194 [markdownValue],195 )196 197 // 에디터에 붙여넣기 시도시 호출된다. 클립보드의 데이터 타입이 이미지일 경우에만 실행되도록 한다.198 const handlePaste = useCallback(199 (event: React.ClipboardEvent<HTMLDivElement>) => {200 const items = Array.from(event.clipboardData?.items ?? [])201 const imageItems = items.filter((item) => item.type.startsWith('image/'))202 203 if (imageItems.length === 0) return204 205 event.preventDefault()206 const view = getCodeMirrorView()207 let insertPosition = getCursorPosition(view)208 209 imageItems.forEach((item) => {210 const file = item.getAsFile()211 if (file) {212 insertPosition = uploadImage(file, insertPosition, view)213 }214 })215 },216 [getCodeMirrorView, getCursorPosition, uploadImage],217 )218 219 // 에디터에 파일을 드롭 시도시 호출된다. 파일이 이미지일 경우에만 실행되도록 한다.220 const handleDrop = useCallback(221 (event: React.DragEvent<HTMLDivElement>) => {222 event.preventDefault()223 224 const files = Array.from(event.dataTransfer?.files ?? [])225 const imageFiles = files.filter((file) => file.type.startsWith('image/'))226 227 if (imageFiles.length === 0) return228 229 const view = getCodeMirrorView()230 const { clientX, clientY } = event.nativeEvent231 const dropPosition =232 view?.posAtCoords({ x: clientX, y: clientY }) ?? markdownValue.length233 let insertPosition = dropPosition234 235 imageFiles.forEach((file) => {236 insertPosition = uploadImage(file, insertPosition, view)237 })238 },239 [getCodeMirrorView, markdownValue, uploadImage],240 )241 242 return {243 isInitialized,244 markdownValue,245 previewWidth,246 postMeta,247 setMarkdownValue,248 handleSave,249 handlePaste,250 handleDrop,251 handleMetaFieldChange,252 handleDraftChange,253 } 254}255
위와 같이 에디터를 조작하거나 이벤트를 처리하기 위한 훅을 사용했다. 기본적인 내용들은 코드내 주석으로 작성했다.
react-markdown-editor는 내부적으로 CodeMirror를 사용한다. CodeMirror는 브라우저 기반의 강력한 코드 에디터 라이브러리인데, VSCode, Chrome DevTools 등에서도 사용되는 텍스트 에디터 엔진이다. 구문 강조(syntax highlighting), 자동 완성, 코드 폴딩 등의 기능을 제공한다. 여기서는 텍스트를 삽입하거나 교체하는 용도로 활용했다.
처음에 CodeMirror를 이용해서 컨텐츠를 조작해야 하는지 몰라서 해멨다.
이미지가 에디터에 붙여지는 과정은 다음과 같다.
- 이미지를 복사해서 붙여넣기 하거나 이미지 파일을 드래그 앤 드롭해서 에디터에 위치 시킨다.
- 붙여넣기 한거나 드롭한 위치에는 ”“라는 문구를 삽입해 둔다.
- 문구 삽입 후, 이미지 업로드를 요청한다.
- 이미지 업로드가 완료된 후 받은 응답에서 아미지 url 을 가저와서 2단계에서 삽입했던 문구와 교체한다.
다음은 저장용 리액트 라우터 페이지의 loader 샘플코드이다.
이미지 업로드 하기
이미지를 업로드 해주는 페이지 컴포넌트의 코드는 다음과 같다.
1import { redirect } from 'react-router'2import { getUserProfile } from '~/features/users/api/queries'3import { makeSsrClient } from '~/supabase-client'4import type { Route } from './+types/blog-upload-image'5 6export const action = async ({ request }: Route.LoaderArgs) => {7 const { serverSideClient: client } = await makeSsrClient(request)8 const formData = await request.formData()9 const file = formData.get('image') as File10 const imageName = formData.get('imageName') as string11 if (!file) {12 return {13 result: 'no file',14 }15 }16 17 // blog 버킷에 images/{username}/{randomId}.xxx 파일 업로드18 const { data, error } = await client.storage19 .from('blog')20 .upload(21 `images/${username}/${imageName}.${file.type.split('/')[1]}`,22 file,23 {24 contentType: file.type,25 upsert: false,26 },27 )28 29 if (error) {30 console.error('이미지 업로드 실패:', error)31 return {32 result: 'error',33 error: error.message,34 }35 }36 37 const {38 data: { publicUrl },39 } = await client.storage.from('blog').getPublicUrl(data.path)40 41 return {42 result: 'ok',43 imageUrl: publicUrl,44 imageName,45 }46}47
supabase 를 이용해서 이미지를 업로드하고 업로드된 이미지의 public url 을 반환해준다.