2022.11.14[Mon]
[シリーズ連載] Lexicalでリッチテキストエディターを自作する

Lexicalでリッチテキストエディターのベースを作る

  • React
  • Javascript

目次

こんにちは、エンジニアチームのさらだです。

簡単な装飾をおこなったコンテンツをGUI上で入力できるリッチテキストエディター。 Wordpressでも利用されているGutenbergなどが有名ですね。

リッチテキストエディターは数あれど、
「この装飾がない!」
「出力をカスタマイズしたいからjson形式で取り出したい!」
「エディター内とフロントアプリケーションのスタイルを合わせたい!」
など、既製ライブラリままだと要件を満たせないこともあると思います。

そこで、今回はMetaのリッチテキストエディターを作れるフレームワーク、Lexicalの使い方を紹介します。
LexicalはReact用のインターフェイスも用意してくれているので、今回はReactでの使い方になります。

ReactでDraft.jsを使っていた方は、Draft.jsのアーカイブ化が決まっております。代替としてLexicalの使用をMetaは勧めています。

環境

TypeScript 4.8.4
React 18.0.0
Lexical 0.5.0

Lexicalとは

Lexicalは前述の通り、リッチテキストエディターを作るフレームワークです。
見た目の作成は担当せず、入力中のコンテンツと何が選択されているかなどをエディターの状態とし、この状態を管理するための様々な機能を提供してくれます。

コアとなるlexical自体には汎用的な管理機能しか提供されていません。
Undo,Redoといった履歴機能や、リスト表示といったDOM変更機能など、特化した機能はサブパッケージとして利用することが出来ます。

Lexicalを用いてリッチテキストエディターを作ると

  1. ユーザーが入力するとEditor StateのNodeツリーが更新される
  2. ユーザーがどこかを選択すると、Editor StateのSelectionが更新される
  3. スタイルを変更するボタンを押したら、Selectionで指定されたNodeが対応するNodeに変更される

といった流れになります。
ここからはLexicalのコンセプトをいくつかピックアップして紹介します。

Editor State

Lexialはコンテンツの入力内容や、選択状態などをエディターの状態としてEditor Stateで管理しています。

[Editor State]

Command

Editorにあらかじめ用意したCommandを登録し、dispatchで呼び出すことが出来ます。

createCommand()
新しいCommandの作成
LexicalEditor.registerCommand()
対象のEditorにCommandを登録
LexicalEditor.dispatchCommand()
対象のCommandをEditor内で実行

Listener

Editor Stateの更新などの各種イベントの発生時に実行したい関数を登録することが出来ます。各イベントへの登録はEditorState.registerXXX()で行います。

[Listener]

Node

コンテンツの入力内容を表現するモデルです。
LexicalNodeを基底クラスとして、RootNodeTextNodeElementNodeなどでコンテンツを表現します。

Editor Stateにはコンテンツの入力内容がRootNodeを根としたNodeツリーで保存されます。

[Node]

Selection

エディターの選択状態です。コンテンツの入力内容を示すツリー内のどのNodeを選択しているかを示します。

いくつかSelectionの種類がありますが、汎的なRangeSelectionではよくanchorfocusという値を利用します。
anchorは選択したコンテンツ内の先端を、focusは末端の位置を示します。

[Selection]

他にもLexicalの根幹をなすコンセプトはありますが、本記事内の紹介でよく使うものだけをピックアップしました。
それでは実際に実装していきます。

インストール

npm i lexical @lexical/react

TypeScriptやreactは既に準備されている前提です。
ここからは公式のデモであるplaygroundを参考に、

  • プレーンなエディターを作る
  • ツールバーを用意する
  • 既存のプラグインを利用した見た目の変更
  • カスタムプラグインを利用した見た目の変更
  • スタイルの調整
  • 外部リソースとのやりとり

という章立てで進めていきます。
本記事では既存のプラグインを利用した見た目の変更までをご紹介します。
残りの3つに関しては後編でご紹介します。

プレーンなエディターを作る

// Editor.tsx
import React, { FC } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import { LinkNode } from '@lexical/link';
import { EditorState } from 'lexical';

type Props = {
}
export const Editor: FC<Props> = ({
}) => {

  const onChange = (editor: EditorState) => {
    console.log(editor);
  };
  
  const onError = (e: Error) => {
    console.log(e)
  };

  return (
    <>
      <LexicalComposer initialConfig={{
        namespace: 'MySample',
        onError,
        nodes: [LinkNode]
      }}>
        <RichTextPlugin
          contentEditable={<ContentEditable />}
          placeholder={<div>Enter some text...</div>}
        />
        <OnChangePlugin onChange={onChange} />
        <div>
          <LexicalComposer initialConfig={{
            namespace: 'MySample',
            onError,
            nodes: [LinkNode]
          }}>
            <div>
              <RichTextPlugin
                contentEditable={<ContentEditable />}
                placeholder={<ContentEditable />}
              />
            </div>
            <OnChangePlugin onChange={onChange} />
            <HistoryPlugin />
            <LinkPlugin />
          </LexicalComposer>
        </div>
      </LexicalComposer>
    </>
  );
};

export default Editor

Editorの本体です。重要な部分をフィーチャーしていきます。

LexicalComposer

Providerの役割を果たします。この配下でEditorのContextを利用することができます。
自作プラグインを含めた各種プラグインは、子コンポーネントとして指定してください。

initialConfigはEditorの初期設定で必須項目です。
いくつかパラメータがありますが、以下の2つが必須項目になっています。

namespace
Editorの名前空間です。ノードのコピペを可能にするclipboardプラグインなどで利用されています。
onError
エラーハンドリング用の関数です。

RichTextPlugin

所謂リッチテキストを提供するプラグインです。
タイトル表記やリスト表記などを行うのに必要です。

contentEditableは実際にコンテンツ入力を行うComponentを指定します。
既製のContentEditable Componentを指定していますが、このComponentのPropsだけでも色々と設定できます。

placeHolderはそのままプレースホルーダーを設定できます。型はJSX.elementなのでDOMやReactNodeなども指定できます。

プレーンなテキストのみを扱うのであればPlainTextPluginもあり、同様のインターフェイスで実装できます。
プレーンなエディターを作ると銘打っていますが、後のお話で置き換えることになるので、こちらではRichTextPluginを利用しています。

OnChangePlugin

Editorに変更があった際のコールバック関数を指定するプラグインです。
onChangeでコールバック関数を指定します。

これがなくてもEditorは更新されるため動作しますが、実際に使用する場合はComponentの外部とのやりとりが必要なので、利用します。

ツールバーを用意する

スタイルの変更などを行うツールバーを用意していきます。
Editor Stateに含まれるSelectionで指定されたNode、つまり選択中のNodeを指定のNodeに変換するボタンを用意していきます。

ボタンの状態は選択されたNodeの状態を見て、アクティブか非アクティブかを表示します。
なので、ツールバーにも状態があることになります。

Playgroundではツールバー内で状態を持っていますが、他のツールバーと共通の状態にしたいので今回はツールバー内の状態をContextで用意していきます。

Contextの準備

// ToolContext.ts
import React, { createContext, useContext, useEffect, useCallback } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getSelection, $isRangeSelection } from 'lexical';

type Context = {
  // 各値の型
};

const ToolContext = createContext<Context>(
  {
    // 各値の初期値
  }
);

const useProvideTool = (): Context => {
  const [ editor ] = useLexicalComposerContext();

  const updateToolCondition = useCallback(() => {
    const selection = $getSelection();
    if(!$isRangeSelection(selection)) return;
    // ここにEditorの状態が更新後に処理したいことを書く
  }, []);

  useEffect(() => {
    editor.registerUpdateListener(({ editorState }) => {
      editorState.read(() => {
        updateToolCondition();
      });
    });
  }, [editor, updateToolCondition]);

  return {
  }
};

export const ToolProvider: React.FC = ({ children }) => {
  const value = useProvideTool();

  return (
    <ToolContext.Provider value={value}>
      {children}
    </ToolContext.Provider>
  )
};

export const useTool = () => {
  return useContext(ToolContext);
};

まだ特に何も値を用意していない状態です。

useLexicalComposerContext

LexicalComposer内で、Editor Stateを参照するためのHookです。
ツールバーの状態の更新するためにEditor Stateを参照します。

updateToolCondition

こちらにEditor State更新後に行いたい処理を書いていきます。

$getSelectionなど$から始まる関数はEditorState.update()、EditorState.read()などの特定のスコープでのみ実行できるLexicalの特殊な関数です。
ReactでいうHookみたいなものを想像してくれと、公式では言及されています。
こちらの関数は、後述のEditorState.read()内で実行するので、この特殊関数を実行できます。

$getSelectionEditorState内のSelectionを取得を、$isRangeSelectionでそのSelectionが、RangeSelectionと呼ばれる一番汎的な選択タイプかを判定を行なっています。

Editor.registerUpdateListener

Editor Stateのリスナーの一つで、EditorStateの状態が更新された時に実行したい処理を関数として登録できます。
こちらのコールバック関数内で、EventState.read()を呼び、その中でツールバーの更新処理を行っております。

なぜEventState.read()を利用する必要があるのか。
これは、LexicalがEditorStateの更新にダブルバッファリングを採用しているためです。
現在の状態と、変更後の未来の状態を保有しているため、各イベントフック内で正確な状態を参照するため、EventState.read()を利用しております。

ToolBarの準備

// ToolbarPlugin.tsx
import React, { FC } from 'react';

import { useTool } from '../context/ToolContext';

export const ToolbarPlugin: FC = ({
}) => {
  const tool = useTool();
  
  return;
};

export default ToolbarPlugin;

こちらがツールバーの表示部分になります。
まだ何もありません。

リストなどの見た目を変更するボタンを作る(既存のプラグインを利用する)

ツールバーに機能を足していきます。
最初の例はリストです。
リストに関しては、既存のListPluginとCheckListPluginで作れるので最初の例として採用しました。

必要なプラグインなどを読み込む

// Editor.tsx
import React, { FC } from 'react';

// 省略

import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin';
import { ListNode, ListItemNode } from '@lexical/list';
import Toolbar from './ToolbarPlugin';
import { ToolProvider } from '../context/ToolContext';

export const Editor: FC = () => {

  // 省略

  return (
    <>
      <LexicalComposer initialConfig={{
        namespace: 'MyEditor',
        onError,
        nodes: [
          ListNode,
          ListItemNode
        ]
      }}>
        <ToolProvider>
          <ToolbarPlugin />
        </ToolProvider>
        <div>
          <RichTextPlugin
            contentEditable={<ContentEditable />}
            placeholder={<p>入力してください</p>}
          />
        </div>
        <OnChangePlugin onChange={onChange} />
        <ListPlugin />
        <CheckListPlugin />
      </LexicalComposer>
    </>
  );
};

export default Editor;
  • 前項で用意したToolProviderToolPlugin
  • ListPluginCheckListPlugin
  • initialConfigListNodeListItemNode

を追加しました。

ListPluginはul、olといった順序なしリスト、順序ありリストをサポートするプラグインです。
CheckListPluginを入れることで、チェックリストも対応できます。

この二つのプラグインを利用するには、対応するNodeを読み込み、Editorに認識してもらう必要があります。
そのため、initialConfignodesパラメータに必要なNodeを指定しています。

変更ボタンの実装

// ToolContext.ts
import React, { createContext, useCallback, useContext, useEffect } from 'react';

// 省略

import {
  INSERT_CHECK_LIST_COMMAND,
  INSERT_ORDERED_LIST_COMMAND,
  INSERT_UNORDERED_LIST_COMMAND,
} from '@lexical/list';

type Context = {
  toggleUnOrderList: () => void;
  toggleOrderList: () => void;
  toggleCheckList: () => void;
};

const ToolContext = createContext<Context>(
  {
    toggleUnOrderList: () => {},
    toggleOrderList: () => {},
    toggleCheckList: () => {},
  }
);

const useProvideTool = (): Context => {
  const [ editor ] = useLexicalComposerContext();

  // 省略

  const toggleUnOrderList = useCallback(() => {
    editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
  }, [editor]);

  const toggleOrderList = useCallback(() => {
    editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
  }, [editor]);

  const toggleCheckList = useCallback(() => {
    editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
  }, [editor]);

  return {
    toggleUnOrderList,
    toggleOrderList,
    toggleCheckList
  }
};

// 省略

Context側では、クリックした際に対応したリストに変更される関数toggleXXX()を用意します。後々ボタンに状態がつくと、解除もできるようになるのでtoggleとつけておきます。

LexicalEditor.dispatchCommand()で、SelectionのNodeを変更するCommandを実行します。

INSERT_UNORDERED_LIST_COMMANDなどのCommandは、各プラグイン内で既にEditorに登録されています。
そのため、プラグイインからインポートしLexicalEditor.dispatchCommand()で実行するだけで済みます。

LexicalEditor.dispatchCommand()の第2引数には、Commandに渡す値を指定できますが、今回利用するCommandでは不要なためundefinedになります。

// ToolbarPlugin.tsx

// 省略

export const ToolbarPlugin: FC = ({
}) => {
  const tool = useTool();
  
  return (
    <ul>
      <li>
        <button onClick={tool.toggleUnOrderList}>
          <span>UL</span>
        </button>
      </li>
      <li>
        <button onClick={tool.toggleOrderList}>
          <span>OL</span>
        </button>
      </li>
      <li>
        <button onClick={tool.toggleCheckList}>
          <span>CL</span>
        </button>
      </li>
    </ul>
  );
};

export default Toolbar;

Component側はシンプルで、Contextで定義した関数をクリック時に実行してあげるだけです。

これで選択中のコンテンツが、ボタンをクリックすることで対応のリスト表記に変更されるようになりました。
(チェックリストに関してはこの時点では見た目は変わりません。DOMを見てみるとliタグにrole="checkbox"が付与されていることが確認できます。)

しかし今のままだと一度リスト表記にしてしまったら、元に戻せなくなってしまいます。
また、選択中のコンテンツにどのスタイルが当たっているのかが一見して分けって欲しいところです。

ということでボタンに状態を持たせて、ちゃんと表記のリセットも行えるようにしてきます。

ボタンに状態を持たせる

// ToolContext.tsx
import React, { createContext, Dispatch, SetStateAction, useCallback, useContext, useEffect, useMemo, useState } from 'react';

//省略

import { $getSelection, $isRangeSelection, $isRootOrShadowRoot, EditorState, LexicalNode } from 'lexical';
import { $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils';
import {
  ListNode,
  $isListNode,
  // 省略
  REMOVE_LIST_COMMAND
} from '@lexical/list';

export const BLOCK_TYPES = {
  UL: 'bullet',
  OL: 'number',
  CL: 'check',
  P: 'paragraph'
} as const;

type BlockType = typeof BLOCK_TYPES[keyof typeof BLOCK_TYPES];

const getSecondRootNode = (targetNode: LexicalNode) => {
  return targetNode.getKey() === 'root'
  ? targetNode
  : $findMatchingParent(targetNode, (node: LexicalNode) => {
    const parentNode = node.getParent();
    return parentNode !== null && $isRootOrShadowRoot(parentNode);
  });
};

type Context = {
  isUnOrderList: boolean;
  isOrderList: boolean;
  isCheckList: boolean;
  // 省略
};

const ToolContext = createContext<Context>(
  {
    isUnOrderList: false,
    isOrderList: false,
    isCheckList: false,
    // 省略
  }
);

const useProvideTool = (): Context => {
  const [blockType, setBlockType] = useState<BlockType>(BLOCK_TYPES.P);
  const [ editor ] = useLexicalComposerContext();

  const updateToolCondition = useCallback(() => {
    const selection = $getSelection();
    if(!$isRangeSelection(selection)) return;
  
    const anchorNode = selection.anchor.getNode();
    const targetNode = getSecondRootNode(anchorNode);
  
    if($isListNode(targetNode)) {
      const parentList = $getNearestNodeOfType<ListNode>(
        anchorNode,
        ListNode,
      );
      const type = parentList
        ? parentList.getListType()
        : targetNode.getListType();
      setBlockType(type);
            return;
    }
    setBlockType(BLOCK_TYPES.P);
  }, []);

  useEffect(() => {
    editor.registerUpdateListener(({ editorState }) => {
      editorState.read(() => {
        updateToolCondition();
      });
    });
  }, [editor, updateToolCondition]);

  // 省略

  const [
    isUnOrderList,
    isOrderList,
    isCheckList, 
  ] = useMemo(() => [
    blockType === BLOCK_TYPES.UL,
    blockType === BLOCK_TYPES.OL,
    blockType === BLOCK_TYPES.CL
  ], [blockType]);

  const toggleUnOrderList = useCallback(() => {
    if(!isUnOrderList) {
      editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
      return;
    }
    editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
  }, [editor, isUnOrderList]);

  // 省略

  return {
    isUnOrderList,
    isOrderList,
    isCheckList, 
    // 省略
  }
};

// 省略

現在選択しているNodeがどのようなブロック要素なのかを示すため、useStateblockTypeを用意します。
blockTypeが取り得る値はBLOCK_TYPESで定数化しています。bulletnumberなどの値は実際にNodeから取得できる値になります。

getSecondRootNode()は指定Nodeの親を辿り、RootNodeを除いた最上位のNodeを取得する関数です。$findMatchingParentで条件を満たす限り、先祖Nodeを探索していきます。
この関数は後に選択中のNodeの中で最上位のものがどのような状態なのかを確認するために使用します。

RangeSelection.anchor.getNode()で、選択範囲内の先端のNodeを取得します。ここから先述のgetSecondRootNode()を利用し、RootNodeを除いた最上位のNodeを取得します。

$isListNodeでこちらのNodeがListNodeであるかを判定します。
これは、ListNodeと他のNodeでブロックの種類を取得する方法が異なるために行います。

もしListNodeであれば、今度はanchorNodeを基準に、$getNearestNodeOfTypeを用いて、最も近いListNodeを先祖Nodeから探索し、ListNode.getListType()でブロックの種類を取得します。

次にそれぞれのblockTypeであるかを示すboolean値を用意します。

toggleXXXにも変更を加えます。
もし既にそのスタイルが適用済みだった場合はRESET_LIST_COMMANDで、リストの表記を解除します。

// ToolbarPlugin.tsx
import React, { FC } from 'react';

import { useTool } from '../context/ToolContext';

export const ToolbarPlugin: FC = ({
}) => {
  const tool = useTool();
  
  return (
    <ul>
      <li>
        <button onClick={tool.toggleUnorderList}>
          {tool.isUnorderList ? <strong>UL</strong> : <span>UL</span>}
        </button>
      </li>
      <li>
        <button onClick={tool.toggleOrderList}>
          {tool.isOrderList ? <strong>OL</strong> : <span>OL</span>}
        </button>
      </li>
      <li>
        <button onClick={tool.toggleCheckList}>
          {tool.isCheckList ? <strong>CL</strong> : <span>CL</span>}
        </button>
      </li>
    </ul>
  );
};

export default Toolbar;

Component側で、先ほどのboolean値を参照し、表示を切り替えます。
これで、どのスタイルかが当たっているかわかるようになり、表示のリセットも行えるようになりました。

まとめ

今回はLexicalがどのようなものかの紹介と、既存プラグインを利用したサンプルの作成まで行いました。
しかし、現状のサンプルではスタイルも当たっておらず、外部とデータのやりとりも出来ません。
後編ではカスタムプラグインの作り方も含め、紹介していきたいと思います。

Share

  1. 01 Lexicalでリッチテキストエディターのベースを作る
  2. 02 Lexicalで自作プラグインを作りリッチテキストエディターをカスタマイズする >
Lexicalで自作プラグインを作りリッチテキストエディターをカスタマイズするStrapiのバックエンド実装で共通処理をまとめる方法