kokoball의 devlog
article thumbnail
728x90

서비스 이미지

 

어느덧 1년을 마무리하는 12월이 되었습니다. (모두 올해 고생하셨습니다!)

 

저는 연말을 의미있게 보내기 위해 이곳 저곳을 기웃거리다가,

간단하더라도 실제 서비스를 오픈해 보고 싶은 마음에 친한 동생과 연말 이벤트를 만들게 되었습니다.

 

그러던 중 '텍스트 에디터' (웹 에디터)를 만들게 되었는데, 그 과정을 이번글에 작성해 보려고 합니다.

기술 스택

많은 개발자들이 사용하는 React 답게 텍스트 에디터 관련 라이브러리 또한 다양합니다.

 

사람들이 주로 사용하는 라이브러리로 React-Quill과 Tiptap을 꼽을 수 있었는데,

저는 이 둘 중 고민하다가 Tiptap을 사용하였습니다.

 

그 이유는 React-QuillXSS 보안 취약성이 있다는 이슈를 보았으며,

무엇보다도 최근 업데이트 및 패치가 감소한 모습이 보였기 때문입니다.

 

또한, Tiptap은 최근 인프런이 사용하기도 했으며,

현재 서비스 개발에 사용중인 Next.js와 사용하기도 좋다고 나와있기에 선택하게 되었습니다.

 

그 이외의 기술 스택은 다음과 같습니다.

  • TypeScript
  • Next.js (+tailwind)
  • React Hook Form
  • zod
  • shadcn-ui

(혼자 진행하는 프로젝트이다 보니 회사에서는 사용 못해본 새로운 기술들을 사용하려고 했습니다.)

 

프로젝트 세팅은 Next.jsTypeScript로 했으며,

빠른 개발을 위해 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-formuseForm과 위에 작성한 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://tiptap.dev/

https://ui.shadcn.com/

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

 

728x90
profile

kokoball의 devlog

@kokoball-dev

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!