Slate.js로 UI에 구애받지 않는 나만의 에디터를 만드는 방법

우빈·2024년 9월 30일
8
post-thumbnail

프론트엔드에서 에디터와 같은 기능을 구현할 때, 에디터에서 제공하는 UI가 서비스와 달라
만족하지 못했던 경험이 있습니다.
그렇다고 에디터를 처음부터 끝까지 자체로 구현하기에도 부담스럽기도 합니다.

이 글에서는...
Slate.js 라이브러리를 사용하여 나만의 에디터를 만드는 방법을 소개합니다.
에디터를 '컴포넌트'가 아닌 '기능'으로 구성하는 방법을 공유드립니다.
*React(Next.js) 환경을 중심으로 설명합니다.

부분 스타일링을 어떻게 구현할 수 있을까

네이버 에디터나 여러가지 시중의 에디터의 코어 로직을 뜯어보신 적이 있으신가요?
대부분의 에디터는 한 줄을 기준으로, 하나의 스타일된 텍스트마다 "블록"이라는 단위로
나누어 렌더링을 진행합니다.

평소에 사용하던 string이라는 구조와 달리, Array<Block>을 사용하는 거죠.
이를 테면...

TEXTAREA

"어제 유명한 OO 카페에 다녀왔어요.\n저는 거기서 아메리카노를 주문했습니다."

EDITOR

[
  { type: "단락", content: "어제 유명한 OO 카페에 다녀왔어요." },
  { type: "단락", content: "저는 거기서 아메리카노를 주문했습니다." }
]

아직은 조금 어색하실 수 있습니다.
하지만 우리가 에디터를 만들 때 보통 어떤 기능을 제일 흔히 요구할까요?
특정 글자에 대한 볼드나 이태릭, 색상 변경 등의 부분 스타일링을 많이 필요로 합니다.
이런 상황에서 textarea는 string만 입력할 수 있기 때문에 바로 제약에 걸리게 됩니다.

하지만 editor의 경우는 다음과 같이 데이터를 표현할 수 있습니다.
"유명한"에 강조를 한다고 가정해보겠습니다.

[
  { 
    type: "단락", 
    content: [
      { text: "어제 " },
      { text: "유명한 ", style: "bold" },
      { text: "OO 카페에 다녀왔어요." }
  	]
  },
  { type: "단락", content: "저는 거기서 아메리카노를 주문했습니다." }
]

이런 식으로 표현하면, Array.prototype.map 함수에서 렌더링 후,
프로퍼티로 넘어오는 style만 catch해서 특정 부분에 대한 스타일링을 진행할 수 있게 됩니다!

Slate.js는 이 기능을 사용자가 직접 구현할 수 있도록 도와줍니다.
줄마다 블록을 구성하는 복잡한 로직들을 기본적으로 제공하고, 개발자가 원하는 곳을
위와 같이 자동으로 나누어 커스터마이징할 수 있도록 도와줍니다.
이에 대한 기능과 함수를 제공하고, 어떻게 렌더링하고 보여줄 지는 완벽한 개발자의 몫입니다.

다른 에디터와 다르게 "빵"이 아닌 "오븐과 밀가루"를 제공한다고 생각하시면 편합니다.

Slate.js로 커스텀 에디터 구현하기

요구 사항에 대해 이해했으니 바로 구현해보겠습니다.
먼저 Slate.js를 install합니다.

$ yarn add slate slate-react

Slate.js로 에디터를 구성하는 데에는 총 3가지의 핵심 로직이 있습니다.

value : 에디터의 content를 배열로 관리하는 하나의 state입니다.
Transform : 에디터를 변경할 때 호출하는 기능을 구현하는 함수입니다.
renderLeaf : value의 프로퍼티가 가진 속성을 받아 렌더링하는 함수입니다.

먼저 에디터의 내용을 관리하는 state부터 정의하겠습니다.

/* initialValue를 주입합니다. useState("")와 동일합니다. */
const [content, setContent] = useState([
  {
    type: "paragraph",
    children: [{ text: "" }]
  }
])

그 다음, 에디터를 control하는 구현체 state를 만들어줍니다.
에디터 구현체가 변경될 사항은 없기 때문에 set함수는 할당하지 않습니다.

import { withReact } from "slate-react";
import { createEditor } from "slate";

const [editor] = useState(() => withReact(createEditor()));

이제 렌더링 단에서 에디터를 렌더링하겠습니다.
렌더링은 총 두 개의 컴포넌트를 사용하여 렌더링합니다.

Slate : 에디터의 control 범위를 지정하는 컴포넌트로, 렌더링에 영향을 미치지는 않습니다.
Editable : 에디터가 실제로 표시되는 컴포넌트입니다.

import { Editable, withReact } from "slate-react";
...
<Slate
  initialValue={content}
  editor={editor}
  onChange={(text) => setContent(text)}
>
  <Editable placeholder="내용을 입력하세요." />
</Slate>

Slate는 Editable의 직속 부모가 아니어도 됩니다. Slate 컴포넌트의 하위에
Editable 컴포넌트가 있기만 하면 되기에, 저는 사용할 때 Editor 컴포넌트의 루트에
Slate를 provide했습니다.

정말 간단하게 모든 세팅이 끝났습니다! 이제 각 텍스트를 스타일링하는 함수만 자유롭게 짜주면 됩니다.

원하는 텍스트 스타일을 등록하기

커스텀 에디터에 대해서, 기존에 디자인된 디자인이나 여러가지 ...
"만들어둔 어떤 버튼을 누르면 어떤 스타일이 되어야 해"라고 잡아둔 컴포넌트가 있으실 겁니다.

해당 컴포넌트를 찾아 onClick 함수 하나만 만들어주면 구현이 끝납니다.

import { BaseEditor, Editor, Text, Transforms } from "slate";
import { useSlate } from "slate-react";

const ItalicButton = () => {
  /* 한 가지 주의점은, useSlate를 호출하는 depth가 위에서 언급드린
   * <Slate /> 컴포넌트의 내부여야 합니다. 그렇지 않으면 작동하지 않습니다.
   */
  const editor = useSlate();
  
  return (
    <button
      onClick={() => {
        toggleItalicMark(editor);
      }}
      className="flex flex-col items-center justify-center w-[22px] h-full gap-1 hover:brightness-95 bg-white"
    >
      <img src="/fontbox/1-2.png" alt="italic" className="w-auto h-[22px]" />
    </button>
  );
};

/* 스타일링 함수는 렌더링과 상호작용하지 않기 때문에 컴포넌트 밖으로 빼도 무관합니다. */

const toggleItalicMark = (editor: BaseEditor) => {
  const isActive = isItalicMarkActive(editor);
  Transforms.setNodes(
    editor,
    { italic: isActive ? null : true },
    { match: (content) => Text.isText(content), split: true }
  );
};

const isItalicMarkActive = (editor: BaseEditor) => {
  const [match] = Editor.nodes(editor, {
    match: (content) => content.italic === true,
    universal: true,
  });
  return !!match;
};

isItalicMarkActive : 특정 글자의 상호작용에 대한 프로퍼티의 변화를 제공합니다.
toggleItalicMark : isItalicMarkActive로 작용을 판별하여 content 프로퍼티를 변경시킵니다.

이 두 가지 함수를 정의하고, useSlate()로 받아온 editor 객체를 넣어주기만 하면 됩니다.
그럼 이제 어떤 특정 텍스트를 select한 후 이태릭 버튼을 누르면, content는 다음과 같이 바뀔 겁니다.

"안녕하세요"라는 문자에서 "하세"에만 italic을 준다고 가정해볼게요.

[
  {
    type: "paragraph",
    children: [
      { text: "안녕" },
      { text: "하세", italic: true },
      { text: "요" }
    ]
  }
]

성공적으로 프로퍼티를 넣었으니, 이제 이를 스타일에 맞게 렌더링시켜주기만 하면 끝입니다!

프로퍼티를 기준으로 엘리먼트를 렌더링 시키기

프로퍼티를 기준으로 개발자가 원하는 스타일로 렌더링시켜주기 위해, Leaf라는 컴포넌트를
사용할 겁니다.

Leaf라는 컴포넌트를 통해서, 쉽게 프로퍼티를 핸들링하여 적용할 겁니다.

const Leaf = (props) => {
  return (
    <span
      {...props.attributes}
      style={{
        fontStyle: props.leaf.italic ? "italic" : "normal",
    >
      {props.children}
    </span>
  );
};

export default Leaf;

이제 아까 정의했던 Editable 컴포넌트에 우리가 만든 Leaf를 등록해주면 끝입니다.

const renderLeaf = useCallback((props) => {
  return <Leaf {...props} />;
}, []); 

...
<Editable renderLeaf={renderLeaf} />

이렇게 짧은 시간 안에, 복잡한 에디터의 코어 로직 없이 자유롭게 에디터를 구현할 수 있게 되었습니다.
추가적인 기능이 필요하면, Transform에서 핸들링하는 프로퍼티들의 이름을
bold, underline등 다양하게 바꾸면 되겠죠?

마무리

저도 에디터를 커스터마이징된 UI로 구현을 했어야 했는데, onInput과 같은
이벤트를 활용하여 처음부터 끝까지 구현하는 것은 너무 어렵고, 시중에 있는
라이브러리들은 제한적인 UI를 제공해서 고민이 많았습니다.

하지만 Slate.js를 통해 에디터를 "컴포넌트"가 아닌 "기능"으로 만들어 컨트롤할 수 있어
요구사항에 완벽하게 개발을 할 수 있었습니다.

커스터마이징이 필요한 UI의 에디터를 빠르게 적은 양의 코드로 개발하고 싶을 때,
Slate.js를 적극 추천드립니다.

profile
프론트엔드 공부중

2개의 댓글

comment-user-thumbnail
2024년 9월 30일

재밌네용 잘읽었습니당
매주 좋은 글 많이 부탁드려용
이힝.

1개의 답글