📝

Reactでリッチテキストエディターを実装!Slate.jsの紹介!!

2023/11/30に公開

はじめに

この記事ではOPEN8で利用しているライブラリの1つである、Slate.js(以下Slate)についての紹介をします。
ZennにおいてSlateの記事があまり見受けられなかったので、こんなライブラリもあるんだといった感じで知っていただければ嬉しいです!!

Slate.js is 何?

Slate.jsはリッチテキストエディタ、WYSIWYG(ウィジウィグ)エディタを表現するためのライブラリです。
リッチテキストエディタでの編集は、テキストへの装飾が入力段階で見えるようになっているのが特徴の1つですね。
エンジニアをはじめとしてITに関わることが多い人がターゲットのサービスだとMarkdownが多そうですが、広いユーザーに使っていただく想定の場合における候補にあがるエディタの1つだと思います。

余談ですが、WYSIWYGは What You See Is What You Get の略で「見たままを得られる」という英文の各単語の頭文字から来ているそうです。

https://github.com/ianstormtaylor/slate
https://docs.slatejs.org/

どうしてSlateを選んだのか

Slateを使う時、他にQuill、Editor.js、Draft.jsなども候補として考えていました。
以下の観点から最終的にSlateを選択しました(ベータ版なのですが、そこは目をつむっています)。

  • Next.js(React)で使いやすさ
    • Reactライクに利用できるか
  • TypeScriptへの対応
  • ライブラリの運用度合
    • 更新頻度
    • スター数
    • etc...
  • 拡張のしやすさ
  • ドキュメントの豊富さ
    • 公式でサンプルの多さ
    • リファレンスの充実度
    • etc...

Slateは公式のサンプルの多さやリファレンスの充実度が高いので、機能の拡張時の参考にしたりできて大変助かります🙆
画像挿入やリスト、リンクなどエディタで良くある表現について、大半のものはサンプルにある気がしています。

Slate紹介

Slate中で利用されるワードや拡張の例についてなど紹介していこうと思います。
一部公式リファレンスにもあるような内容の紹介となっています。

Slateを構成する要素

SlateはNodeと呼ばれるオブジェクトを配列の階層構造で持ち、これらのNodeを解釈してエディターに表示されるコンテンツを表現しています。
Nodeには大きく分けて3つ種類が存在します。

  • Editor:トップレベルに存在するNode
    • 子要素としてElementやTextたちを持つ
    • コンテンツの変更などに関する処理などEditorだけが持つプロパティを持つ
  • Element:Editorの配下に配置されるNode
    • 子要素に他のElementやTextを持つ
    • 大きな括りとしてどんな要素(例えば、画像要素であるなど)であるかという情報や処理を持つ
  • Text(Leaf):Element配下に配置されるNode
    • 末端のNodeなのでコンテンツとして表示する文字列を持つ
    • 細かいテキストの装飾に関しての情報を持つ

Leaf(葉っぱ)は木構造の末端であることから来ていそうですね!👀

導入

create-react-appで新規に作成した実装例を示していきます。
Reactが既に入ってるプロジェクトであれば以下のコマンドでslate, slate-reactを追加してださい。

yarn add slate slate-react

Reactの準備などがまだだという方は追加でreact, react-domの追加をしてください。

yarn add react react-dom

slate-reactは公式が提供しているプラグインの一種でReactで動かすことを主としている関係上ほぼ必須だと思ってください。
これにより、専用のhooksやSlateを扱う便利機能などが使えるようになります!
どんな機能があるかの細かい紹介はここでは省きます。

実装例

以下のコンポーネントをレンダリングすることで画面上に 初期テキスト の表示とともにシンプルな入力エリアが表示されているかと思います。

import { useState } from 'react'
import { createEditor, BaseEditor, Descendant } from 'slate'
import { Slate, Editable, withReact } from 'slate-react'
import { ReactEditor } from 'slate-react'

type CustomElement = { type: 'paragraph'; children: CustomText[] }
type CustomText = { text: string }

declare module 'slate' {
  interface CustomTypes {
    Editor: BaseEditor & ReactEditor
    Element: CustomElement
    Text: CustomText
  }
}

const initialValue: Descendant[] = [
  {
    type: 'paragraph',
    children: [{ text: '初期テキスト' }],
  },
]

export const SlateApp = () => {
  const [editor] = useState(() => withReact(createEditor()))
  return (
    <Slate editor={editor} initialValue={initialValue}>
      <Editable />
    </Slate>
  )
}

Slateコンポーネント

主に編集におけるデータを扱うコンポーネントです。
propsにはonChangeなどもあり変更した値を受け取ることが可能です。

その他の役割としてReact機能の1つであるContextのProviderとしての役割も持っていまして、react-slateで提供されるhooksを利用するときにSlateコンポーネントの子要素である必要があります。

Editableコンポーネント

Editableはinputのような役割をもつ編集用のコンポーネントです。
placeholderが設定できたり、カスタマイズするときに登場するrenderElementrenderLeafをpropsに持ちます。
これらの説明は後述します!

CustomElement, CustomText, Descendantの型

CustomElementは先述の構成要素におけるElementを指しています。
今後新たなElementが増えるときにどんどん同様の型が増えていくことになります。

type PragraphElement = { type: 'paragraph'; children: CustomText[] }
type ImageElement = { type: 'image'; children: CustomText[], src: string; }
type CustomElement = PragraphElement | ImageElement;

今回は登場しませんが、Elementの下にElementを置く(リンクなどインライン要素となりうるものがある)ときはchildrenに特定のElement型を許容するようにします。

CunstomTextはElment同様に先述の構成要素におけるText(Leaf)にあたります。
こちらはカスタマイズするときに現状の型にプロパティを足していくことが多いかなと思います。

type CustomText = { text: string, bold?: boolean }

Descendantはslate moduleが持つCustomTypesのElement、Textのいずれかで、本文の構成要素の型となります。

Elementの作成

次に新しいElementの追加のサンプルを紹介します。
今回は、画像を表示できるようにしようと思います。
画像を表示するElementの型は先ほどのElementの型の紹介ところでも出てきた以下のものを使います。

type ImageElement = { type: 'paragraph'; children: CustomText[], src: string; }

描画に関してはEditableのrenderElementを活用します。
typeがparagraphとimageそれぞれの描画用コンポーネントとして以下を用意した上でrenderElementに渡します。

const ParagraphElm = (props: RenderElementProps) => {
  return <p {...props.attributes}>{props.children}</p>
}

const ImageElm = (props: RenderElementProps) => {
  const {attributes, children, element} = props;

  if (element.type !== 'image') {
    return  <p {...props.attributes}>{props.children}</p>
  }

  return (
    <span {...attributes}>
      <span>{children}</span>
      <img src={element.src} alt='埋め込み画像' />
    </span>
  )
}
export const SlateApp = () => {
  const [editor] = useState(() => withReact(createEditor()))

+ const renderElement = useCallback((props: RenderElementProps) => {
+   switch (props.element.type) {
+     case 'image':
+       return <ImageElm {...props} />
+     default:
+       return <ParagraphElm {...props} />
+   }
+ }, [])

  return (
    <Slate editor={editor} initialValue={initialValue}>
      <Editable
+       renderElement={renderElement}
      />
    </Slate>
  )
}

Imageに関しての振る舞いの調整を行います。
以下の関数を作成し、withReact同様にcreateEditorをラップします。このときwithReactは残したままでwithReactごとラップしてしまいましょう。

export const withImage = (editor: Editor) => {
  const { isVoid } = editor;
  editor.isVoid = (element: Element) => element.type === 'image' || isVoid(element);
  return editor;
};

ここでは、Imageがvoidであることだけ振る舞いとして追加しています。
voidというのはSlateにおけるElementの描画挙動の一種でvoidである場合はchildrenなどの子要素を描画しなくなります。
今回はisVoidしか使っていませんが、他にはinsertTextinsertDatainsertBreakなど貼り付け、改行なんかにおける振る舞いの制御をElement単位で行うこともできます!

最後にElement(画像)を挿入するためのボタンを作成しましょう

export const InsertImgButton: React.FC = () => {
  const editor = useSlateStatic()
  const insertImageElement = useCallback(
    () => {
      const element: ImageElement = {
              type: 'image',
              src: 'https://dummyimage.com/600x400/000/fff',
              children: [{ text: '' }],
            }

      Transforms.insertNodes(editor, element);

      if (editor.selection) {
        Transforms.select(editor, editor.selection);
        ReactEditor.focus(editor);
      }
    },
    [editor],
  );
  return <button onClick={insertImageElement}>画像挿入</button>
}

今回はサンプルなのでsrcにダミー画像を固定で入れていますが、目的に合わせて変更してください。
この処理で重要なのはTransforms.insertNodes(editor, element)で、この処理によって変数elementを挿入しています。
最後にあるif文の中身はテキストエディターにフォーカスを戻すための処理になるのであってもなくても画像挿入には関係ありません。
Transforms、ReactEditorが初出ですがslate、slate-reactからそれぞれ提供されているものなのでimportしてあれば利用できます。

この作成したボタンにはuseSlateStaticが存在するのでSlateコンポーネント配下に置くことだけ注意しつつ描画すればOKです!

文字の装飾

文字の装飾についてはElementよりは簡単で描画の切り分けと装飾の追加処理の2つがあれば完了します。
boldというプロパティを追加して、太文字への対応を考えます。

type CustomText = { text: string, bold?: boolean }

描画に関しては、renderElementと同様にrenderLeafというpropsがEditableにあるので利用します。
太文字にするケースなので、rednerLeafを以下のように実装しておきます。

  const renderLeaf = useCallback((props: RenderLeafProps) => {
    return (
      <span
        {...props.attributes}
        style={{ fontWeight: props.leaf.bold ? 'bold' : 'normal' }}
      >
        {props.children}
      </span>
    )
  }, [])

propsの型、RenderLeafPropsが初登場していますが、slate-reactから提供されている型でimportして利用できます。
props.leafがCustomText型になっていますので、追加したboldがtureであるか確認してfontWeightを調整します。

テキストへのboldの追加の仕方は以下の処理をbuttonのonClickやEditableのonKeyDownなどで適切に発火させればできます。

Editor.addMark(editor, 'bold', true)

Editorについてもslateから提供されているのでimportしてあれば大丈夫です。
もし、太文字だった場合に解除したい場合は、Nodes.match などで選択範囲かつboldを持つなどを判定したりできるので切り替えすることができます。

Elementよりもすることは少ないので簡単な紹介となりますが文字の装飾は以上です。
太文字以外にもCSSで成り立つものは同様にすればすぐに実装できるかなと思いますので、気になる人は色々試してみてください!!

おわりに

本記事では、Slate.jsの概要や特徴の紹介・簡単な拡張の説明をしました。
紹介した拡張は本当に簡易なものなので、Slateが提供する機能でこんなことができるんだなと雰囲気を掴んでもらえたなら嬉しいです!!
本記事では紹介にとどめて、またSlate周りのことを書く機会があればSlateのvoid要素についての改行・削除の振る舞いの改善など独自に対応したことなど紹介できればなと思います🙌

OPEN8 テックブログ

Discussion