React 웹 페이지에서 Quill 에디터 텍스트 수정 페이지 작업하기

Nowon LeeNowon Lee
4 min read

Table of contents

Quill

Quill은 웹에서 사람들이 텍스트를 편하게 입력할 수 있도록 도와주는 텍스트 에디터이다.

Quill을 도입한 이유는 다음과 같다.

  • Quill은 다양한 서식의 텍스트를 작성할 수 있는 위지윅 에디터이다.

  • 다양한 플랫폼을 지원한다.

  • 오픈소스로 공개되어 도입하기 어렵지 않다.

  • 프로젝트에서 필요로 하는 모듈만 도입하여 에디터를 개발할 수 있다.

React에서 Quill

공식문서에서는 에디터를 리액트 웹 페이지에 렌더링하는 방법을 알려주고 있다. 타입스크립트 프로젝트에서 도입하기 위해서 추가적인 타입을 지정할 수 있다.

import Quill, { Delta, EmitterSource, Op, Range } from "quill";
import { forwardRef, useEffect, useLayoutEffect, useRef } from "react";

import "quill/dist/quill.snow.css";

export interface EditorProps {
  readOnly?: boolean;
  defaultValue?: Delta | Op[];
  onTextChange?: (
    delta: Delta,
    oldContent: Delta,
    source: EmitterSource
  ) => unknown;
  onSelectionChange?: (
    range: Range,
    oldRange: Range,
    source: EmitterSource
  ) => unknown;
}

const Editor = forwardRef<Quill, EditorProps>(function Editor(
  { readOnly, defaultValue, onTextChange, onSelectionChange },
  ref
) {
  const containerRef = useRef(null as HTMLDivElement | null);
  const defaultValueRef = useRef(defaultValue);
  const onTextChangeRef = useRef(onTextChange);
  const onSelectionChangeRef = useRef(onSelectionChange);

  useLayoutEffect(() => {
    onTextChangeRef.current = onTextChange;
    onSelectionChangeRef.current = onSelectionChange;
  });

  useEffect(() => {
    if (typeof ref === "function") {
      throw new Error("Editor ref should not be function.");
    }
    if (ref && ref.current) {
      ref.current.enable(!readOnly);
    }
  }, [ref, readOnly]);

  useEffect(() => {
    if (typeof ref === "function") {
      throw new Error("Editor ref should not be function.");
    }
    const container = containerRef.current;
    if (!container) {
      return;
    }
    if (container && ref) {
      const editorContainer = container.appendChild(
        container.ownerDocument.createElement("div")
      );
      const quill = new Quill(editorContainer, {
        theme: "snow",
      });
      ref.current = quill;

      if (defaultValueRef.current) {
        quill.setContents(defaultValueRef.current);
      }

      quill.on(Quill.events.TEXT_CHANGE, (...args) => {
        onTextChangeRef.current?.(...args);
      });

      quill.on(Quill.events.SELECTION_CHANGE, (...args) => {
        onSelectionChangeRef.current?.(...args);
      });
    }

    return () => {
      if (ref) {
        ref.current = null;
        container.innerHTML = "";
      }
    };
  }, [ref]);

  return <div ref={containerRef}></div>;
});

export default Editor;

만약 에디터를 서버 사이드 렌더링을 지원하는 프로젝트에서 도입하려면 추가적인 방법이 필요하다. Next.js의 경우 dynamic 메소드로 에디터를 서버가 아닌 브라우저에서만 렌더링하도록 할 수 있다.

dynamic 메소드의 특징은 다음과 같다.

  • 컴포넌트 또는 라이브러리를 별도의 모듈로 분리하는 메소드이다.

  • 분리된 모듈을 필요한 때에 불러오게 함으로써 페이지 초기 로딩 시간을 단축시킬 수 있도록 도와준다.

  • ssr 속성을 통해서 서버 사이드 렌더링의 여부를 결정할 수 있다.

  • loading 속성을 통해서 모듈을 불러오는 동안 표시할 컴포넌트를 지정할 수 있다.

import dynamic from "next/dynamic";
import Quill from "quill";
import { LegacyRef } from "react";
import { EditorProps } from "./Editor";

const QuillEditor = dynamic(
  async () => {
    const { default: EditorModule } = await import("./Editor");

    return function Editor({
      forwardedRef,
      ...props
    }: EditorProps & { forwardedRef: LegacyRef<Quill> }) {
      return <EditorModule ref={forwardedRef} {...props} />;
    };
  },
  { ssr: false }
);

에디터로 텍스트 생성 페이지를 작성한 예시이다.

  • Quill 에디터는 제어되지 않는 컴포넌트이기 때문에 useState가 아닌 useRef로 관리한다.

  • 텍스트가 변경되었을 때 콜백 함수를 실행시키려면 onTextChange 속성에 콜백 함수를 명시할 수 있다.

import QuillEditor from "@/components/Editor";
import { useQueryClient } from "@tanstack/react-query";
import Quill from "quill";
import { useRef, FormEventHandler } from "react";

export default function Page() {
  const ref = useRef<Quill | null>(null);
  const onSubmit: FormEventHandler<HTMLFormElement> = (event) => {
    if (ref.current) {
      const content = editorRef.current.getSemanticHTML();
      // 폼(Form) 제출
    }
  };
  return (
    <form method="POST" onSubmit={onSubmit}>
      <QuillEditor forwardedRef={ref} readOnly={false} defaultValue={[]} />
    </form>
  );
}

만약 텍스트 생성하는 페이지가 아닌 작성된 텍스트를 수정 및 업데이트하는 페이지를 작성한다면 추가적인 작업이 필요하다.

  • 에디터에 초기 값으로 전달할 수 있는 매개변수 defaultValue는 Quill에서 제공하는 Delta 타입으로 텍스트에서 별도로 변환해줘야 한다.

  • 변환된 데이터를 defaultValue에 값을 전달했을 때 에디터에 텍스트가 보이지 않는 이슈가 있었다. 서버에서 텍스트를 불러오는 동안 에디터는 이미 렌더링이 끝난 상태기 때문이다.

const [initialDelta, setInitialDelta] = useState<Delta>();
const ref = useRef<Quill | null>(null);
const toDelta = (html: string) => {
  const delta = ref.current?.clipboard.convert({ html });
  setInitialDelta(delta);
};

변환된 데이터를 에디터에 초기값으로써 표시하려면 다음과 같은 작업이 필요했다.

  • 서버에서 받은 HTML 텍스트를 Delta 데이터로 변환한다.

  • Delta 데이터 변환이 완료되면 조건부 렌더링으로 에디터를 렌더링시킨다.

이런 과정으로 페이지에 접속했을 때 에디터에 작성한 텍스트가 보여지게 할 수 있다. 데이터가 준비되기 전까지는 읽기만 가능한 에디터를 대신 보여주게할 수 있다.

  • useQuillClipboard Hook은 html 문자열과 onChange 콜백 함수를 전달할 수 있다.

  • 임시 HTML 요소를 기반으로 Quill 에디터를 생성하여 HTML 텍스트를 변환시킨다.

  • 변환이 끝나면 onChange 함수를 호출시킨다.

import Quill, { Delta } from "quill";
import { useEffect } from "react";

export interface UseQuillClipboardProps {
  html?: string;
  onChange?: (delta: Delta) => unknown;
}

const useQuillClipboard = (props: UseQuillClipboardProps) => {
  const { html, onChange } = props;

  useEffect(() => {
    if (!html) {
      return;
    }
    const element = document.createElement("div");
    const quill = new Quill(element);
    const delta = quill.clipboard.convert({ html });

    onChange?.(delta);

    return () => {
      element.remove();
    };
  }, [html, onChange]);

  return;
};

export default useQuillClipboard;
import { useQuillClipboard } from "@nwleedev/quill-html";
import Quill, { Delta } from "quill";
import "quill/dist/quill.snow.css";
import { useCallback, useEffect, useRef, useState } from "react";
import Editor from "./Editor";

// 백엔드 API를 모방한 함수이다.
async function fetchApi() {
  await new Promise((res) => {
    setTimeout(() => {
      res(null!);
    }, 1000);
  });

  return "<p>Hello World!</p>";
}

const App = () => {
  const ref = useRef(null as Quill | null);
  const [html, setHtml] = useState("");
  const [initialDelta, setInitialDelta] = useState<Delta>();
  const onChange = useCallback((delta: Delta) => {
    setInitialDelta(delta);
  }, []);

  useQuillClipboard({ html, onChange });

  useEffect(() => {
    fetchApi().then((data) => {
      setHtml(data);
    });
  }, []);

  return (
    <div>
      {initialDelta && (
        <Editor ref={ref} readOnly={false} defaultValue={initialDelta} />
      )}
      {!initialDelta && (
        <Editor
          ref={ref}
          readOnly={true}
          defaultValue={[{ insert: "로딩 중입니다." }]}
        />
      )}
    </div>
  );
};

export default App;
0
Subscribe to my newsletter

Read articles from Nowon Lee directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nowon Lee
Nowon Lee