더 많은 포스팅 보기 자세히보기

프론트엔드/React | Next

리액트 텍스트 에디터 구현기(WYSIWYG Editor)

유도혁 2023. 9. 2. 16:28

대부분의 커뮤니티가 html을 기반으로 한 텍스트에디터를 사용한다. 그것은 당연하게도 렌더링할 때 서버에서 받아온 내용 그대로 출력하기에 용이하고, 그 이상의 요구사항이 필요 없기 때문이다.

하지만, 어느 서비스에서 특화된 컴포넌트를 보여주어야 하거나 모바일 앱과도 연계해야 한다면 어떨까? 이 순간 텍스트에디터는 진화하여야 한다.

만난계기

해당 요구사항의 웹 서비스를 구현해야 했는데 맨붕이왔다. 당연히도 그럴 것이 앱에서는 이미 독자 규격으로 컴포넌트형 콘텐츠를 사용한 커뮤니티가 서비스 중이었기 때문이다.

Lead와 Delete는 받아온 데이터를 규격에 따라 컨버팅하고 맞는 컴포넌트에 렌더링하여 해결하였다. 문제는 역시 Create와 Update인데 얼마 남지 않은 개발기간 동안 텍스트에디터에 손대보지 않다가 이것을 처음 구현하기란 어려웠다. 주변에서는 적당한 라이브러리를 찾아서 사용하면 되는 것이 아니냐고 하였지만, 사용자의 개인 데이터를 불러오고 그중에 원하는 것을 추가하여 공유하는 스펙이 있었다. 구현해야 하는 디자인도 그렇고 단순히 html을 편집하는 방식은 아닌 것 같았다. 참고를 위해 노션이나 네이버 블로그나 카페에서 사용하는 에디터로 글을 작성해 보며 어떻게 동작하는 걸까 고민했다. 그런데 마침 타이밍 좋게 FECONF 2022 를 보고 있었는데 짠! 하고 텍스트 에디터에 대한 내용이 나오는 게 아니겠는가…

contenteditable

사용자가 요소를 편집할 수 있는지 나타내는 특성

텍스트에디터의 역사

1세대

contenteditable을 기반으로 html 형식에 의존하여 내용을 작성하면 그 자체가 html로 기록되어 바로 보여주며, html 편집으로도 작성할 수 있다. html이다 보니 웹브라우저 이외의 환경에서 사용이 불가하다.

예시로는 메일 편집기나 티스토리 html로 글 작성과 같은 방법이다.

1.5세대

json을 기본 데이터형식으로 사용하여 멀티 디바이스에 대응할 수 있다. 블록 단위 편집해야 하고 다른 블록의 텍스트를 함께 선택하는 것이 불가능하다.

2세대

가상 커서와 인풋 버퍼를 사용하여 어색한 부분을 해결하였지만, 브라우저 네이티브 기능을 사용하기 어려움과 다국어에 따라 입력방식이 다른 점을 대응하기 힘듦.

3세대 에디터

React

vDOM으로 데이터 변경시 컴포넌트 단위 리렌더링 기능 제공

MobX

상태관리 라이브러리로 React와 함께 사용하면 리렌더링에 관여할 수 있음

observer: 상태 변화를 감지하여 렌더링에 관여할 수 있음.

ContentEditable

콘텐츠를 편집할 수 있음.

기존 기술과의 충돌

두 기술의 데이터를 관리하는 절차의 차이로 데이터의 싱크가 깨지는 문제가 발생한다.

싱크 문제 해결

MobX의 untracked API를 이용하여 contenteditable을 이용하는 상황에는 react의 반응을 중단하고 있다가, 백스페이스나 엔터와 같이 데이터 그룹에 영향이 가는 부분은 store를 업데이트하고 vDom을 업데이트할 수 있도록 한다.

function Edit({ content }: EditProps) {
  const {
    contentEditableRef,
    lines,
    upstreaming,
    handleInput,
    handleKeyDown,
    ...
  } = useEdit(content);

  const Content = () => {
    const render = () => {...};

    if (upstreaming) {
      return <span>{untracked(render)}</span>;
    }

    return <span>{render()}</span>;
  };

  return (
    <Editer
      ref={contentEditableRef}
      contentEditable={true}
      suppressContentEditableWarning={true}
      onInput={handleInput}
      onKeyDown={handleKeyDown}
      ...
    >
      <Content />
    </Editer> 
  );
};
function useEdit(content: Content | 'post') {
  const contentEditableRef = useRef<HTMLDivElement | null>(null);
  const {
    lines,
    upstreaming,
    setLines,
    setUpstreaming,
    updateStore,
    keyDownEnter,
    keyDownBackspace,
    ...
  } = useStore<EditorStore>('editorStore');

  const handleInput = () => {
    setUpstreaming(true);
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (e.nativeEvent.isComposing) return e.preventDefault();

    switch (e.key) {
      case 'Enter':
        return keyDownEnter(e);
      case 'Backspace':
        return keyDownBackspace(e);
      ...
    }
  };

  useEffect(() => {
    const mo = new MutationObserver((mutations) => {
      if (!upstreaming) return;

      mutations.forEach(updateStore);
    });

    if (contentEditableRef.current)
      mo.observe(contentEditableRef.current, {...});

    return () => {
      mo.disconnect();
    };
  }, []);

  return {
    contentEditableRef,
    lines,
    upstreaming,
    handleInput,
    handleKeyDown,
    ...
  };
};

참고링크들

https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable

https://www.youtube.com/watch?v=xDyUFE1pmmY

https://deview.kr/data/deview/session/attach/[114]중요한_건_꺾이지_않는_마음_최종.pdf