目次
- - 環境
- - Lexicalとは
- - Editor State
- - Command
- - Listener
- - Node
- - Selection
- - インストール
- - プレーンなエディターを作る
- - LexicalComposer
- - RichTextPlugin
- - OnChangePlugin
- - ツールバーを用意する
- - Contextの準備
- - useLexicalComposerContext
- - updateToolCondition
- - Editor.registerUpdateListener
- - ToolBarの準備
- - リストなどの見た目を変更するボタンを作る(既存のプラグインを利用する)
- - 必要なプラグインなどを読み込む
- - 変更ボタンの実装
- - ボタンに状態を持たせる
- - まとめ
こんにちは、エンジニアチームのさらだです。
簡単な装飾をおこなったコンテンツを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を用いてリッチテキストエディターを作ると
- ユーザーが入力するとEditor StateのNodeツリーが更新される
- ユーザーがどこかを選択すると、Editor StateのSelectionが更新される
- スタイルを変更するボタンを押したら、Selectionで指定されたNodeが対応するNodeに変更される
といった流れになります。
ここからはLexicalのコンセプトをいくつかピックアップして紹介します。
Editor State
Lexialはコンテンツの入力内容や、選択状態などをエディターの状態としてEditor Stateで管理しています。
Command
Editorにあらかじめ用意したCommandを登録し、dispatchで呼び出すことが出来ます。
createCommand() | 新しいCommandの作成 |
LexicalEditor.registerCommand() | 対象のEditorにCommandを登録 |
LexicalEditor.dispatchCommand() | 対象のCommandをEditor内で実行 |
Listener
Editor Stateの更新などの各種イベントの発生時に実行したい関数を登録することが出来ます。各イベントへの登録はEditorState.registerXXX()
で行います。
[Listener]
Node
コンテンツの入力内容を表現するモデルです。LexicalNode
を基底クラスとして、RootNode
、TextNode
、ElementNode
などでコンテンツを表現します。
Editor Stateにはコンテンツの入力内容がRootNode
を根としたNodeツリーで保存されます。
[Node]
Selection
エディターの選択状態です。コンテンツの入力内容を示すツリー内のどのNodeを選択しているかを示します。
いくつかSelectionの種類がありますが、汎的なRangeSelectionではよくanchor
とfocus
という値を利用します。anchor
は選択したコンテンツ内の先端を、focus
は末端の位置を示します。
他にも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()
内で実行するので、この特殊関数を実行できます。
$getSelection
でEditorState
内の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;
- 前項で用意した
ToolProvider
とToolPlugin
ListPlugin
とCheckListPlugin
initialConfig
にListNode
とListItemNode
を追加しました。
ListPlugin
はul、olといった順序なしリスト、順序ありリストをサポートするプラグインです。CheckListPlugin
を入れることで、チェックリストも対応できます。
この二つのプラグインを利用するには、対応するNodeを読み込み、Editorに認識してもらう必要があります。
そのため、initialConfig
のnodes
パラメータに必要な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がどのようなブロック要素なのかを示すため、useState
でblockType
を用意します。blockType
が取り得る値はBLOCK_TYPES
で定数化しています。bullet
、number
などの値は実際に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がどのようなものかの紹介と、既存プラグインを利用したサンプルの作成まで行いました。
しかし、現状のサンプルではスタイルも当たっておらず、外部とデータのやりとりも出来ません。
後編ではカスタムプラグインの作り方も含め、紹介していきたいと思います。
- 01 Lexicalでリッチテキストエディターのベースを作る
- 02 Lexicalで自作プラグインを作りリッチテキストエディターをカスタマイズする >