Skip to content

Instantly share code, notes, and snippets.

@zaru
Created June 23, 2025 03:38
Show Gist options
  • Save zaru/85121db133b3b575af52a96102c16886 to your computer and use it in GitHub Desktop.
Save zaru/85121db133b3b575af52a96102c16886 to your computer and use it in GitHub Desktop.

Lexical開発入門入門

  • このドキュメントはLexicalを使ってエディタを開発するための最初の取っ掛かりです
  • 詳細な解説は省き、全体像を理解するのを目的としています

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について

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の操作例

// 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再構築用に使うワークスペースで状態変更可能

Node クラス

主要なNodeタイプ

  • ElementNode: 子ノードを持つ要素(例: 段落)
  • TextNode: プレーンテキストとインラインスタイル
  • DecoratorNode: React UI を埋め込むコンテナ
  • MarkNode: インライン範囲をまたぐ装飾
  • カスタムノード: ListNode / TableNode など自由に拡張

NodeState

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発火]
Loading

1: Update フェーズ(pending 作成 & 変更)

// 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の最終加工が可能

2: Commit フェーズ(DOM 反映 & pending / current 入れ替え)

// 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());
    }
  });
}

よくある問題と解決方法

1. NodeTransformで無限ループが発生する

// ❌ 悪い例:常に新しいノードを作成
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');
  }
});

2. EditorStateの直接変更

// ❌ 悪い例:EditorStateを直接変更
const editorState = editor.getEditorState();
editorState._nodeMap.forEach(node => {
  // 直接変更はNG
});

// ✅ 良い例:editor.update()内で変更
editor.update(() => {
  const root = $getRoot();
  root.getChildren().forEach(child => {
    // 正しい変更方法
  });
});

3. リスナーの重複登録

// ❌ 悪い例:useEffect内でdependencyを忘れる
useEffect(() => {
  return editor.registerCommand(...); // 毎レンダリングで登録される
});

// ✅ 良い例:適切なdependency配列
useEffect(() => {
  return editor.registerCommand(...);
}, [editor]); // editorが変わった時のみ再登録

QA

Q: 操作して更新されたDOMを直接さわるには?

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');
      }
    }
  }
);

Q: リスナーどう使い分ける?

「とりあえず何か変わったら全部知りたい」 → registerUpdateListener(ただし重いのでタグでフィルタするか NodeTransform へ移行を検討)

「文字数や全文検索インデックスだけ更新したい」 → registerTextContentListener が最適。不要なツリー走査が発生しない

「画像ノードが削除されたら S3 も削除」「カスタム の mount/unmount を監視」 → registerMutationListener 一択。ノード単位の厳密なライフサイクルが取れる

Q: NodeTransformの使い所は?

固い言い方をすると「NodeTransform はDirty になったノードに対して自動で走る正規化ロジックを実装する場所」。DirtyつまりEditorStateが変更されたときにNodeTransformが実行される。そこでデータを最終的なフォーマットに変換させるために使う。

例えば、URLをリンクに変換する処理や、コンテンツ埋め込み形式への変換、不要な空Nodeの削除などを行う。

注意点としては、NodeTransformでEditorStateを変更したら、再度NodeTransformが起動するため無限ループにならないようにする必要がある。

用語集

$ プレフィックス関数

Lexicalでは、EditorState内でのみ使用可能な関数に$プレフィックスが付いています。

  • $getRoot(): ルートノードを取得
  • $getSelection(): 現在の選択範囲を取得
  • $createParagraphNode(): 段落ノードを作成
  • $isRangeSelection(): RangeSelectionかどうかを判定

Command(コマンド)

エディタ操作を抽象化したイベントシステム。キーボード入力やユーザーアクションをコマンドとして定義し、ハンドラで処理します。

Transform(トランスフォーム)

ノードが変更された際に自動的に実行される正規化処理。テキストの自動フォーマットやバリデーションに使用されます。

Dirty Node

EditorStateで変更されたノード。これらのノードに対してTransformが実行されます。

APIリファレンス

よく使うEditor API

// エディタインスタンスの作成
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();

Selection API

// 選択範囲の操作
const selection = $getSelection();
if ($isRangeSelection(selection)) {
  selection.insertText('テキスト');
  selection.formatText('bold');
  selection.insertNodes([node]);
}

Node API

// ノードの作成
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();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment