Reactで利用できるリッチテキストエディタには、Draft.jsQuillTipTapなど複数の選択肢があります。本文書では、その中の一つ”Slate Editor”について初心者にも分かりやすいように基礎から解説します。簡単なコード例を交えながら、Slate Editorの特徴や活用方法を丁寧に紹介していきます。

リッチテキストエディタをフォームに配置することで、ブラウザ上で太字やイタリックなどの装飾を施したり、見出しを設定したりすることが可能です。そのため、HTML構文の知識がなくても表現力豊かなページを作成する手助けになります。

Slateは、ヘッドレスCMSのPayloadをはじめ、さまざまなプロジェクトで採用されており、Reactにリッチテキストエディタを実装する際に必ず候補にあがる人気のリッチテキストエディタの一つです。ただし、コンポーネントを配置するだけで全てが自動で設定され即座とエディタとして利用できるわけではありません。リッチエディタとして機能させるためには、開発者が見出しや装飾に対応するコードを記述する必要があります。そのため、JavaScriptやReactを学び始めたばかりの方には少しハードルが高く感じられるかもしれません。しかし、UIデザインを含めた自由なカスタマイズが可能な点は大きな魅力です。

さらに、SlateにはLiveで動作確認したり、実際のコードをチェックできるExampleページが用意されています。これを参考にすることで、実装時の理解が深まります。本記事でもこれらを参考にしながら、Slate Editorの活用ポイントを詳しく解説します。

Slateを利用して公開されているライブラリにPlateというものがあります。Plateを利用することでNotion Likeなエディタを実装することができます。

Reactプロジェクトの作成

npm create vite@latestコマンドを利用してReactのプロジェクトの作成を行います。JavaScriptを選択しています。


 % npm create vite@latest

> npx
> create-vite

✔ Project name: … react-slate-editor
✔ Select a framework: › React
✔ Select a variant: › JavaScript

Scaffolding project in /Users/mac/Desktop/react-slate-editor...

Done. Now run:

  cd react-slate-editor
  npm install
  npm run dev

プロジェクトの作成が完了したらプロジェクトディレクトリに移動して”npm install”コマンドを実行してJavaScriptのライブラリのインストールを行います。

React上でSlateを利用するためにslate, slate-reactの2つのパッケージのインストールを行います。


 % npm install slate slate-react 

はじめてのSlate

Slateのドキュメントを参考にブラウザ上で文字列を入力して、入力した内容をそのまま表示するところまで確認していきます。Slateの設定を行う前にデフォルトのスタイルを無効にするためmain.jsxファイルでimportしているindex.cssをコメントしておきます。


import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
// import './index.css'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

componentsディレクトリを作成してMyEditor.jsxファイルを作成して以下のコードを記述します。Slateを利用するための必要最低限のコードです。入力した内容はuseState Hookの戻り値であるeditorの中に保存されます。initialValuesには初期値を設定しています。initialValuesの設定は必須です。


import { useState } from 'react';
import { createEditor } from 'slate';
import { Editable, Slate, withReact } from 'slate-react';

const initialValue = [
  {
    type: 'paragraph',
    children: [{ text: 'A line of text in a paragraph.' }],
  },
];

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

export default MyEditor;

App.jsxから作成したMyEditorコンポーネントをimportします。


import MyEditor from './components/MyEditor';

const App = () => {
  return (
    <div>
      <h1>リッチテキストエディタSlate</h1>
      <MyEditor />
    </div>
  );
};

export default App;

動作確認するための初期設定が完了したので”npm run dev”コマンドで開発サーバを起動します。


 % npm run dev

> react-slate-editor@0.0.0 dev
> vite


  VITE v5.4.2  ready in 318 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

ブラウザからlocalhost:5173にアクセスします。問題がなければinitialValueに設定した文字列がブラウザ上に表示されます。文字が表示されているだけなのでこれだけを見ただけではこれがエディタなのかさえわからないと思います。

はじめてのSlate
“A line of text in a paragraph”の文字列にクリックを行うとフォーカスされ太枠が表示されます。太枠が表示されたら文字の更新、入力を行うことができます。
太枠が表示
太枠が表示

太枠の中では文字を入力できるだけではなく改行も行うことができ、複数行の入力が可能で、textarea要素のように動作します。

ブラウザ上での文字の入力
ブラウザ上での文字の入力

ブラウザ上で文字の入力が行えるのはtextarea要素やinput要素が設定されてるわけではなくdiv要素にcontenteditable=”true”が設定されているためです。

div要素へのcontentable="true"属性の設定
div要素へのcontentable=”true”属性の設定

Reactでのcontenteditable属性をもう少し深掘りしたい場合には以下の文書が参考になります。

保存されている内容の確認

Slateはエディタなので入力した内容を保存する必要があります。保存する前に入力した内容がどのように保存されているのか確認するためbutton要素にクリックイベントでsaveContent関数を設定してeditorの中身を確認します。


import { useState } from 'react';
import { createEditor } from 'slate';
import { Editable, Slate, withReact } from 'slate-react';

const initialValue = [
  {
    type: 'paragraph',
    children: [{ text: 'A line of text in a paragraph.' }],
  },
];

const MyEditor = () => {
  const [editor] = useState(() => withReact(createEditor()));
  const saveContent = () => {
    console.log(editor);
  };
  return (
    <>
      <div>
        <button onClick={saveContent}>保存</button>
      </div>
      <Slate editor={editor} initialValue={initialValue}>
        <Editable />
      </Slate>
    </>
  );
};

export default MyEditor;

保存ボタンをクリックするとデベロッパーツールのコンソールにはオブジェクトが表示され、childrenプロパティに配列が設定されていることがわかります。

editorの中身を確認
editorの中身を確認

ドキュメントでEditorを確認するとchildrenのプロパティには”The children property contains the document tree of nodes that make up the editor’s content.”が入っている通りコンテンツを構成するNodeが含まれていることがわかります。Nodeについてはどのような情報か後ほど説明しています。入力したコンテンツの中身を確認したいのでsaveContent関数を更新してchildrenのみ表示させるように設定します。


const saveContent = () => {
  console.log(editor.children);
};
editor.childrenの中身の確認
editor.childrenの中身の確認

表示されている内容をよく見るとinitialValueで設定した内容が保存されていることがわかります。


const initialValue = [
  {
    type: 'paragraph',
    children: [{ text: 'A line of text in a paragraph.' }],
  },
];

改行を含め複数の行を入力した場合の内容も確認しておきます。各行は配列の要素として保存されていることがわかります。

複数行のeditor.childrenの中身
複数行のeditor.childrenの中身

typeの利用

initialValueにtypeで”paragraph”を設定していましたが設定したにも関わらず表示される内容に何も影響はありません。typeプロパティのBlock要素のタイプ(見出し、段落、画像など)を表しており”paragraph”を段落に対応します。デフォルトの設定ではtypeプロパティの値はブラウザ上の表示に影響を与えていないのでdivとして設定されています。

デフォルトではdiv要素として描写
デフォルトではdiv要素として描写

各自がtypeプロパティの値を利用してブラウザ上にどのようにBlock要素のタイプとしてさせるのか設定していく必要があります。

renderElement props

typeを利用してブラウザ上に表示させる内容を設定するにはEditableコンポーネントのrenderElement propsを利用します。renderElement propsにはrenderElement関数を設定します。


const MyEditor = () => {
  const [editor] = useState(() => withReact(createEditor()));
  const saveContent = () => {
    console.log(editor.children);
  };

  const renderElement = (props) => {
    console.log('props', props);
    return <p {...props.attributes}>{props.children}</p>
  };

  return (
    <>
      <div>
        <button onClick={saveContent}>保存</button>
      </div>
      <Slate editor={editor} initialValue={initialValue}>
        <Editable />
      </Slate>
    </>
  );
};

renderElement関数の引数ではpropsを受け取ることができるのでpropsの中身を確認します。

renderElementのpropsの中身を確認
renderElementのpropsの中身を確認

ブラウザのデベロッパーツールのコンソールからpropsの中にはattributes, children, elementの3つのプロパティが含まれており、attributes, childrenの値を利用してブラウザ上に描写させる内容を設定しています。pタグとして表示させるように設定しています。


const renderElement = (props) => {
  console.log('props', props);
  return <p {...props.attributes}>{props.children}</p>
};

renderElement関数を設定後に再度要素を確認します。divタグからpタグに変更されていることがわかります。

renderElementによるpタグの設定
renderElementによるpタグの設定

Slateでは入力した行をpタグとして表示させるためには各自が設定を行う必要があります。Slateがtypeの値を読み取り自動でpタグとして表示させるわけではありません。

paragraph以外のtypeを設定した場合

initialValueの設定でtypeにparagraphのみを設定しましたがtypeに別の値を設定した場合にはどのような設定が必要なのか確認していきます。


const initialValue = [
  { type: 'heading-one', children: [{ text: 'Heading 1' }] },
  {
    type: 'paragraph',
    children: [{ text: 'A line of text in a paragraph.' }],
  },
];

typeプロパティの値に”heading-one”を追加しましたがrenderElement関数の戻り値ではすべてpタグになるためブラウザ上ではtypeの値をparagraphで設定した文字列と同じ表示となります。typeによって描写の内容を変更するためにrenderElement関数の中で値によって表示させるタグを変更させる必要があります。ここではswitchによる分岐を利用します。分岐にはpropsに含まれていたelementプロパティのtypeの値を利用します。


const renderElement = (props) => {
  switch (props.element.type) {
    case 'heading-one':
    return <h1 {...props.attributes}>{props.children}</h1>;
    default:
    return <p {...props.attributes}>{props.children}</p>;
  }
};

ブラウザで確認するとtypeに”heading-one”で設定した文字列がh1タグとして描写されていることが確認できます。

typeの値によって描写に利用するタグを変更する
typeの値によって描写に利用するタグを変更する

ここまでの動作確認でtypeの値によってHTMLのタグを変更することでブラウザ上での表示を変更することができることがわかりました。type毎にrenderElemnt関数内の分岐を増やすことでさらにtypeを追加しても追加したtypeに対応するHTMLタグを指定することでブラウザ上での表示を変更できることも理解できたかと思います。

おそらく次に疑問が浮かぶのはinitialValueではなくブラウザ上でtypeの値を変更することでブラウザ上で描写をリアルタイムで変更することだと思います。次にその設定を行っていきます。

ブラウザ上でのtypeの変更

ブラウザ上からtypeの値を変更できるように設定を行っていきます。

新たにH1ボタンを追加してonClickイベントを設定し、changeBlock関数を追加します。引数には”heading-one”を設定しています。この値をtypeの値の変更に利用します。


<>
    <div>
    <button onClick={saveContent}>保存</button>
    <button onClick={() => changeBlock('heading-one')}>H1</button>
    </div>
    <Slate editor={editor} initialValue={initialValue}>
    <Editable renderElement={renderElement} />
    </Slate>
</>

下記のコードのchangeBlock関数ではTransformsのsetNodesメソッドを利用してtypeをformatに変更しています。formatの値には”heading-one”が入っています。


import { useState } from 'react';
import { createEditor, Transforms } from 'slate';
import { Editable, Slate, withReact } from 'slate-react';
//略
const changeBlock = (format) => {
 Transforms.setNodes( 
   editor,
   { type: format },
   {
     match: (n) => Element.isElement(n),
   }
};

Tranforms.setNodesではNodeのプロパティを変更することができます。NodeにはEditor | Element | Textの3つがあるため、match関数の中でElement.isElementメソッドを利用してElementのNodeなのかチェックを行い、ElementのNodeの場合のみElementのNodeのtypeプロパティのみ更新するようにしています。Nodeと突然言われても何のことわからないと思いますが後ほどEditor, Element, TextのNodeがどのようなものなのか説明します。

H1ボタンをクリックする前に”A line of text in aparagraph”の文字列のどこかにカーソルを当てます。

変更前の状態
変更前の状態

H1ボタンをクリックすると文字列がh1タグと描写されます。ブラウザ上でtypeを変更することができました。これでブラウザ上からtypeの値を変更する方法がわかりました。

変業後の画面
変業後の画面

Nodeとは

Nodeにはどのような値が入っているか確認するためにmatch関数を利用して確認します。


const changeBlock = (format) => {
  Transforms.setNodes(
    editor,
    { type: format },
    {
      match: (node) => {
        console.log('node', node);
        console.log('isEditor:', Editor.isEditor(node));
        console.log('isElement:', Element.isElement(node));
        console.log('isText:', Text.isText(node));
        return Element.isElement(nodes);
      },
    }
  );
};

‘A line of text in a paragraph’の文字列にカーソルを合わせて”H1″ボタンをクリックすると3つのNodeが表示されます。Editor.isEditorではEditor Node、Element.isElementではElement Node, Text.isTextではText Nodeかどうかチェックすることができます。結果は下記になります。最初に表示されたNodeはEditor Nodeであることがわかります。2番目はElement Node, 3番目はText Nodeになっています。

Nodeの確認
Nodeの確認

typeプロパティを持っているのはElement NodeなのでElement.isElementがtrueであるNodeをmatch関数で取得してtypeの値を更新しています。表示されているElement Nodeの内容からinitialValueで設定した配列の要素だということもわかります。そのためElement Nodeのtypeプロパティの値を更新すれば表示に利用するタグも変わりブラウザでの表示も変わるということも簡単にイメージできます。

typeをparagraphに戻す

typeを”paragraph”から”heading-one”に更新することができたので次はheading-oneからparagraphに戻す設定を確認します。

H1ボタンの横にPボタンを追加します。


<div>
  <button onClick={saveContent}>保存</button>
  <button onClick={() => changeBlock('heading-one')}>H1</button>
  <button onClick={() => changeBlock('paragraph')}>P</button>
</div>

changeBlock関数の引数にparagraphを設定します。changeBlock関数ではmatch関数の条件にnode.typeが現在設定されているtypeと異なる値を持つformatの場合のみsetNodesメソッドでtypeを更新するように変更します。


const changeBlock = (format) => {
  Transforms.setNodes(
      editor,
      { type: format },
      {
        match: (node) => Element.isElement(node) && node.type !== format,
      }
  );
};

設定後にh1タグで描写されている文字列にカーソルを合わせてPボタンをクリックするとpタグとして描写されます。

h1タグをpタグへ
h1タグをpタグへ

Leafの設定(スタイル、装飾の変更)

typeプロパティの値とHTMLタグを利用することで行全体のブラウザ上での描写を変更することができました。HTMLでは行全体ではなく行の一部の文字列のみスタイルや装飾を設定することができます。ここでは文字列の一部を太字(Bold)に変更する方法を確認していきます。typeではHTMLのBlock要素の変更を行いましたがLeafでInline要素の変更を行います。

太字にしたい文字列を選択してボタンを押すと太字に変更ができるようにPボタンの横にBボタンを追加してonClickイベントでchangeLeaf関数を設定します。


<div>
  <button onClick={saveContent}>保存</button>
  <button onClick={() => changeBlock('heading-one')}>H1</button>
  <button onClick={() => changeBlock('paragraph')}>P</button>
  <button onClick={() => changeLeaf('bold')}>B</button>
</div>

changeLeaf関数の中ではEditor.addMarkメソッドを利用します。Slateの中ではMarkは、テキストの一部に適用されるスタイルや属性(例:太字、斜体、下線など)を表しているのでaddMarkという名前のメソッドになっています。引数のformatには”bold”が入ります。


const changeLeaf = (format) => {
  Editor.addMark(editor, format, true);
};

設定後、”A line of text in a paragraph.”の文字列のtext部分を選択してBボタンをクリックします。

文字列を選択してBボタンをクリック
文字列を選択してBボタンをクリック

Bボタンを押してもブラウザ上には変化はありません。ここまではtypeプロパティと同じで何かNodeに変更を加えたら対応するコードを追加する必要がります。addMarkメソッドの結果はeditorオブジェクトには反映されているので”保存”ボタンをクリックしてeditorオブジェクトの中身を確認します。

Editor.addMark実行後の更新内容の確認
Editor.addMark実行後の更新内容の確認

これまで配列に一つの要素として保存されていた”A line of text in a paragraph.”が3つの配列の要素に分割され、1番目の要素にはboldプロパティが追加されていることが確認できます。この追加されたboldをブラウザ上の描写に反映させるためにEditableコンポーネントにrenderLeaf propsの設定を行います。

render leaf propsの設定

EditableコンポーネントにrenderLeaf propsを追加してrenderLeaf関数を指定します。


<Slate editor={editor} initialValue={initialValue}>
  <Editable renderElement={renderElement} renderLeaf={renderleaf} />
</Slate>

renderLeaf関数では引数からpropsを受け取り、props.leafからboldの値を取得することができます。props.leaf.boldがtrueの場合のみstyle属性を設定することで太字表示にしています。renderLeaf関数ではpropsの中身を確認できるようにconsole.logを設定しています。


const renderLeaf = (props) => {
  console.log('props', props);
  return (
    <span
      {...props.attributes}
      style={{ fontWeight: props.leaf.bold ? 'bold' : '' }}
    >
      {props.children}
    </span>
  );
};

renderLeaf関数を設定後は文字列の”text”の太字として表示されていることが確認できます。

太文字表示の確認
太字表示の確認

コンソールにpropsの値が表示されるのでpropsの値を確認するとattributes, children, leaf, textで構成されていることがわかります。

propsの中身の確認
propsの中身の確認

分割代入と分岐を利用してleafの値がbold以外を設定しても動作するようにrenderLeaf関数のコードを更新します。style属性からstrongタグに変更しています。コードを更新しても先ほど動作は変わりません。


const renderLeaf = ({ attributes, children, leaf }) => {
  if (leaf.bold) {
    children = <strong>{children}</strong>;
  }

  return <span {...attributes}>{children}</span>;
};

pタグやh1タグのようなBlock要素の単位でけではなくstrongタグのようなinline要素の単位でもブラウザ上での表示を変更できるようになりました。

Boldの切り替え

Bボタンをクリックすると選択した文字列を太字にすることができたので再度ボタンをクリックすると元のテキストに戻るようにchangeLeaf関数を更新します。

changeLeaf関数の中で利用しているEditor.marksメソッドは選択した範囲に適用されているマーク(ここでは太字)を取得するための関数です。適用されているマークを解除する場合はEditor.removeMarkメソッドを利用します。


const changeLeaf = (format) => {
  const marks = Editor.marks(editor, format);

  console.log('marks:', marks);

  if (marks && marks[format]) {
    Editor.removeMark(editor, format);
    return;
  }

  Editor.addMark(editor, format, true);
};

太字になった文字列を選択して再度Bボタンをクリックすると元のスタイルに戻ります。マークを追加するか削除するかの分岐に利用しているEditor.marksの戻り値にはどのような値が含まれるのかconsole.logで設定しているので確認します。

同じ文字列を選択し、3回Bボタンをクリックしています。1回目は何も設定されていないので空のオブジェクトとなり、2回目にクリックした時に太字が設定されているので{bold:true}が戻されます。3回目は太字が解除されているので空のオブジェクトになっています。戻されるmarksにどのような値が入っているか理解することができました。

Editor.marksメソッドの戻り値の確認
Editor.marksメソッドの戻り値の確認

他のスタイルの追加

文字列に下線をつけたい場合にはボタンを追加してrenderLeaf関数に分岐を追加するだけです。追加したUボタンのchangeLeaf関数の引数にはunderlineを設定しています。


<div>
  <button onClick={saveContent}>保存</button>
  <button onClick={() => changeBlock('heading-one')}>H1</button>
  <button onClick={() => changeBlock('paragraph')}>P</button>
  <button onClick={() => changeLeaf('bold')}>B</button>
  <button onClick={() => changeLeaf('underline')}>
    <u>U</u>
  </button>
</div>

renderLeaf関数ではleaf.underlineの値のチェックを分岐で利用して値が存在する場合にはuタグを追加するように設定を行っています。


const renderLeaf = ({ attributes, children, leaf }) => {
  if (leaf.bold) {
    children = <strong>{children}</strong>;
  }
  if (leaf.underline) {
    children = <u>{children}</u>;
  }

  return <span {...attributes}>{children}</span>;
};

文字列を選択してUボタンをクリックすることで選択した文字列に下線が表示されるようになりました。

Uボタンによる文字列に下線が表示
Uボタンによる文字列に下線が表示

ここまで説明してきた内容よりは少し複雑になりますが、SlateのExampleで公開されているRich Textの内容を確認してみてください。Slateを利用してRich Text Editorを実装する助けになると思います。

入力したコンテンツの保存

これまでと少し話が変わりますが、入力したコンテンツの保存を確認していきます。ここではコンテンツの保存はsaveContent関数の中で行います。

通常はバックエンドサーバなどにデータを送信してデータベースに保存することになりますがここではブラウザのlocalStorageを利用します。localStorageに文字列として保存するためJSON.stringifyを利用しています。


const saveContent = () => {
  const content = JSON.stringify(editor.children);
  localStorage.setItem('content', content);
};

ブラウザ上から文字列を入力して保存ボタンをクリックしてください。ブラウザのデベロッパツールのApplicationを確認するとLocal Storageに入力した内容が保存されていることが確認できます。

localStorageに保存したコンテンツの確認
localStorageに保存したコンテンツの確認

保存を行なってもブラウザをリロードすると表示されている内容はクリアされてしますのでlocalStorageに保存したコンテンツを取得する処理を追加します。今度は文字列からオブジェクトに変換するためJSON.parseを利用します。


import { createEditor, Editor, Transforms, Element, Text, nodes } from 'slate';
import { Editable, Slate, withReact } from 'slate-react';

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

  const initialValue = useMemo(
    () =>
      JSON.parse(localStorage.getItem('content')) || [
        {
          type: 'paragraph',
          children: [{ text: 'A line of text in a paragraph.' }],
        },
      ],
    []
  );
//略

ブラウザをリロードしても入力したコンテンツを維持することができます。これで入力したコンテンツをlocalStorangeで保持できるようになりました。

HTMLに変換

Slateで入力した内容をHTMLとして保存したい場合もあるかと思いますその場合はHTMLへの変換を行います。

HTMLボタンを追加してonClickイベントにtoHTML関数を指定します。


<div>
  <button onClick={saveContent}>保存</button>
  <button onClick={toHtml}>HTML</button>
  <button onClick={() => changeBlock('heading-one')}>H1</button>
  <button onClick={() => changeBlock('paragraph')}>P</button>
  <button onClick={() => changeLeaf('bold')}>B</button>
  <button onClick={() => changeLeaf('underline')}>
    <u>U</u>
  </button>
</div>

tserialize関数はこの後作成しますが、toHTML関数ではeditor.children.map関数を実行してserialze関数の引数に展開したNodeを渡します。


const toHtml = () => {
  const html = editor.children.map((node) => serialize(node)).join('');
  console.log('html:', html);
};

serialize関数では本文書で設定したtypeの値(paragraph, heading-one)やleaftの値(bold, underline)の値を利用してタグを設定しています。typeの値やleafの値を増やした場合やtypeやleafに異なる名前を設定したい場合はこの関数を更新する必要があります。


import { useMemo, useState } from 'react';
import { createEditor, Editor, Transforms, Element, Text } from 'slate';
import { Editable, Slate, withReact } from 'slate-react';
import escapeHtml from 'escape-html';
//略
const serialize = (node) => {
  if (Text.isText(node)) {
    let string = escapeHtml(node.text);
    if (node.bold) {
      string = `<strong>${string}</strong>`;
    }
    if (node.underline) {
      string = `<u>${string}</u>`;
    }
    return string;
  }

  const children = node.children.map((n) => serialize(n)).join('');

  switch (node.type) {
    case 'heading-one':
      return `<h1>${children}</h1>`;
    case 'paragraph':
      return `<p>${children}</p>`;
    default:
      return children;
  }
};

実際に動作確認を行うとブラウザ上に表示された内容でHTMLが作成されます。

HTMLへの変換
HTMLへの変換

画像の設定

画像の設定については主にSlateのExampleにあるImages(https://www.slatejs.org/examples/images)を参考に設定しています。画像は簡単に表示されますがいくつか追加設定が必要になります。

画像の表示

Viteでプロジェクトを作成しているのでpublicディレクトリに保存されている画像を利用して動作確認します。デフォルトではvite.svgファイルが保存されているのでvite.svgファイルを利用します。

表示の確認なのでまずはinitValueに画像に関する情報を追加します。typeには’image’を設定して画像のurlを設定しています。


const initialValue = [
  { type: 'heading-one', children: [{ text: 'Heading 1' }] },
  {
    type: 'paragraph',
    children: [{ text: 'A line of text in a paragraph.' }],
  },
  {
    type: 'image',
    url: '/vite.svg',
  },
];

開発サーバを起動してブラウザからアクセスするとブラウザには何も表示されず”Error: [Slate] initialValue is invalid! Expected a list of elements”のエラーがブラウザのコンソールに表示されます。

追加したElementがinvalidだと言われている通り、Elementにchildrenが必要なので追加します。


const initialValue = [
  { type: 'heading-one', children: [{ text: 'Heading 1' }] },
  {
    type: 'paragraph',
    children: [{ text: 'A line of text in a paragraph.' }],
  },
  {
    type: 'image',
    url: '/vite.svg',
      children: [{ text: '' }],
  },
];

エラーは解消されましたが画像はブラウザ上には表示されません。ここまでの説明を理解している人であればtypeの値imageに対応するタグをrenderElement関数に追加する必要があることがわかります。swtich構文のcaseに’image’を追加してimageタグを設定します。


  const renderElement = (props) => {
    switch (props.element.type) {
      case 'heading-one':
        return <h1 {...props.attributes}>{props.children}</h1>;
      case 'image':
        return <img {...props.attributes} src={props.element.url} alt="" />;
      default:
        return <p {...props.attributes}>{props.children}</p>;
    }
  };

ブラウザで確認するとvite.svgファイルの画像が表示されます。

vite.svgファイルの表示
vite.svgファイルの表示

画像は表示されましたが正しく画像を設定するためには追加設定が必要です。

Void要素

HTMLではimageタグのように子要素を持たない要素をVoid要素と呼びます。その他にはbr, hrタグがあります。Slateではimageタグを利用する場合にVoid要素であることを設定する必要があります。

withImages関数を追加してwithReact関数をラップします。


const withImages = (editor) => {
  const { isVoid } = editor;

  editor.isVoid = (element) => {
    return element.type === 'image' ? true : isVoid(element);
  };

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

設定を行うとブラウザ上ではimageタグにdata-slate-void=”true”が追加されます。

void設定後のimageタグ
void設定後のimageタグ

さらにVoid要素の場合にはいくつかのルールがあることがドキュメントに記載されています。

Void要素のMust設定
Void要素のMust設定

上記のルールを反映させるためrenderElement関数のコードを更新します。


const renderElement = (props) => {
  switch (props.element.type) {
    case 'heading-one':
      return <h1 {...props.attributes}>{props.children}</h1>;
    case 'image':
      return (
        <div {...props.attributes}>
          <img src={props.element.url} alt="" />
          {props.children}
        </div>
      );
    default:
      return <p {...props.attributes}>{props.children}</p>;
  }
};

画像の挿入

画像の表示を行うことができたので画像の挿入方法を確認します。画像を含めたElement Nodeの追加にはTransforms.insertNodesメソッドを利用することができます。

画像ボタンを追加してonClickイベントを設定してaddImage関数を指定します。


<div>
  <button onClick={saveContent}>保存</button>
  <button onClick={addImage}>画像</button>
  <button onClick={toHtml}>HTML</button>
  <button onClick={() => changeBlock('heading-one')}>H1</button>
  <button onClick={() => changeBlock('paragraph')}>P</button>
  <button onClick={() => changeLeaf('bold')}>B</button>
  <button onClick={() => changeLeaf('underline')}>
    <u>U</u>
  </button>
</div>

addImage関数ではwindowのpromptメソッドを利用してURLの入力画面を表示させます。


const insertImage = (editor, url) => {
  const text = { text: '' };
  const image = { type: 'image', url, children: [text] };
  Transforms.insertNodes(editor, image);
  Transforms.insertNodes(editor, {
    type: 'paragraph',
    children: [{ text: '' }],
  });
};

const addImage = () => {
  const url = window.prompt('Enter the URL of the image:');
  if (url) url && insertImage(editor, url);
};

その後urlが入力されたらinsertImage関数を実行してTransforms.insertNodesメソッドで画像を追加します。画像の後にコンテンツを追加できるように空のparagraphの追加を行っています。

実際に動作確認すると”paragraph.”の文字列の後にカーソルを合わせて画像ボタンをクリックするとURLを入力するためのプロンプトが表示させるのでvite.svgを入力してOKボタンをクリックします。

URLの入力プロンプトの表示
URLの入力プロンプトの表示

挿入した画像の確認とその下の行に文字列が追加できたことを確認することができます。

挿入した画像の表示と追加された行への入力
挿入した画像の表示と追加された行への入力

Slateでの画像の表示と挿入方法について確認できました。