장난감 연구소

[React] Next.js에서 Quill 에디터 사용하기 본문

개발/React

[React] Next.js에서 Quill 에디터 사용하기

changi1122 2021. 4. 25. 18:14
    728x90

    이 글에는 Next.js에서 react-quill의 사용 없이 Quill 에디터를 사용하기 위한 코드를 제공합니다. 코드의 부족한 점과 문의 사항은 댓글 주시면 답변 및 수정하도록 하겠습니다.

    샘플 프로젝트 실행시 localhost:3000 접속시 웹페이지

    샘플 프로젝트 다운로드

    quill-sample.zip
    0.10MB

    샘플 프로젝트는 create-next-app에서 일부분만 수정하여 위 사진과 같이 Quill 에디터를 사용해볼 수 있게 하였다.

    해당 폴더로 이동 후 node module 설치 후 개발 서버 실행

    $ npm install
    $ npm run dev

    아래 방식을 사용하게된 이유

    Next.js를 통해 개발을 진행하면서 Quill 에디터를 사용하고자 했다. 그런데 Quill에서 바꾸고 싶은 부분이 있어 그 부분을 수정한 후 직접 빌드하여 사용해야 했다. 직접 빌드된 걸 react-quill에 적용하기 번거로워 보였다. 또, 어떤 블로그 글에서 react-quill을 사용하는 것이 직접 스크립트를 불러오는 것보다 번거롭다는 말이 있어 react-quill을 사용하지 않고 아래와 같은 방식으로 시도하였다.

    이 글의 코드는 이 분의 블로그에서 소개된 내용에서 React Hook으로 변환 및 일부 코드를 추가한 것이다. 해당 블로그에서 React에서 Quill을 적용하는 방법을 올려주셔 잘 적용할 수 있었으나, 아쉽게 글을 내리신 걸로 보인다. 정말 큰 도움이 되어 감사하고 혹시 블로그 글에 링크를 걸은 것 때문에 삭제하신 거면 죄송하다고 전하고 싶다.

    그래서 혹시 Next.js에서 Quill 에디터를 직접 로드하는 방식으로 사용하려는 분이 있다면 코드를 참고하라고 이 글을 올리게 되었다.

    코드

    /pages/index.js
    
    
    
    
    export default function Home() {
    
      const [body, setBody] = useState('');  // Quill 에디터의 innerHTML을 담는 state
      const [mountBody, setMountBody] = useState(false); // 리렌더링 용도 state
    
      /* 외부에서 body의 수정이 일어난 경우 body에 자동으로 적용되지 않습니다!
         이 함수를 호출했을 때 컴포넌트 내의 useEffect가 실행되어 body의 수정 사항이 적용됩니다.*/
      function rerenderBody() {
        setMountBody(mb => !mb);
      }
    
      return (
        <div className={styles.container}>
          <Head>
            <title>Quill Sample</title>
            <link rel="icon" href="/favicon.ico" />
            
            {/* 관련된 리소스 로드 (CSS는 _app.js에서 global CSS로 로드하는 편이 좋을 거 같다.)*/}
            <link href="//cdn.jsdelivr.net/npm/katex@0.13.3/dist/katex.min.css" rel="stylesheet"/>
            <script src="//cdn.jsdelivr.net/npm/katex@0.13.3/dist/katex.min.js"></script>
            <script src="//cdn.jsdelivr.net/gh/highlightjs/cdn-release@10.7.2/build/highlight.min.js"></script>
            <script src="//cdn.quilljs.com/1.3.6/quill.min.js"></script>
            <link rel="stylesheet" href="//cdn.jsdelivr.net/gh/highlightjs/cdn-release@10.7.2/build/styles/default.min.css"/>
            <link rel="stylesheet" href="//cdn.quilljs.com/1.3.6/quill.snow.css"/>
          </Head>
    
          <h1 className={styles.title}>
            Quill Sample
          </h1>
    
          <div style={{ width: '80%', marginTop: '40px' }}>
            <QuillEditor
              body={body}
              handleQuillChange={setBody}
              mountBody={mountBody}
            />
          </div>
          <div style={{ width: '80%' }}>
            <p>body state 미리보기</p>
            {body}
          </div>
          <div>
            <button onClick={() => { setBody((b) => (b + '<p>수정</p>')) }}>body 수정 발생</button>
            <button onClick={rerenderBody}>body 수정 사항 적용</button>
          </div>
        </div>
      )
    }

    관련 리소스 로드 (24번 줄 ~ 29번 줄)

    <head>에서 quill.min.js 등의 스크립트 파일과 CSS 파일을 로드해야 한다. QuillEditor 컴포넌트 내에 이를 넣으면 QuillEditor 컴포넌트가 생길 때마다 똑같은 파일을 서버로부터 받아오니 script와 link는 각 페이지(/pages/index.js 등)마다 하나씩 넣어야 된다.

    body State (8번 줄)

    body는 Quill 에디터의 innerHTML을 담는 용도이다. Quill에서 text-change 이벤트가 일어날 때마다 setBody를 호출하여 업데이트한다.

    rerenderBody (9번 줄~ 15번 줄)

    아래 나오는 QuillEditor 컴포넌트의 useEffect 실행 조건에 body가 없어, 외부에서 body의 수정이 일어나도 body에 자동으로 적용되지 않는다. 외부에서 body 수정이 일어나면 rerenderBody 함수를 호출하면 body의 수정 사항이 적용된다.


    /components/QuillEditor.js
    
    export default function QuillEditor({ body, handleQuillChange, mountBody }) {
        const quillElement = useRef();
        const quillInstance = useRef();
    
        const [isError, setIsError] = useState(false);
        const [isLoaded, setIsLoaded] = useState(false);
    
        useEffect(() => {
            if (isLoaded) {
                /* isLoaded가 true인 상태에서 rerenderBody를 통해 body 적용시 Quill 초기화 없이
                   innerHTML만 body로 바꿉니다. 이 조건이 없을 시 툴바가 중복되어 여러 개 나타나게
                   됩니다. */
                const quill = quillInstance.current;
                quill.root.innerHTML = body;
                return;
            }
            if (quillElement.current && window.Quill) {
                /* isLoaded가 false일 때는 Quill을 초기화합니다. */
    
                /* Quill 옵션을 원하는 대로 수정하세요. */
                const toolbarOptions = {
                    container: [
                        [{ 'size': ['small', false, 'large', 'huge'] }],    // custom dropdown
                        [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
                        [{ 'align': [] }],
                        ['bold', 'italic', 'underline', 'strike'],          // toggled buttons
                        [{ 'color': [] }, { 'background': [] }],            // dropdown with defaults from theme
                        [{ 'header': 1 }, { 'header': 2 }],                 // custom button values
                        [{ 'list': 'ordered'}, { 'list': 'bullet' }],
                        [{ 'script': 'sub'}, { 'script': 'super' }],        // superscript/subscript
                        [{ 'indent': '-1'}, { 'indent': '+1' }],            // outdent/indent
                        [{ 'direction': 'rtl' }],                           // text direction
                        ['clean'],                                          // remove formatting button
                        ['blockquote', 'link', 'code-block', 'formula', 'image', 'video'] // media
                    ],
                };
    
                quillInstance.current = new window.Quill(quillElement.current, {
                    modules: {
                        history : {
                            delay: 2000,
                            maxStack: 500,
                            userOnly: true
                        },
                        syntax : true,
                        toolbar : toolbarOptions
                    },
                    placeholder: "본문 입력",
                    theme : 'snow'
                });
    
                const quill = quillInstance.current;
    
                quill.root.setAttribute("spellcheck", "false");
    
                // 초기 body state 적용
                quill.root.innerHTML = body;
    
                /* quill에서 text-change 이벤트 발생시에 setBody(innerHTML)을 통해 body를 업데이트합니다.
                   body가 업데이트되어도 useEffect 발생 조건 인자([isError, mountBody])에 body가 없으므로
                   QuillEditor 컴포넌트는 다시 렌더링되지 않습니다. 이는 입력 중 커서가 맨 앞으로 이동하는
                   문제를 방지합니다. 대신 외부에서 body가 수정되어도 rerenderBody가 호출되지 않으면 변경된
                   body가 적용되지 않습니다. */
                quill.on("text-change", () => {
                    handleQuillChange(quill.root.innerHTML);
                });
    
                setIsLoaded(true);
            } else {
                /* quill.min.js가 로드되어 있지 않아 window.Quill이 undefined이면 isError가
                   계속 변경되면서 재시도합니다. */
                setIsError((prevIsError) => (!prevIsError));
            }
        }, [isError, mountBody]);
    
    
        return (
            <div ref={quillElement}></div>
        );
    }

    useEffect (10번 줄 ~ 76번 줄)

    useEffect에서는 Quill의 초기화와 에디터의 innerHTML을 body로 적용하는 작업이 일어난다.

    첫 컴포넌트 렌더링 시 quill.min.js가 로드되어 있지 않아 window.Quill이 undefined이면 isError가 계속 변경되면서 useEffect를 반복 실행한다.

    window.Quill이 선언된 상태이면 Quill이 초기화되면서 quillElement 위치에 에디터가 표시되고, innerHTML로 body가 적용된다. 그리고 quill.on을 통해 text-change 이벤트가 발생할 때 handleQuillChange가 실행되도록 한다. 이때 body가 업데이트되어도 useEffect의 발생 조건 인자([isError, mountBody])에는 body가 없기 때문에 QuillEditor 컴포넌트는 다시 렌더링되지 않는다. 이는 매 입력마다 useEffect가 실행되면 입력 커서가 맨 앞으로 이동하는 문제가 있었기에 rerenderBody 함수가 호출될 때만 useEffect가 실행되도록 하였다.

    툴바 옵션이나 Quill 생성자의 옵션 등은 Quill 공식 문서 페이지를 참고하여 원하는 대로 설정할 수 있다.

    이미 Quill의 초기화가 완료된 상태(isLoaded가 true)에서는 useEffect 실행 시에 body의 변경 사항만 적용되도록 하였다.


    샘플 프로젝트를 다운로드 받아 /components/QuillEditor.js 파일만 복사해 원하는 프로젝트 가져가고, /pages/index.js에서 어떻게 사용하는지만 확인하여 이식하면 바로 QuillEditor를 사용할 수 있을 것이다. 그 후 리소스 로드 URL, 툴바 옵션, Quill 옵션 등을 원하는 대로 수정바란다.

     

    도움이 된 자료

    HotHandCoding

    '개발 > React' 카테고리의 다른 글

    [경험] React에서 Quill 입력시 입력 오류 해결  (0) 2021.01.30