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

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;
Subscribe to my newsletter
Read articles from Nowon Lee directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
