tiptap으로 React rich text editor 구현하기

LEEJAEJUN·2024년 2월 7일
0

fasttime-2024

목록 보기
2/4

Rich text 에디터

문제점

당시 저는 에디터가 어떻게 구현되는지, 에디터의 종류는 무엇이 있는지 전혀 모르는 상태였습니다. 또 tiptap을 선택했지만 러닝 커브가 좀 컸다는 것입니다. useEditor, useCurrentEditor, 메뉴바 생성, 컨텐츠 분리 등 다양한 곳에서 모르는 것이 생겼습니다.

고민과 해결

커뮤니티 핵심인 에디터를 구현하기 위해 tiptap 라이브러리를 사용했습니다. 보통 인터넷에서 볼 수 있는 에디터는 rich text editor라고 부르는 것 같기에, 해당 키워드로 접목할 만한 툴이 있을지 찾아보았습니다.

다양한 에디터가 있었지만 그 중 tiptap을 선택하게 된 이유는 ‘커스텀’이 가능하다는 점 때문이었습니다. 다양한 기능과 상세한 문서는 물론이고 내가 원하는 기능을 붙일 수도 있다니 멀리 봤을 때 가장 이상적이라고 생각했습니다. 심지어 Collaborative editing, 여러 사람이 동시에 편집하는 기능도 제공하고 있었습니다.

메뉴바 만들기

특히나 문제였던 것은 메뉴입니다. 굵게하기 버튼을 하나 만들기 위해서 editor에 달려있는 다양한 메소드를 이해해야 했습니다. 이미지를 추가하는 것도 상당히 복잡한 과정이 필요했습니다. (알고 보니 해당 익스텐션을 제공하고 있었습니다.)

  1. 이미지 버튼 추가하기

    <button>
      <label
        style={{ display: 'flex', alignContent: 'center', cursor: 'pointer' }}
        htmlFor="image"
      >
        <BsCardImage />
      </label>
      <input
        style={{ display: 'none' }}
        id="image"
        type="file"
        accept="image/*"
        onChange={handleFileChange}
      />
    </button>
  2. handleFileChange onChange 핸들러 제작 - 파일에 변화가 있을 때, 이미지를 firebase에 업로드하고 반환된 downloadUrl을 프리뷰 url로 설정합니다.

    const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (!file) return;
      const firebaseUrl = await getFirebaseUrl(file);
      setUrl(firebaseUrl!);
      e.target.value = '';
    };
  3. 이미지를 에디터에 나타내기 - 이미지를 업로드하면 커서가 해당 이미지로 가있어서 바로 텍스트를 입력할 수 없었습니다. 이는 UX에 치명적일 거라 생각했습니다.

    1. 에디터에 해당 이미지를 setImage 로 업로드합니다.
    2. 이미지가 업로드 된 후 새로운 p 태그를 다음 줄에 삽입합니다.
    3. 해당 위치에 focus합니다.
    useEffect(() => {
        if (url) {
          editor.chain().focus().setImage({ src: url }).run();
    
          const { to } = editor.state.selection;
          const tr = editor.state.tr.insert(to, editor.schema.nodes.paragraph.create());
          editor.view.dispatch(tr.setSelection(TextSelection.create(tr.doc, to + 1)));
    
          editor.view.focus();
        }
      }, [url, editor]);

제목과 본문 분리하기

에디터가 하나로 합쳐져 있었기 때문에, 제목과 본문으로 분리가 필요했습니다.

  1. document를 title과 block으로 분리합니다.
  2. 커스텀 title node를 만들고 extensions 배열에 추가합니다.
  3. 본문은 h1인 경우 h2로 변경합니다. (CustomHeading)
// extensions 배열에 추가할 커스텀 노드들
const DocumentWithTitle = Document.extend({
  content: 'title block+',
});

const CustomHeading = Heading.extend({
  parseHTML() {
    return this.options.levels.map((level) => ({
      tag: `h${level}`,
      attrs: { level: adjustLevel(level) },
    }));
  },
  addInputRules() {
    return this.options.levels.map((level) => {
      return textblockTypeInputRule({
        find: new RegExp(`^(#{1,${level}})\\s$`),
        type: this.type,
        getAttributes: {
          level: adjustLevel(level),
        },
      });
    });
  },
});

const Title = Node.create<TitleProps>({
  name: 'title',

  addOptions() {
    return {
      level: 1,
      HTMLAttributes: {},
    };
  },

  content: 'text*',

  marks: '',

  group: 'block',

  defining: true,

  renderHTML({ HTMLAttributes }) {
    const level = this.options.level;

    return [`h${level}`, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
  },
});

작성, 수정 에디터 분리하기

글 수정하기는 기존 글을 서버에서 받아와야 합니다.

  • 수정하기 버튼을 클릭해서 들어오는 게 일반적이지만, 해당 url로 바로 접근할 수도 있기 때문에 client state만으로 관리하기에는 아쉽습니다.
  • useArticle이라는 react query 훅이 이미 있기 때문에 굳이 이중으로 상태 관리를 할 필요 없이 서버 상태 하나로 관리하는 게 나을 것 같았습니다.
  • 실제로 다른 (velog, medium) 사이트도 수정 페이지 url로 바로 접근 시 데이터를 받아오는 것으로 확인 되었습니다.

아래와 같은 방식으로 고민하고 풀어나갔습니다.

두 개의 다른 에디터 페이지를 어떤 방식으로 나눌 것인가? (1)

  • 하나의 Editor 컴포넌트에서 props로 mode를 받아와서 ‘edit’, ‘write’로 나눕니다.
  • 에디터 디자인은 동일하기 때문에 로직만 두 가지로 나누면 됩니다.
  • edit일 경우, 수정하는 게시글의 id 가 필요합니다.
  • usePublish.ts
    import ArticleService, { ArticleRequest } from '@/api/articleService';
    import { uploadImageToFirebase } from '@/hooks/useImageConvert';
    import { useMutation } from '@tanstack/react-query';
    import { useEffect, useState } from 'react';
    import { useNavigate } from 'react-router-dom';
    import { useRecoilValue } from 'recoil';
    import { editorState } from '../atoms/editor.atom';
    
    type PublishConfig = {
      mode: 'write' | 'edit';
      articleId?: number;
    };
    
    const api = new ArticleService();
    
    const usePublish = ({ mode, articleId }: PublishConfig) => {
      const [newContent, setNewContent] = useState('');
      const [newTitle, setNewTitle] = useState('');
      const { content, isAnonymity } = useRecoilValue(editorState);
      const navigate = useNavigate();
    
      useEffect(() => {
        const processContent = async () => {
          const parser = new DOMParser();
          const document = parser.parseFromString(content, 'text/html');
    
          const titleEl = document.querySelector('h1');
          if (titleEl && titleEl.textContent) {
            setNewTitle(titleEl.textContent);
            titleEl.remove();
          }
    
          // TODO: 에러 핸들링 과정 추가
          // TODO: 해당 과정 Promise.all()로 업데이트
          const images = document.querySelectorAll('img');
          for (const img of images) {
            const blob = await fetch(img.src).then((res) => res.blob());
            const firebaseUrl = await uploadImageToFirebase(blob);
            img.src = firebaseUrl;
          }
    
          setNewContent(document.body.innerHTML);
        };
        processContent();
      }, [content]);
    
      const mutation = useMutation({
        mutationKey: ['publish'],
        mutationFn: (articleData: ArticleRequest) => {
          return mode === 'write' ? api.post(articleData) : api.edit(articleData);
        },
        onSuccess: () => {
          navigate(`/community/${articleId}`);
        },
        onError: (error) => {
          console.error(error);
        },
      });
    
      const handleSubmit = () => {
        const articleData: ArticleRequest = { title: newTitle, content: newContent, isAnonymity };
    
        if (mode === 'edit') {
          articleData.id = articleId;
        }
    
        mutation.mutate(articleData);
      };
    
      const handleCancel = () => {
        if (mode === 'write') {
          navigate('/community');
        } else {
          navigate(`/community/${articleId}`);
        }
      };
    
      return { handleSubmit, handleCancel, status: mutation.status };
    };
    
    export default usePublish;

문제점

  • 제목, 본문, 발행수정 버튼 컴포넌트 세 곳에서 mode props를 넘겨줘야 했습니다.
  • mutationFn, onSuccess, handleSubmit, handleCancel 등 모든 함수 내부에서 mode에 따른 분기 처리를 해줘야 했습니다.
  • mode에 따른 mutationFn의 return 값이 달라서 onSuccess의 파라미터에 data: <TData> 를 넣을 수 없었습니다.
    • 지금 생각해보니 mutationOptions에 TData를 Union types로 했으면 해결되었을지도 모르겠습니다.

두 개의 다른 에디터 페이지를 어떤 방식으로 나눌 것인가? (채택)

  • 글 작성하기는 기존 비어있는 에디터 컴포넌트를 사용하고, 글 수정하기는 받아온 데이터를 적용한 에디터 컴포넌트로 새로 만듭니다. 기존 article이 있으면, article을 적용하고 없으면 빈 content를 에디터에 적용합니다.
  • DefaultEditor.tsx
    // 일부 코드입니다.
    interface DefaultEditorProps {
      article?: Article;
    }
    
    const DefaultEditor = ({ article }: DefaultEditorProps) => {
      const content = article ? `<h1>${article.title}</h1> ${article.content}` : `<p></p>`;
    
      if (!editor) return null;
    
      return (
        <div className="editor-main">
          <MenuBar editor={editor} />
          <EditorContent editor={editor} />
        </div>
      );
    };
    
    export default DefaultEditor;
  • EditEditor.tsx
    import DefaultEditor from '@/pages/editor/components/editor';
    import { useArticle } from '../../../articleDetail/hooks/useArticle';
    
    const EditEditor = () => {
      const { data: article, isLoading } = useArticle();
    
      if (isLoading) return null;
    
      return <DefaultEditor article={article} />;
    };
    
    export default EditEditor;

아쉬운 점

  • 두 컴포넌트의 코드가 비슷해집니다.
  • 필요 이상으로 컴포넌트가 분리된 느낌이 듭니다.
profile
always be fresh

0개의 댓글