어느덧 1년을 마무리하는 12월이 되었습니다. (모두 올해 고생하셨습니다!)
저는 연말을 의미있게 보내기 위해 이곳 저곳을 기웃거리다가,
간단하더라도 실제 서비스를 오픈해 보고 싶은 마음에 친한 동생과 연말 이벤트를 만들게 되었습니다.
그러던 중 '텍스트 에디터' (웹 에디터)를 만들게 되었는데, 그 과정을 이번글에 작성해 보려고 합니다.
기술 스택
많은 개발자들이 사용하는 React 답게 텍스트 에디터 관련 라이브러리 또한 다양합니다.
사람들이 주로 사용하는 라이브러리로 React-Quill과 Tiptap을 꼽을 수 있었는데,
저는 이 둘 중 고민하다가 Tiptap을 사용하였습니다.
그 이유는 React-Quill은 XSS 보안 취약성이 있다는 이슈를 보았으며,
무엇보다도 최근 업데이트 및 패치가 감소한 모습이 보였기 때문입니다.
또한, Tiptap은 최근 인프런이 사용하기도 했으며,
현재 서비스 개발에 사용중인 Next.js와 사용하기도 좋다고 나와있기에 선택하게 되었습니다.
그 이외의 기술 스택은 다음과 같습니다.
- TypeScript
- Next.js (+tailwind)
- React Hook Form
- zod
- shadcn-ui
(혼자 진행하는 프로젝트이다 보니 회사에서는 사용 못해본 새로운 기술들을 사용하려고 했습니다.)
프로젝트 세팅은 Next.js와 TypeScript로 했으며,
빠른 개발을 위해 shadcn-ui를 사용하여 form, button, input 같은 기본 컴포넌트들을 빠르게 구현하였습니다.
(참고로 shadcn-ui를 사용하기 위해서는 tailwaind는 필수입니다.)
여기에 여러 input 상태 관리에 편리한 react-hook-form과 validation을 위한 Zod를 같이 사용하였다.
개발 과정
입력 form 만들기
우선 Zod를 이용해서 react-hook-form에서 사용할 schema를 아래와 같이 작성해 주었습니다.
참고로 schema란 단순 문자열에서 복잡한 중첩 객체에 이르기까지 모든 데이터 유형을 광범위하게 저장하기 위해 사용된 용어라고 합니다.
const formSchema = z.object({
title: z
.string()
.min(5, { message: "제목이 너무 짧아요" })
.max(100, { message: "제목이 너무 길어요" }),
price: z.number().min(5, { message: "제목은 5글자가 넘어야 합니다." }),
description: z
.string()
.min(5, { message: "설명이 너무 짧아요" })
.max(100, { message: "설명이 너무 길어요" }),
});
그 다음 react-hook-form의 useForm과 위에 작성한 schema를 이용하여 form을 작성해 줍니다.
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
title: "",
price: 29.99,
description: "",
},
});
또한 form 제출 시 사용할 submit 함수도 작성해 줍니다.
function onSubmit(values: z.infer<typeof formSchema>) {
...
.....
}
이것들을 미리 구현한 Form 컴포넌트와 결합시켜주면 끝!
(필자는 shadcn-ui로 form 컴포넌트를 구현하였습니다.)
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Tiptap description={field.name} onChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
...
</form>
</Form>
Tiptap 컴포넌트 구현
이제 오늘 글의 목적인 Tiptap 컴포넌트를 구현하러 가보겠습니다.
참고로 저는 기능을 다루는 Toolbar 영역과 text가 담길 Content 영역으로 분리하였습니다.
우선 두 영역에 공통으로 사용할 함수를 useEditor를 이용하여 구현겠습니다.
useEditor 함수는 에디터 인스턴스를 생성하고 관리하는데 사용되는데,
에디터의 상태를 관리하고 상호작용하는데 주로 사용됩니다.
const editor = useEditor({
extensions: [
StarterKit.configure({}),
Heading.configure({
HTMLAttributes: {
class: "text-xl font-bold",
levels: [2],
},
}),
],
content: description,
editorProps: {
attributes: {
class:
"rounded-md border min-h-[150px] ~",
},
},
onUpdate({ editor }) {
onChange(editor.getHTML());
},
});
그 후 위에서 작성한 함수를 가져와 아래와 같이 설정해 줍니다.
이때 EditorContent 영역은 Tiptap에서 제공하는 함수를 그대로 가져와 쓰면 됩니다.
return (
<div className="flex flex-col justify-stretch ~~">
<Toolbar editor={editor} />
<EditorContent editor={editor} />
</div>
);
Toolbar 컴포넌트 에서는 각 버튼들 (ex. H2, Bold, 등등)을 toggle로 구현하였습니다.
(Toggle 컴포넌트 역시 shadcn-ui로 구현하였습니다.)
<div className="border border-input bg-transparent ">
<Toggle
size="sm"
pressed={editor.isActive("heading")}
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2 className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("bold")}
onPressedChange={() => editor.chain().focus().toggleBold().run()}
>
<Bold className="h-4 w-4" />
</Toggle>
....
</div>
결과
전체 코드
Home.tsx
import Tiptap from "@/components/ui/Tiptap";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
export default function Home() {
const formSchema = z.object({
title: z
.string()
.min(5, { message: "Hey the title is not long enough" })
.max(100, { message: "Its too loong" }),
price: z.number().min(5, { message: "Hey the title is " }),
description: z
.string()
.min(5, { message: "Hey the title is not long enough" })
.max(100, { message: "Its too loong" }),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
title: "",
price: 29.99,
description: "",
},
});
function onSubmit(values: z.infer<typeof formSchema>) {}
return (
<main className="p-24">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Main title for your project" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Tiptap description={field.name} onChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<Button className="my-4" type="submit">
Submit
</Button>
</form>
</Form>
</main>
);
}
Tiptap.tsx
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Toolbar from "./ToolBar";
import Heading from "@tiptap/extension-heading";
export default function Tiptap({
description,
onChange,
}: {
description: string;
onChange: (richText: string) => void;
}) {
const editor = useEditor({
extensions: [
StarterKit.configure({}),
Heading.configure({
HTMLAttributes: {
class: "text-xl font-bold",
levels: [2],
},
}),
],
content: description,
editorProps: {
attributes: {
class:
"rounded-md border min-h-[150px] border-input bg-back border-input bg-back disabled:cursor-not-allowed disabled:opacity-50",
},
},
onUpdate({ editor }) {
onChange(editor.getHTML());
},
});
return (
<div className="flex flex-col justify-stretch min-h-[250px] ">
<Toolbar editor={editor} />
<EditorContent editor={editor} />
</div>
);
}
ToolBar.tsx
"use client";
import { type Editor } from "@tiptap/react";
import { Bold, Strikethrough, Italic, List, ListOrdered, Heading2 } from "lucide-react";
import { Toggle } from "@/components/ui/toggle";
type Props = {
editor: Editor | null;
};
export default function Toolbar({ editor }: Props) {
if (!editor) {
return null;
}
return (
<div className="border border-input bg-transparent ">
<Toggle
size="sm"
pressed={editor.isActive("heading")}
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2 className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("bold")}
onPressedChange={() => editor.chain().focus().toggleBold().run()}
>
<Bold className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("italic")}
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
>
<Italic className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("strike")}
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
>
<Strikethrough className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("bulletList")}
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
>
<List className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("orderedList")}
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrdered className="h-4 w-4" />
</Toggle>
</div>
);
}
참고 링크
https://www.youtube.com/watch?v=ml4USMIm594
https://velog.io/@bae-sh/React-quill%EC%97%90%EC%84%9C-tiptap-%EC%9C%BC%EB%A1%9C
'WEB > 기타' 카테고리의 다른 글
구글 개발자 센터 한번에 인증받기 (제한된 개발자 계정 (0) | 2025.01.07 |
---|---|
[Git] Could not read from remote repository 에러 해결하기 (0) | 2024.04.16 |
Next.js + MongoDB 사용하기 (0) | 2023.12.27 |
가상 클래스와 가상 요소 비교 정리 (0) | 2023.09.12 |
Git commitizen 사용방법 및 cz-customizable 를 이용해 template 변경하기 (0) | 2023.08.15 |