- このドキュメントはLexicalを使ってエディタを開発するための最初の取っ掛かりです
- 詳細な解説は省き、全体像を理解するのを目的としています
Lexicalは、Metaが開発したReact向けの高性能リッチテキストエディタフレームワークです。DraftJSの後継として設計され、より柔軟で拡張性の高いアーキテクチャを持っています。
- 高いパフォーマンス: ダブルバッファリングによる効率的なレンダリング
- 拡張性: プラグインシステムによる機能の追加が容易
- 型安全: TypeScriptファーストな設計
- フレームワーク非依存: コアはVanilla JSで、React版は別パッケージ
npm install lexical @lexical/react
import { $getRoot, $getSelection } from 'lexical';
import { useEffect } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
function MyEditor() {
const initialConfig = {
namespace: 'MyEditor',
theme: {},
onError: (error: Error) => console.error(error),
};
return (
<LexicalComposer initialConfig={initialConfig}>
<div className="editor-container">
<RichTextPlugin
contentEditable={<ContentEditable className="editor-input" />}
placeholder={<div className="editor-placeholder">テキストを入力...</div>}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
</div>
</LexicalComposer>
);
}
レイヤ | 役割 | 代表的な API |
---|---|---|
Editor | 編集セッションの中核クラス。React 版では が生成 | createEditor, editor.update() |
EditorState | 不変(immutable)なドキュメント・スナップショット。ダブルバッファ方式で高速レンダリング | editor.getEditorState(), editor.setEditorState() |
Node ツリー | ドキュメントを構成する木構造。組込みノード+カスタムノードで拡張 | TextNode, ParagraphNode, DecoratorNode ほか |
Selection | 範囲選択を抽象化(Range / Node / Grid) | $getSelection(), $setSelection() |
Command & Plugin | 入力イベントをコマンドに抽象化し、プラグインがハンドリング | registerCommand, dispatchCommand |
EditorStateは、ドキュメントデータを木構造で表現したJSONデータです。Nodeクラスを複数持ち、子階層はchildrenとして表現します。
{
"root": {
"children": [
{
"detail": 0,
"format": 0,
"mode": "normal",
"style": "",
"text": "Hello World",
"type": "text",
"version": 1,
},
],
"direction": null,
"format": "",
"indent": 0,
"type": "root",
"version": 1
}
}
// EditorStateをJSONとして出力
const json = editor.getEditorState().toJSON();
// JSONをEditorStateとして読み込む
const restored = editor.parseEditorState(json);
editor.setEditorState(restored);
// EditorStateを更新する
editor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const text = $createTextNode('新しいテキスト');
paragraph.append(text);
root.append(paragraph);
});
EditorStateはcurrent/pending2つの状態が存在します:
- current: イミュータブルで確定したスナップショット
- pending: 次回のDOM再構築用に使うワークスペースで状態変更可能
- ElementNode: 子ノードを持つ要素(例: 段落)
- TextNode: プレーンテキストとインラインスタイル
- DecoratorNode: React UI を埋め込むコンテナ
- MarkNode: インライン範囲をまたぐ装飾
- カスタムノード: ListNode / TableNode など自由に拡張
NodeStateは、各種Nodeを拡張して独自のデータをもたせることができます。
// NodeStateの定義
export const CommentState = createState("comment", {
parse: (v) => (typeof v === "string" ? v : ""),
});
// NodeStateの使用例
editor.update(() => {
const textNode = $getNodeByKey(nodeKey);
if ($isTextNode(textNode)) {
CommentState.set(textNode, "これはコメントです");
}
});
// NodeStateの読み取り
editor.getEditorState().read(() => {
const textNode = $getNodeByKey(nodeKey);
if ($isTextNode(textNode)) {
const comment = CommentState.get(textNode);
console.log(comment); // "これはコメントです"
}
});
NodeStateを持つNodeのJSON表現:
{
"type": "text",
"$": { "comment": "これはコメントです" }
}
Lexicalのライフサイクルは大きく分けて、Update → Commitの2段階フェーズで構成されます。
graph LR
A[ユーザ操作] --> B[editor.update]
B --> C[1:Updateフェーズ]
C --> D[NodeTransform適用]
D --> E[2:Commitフェーズ]
E --> F[各種Listeners発火]
// Updateフェーズの例
editor.update(() => {
// この中でpending EditorStateに対して変更を加える
const root = $getRoot();
const paragraph = $createParagraphNode();
const text = $createTextNode('新しいテキスト');
// フォーマットを適用
text.toggleFormat('bold');
paragraph.append(text);
root.append(paragraph);
});
editor.update()
もしくはeditor.dispatchCommand()
をトリガーに、EditorStateをcurrentからpendingにcloneする- ユーザは、pendingに対して各種操作を行う
- Updateが完了した直後(DOMコミット前)に
registerNodeTransform()
が実行されNodeの最終加工が可能
// Commitフェーズで実行されるリスナーの例
editor.registerUpdateListener(({editorState, dirtyElements, dirtyLeaves, prevEditorState, tags}) => {
// DOM更新後に実行される
console.log('エディタが更新されました');
// 変更されたノードの確認
editorState.read(() => {
// ここで最新のEditorStateを読み取れる
});
});
editor.update()
呼び出しが終わると、マイクロタスクでpending/currentの差分計算、DOMへの反映を行う- pending → currentへ置き換えがされ、DOMが同期完了
- 各種リスナーが呼ばれる:
registerUpdateListener()
: 全体的な更新を監視registerTextContentListener()
: テキスト内容の変更を監視registerMutationListener()
: 特定ノードタイプの変更を監視
import { createCommand, COMMAND_PRIORITY_NORMAL } from 'lexical';
// カスタムコマンドの定義
export const INSERT_EMOJI_COMMAND = createCommand<string>('INSERT_EMOJI_COMMAND');
// コマンドハンドラの登録
editor.registerCommand(
INSERT_EMOJI_COMMAND,
(emoji: string) => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText(emoji);
}
return true; // コマンドを処理したらtrue
},
COMMAND_PRIORITY_NORMAL
);
// コマンドの発火
editor.dispatchCommand(INSERT_EMOJI_COMMAND, '😀');
// 特定キーの処理
editor.registerCommand(
KEY_ENTER_COMMAND,
(event: KeyboardEvent) => {
// Shift+Enterで改行、Enterで新段落
if (event.shiftKey) {
editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND);
return true;
}
return false;
},
COMMAND_PRIORITY_LOW
);
// フォーマットコマンドの使用
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
// 選択変更の監視
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// ツールバーの表示/非表示など
updateToolbar(selection);
}
return false;
},
COMMAND_PRIORITY_LOW
);
COMMAND_PRIORITY_CRITICAL
: システムレベルの処理(最優先)COMMAND_PRIORITY_HIGH
: 重要な機能COMMAND_PRIORITY_NORMAL
: 通常の機能(デフォルト)COMMAND_PRIORITY_LOW
: 補助的な機能COMMAND_PRIORITY_EDITOR
: エディタのデフォルト動作(最低優先)
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useEffect } from 'react';
import { $getSelection, $isRangeSelection, SELECTION_CHANGE_COMMAND } from 'lexical';
function MyCustomPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// 選択範囲の文字数を表示
const text = selection.getTextContent();
console.log(`選択文字数: ${text.length}`);
}
return false;
},
COMMAND_PRIORITY_LOW
);
}, [editor]);
return null;
}
// 使用例
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin ... />
<MyCustomPlugin />
</LexicalComposer>
import { $createLinkNode, $isLinkNode, LinkNode } from '@lexical/link';
import { $createTextNode, $isTextNode, TextNode } from 'lexical';
function AutoLinkPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
const removeTransform = editor.registerNodeTransform(TextNode, (textNode) => {
const text = textNode.getTextContent();
const urlRegex = /(https?:\/\/[^\s]+)/g;
const matches = text.match(urlRegex);
if (matches) {
const parent = textNode.getParent();
if (!$isLinkNode(parent)) {
// URLを含むテキストをリンクに変換
const parts = text.split(urlRegex);
const nodes: (TextNode | LinkNode)[] = [];
parts.forEach((part, index) => {
if (matches.includes(part)) {
const linkNode = $createLinkNode(part);
linkNode.append($createTextNode(part));
nodes.push(linkNode);
} else if (part) {
nodes.push($createTextNode(part));
}
});
textNode.replace(...nodes);
}
}
});
return removeTransform;
}, [editor]);
return null;
}
// エディタの状態をコンソールに出力
function logEditorState(editor: LexicalEditor) {
const editorState = editor.getEditorState();
console.log('EditorState JSON:', editorState.toJSON());
editorState.read(() => {
const root = $getRoot();
console.log('Root children:', root.getChildren());
});
}
// 選択範囲の詳細情報
function logSelection(editor: LexicalEditor) {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
console.log('Anchor:', selection.anchor);
console.log('Focus:', selection.focus);
console.log('Selected text:', selection.getTextContent());
}
});
}
// ❌ 悪い例:常に新しいノードを作成
editor.registerNodeTransform(TextNode, (node) => {
const newNode = $createTextNode(node.getTextContent());
node.replace(newNode); // 無限ループ!
});
// ✅ 良い例:条件をチェックしてから変更
editor.registerNodeTransform(TextNode, (node) => {
const text = node.getTextContent();
if (text.includes('bad') && !node.hasFormat('strikethrough')) {
node.toggleFormat('strikethrough');
}
});
// ❌ 悪い例:EditorStateを直接変更
const editorState = editor.getEditorState();
editorState._nodeMap.forEach(node => {
// 直接変更はNG
});
// ✅ 良い例:editor.update()内で変更
editor.update(() => {
const root = $getRoot();
root.getChildren().forEach(child => {
// 正しい変更方法
});
});
// ❌ 悪い例:useEffect内でdependencyを忘れる
useEffect(() => {
return editor.registerCommand(...); // 毎レンダリングで登録される
});
// ✅ 良い例:適切なdependency配列
useEffect(() => {
return editor.registerCommand(...);
}, [editor]); // editorが変わった時のみ再登録
updateフェーズでNode分割や装飾などをし更新されたDOMは、commitフェーズの段階(registerMutationListenerなど)でさわることが可能。
editor.registerMutationListener(
TextNode,
(mutatedNodes) => {
for (const [nodeKey, mutation] of mutatedNodes) {
const dom = editor.getElementByKey(nodeKey);
if (dom && mutation === 'created') {
// 新しく作成されたDOMに対する処理
dom.classList.add('fade-in');
}
}
}
);
「とりあえず何か変わったら全部知りたい」 → registerUpdateListener(ただし重いのでタグでフィルタするか NodeTransform へ移行を検討)
「文字数や全文検索インデックスだけ更新したい」 → registerTextContentListener が最適。不要なツリー走査が発生しない
「画像ノードが削除されたら S3 も削除」「カスタム の mount/unmount を監視」 → registerMutationListener 一択。ノード単位の厳密なライフサイクルが取れる
固い言い方をすると「NodeTransform はDirty になったノードに対して自動で走る正規化ロジックを実装する場所」。DirtyつまりEditorStateが変更されたときにNodeTransformが実行される。そこでデータを最終的なフォーマットに変換させるために使う。
例えば、URLをリンクに変換する処理や、コンテンツ埋め込み形式への変換、不要な空Nodeの削除などを行う。
注意点としては、NodeTransformでEditorStateを変更したら、再度NodeTransformが起動するため無限ループにならないようにする必要がある。
Lexicalでは、EditorState内でのみ使用可能な関数に$
プレフィックスが付いています。
$getRoot()
: ルートノードを取得$getSelection()
: 現在の選択範囲を取得$createParagraphNode()
: 段落ノードを作成$isRangeSelection()
: RangeSelectionかどうかを判定
エディタ操作を抽象化したイベントシステム。キーボード入力やユーザーアクションをコマンドとして定義し、ハンドラで処理します。
ノードが変更された際に自動的に実行される正規化処理。テキストの自動フォーマットやバリデーションに使用されます。
EditorStateで変更されたノード。これらのノードに対してTransformが実行されます。
// エディタインスタンスの作成
const editor = createEditor(config);
// 更新
editor.update(() => { /* 変更処理 */ });
// コマンド発火
editor.dispatchCommand(command, payload);
// リスナー登録
editor.registerCommand(command, handler, priority);
editor.registerUpdateListener(listener);
editor.registerNodeTransform(NodeClass, transform);
// 状態取得
editor.getEditorState();
editor.getRootElement();
editor.isEditable();
// 選択範囲の操作
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText('テキスト');
selection.formatText('bold');
selection.insertNodes([node]);
}
// ノードの作成
const paragraph = $createParagraphNode();
const text = $createTextNode('テキスト');
const link = $createLinkNode('https://example.com');
// ノードの操作
node.append(childNode);
node.insertBefore(newNode);
node.insertAfter(newNode);
node.replace(newNode);
node.remove();