React-quill에서 tiptap 으로 - 1부

SeongHyeon Bae·2023년 11월 5일
3
post-thumbnail

React-Quill을 버린 이유

게시글 기능 구현을 위해 react-quill 라이브러리를 사용했었다. 하지만 본인 프로젝트의 경우 NEXTJS 환경에서 구현을 하다보니 사용하면서 많은 걸림돌이 발생했었다.

1. SSR의 지원 x

먼저, Quill 에디터의 경우document에 접근하는 코드가 존재하는데 SSR 환경에서 렌더링 시 브라우저가 없지 않은가? 그럼 당연히 window 객체는 존재하지 않게되고 이미지와 같은 에러가 발생하였다.

구글링을 하다가 링크를 보며 dynamic import를 사용하여 동적으로 브라우저에서 CSR에서 시도하여 해결은 하였다.(이때 오픈소스를 기여하고 싶었지만 방대한 코드의 양에 압도당해 미뤄두었다.😭)

2. 커스터마이징 불편

벨로그와 같은 깔끔한 디자인의 에디터를 제공하고 싶었는데 생각보다 커스터마이징 하기가 힘들었다. 또한, react-quill의 늦은 업데이트 속도는 다른 라이브러리 이전 욕구가 뿜뿜했다.

직접 라이브러리 구현?

사실 처음에는 내가 직접 에디터 라이브러리를 만들어 보자! 로 시작하였다. Velog, Tiptap, 오늘의집 등등 다양한 코드와 기술블로그를 학습하며 적용해보았으나 구문 parsing 하는 법, 디자인, 렌더링 등등 너무 고려할 부분이 많아 시간적으로 부담이 되었다. 다음 기회에 있으면 다시 도전해보려고 한다..😱

Tiptap 도입

다른 라이브러리들 중에 Tiptap을 고른이유는 몇가지 있었다. 먼저 지인들의 추천으로 Tiptap 사이트를 들어갔었는데 너무나 깔끔한 디자인과 직관적인 example 구성방식은 주니어 개발자 나도 쉽게 따라할 수 있을거 같다는 느낌을 받았다. 또한, 다양한 프레임워크(React,Vue,NextJS,Svelt 등)를 지원한다는 점이 매력적이였다. 또한 수많은 extension plugin은 내가 Tiptap을 사용하지 않을 이유가 없었다.

기능 구현

Example 이 너무 잘 나와 있어서 그대로 보면서 구현을 했다. 최종 작성한 Tiptap의 컴포넌트를 먼저 확인하고 세부 내용으로 들어가 보자.

//Tiptap.tsx
import React, { useEffect } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import ToolBar from '../Toolbar';

//tiptap
import StarterKit from '@tiptap/starter-kit';
import Highlight from '@tiptap/extension-highlight';
import Image from '@tiptap/extension-image';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';

import { common, createLowlight } from 'lowlight';

interface TiptapProps {
  content: string;
}

const Tiptap = ({ content }: TiptapProps) => {
  const lowlight = createLowlight(common);
  const editor = useEditor({
    extensions: [
      StarterKit,
      Highlight,
      Image.configure({ inline: true, allowBase64: true }),
      CodeBlockLowlight.configure({
        lowlight,
      }),
    ],
  });

  useEffect(() => {
    if (content) {
      editor?.commands.setContent(content);
    }
  }, [content]);
  return (
    <div className="border-2">
      <ToolBar editor={editor} />
      <EditorContent
        id="tiptap"
        editor={editor}
        onClick={() => editor?.commands.focus()}
      />
    </div>
  );
};

export default Tiptap;

Toolbar

Toolbar의 경우 기본 제공하는 것이 이쁘지가 않아서 google icon 을 사용하여 커스터마이징을 하였다. css는 tailwindcss로 스타일링하였다.

Toolbar code

//Toolbar.tsx
import React from 'react';
import { Editor } from '@tiptap/react';
import { Icon } from '../icons';

interface ToolBarProps {
  editor: Editor | null;
}
function ToolBar({ editor }: ToolBarProps) {
  if (!editor) return null;

  return (
    <div className="flex items-center justify-center gap-2 p-6 py-3 border-b-2 sm:gap-8">
      <div className="flex items-center justify-center gap-2">
        <Icon.H1 editor={editor} />
        <Icon.H2 editor={editor} />
        <Icon.H3 editor={editor} />
      </div>
      <div className="flex items-center justify-center gap-2">
        <Icon.Bold editor={editor} />
        <Icon.Italic editor={editor} />
        <Icon.Strikethrough editor={editor} />
        <Icon.Code editor={editor} />
      </div>

      <div className="flex items-center justify-center gap-2">
        <Icon.Quote editor={editor} />
        <Icon.AddPhoto editor={editor} />
      </div>
    </div>
  );
}

export default ToolBar;

여기서 특히 어려웠던 점은 이미지 업로드 툴바였다. Tiptap의 이미지기능 을 보면 다음과 같은 로직으로 editor에 사진이 설정되는 것을 알 수 있다.

  1. 이미지의 URL을 입력한다.
  2. 이미지의 URL을 <img src={URL}/> 의 html로 변경한다.
  3. 이 변경된 html을 editor에 추가된다.

하지만 Velog나 다른 에디터들을 보면 로컬에 있는 사진을 업로드 하거나 드래그 앤 드랍 방식으로 하는 것이 보편적인 것을 알 수 있다. 그럼 내가 직접 구현해야하는 것이 뭘까?

  1. <input type='file'/> 을 이용하여 이미지 추가 툴바 클릭 시 로컬에서 파일 받아오기 기능 구현
  2. 사진을 에디터로 드래그 앤 드랍 시 이 이미지의 정보를 저장하기

그럼 차례대로 해결해 보자.

AddImage

로컬에서 파일 받아오기

해당 버튼 아이콘에 <input type='file'/> 를 추가하여 아이콘 클릭 시 다음과 같은 창을 띄워 파일을 선택하려고 한다.

그러기 위해서 아래 사진 같이 <input type='file'/> 코드를 작성하면 나오는 input의 고유 버튼 인 파일 선택 버튼을 클릭해야하는데 file 타입의 스타일링이 조금 까다로워 버튼 이미지 크기를 파일 선택 버튼 크기로 꽉 채워서 해결을 하였다.

icon 적용 전

icon 적용 후

그럼 이제 버튼 클릭 시 파일을 불러올 수 있다. 그럼 어떻게 파일의 정보를 받아올까? 일반적으로 input 태그의 onChange 태그의 event를 통해서 값을 접근할 수 있다. 다음 코드를 보면 이해가 될 것이다.

function AddPhoto() 

  const handleUploadPhoto = async (files: FileList | null) => {
    if (files === null) return;
    const file = files[0];
    console.log(file);
  };
  return (
    <button
      type="button"
      className="relative w-8 h-8 cursor-pointer opacity-70 hover:opacity-40"
    >
      <input
        type="file"
        className="absolute top-0 left-0 w-8 h-8 outline-none opacity-0 file:cursor-pointer"
        accept="image/*"
        onChange={(e) => {
          handleUploadPhoto(e.target.files);
        }}
      />
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="32"
        height="32"
        viewBox="0 -960 960 960"
      >
        <path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h360v80H200v560h560v-360h80v360q0 33-23.5 56.5T760-120H200Zm480-480v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80ZM240-280h480L570-480 450-320l-90-120-120 160Zm-40-480v560-560Z" />
      </svg>
    </button>
  );
}

필자는 input에 파일 데이터에 변화가 생기면 데이터를 백엔드에 Post 요청을 하여 저장을 하였고 응답 값으로 해당 URL을 받아오는 로직으로 구현을 하였다.

최종적으로 이미지 추가 버튼 코드이다.

//core
import React from 'react';
import { useSession } from 'next-auth/react';

//constants
import { ModalType, errorMessage } from '@/constants/constant';

//service
import { BASE_URL } from '@/service/base/api';
import { postManager } from '@/service/post';

//third party
import { Editor } from '@tiptap/react';
import { useModal } from '@/hooks';

function AddPhoto({ editor }: { editor: Editor }) {

  const handleUploadPhoto = async (files: FileList | null) => {
    if (files === null || !editor) return;

    const file = files[0];
    const formData = new FormData();
    formData.append('file', file);

     const imgHash = await postManager.uploadImage(formData, accessToken);// 백엔드에게 이미지 Post요청 후 URL 받기
     const IMG_URL = `${BASE_URL}${imgHash}`;

     editor.commands.setImage({ src: IMG_URL });
  };
  return (
    <button
      type="button"
      className="relative w-8 h-8 cursor-pointer opacity-70 hover:opacity-40"
    >
      <input
        type="file"
        className="absolute top-0 left-0 w-8 h-8 outline-none opacity-0 file:cursor-pointer"
        accept="image/*"
        onChange={(e) => {
          handleUploadPhoto(e.target.files);
          e.target.value = ''; // 중복해서 데이터 넣을 경우 가능 예외 처리 
        }}
      />
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="32"
        height="32"
        viewBox="0 -960 960 960"
      >
        <path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h360v80H200v560h560v-360h80v360q0 33-23.5 56.5T760-120H200Zm480-480v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80ZM240-280h480L570-480 450-320l-90-120-120 160Zm-40-480v560-560Z" />
      </svg>
    </button>
  );
}

export default AddPhoto;

Drag And Drop (미해결)

두번째로 드래그 앤 드랍으로 에디터에 바로 사진을 넣고싶었다. 다행히도 Tiptap pro의 File Handler extension를 이용하면 쉽게 해결될 줄 알았지만 생각보다 난관이 있었다.

pro라는 어감이 유로 버전이라 생각 했지만 무료 버전도 어느정도 제공되는것 같아서 다행이었다.

.npmrc

문서를 보며 따라하던 중 .npmrc 라는 새로운 파일을 알게되었다. .npmrc 파일에 나의 토큰 정보를 넣어서 npm 모듈 설치 할때 인증인가를 도와주는 파일이라는 것을 보고 npm 라이브러리가 private으로 제공할 수도 있구나라는 것을 알게되었다. npmrc는 인증인가 뿐 아니라 npm 환경설정, 패키지 scope 등 다양한 기능을 제공하는 것 같았다. 참고

필자의 경우 node 패키지 관리툴로 yarn을 사용하고 있어 다음과 같이 .yarnrc 를 설정하였다.

"@tiptap-pro:registry" "https://registry.tiptap.dev/"
"//registry.tiptap.dev/:_authToken" "나의토큰정보"

그뒤 yarn add @tiptap-pro/extension-unique-id 를 실행 하였는데 다음과 같은 에러가 발생하였다.

공식문서의 예시대로 npm으로 설치할 경우에는 잘 되는 것을 보면 2가지 정도로 원인을 생각해 볼 수 있을거 같다.

  1. .yarnrc의 파일 설정을 잘못하여서 authCode를 읽지 못한경우
  2. tiptap-pro의 경우 yarn 지원을 하지않는다?

아무래도 .yarnrc 파일을 처음 사용하다 보니 나의 문제라고 생각은 되지만 많은 구글링과 지피티를 사용해도 문법적인 오류는 발견을 못했다. (혹시 알고 계신분은 댓글로 제발 알려주세요😭😭)

그럼 두번째 경우로 tiptap 라이브러리 내부 문제인데 500에러라고 무조껀 서버 문제라고 단정지을수도 없을 뿐더러 npm은 되고 yarn은 안되는 경우를 겪은적이 없어서 당황스럽다. npm으로 설치를 하면 작동은 되겠지만 두가지의 노드패키지를 사용하기에는 관리 차원에서 부담스럽기도 하기에 이후에 해결하려고 남겨두었다. (나중에 업데이트가 되면 될 수도 있지 않을까 하는 행복회로?)

작성하다 보니 글이 길어진 것 같아 lowlight를 활용하여 code block에 색상을 넣은 경험을 2부로 넘기려고 한다!

혹시 잘못된 부분이나 더 나은 해결방법이 있으면 댓글로 알려주시면 감사하겠습니다.🙇🏻‍♂️

profile
FE 개발자

2개의 댓글

comment-user-thumbnail
2023년 11월 5일

저도 React-quill과 Tiptap을 써본 경험이 있는데, iOS Safari에서 한글 Selection이 제대로 안 되는 문제가 있어서 컴포넌트를 Lexical로 바꿨어요. 다행히 Lexical은 문제 없이 잘 동작하더라구요. 혹시나 나중에 비슷한 문제로 고민하실까봐 미리 글 남겨봅니다 :)

1개의 답글