이번 프로젝트에서 관리자의 게시글 작성 기능을 추가해야 한다. 다만, 단순히 textarea 태그로 해결하기에는 사용성적인 측면에서도 아쉽지만, 가독성을 챙길 수 없다는 큰 단점을 가지고 있다.
이전 프로젝트에서 React-Quill 위지윅 에디터를 사용해본 경험이 있었지만, 별로 좋은 경험은 아니였다. 그 이유에 대해서 알아보니 최근 React 버전과 호환성 문제로 여러 오류를 발생하고 있지만, 해결하지 않고 있다고 한다.
가장 난감했던 트러블 슈팅 중 하나가 SSR 오류와 Hydration 오류였는데, document나 window 객체에 강하게 의존하기 때문에 발생하는 오류라고 한다. 그래서 이번에는 다른 에디터를 사용해보고 싶었다. 처음에는 Lexical을 많이들 추천했다. Next.js와 Tailwind를 사용하는 환경에 적합하다는 댓글을 봤는데, Lexical을 사용하려고 공식 문서를 보니 너무 당황스러웠다. ㅎㅎ;
이전에 쓰던 Quill은 오류를 제외하고는 큰 어려움은 없었다. 물론 onPaste가 동작 안 하는 이슈를 해결하진 못했지만. 그래도 최소 요구사항들은 쉽게 구현할 수 있는 수준이었는데, 얘는 좀 복잡했다. 러닝커브가 굉장히 높을 것 같다는 생각이 들었다. 실제로 찾아보니 초반 진입장벽이 높긴하다고 한다. 하지만, 성능 측면에서는 좋은 것 같다. 나중에 게시글이 정말 중요해지는 프로젝트를 하게 된다면, 그때는 배워봐야겠지! 하지만, 지금은 게시글 자체가 주요 기능이긴 하지만, 디테일한 요구사항이 있는 건 아니기 때문에 조금 더 학습곡선이 낮은 에디터로 선택하고 싶었다.
그 다음으로 많이 언급이 된 게 사용하게 된 tiptap이다.
Quill에서 너무 스트레스를 받았던 이슈들에서도 해방될 수 있다고 해서 관심을 가졌다. 그 이유이자 가장 큰 특징이 headless 구조로 설계되었다는 점인데, headless 구조는 UI가 없다는 의미이다. 일반적으로 제공하는 UI가 없고 전부 커스텀을 해야하기 때문에 사실 디자인을 구현할 생각이 없다면 좋은 선택은 아닐 수 있지만... 그정도는 뭐 GPT로 짜달라고 해서 쓰는 게 더 좋을 수도 있으니까! 오히려 전부 커스텀을 해야한다는 건 제약이 없다는 것과 거의 같기에 좋은 선택지가 될 수 있다. tiptap도 처음에 지식없이 시작하면, 이게 뭐지? 싶지만 그래도 어느정도 검색과 GPT의 도움을 받으면 정말 쉽게 구현 가능하다.
<form className="mt-11 w-full" onSubmit={handleSubmit(handleUpload)}>
<input
className="w-full py-2.5 px-4 border border-gray-200 bg-white placeholder:text-gray outline-none"
placeholder="제목을 입력해주세요."
{...register("title")}
/>
<TextEditor setValue={setValue} />
<ConfirmButtons
setPopupType={setPopupType}
onConfirm={() => {}}
confirmText="등록"
/>
</form>
구조는 이렇게 설계를 했다. form은 RHF을 활용해서 관리해주고 있고, TextEditor를 form 안에 넣어 관리해준다. tiptap은 전부 TextEditor에서만 import 된다.
useEditor는 에디터의 모든 상태, 설정, 기능을 정의하는 Hook으로 에디터 인스턴스를 생성하고 관리한다. 어떤 확장 기능을 쓸지, 초기 내용은 무엇인지, 값이 변할 때 무엇을 할지 등을 결정하게 된다.
extensions: 굵게, 기울임, 이미지 등을 장착한다.
content: 에디터가 처음 켜졌을 때 보여줄 내용.
onUpdate: 사용자가 글을 쓸 때마다 실행될 콜백 함수.
editorProps: 에디터 본문(DOM)에 부여할 클래스명이나 속성.
const editor = useEditor({
extensions: [
StarterKit,
Underline,
Link.configure({
openOnClick: false,
}),
Placeholder.configure({
placeholder: "내용을 입력해주세요.",
}),
],
immediatelyRender: false,
content: initialContent || "",
onUpdate: ({ editor }) => {
const html = editor.getHTML();
setValue("content", html, { shouldValidate: true, shouldDirty: true });
},
editorProps: {
attributes: {
class:
"prose py-2.5 px-4 border border-gray-200 bg-white placeholder:text-gray outline-none max-w-none h-[300px] overflow-y-auto",
},
},
});
여기서 immediatelyRender: false,은 Next.js 같은 SSR 프레임워크를 사용할 때 발생하는 Hydration Mismatch 에러를 해결하기 위해 사용하는 설정이다. 서버에는 브라우저의 window나 document 객체가 없고, Tiptap은 이를 참조해서 에디터를 그려야 하다보니 발생하는 문제이다. 이 속성을 false 해주면, 클라이언트 측에서 컴포넌트가 완전히 Mount된 후에 렌더링을 시작해 문제를 해결할 수 있다.
여기서 content는 initialContent 또는 "" 인데, 그 이유는 게시글이 단순히 쓰기만 가능한 게 아니라 수정도 가능해야하기 때문이다. initialContent가 존재하면, 그 값을 초기값으로 두어 기존 글을 에디터에 삽입할 수 있게 된다.
onUpdate에서는 getHTML로 html을 받아오고 이걸, setValue를 통해 저장을 해준다. setValue는 RHF의 속성이다. 이를 통해 update가 발생할 때마다 formData에 값을 넣어줄 수 있다.
editorProps에서 class에 tailwind 속성들을 넣어주면, 쉽게 CSS 작업도 가능하다. 이게 정말 좋은 장점인 것 같다. 커스텀이 너무 쉽다. 굿.
extensions는 StarterKit에 사용하는 대표적인 기능들이 포함되어 있다. 여기에 더해서 underline과 placeholder까지 추가로 넣어주었다.
추가로 placeholder를 저렇게 설정만 하면 아무것도 보여지지 않는다. 표시하기 위해서는 CSS를 작성해주어야 한다. global.css에 아래 코드를 넣어주었다.
.tiptap p.is-editor-empty:first-child::before {
color: #898989;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
return (
<div className="w-full mt-6">
<EditorToolbar editor={editor} />
<EditorContent editor={editor} />
</div>
);
여기서 EditorContent는 tiptap의 컴포넌트이다. toolbar는 자체적으로 구현한 컴포넌트임에 유의!
EditorContent를 통해 화면에 렌더링해준다. props로는 editor 객체만 받는다.
추가로 이전 게시글에서 트러블슈팅으로 다룬 적이 있지만, 추가적인 부분이 있어서 설명하자면, 에디터로 작성한 글은 HTML 형식으로 된 문자열이다. <h1>/<p> 와 같은 태그들로 감싸져있기 때문에 이를 그대로 렌더링하면 textarea와 똑같다. 얘를 태그에 맞춰서 렌더링하기 위해서는 tailwind에서 prose 속성을 사용해야 한다.
prose를 사용하기 위해서는 @tailwindcss/typography 플러그인을 사용해야 한다. 이에 대한 설명은 위 링크에서 확인 가능하다.
다만, tailwind가 v4 기준으로 바뀐점이 있어서 그 부분만 덧붙이자면, 기존에는 tailwind.config.js 파일에서 관리해주었지만 이제는 CSS에서 import를 해서 사용한다.
@plugin "@tailwindcss/typography";
global.css에서 다음과 같이 작성해주면 바로 사용 가능하다.
prose를 적용했을 때 UI가 바뀔 수 있다. width가 이전에 설정한대로 되지 않고 작아지는 문제가 있었는데, 해당 이유에 대해서 알아보니 prose를 적용하면, 기본적으로 max-width가 설정되어 있기 때문이다. 가독성을 위해 한 줄에 너무 많은 글자가 들어오지 않게끔 설정을 해준거라고 한다. 하지만, 이 때문에 UI를 수정할 순 없으므로 max-w-none을 추가해서 해당 설정을 제거해준다.
toolbar는 간단하게만 설명하자면,
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={btnClass(editor.isActive("bold"))}
>
<Bold size={20} />
</button>
다음과 같은 button이 나열되도록 해주었다. lucide-react 라이브러리를 이용해 아이콘을 import 해서 사용해주었다. onClick시에 동작하는 함수를 통해 특정 기능의 활성화를 toggle 할 수 있다. className에는 tailwind 속성을 넣어주었는데, active 될 때마다 아이콘의 색상을 바꿔주기 위해서 함수를 만들어주었다.
const btnClass = (active: boolean) =>
`p-2 rounded-md hover:bg-gray-200 transition-colors ${
active ? "text-main" : "text-gray-400"
}`;
이를 통해 활성화 시에 색상이 바뀌게 된다.
하지만, 실제로 적용하면 활성화가 표시되지는 않는다. 그 이유는 active가 된다고 해서 toolbar가 리렌더링이 되지 않기 때문이다.
이를 위해 useEffect와 useState를 통해 update가 발생할 때마다 강제로 리렌더링을 시켜주어야 한다.
useEffect(() => {
if (!editor) return;
const handler = () => {
setUpdate((prev) => prev + 1);
};
editor.on("transaction", handler);
return () => {
editor.off("transaction", handler);
};
}, [editor]);
transaction은 굵게, 밑줄 등과 같은 모든 설정들이 변경되었을 때 발생하는 이벤트로 해당 이벤트가 발생하면 handler를 실행해 update 변수를 set해준다. 이를 통해 리렌더링이 발생하는 것이다.
예시 이미지도 보여주고 싶지만... 개인 프로젝트가 아니기 때문에...ㅜ