フロント初心者が Meta 製ライブラリ Lexical を使ってリッチテキストエディタを作ってみた

この記事は NewsPicks Advent Calendar 2025 の22日目の記事です。

昨日は くろみやあい さんによる「育休明け、時短勤務で働くということ──プロダクトデザイナーのキャリアの話」でした。


こんにちは。ソーシャル経済メディア「NewsPicks」のPlatform Engineeringチームの崔(ちぇ)です。

私は入社して以来フロントエンドを触ったことなく過ごしてきました。

新しいチャレンジとして、社内のエディタ作り直しプロジェクトを担当することになり、今年の下半期はほぼ React & Lexical との戦いでした。今日は、Lexicalでエディタを作ってみて色々とわかったことをまとめたいと思います。Lexicalの導入を検討されている方、エディタ作りに挑戦したい方はぜひ読んでいただけると嬉しいです!

はじめに

NewsPicksでは諸メディアの記事をキューレーションするだけでなく、オリジナル記事も掲載しております。そのため、初期から自作のエディタを開発して使っています。が、すべての機能を自作しているが故に、今となってはメンテしにくいシステムになってきております。そこで、ライブラリを使ってモダンでメンテしやすいエディタに作り直そう!ということになりました。

いくつかの候補↓があった中、最終的にLexicalを採用しました(候補はPerplexityに「トレンドなWYSIWYGエディタ」をお勧めしてもらった結果です)。

FYI: WYSIWYG(ウィジウィグ)エディタとは?

WYSIWYG は What You See Is What You Get の略です。言葉の通り、編集画面に表示されるものがそのまま公開後の表示になります。WYSIWYG エディタは、作りながら結果がわかるので直感的で、特にHTMLなどの知識がなくてもいいというところがメリットです。最近よく見るWYSIWYGエディタだと note などがあります。

採用理由は以下の通りです。

  • 社内システムがReact基盤なので載せやすい
  • 別途サーバーを立てないといけないとかの、インフラ周りの整備が不要
  • 機能の拡張しやすさ
  • OSSライブラリなので無料
  • 安定して使い続けられているライブラリ(下の添付図が当時みていた結果です)

ライブラリ決定時に確認したnpmインストール状況

一点残念なものでいうと、ドキュメントが親切ではないという点です。が、Lexicalのコードをローカルにクローンして、内部を参照すればいいので大きな問題にはなっておりません(特にClaude君を利用すれば)。また、Playgroundにあらかたエディタで使われるオーソドックスな機能が作られているので、参考になります。

Lexicalでエディタを作ってみよう

Lexicalとは、Meta社が作ったリッチテキストエディタ用のライブラリです。ReactやVanilla JS、Vueなどのいろんなフロントエンドのフレームワーク上で利用可能ですが、Reactの場合、専用のインターフェイスが用意されているため親和性が高いです。

github.com

まずは、世界観を理解する

上でも触れましたが、Lexicalは拡張しやすいです。ざっくりではありますが、NodeやPluginをカスタムするだけです。それらが何かについて説明する前にまず、Lexicalの構造について軽くご説明します。

EditorStateというもの

エディタはEditorStateというもので状態を管理します。なので、何かを書き込む際にはEditorStateを update し、書いた記事を読み込む際にはEditorStateを read します。

EditorStateはNodeツリーとSelectionオブジェクトを持ちます。各NodeはRootNodeから生えています。Selectionはアンカーが置かれた位置や範囲情報を持っています。

Chrome拡張のLexical Developer Toolsで確認したNodeツリーとSelection

Nodeというもの

上記のNodeツリーでお分かりだと思いますが、テキストだけの段落はParagraphNodeになり、その子供としてTextNodeやLinkNodeなどが生えています。つまり、Nodeはエディタの内容を表現する単位であり、データモデルです。

NodeStateというもの

Nodeの状態はNodeStateで管理します。そのNodeが持つべきメタ情報を自由に持たせることができます。 例えば、画像を表示するためのImageNodeがあったとします。エディタに画像を埋め込んでから、その画像にリンクを添付したくなったとしましょう。リンクをImageNodeのNodeStateに持たせ管理させるのです。それをDOM出力時に取り出して保存することも可能です。

FYI: NodeStateはv0.26.0以降から利用可能です。

Pluginというもの

Nodeをエディタに挿入するにはCommandが必要です。例えば INSERT_IMAGE_COMMAND のようなものです。そのCommandのイベントハンドラーを管理するのがPluginです。

つまり、Lexicalエディタの仕組みは下記の通りです。

  • PluginでCommandのイベントハンドラー処理を登録(registerCommand)する
  • ツールバーのボタンをクリックして、Commandを発行(dispatchCommand)する
  • そのCommandのイベントハンドラーがEditorStateを更新(update)する
  • DOM出力する際にはEditorStateを読み取る(read

Node, Command, Pluginの3セットは常に一緒です!

FYI : Plugin と Extension

本記事では Plugin を作る従来の手法をご紹介していますが、v0.36.1から Extension という概念が登場しました。Plugin は一般的な React のコンポーネントで、機能とUIを一気に提供します。その一方で、Extension は特定のフレームワークに依存せず、機能 / 設定 / UIを切り離したようなより小さい単位のモジュールです。弊社では、Extension がまだ実験的な段階にいるので Plugin をベースに実装しています。

NodeやPluginによる拡張

エディタの作り方

太字や箇条書きなどの基本的な機能は、Lexicalが提供してくれるので、全ての機能を我々が作る必要はありません。それらを必要に応じて選択し、各々のエディタの機能として「登録」します。

部品さえあれば、自由に取り外しができるので、同じ部品で全く別のエディタを作ることが容易にできるのです。

最もシンプルな(TextNodeのみが使える)エディタは以下のように作ることができます。

import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { ContentEditable as LexicalContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'

const Editor = () => {
  const initialConfig = {
    namespace: 'SimpleEditor',
    onError: (error: Error) => {
      // do something 
    },
  }

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <RichTextPlugin contentEditable={<LexicalContentEditable />} ErrorBoundary={LexicalErrorBoundary} />
    </LexicalComposer>
  )
}

機能を追加してみる

Lexicalが提供する箇条書き(ListNode, ListItemNode)機能を使いたくなったら「登録」します。もちろん、Commandを発行するボタンは作らないといけませんが、それだけです!

import { Button } from '@chakra-ui/react'
import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'

export const OrderedListButton = () => (
  <Button
    onClick={() => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)}
  />
)

export const UnorderedListButton = () => (
  <Button
    onClick={() => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)}
  />
)
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { ContentEditable as LexicalContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { ListItemNode, ListNode } from '@lexical/list'
import { HeadingNode } from '@lexical/rich-text'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'

import { OrderedListButton, UnorderedListButton } from '@/components/buttons/ListButton'

const Editor = () => {
  const [editor] = useLexicalComposerContext()

  const initialConfig = {
    namespace: 'SimpleEditor',
    onError: (error: Error) => {
      // do something 
    },
    nodes: [ListNode, ListItemNode],  // <- ここにNodeを追加します。
  }

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <OrderedButton />
      <UnorderedButton />

      <RichTextPlugin contentEditable={<LexicalContentEditable />} ErrorBoundary={LexicalErrorBoundary} />

      {/* ↓にPluginを追加します */}
      <ListPlugin />
    </LexicalComposer>
  )
}

カスタムのNodeとPluginを作って拡張する

仮に、画像を挿入するための機能を追加したいとします。下記の部品が必要になるでしょう。

部品名 役割
ImageButton 画像を挿入するためのボタン
INSERT_IMAGE_COMMAND そのボタンで発行するCommand
ImagePlugin Commandのイベントハンドラーを管理するPlugin
ImageNode EditorStateに挿入されるNode
ImageBlock エディタに実際表示するためのUIコンポーネント

以下のようにImageNodeが作れます。DecoratorBlockNode はUIコンポーネントをブロックで表示するノードで、decorate メソッドで見た目を指定することができます。NodeStateが使いたいので、$config メソッドを定義します。

import { createState, type StateConfig, $create, $setState, type LexicalNode, createCommand, type LexicalCommand } from 'lexical'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import { ImageBlock } from '@/components/editor/blocks/ImageBlock'

export class ImageNode extends DecoratorBlockNode {

  $config() {
    return this.config('image', {
      extends: DecoratorBlockNode,
      stateConfigs: createState<string>('link'),  // linkというキーには文字列の値だけセットします
    })
  }

  createDOM(): HTMLElement {
    return document.createElement('div')
  }

  updateDOM(): false {
    return false
  }

  decorate(_editor: LexicalEditor, _config: EditorConfig): JSX.Element | null {
    return (
      <ImageBlock nodeKey={this.getKey()} />
    )
  }

  setLink(link: string) {
    const writable = this.getWritable();
    $setState(writable, this.config().stateConfigs.link, link)
  }

  getLink(): string | undefined {
    const self = this.getLatest();
    return $getState(self, this.config().stateConfigs.link)
  }
}

Lexicalの他の実装を参考にヘルパー関数も用意します。

export const $createImageNode = (link?: string) => {
  const node = $create(ImageNode)

  if (link !== undefined) {
    node.setLink(link)
  }

  return node
}

次に、ImageNodeを挿入するトリガーとなるCommandを定義します。

export const INSERT_IMAGE_COMMAND: LexicalCommand<void> = createCommand('INSERT_IMAGE_COMMAND')

イベントハンドラーを管理するPluginも作りましょう。

import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR } from 'lexical'
import { $insertNodeToNearestRoot } from '@lexical/utils'

import { INSERT_IMAGE_COMMAND } from '@/components/nodes/ImageNode'


export const ImagePlugin = () => {
  const [editor] = useLexicalComposerContext()

  const onInsert = () => {
    // アンカーが置かれたところを探します。
    const selection = $getSelection()
    if (!$isRangeSelection(selection)) return false

    const imageNode = $createImageNode()

    // 連続して埋め込む際に、間にキャレントが置ける空の段落を前後につけるためです。
    $insertNodeToNearestRoot(imageNode)

    decoratorBlockNode.selectNext()

    return true
  }

  useEffect(() => {
    if (!editor.hasNodes([ImageNode])) {
      throw new Error('ImageNodeをinitialConfigに登録してください。')
    }

    return editor.registerCommand(INSERT_IMAGE_COMMAND, onInsert, COMMAND_PRIORITY_EDITOR)
  }, [editor, onInsert])

  // ここで必要に応じて、画像を選択するためのモーダルを返すこともできます。
  return null
}

あとはこれらをエディタに「登録」すれば完了です!

const Editor = () => {
  const [editor] = useLexicalComposerContext()

  const initialConfig = {
    namespace: 'SimpleEditor',
    onError: (error: Error) => {
      // do something 
    },
    nodes: [ListNode, ListItemNode, ImageNode],  // <-ここにImageNodeを追加します。
  }

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <OrderedListButton />
      <UnorderedListButton />
      {/* ↓にImageButtonを追加します。 */}
      <ImageButton />

      <RichTextPlugin contentEditable={<LexicalContentEditable />} ErrorBoundary={LexicalErrorBoundary} />

      <ListPlugin />
      {/* ↓にImagePluginを追加します。 */}
      <ImagePlugin />
    </LexicalComposer>
  )
}

スタイルを調整したい

ImageNodeだとDecoratorBlockNodeを使って見た目をこちらで作って表示させることができました。

さて、Lexicalがすでに作っておいたListNodeとかはどうしたらいいでしょうか?または、ElementNodeのように decorate メソッドがない場合はどうしたらいいでしょうか?ListNodeを継承した別のカスタムノードを作ることも可能ですが、それより簡単な方法があります!

LexicalComposerに渡していた initialConfig を覚えていますか?そこに theme 設定を渡します。Lexicalが提供するParagraphNodeのスタイルを指定して、テキスト段落の見た目を調整してみましょう。

.paragraph {
  margin: 16px 0;
  text-indent: 9px;
  color: #222;
  font-size: 18px;
}
import styles from '@/styles/paragraph.module.css'

const initialConfig = {
  namespace: 'SimpleEditor',
  onError: (error: Error) => {
    // do something 
  },
  theme: {
    paragraph: styles.paragraph
  },
}

FYI

ここではCSSファイルを定義する方法をご紹介しましたが、vanilla-extractPanda CSS などの CSS in JS のライブラリを使うことも可能です。

上記のような設定だと以下のような変化が現れます。スタイルが適用できる対象についてはEditorThemeClassesをご確認ください。

デフォルト

スタイル適用後

DOM出力をカスタマイズしたい

EditorStateの各NodeをHTMLなどに出力する際に、DOM出力の結果をカスタマイズすることもできます。デフォルトでは以下のように単なる pa タグになります。

<p class="paragraph_paragraphStyle__13ch43n0">NodeやPluginが何かについて説明する前にまず、Lexicalの構造について軽くご説明します。エディタは
  <a href="https://lexical.dev/docs/concepts/editor-state" rel="noreferrer" class="link_linkStyle__1veeib70">EditorState</a>というもので状態を管理します。なので、何かを書き込む際にはEditorStateを `update` し、書いた記事を読み込む際にはEditorStateを `read` します。
</p>
<p class="paragraph_paragraphStyle__13ch43n0">
  <br>
</p>

LinkNodeのDOM出力をカスタマイズして、a タグの代わりに custom-tag タグにしたいとしましょう。

<a href="https://lexical.dev/docs/concepts/editor-state" rel="noreferrer" class="link_linkStyle__1veeib70">

<custom-tag data-type="link" data-link="https://lexical.dev/docs/concepts/editor-state">

今度は initialConfightml に設定を渡します。HTMLConfigを作って渡す必要があり、exportimportというキーを持ちます。exportの設定は、NodeからDOMやJSONにする際に参照され、importの設定はその逆パターンで参照されます。

以下のような設定をすることで、それが実現できます。

import { $createLinkNode, $isLinkNode, LinkNode } from '@lexical/link'
import { type DOMConversionMap, type DOMExportOutputMap, type LexicalEditor, type LexicalNode } from 'lexical'
import { match } from 'ts-pattern'

export const exportLink = (): DOMExportOutputMap => {
  return new Map([
    [
      LinkNode,
      (_editor: LexicalEditor, node: LexicalNode) => {
        if (!$isLinkNode(node)) return { element: null }

        const element = document.createElement('custom-tag')
        element.setAttribute('data-link', node.getURL())
        element.setAttribute('data-type', 'link')

        return { element }
      },
    ],
  ])
}

export const importLink = (): DOMConversionMap => {
  return {
    'custom-tag': (node: HTMLElement) => {
      const type = node.getAttribute('data-type')
      return match(type)
        .with('link', () => {
          const link = node.getAttribute('data-link')
          if (!link) return null

          return {
            conversion: () => ({
              node: $createLinkNode(link),
            }),
            priority: 2,
          }
        })
        .otherwise(() => null)
    },
  }
}
const htmlConfig: HTMLConfig = {
  export: new Map([...exportLink()]),
  import {
    ...importLink()
  }
}

const initialConfig = {
  namespace: 'SimpleEditor',
  onError: (error: Error) => {
    // do something 
  },
  theme: {
    paragraph: styles.paragraph
  },
  html: htmlConfig,
}

上記の a タグが custom-tag タグになりました!

<p class="paragraph_paragraphStyle__13ch43n0">NodeやPluginが何かについて説明する前にまず、Lexicalの構造について軽くご説明します。エディタは
  <custom-tag data-type="link" data-link="https://lexical.dev/docs/concepts/editor-state">EditorState</custom-tag>というもので状態を管理します。なので、何かを書き込む際にはEditorStateを `update` し、書いた記事を読み込む際にはEditorStateを `read` します。
</p>
<p class="paragraph_paragraphStyle__13ch43n0">
  <br>
</p>

この設定をうまく活用すれば、外部のページからコピーしたスタイル付きテキストを、エディタ上のNodeに変換してスタイルが失われないように(というか我々が作ったエディタ上のスタイルになるように)貼り付けさせるすることもできるようになります。

おわりに

前述したようにLexicalはPlaygroundで機能を一通り触ることができ、それだと「もしかしたらほとんどの機能が提供されてるのでは?」と思えるのですが、予想とは裏腹にPlayground専用の機能としてしか存在しないものが多々あり、そこにハマっていた気がします。

たとえば、見出しは、どのエディタにもある機能でPlaygroundにもあります。なので、当たり前に「HeadingNodeやHeadingPlugin、INSERT_HEAD_COMMANDがあるだろうからそれを使おう!」と思ったわけです。しかし、意外とHeadingNodeしか提供されておらず、HeadingPluginはPlayground用に書かれたコードを見て真似する必要がありました。

そのほかにも、スタイル指定でハマるポイントがありました。弊社の、社内エディタを管理するリポジトリの制約で、CSSファイルを用いずChakra UIでUIコンポーネントを作らないといけません。しかし、Lexicalの提供するNode群だと別途スタイルを指定する必要があり、CSSファイルのようなものが必要でした。そこで導入したのがvanilla-extractだったのですが、新たにCSS in JSライブラリを検証するなど、当初見積もっていなかった工数がかかったりしました。

色々と試行錯誤しながらこの数ヶ月間、このプロジェクトで頑張ってきていますが、まだReactもLexicalもわからないものが多く、来年もたくさん学ぶ年になるだろうなと思います。

今回はエディタといえばすぐ思い浮かぶような機能にだけ触れました。プロジェクトのスコープとしては、コメント機能やバージョン管理、コラボ機能などもっと複雑な機能の実装が残っているので、次回はLexicalを使ったより工夫の必要な機能の実現について語れるように頑張ります!

Page top