React-Quill를 사용하여 게시글 에디터 구현하기

Noma·2021년 8월 25일
10
post-thumbnail

사이드 프로젝트로 블로그를 만들고 있던 중, 기존의 textarea를 통한 입력방식을 좀 더 다양한 기능을 지원하는 에디터로 업그레이드 하고 싶다는 생각이 들었다. 처음엔 직접 만들어보려고 했으나 따로 프로젝트를 하나 파야될 정도로 생각보다 크고 복잡했기에 일단은 기존에 잘만들어진 WYSIWYG 라이브러리를 도입하는 것으로 대체했다.

React-Quill을 선택한 이유

React에서 많이 사용되는 또 다른 WYSIWYG 에디터로는 react-draft-wysiwyg도 있다. 이는 페이스북에서 관리하는 draft.js 에디터를 리액트용으로 만든 것이다. 처음엔 react-draft-wysiwyg을 사용해서 진행하려고 했지만, 기능면에서 꽤 많은 버그들이 존재했고 이에 대한 수많은 이슈들에도 피드백이 없는 것을 보고 다른 것을 찾아보게 되었다.

사실 엄청난 기능이 필요하기 보단,

  • 깔끔한 UI (커스텀 가능)
  • 쉬운 사용법
  • 이미지 업로드를 따로 핸들링 할 수 있어야함 (multer 사용 가능)
  • 기본적인 에디터로서의 구실 (폰트 크키 및 색상 변경, 이미지 업로드, 인용문, 정렬 기능 등..)

위 조건만 만족하면 되었기에 React-Quill을 사용하게 되었다. 아래 사진은 최종적으로 구현된 모습이다. 툴바에 더 많은 기능을 넣을 수 있지만, 딱히 필요없는 것들은 빼고 진행했다.

React-Quill 사용하기

사실 해당 에디터만 가지곤 게시글을 작성하기 어렵다. 이는 게시글의 내용을 입력하는 데에만 쓰이기 때문에 제목, 폴더, 태그, 썸네일 등을 위한 추가적인 input이 필요하다.

따라서 필자는 PostEditor라는 부모 컴포넌트를 만들고 그 안에 QuillEditor라고 react-quill을 사용하는 컴포넌트를 따로 만들었다.

react-quill로 인해 변화되는 state를 부모 컴포넌트인 PostEditor에서 관리하고 있고, QuillEditor에서 필요한 것들(Ref, state, 핸들러..)을 props로 내려주고 있다는 점을 주목하자. 이렇게 해놓으면 PostEditor에 추가적인 input들을 핸들링하기도 편하고, submit 버튼을 클릭했을 때 한번에 서버로 보낼 수 있어 좋다.

게시글 생성 및 수정

게시글을 생성하고 수정하는 데엔 중복되는 코드가 많다. 그래서 하나의 컴포넌트에서 url parameter에 따라 글을 생성하거나 수정하도록 만들고 싶었다.

http://localhost:3000/@사용자ID/posts/edit/포스팅ID
http://localhost:3000/@사용자ID/posts/create

필자의 경우 useParams()를 이용하여 포스팅의 id(postId)를 추출했고, 이 postId가 있을 경우엔 기존 글의 데이터를 가져와 편집하도록 하고 없을 경우 글을 새로 생성하도록 하였다.

(이 글은 react-quill을 정리하기 위해 쓴 글인만큼 다른 기능에 대한 로직과 state들은 제거했다. 🌈부분만 참고하고 QuillEditor 컴포넌트로 넘어가도 좋다.)

// src/pages/PostEditor/postEditor.jsx
import React, { useEffect, useRef, useState, memo } from 'react'
import { useHistory, useParams } from 'react-router-dom';
import styles from './postEditor.module.css';
import QuillEditor from '../../components/QuillEditor/quillEditor';

const PostEditor = memo(({ api, user }) => {
    const history = useHistory();
    const [htmlContent, setHtmlContent] = useState(""); //🌈
    const { id: postId } = useParams();
    const quillRef = useRef(); //🌈

    const handleSubmit = async () => {
        const description = quillRef.current.getEditor().getText(); //태그를 제외한 순수 text만을 받아온다. 검색기능을 구현하지 않을 거라면 굳이 text만 따로 저장할 필요는 없다.
        if (description.trim()==="") {
            alert("내용을 입력해주세요.")
            return;
        }
        if (postId) {
            //기존 게시글 업데이트
            await api.updatePost({postId,description,htmlContent});
            //history.push(`/@${user.name}/post/${postId}`);
        } else {
            //새로운 게시글 생성
            await api.createNewPost({description,htmlContent});
            //history.push(`/@${user.name}/posts?folder=${selectedFolder}`);
        }
    }
    useEffect(() => {
        if(!postId){
            return;
        }
        const fetchData = async () => {
            const { htmlContent: prevHtml } = await api.fetchPostDetail(postId);
            setHtmlContent(prevHtml);
        };
        fetchData();
    }, [postId, api])

    return (
        <div>
            //🌈
            <QuillEditor quillRef={quillRef} htmlContent={htmlContent} setHtmlContent={setHtmlContent} api={api} />
            <button className={styles.submit} onClick={handleSubmit}>Done</button>
        </div>
    )
})
export default PostEditor

이제 자식 컴포넌트에서 react-quill을 본격적으로 사용해보자.

$ yarn add react-quill 또는 $ npm i react-quill로 모듈을 설치하고 아래와 같이 작성한다.

import React, { useMemo, useCallback, memo } from 'react'
import styles from './quillEditor.module.css';
import ReactQuill from 'react-quill'; 
import 'react-quill/dist/quill.snow.css'; // react-quill과 css파일 import 하기

const QuillEditor = memo(({ quillRef, api, htmlContent, setHtmlContent }) => {
    // 툴바의 사진 아이콘 클릭시 기존에 작동하던 방식 대신에 실행시킬 핸들러를 만들어주자.
    const imageHandler = useCallback(() => {
        const formData = new FormData(); // 이미지를 url로 바꾸기위해 서버로 전달할 폼데이터 만들기
        
        const input = document.createElement("input"); // input 태그를 동적으로 생성하기
        input.setAttribute("type", "file");
        input.setAttribute("accept", "image/*"); // 이미지 파일만 선택가능하도록 제한
        input.setAttribute("name", "image");
        input.click(); 

        // 파일 선택창에서 이미지를 선택하면 실행될 콜백 함수 등록
        input.onchange = async () => {
            const file = input.files[0];
            formData.append("image", file); // 위에서 만든 폼데이터에 이미지 추가
            
            // 폼데이터를 서버에 넘겨 multer로 이미지 URL 받아오기
            const res = await api.uploadImage(formData);
            if (!res.success) {
                alert("이미지 업로드에 실패하였습니다.");
            }
            const url = res.payload.url;
            const quill = quillRef.current.getEditor();
            /* ReactQuill 노드에 대한 Ref가 있어야 메서드들을 호출할 수 있으므로
            useRef()로 ReactQuill에 ref를 걸어주자.
            getEditor() : 편집기를 지원하는 Quill 인스턴스를 반환함
            여기서 만든 인스턴스로 getText()와 같은 메서드를 사용할 수 있다.*/
            
            const range = quill.getSelection()?.index; 
            //getSelection()은 현재 선택된 범위를 리턴한다. 에디터가 포커싱되지 않았다면 null을 반환한다.
            
            if (typeof (range) !== "number") return;
            /*range는 0이 될 수도 있으므로 null만 생각하고 !range로 체크하면 잘못 작동할 수 있다.
            따라서 타입이 숫자이지 않을 경우를 체크해 리턴해주었다.*/
          
            quill.setSelection(range, 1);
            /* 사용자 선택을 지정된 범위로 설정하여 에디터에 포커싱할 수 있다. 
               위치 인덱스와 길이를 넣어주면 된다.*/
          
            quill.clipboard.dangerouslyPasteHTML(
                range,
                `<img src=${url} alt="image" />`);
        }   //주어진 인덱스에 HTML로 작성된 내용물을 에디터에 삽입한다.
    }, [api, quillRef]);
  
    const modules = useMemo(
        () => ({
            toolbar: { // 툴바에 넣을 기능들을 순서대로 나열하면 된다.
                container: [
                    ["bold", "italic", "underline", "strike", "blockquote"],
                    [{ size: ["small", false, "large", "huge"] }, { color: [] }],
                    [
                        { list: "ordered" },
                        { list: "bullet" },
                        { indent: "-1" },
                        { indent: "+1" },
                        { align: [] },
                    ],
                    ["image", "video"],
                ],
                handlers: { // 위에서 만든 이미지 핸들러 사용하도록 설정
                    image: imageHandler,
                },
            },
        }), [imageHandler]);
    return (
        <>
            <ReactQuill
                ref={quillRef}
                value={htmlContent}
                onChange={setHtmlContent}
                modules={modules}
                theme="snow"
                className={styles.quillEditor}
            />
        </>
    )
})

export default QuillEditor

끝.🥳🔥

profile
Frontend Web/App Engineer

0개의 댓글