✏️

React Native でリッチエディタどうする?Lexical WebView 実装という選択肢

に公開

こんにちは☄️ Gaudiyでプロダクトエンジニアとしてプロダクト開発に携わっている Sho です。

この記事は、#GauDev Advent Calendar 2025 の 22 日目の記事です🎄

はじめに

僕が携わっているプロジェクトでは Web アプリのリッチエディタに Lexical を採用しています。
画像やリンク表示・区切り線などのカスタムノードも実装しています。


Webアプリのリッチエディタより抜粋

これらを React Native を使った ネイティブアプリ でも同じ編集体験を提供したいという要件が出てきました。

いくつかのライブラリを検討した結果、Web アプリとの体験統一カスタムノードの流用 を重視して、Lexical を WebView で動かすアプローチに挑戦することにしました。

この記事では、検討したライブラリの紹介と、Lexical WebView 実装でカスタムノードを流用して、React Native で利用する方法を紹介します。

検討したライブラリ

採用候補として以下のライブラリを検討しました。

  • react-native-enriched - Software Mansion 製のネイティブ実装。WebView を使わないのでパフォーマンス面で理想形だと感じましたが、まだ弊プロジェクトに対しては機能不足を感じましたし、機能追加を試みるにもネイティブ実装が必要なため実装の重さを感じました。
  • @10play/tentap-editor - TipTap(ProseMirror)ベースの WebView 実装。キーボード操作も組み込まれており、使い勝手も良さそうでしたが、1からカスタム実装を作る必要があります。
  • react-native-pell-rich-editor - 軽く調べた限りは、injectJavascriptを使ってカスタム実装が出来そうですが、カスタムノードのような高度なカスタマイズは難しそうに感じたのと、複雑性が大きくなりそうに感じました。

サンプルリポジトリ:
https://github.com/shoNagai/rich-text-editor-playground

WebView ベースの共通課題

WebView ベースのアプローチについて、Expo のドキュメントにはこう書かれています:

They wrap an existing rich text editor built for web with JavaScript inside a react-native-webview. It works on all platforms (Android, iOS, Web) and can take advantage of popular rich text editors available for the Web platform, but it has a performance and UX penalty.

You will not be able to use native UI components inside the editor.

https://docs.expo.dev/guides/editing-richtext/

Web のエコシステムを活用できるメリットはありつつも、パフォーマンスや UX 面での課題は認識しておく必要がありました。

実際に、初期表示でプレースホルダーの表示にラグを感じるなど、UXを考慮した実装面で気を使う必要がありそうでした。


エディタ表示時にプレースホルダー反映までラグがある

なぜ Lexical WebView 実装を選んだか

最終的に Lexical を WebView で動かす自作実装 を選択しました。

選んだ理由

弊社のプロジェクトでは、以下の点を重視しました:

  1. Web 版との体験統一 - ユーザーが Web でもネイティブでも同じ編集体験を得られる
  2. カスタムノードの流用 - Web 版で実装済みの ImageNode、DividerNode などを流用できる
    • 後述しますが、実際の利用には修正は必要です。
  3. Lexical JSON の互換性 - 同じデータ形式でサーバーに保存できる
    • 一番大きい点かもしれませんが、Web アプリ側でデータをLexical形式のJSON構造体で保存していたため、同じフォーマットが使えるのは、変換処理の実装を避けれる点でも有用でした。

将来的な展望

とは言っても、WebView ベースの課題(初期表示の遅さなど)は認識しています。将来的には react-native-enriched のようなネイティブ実装への移行 を検討したいと考えています。

また、Lexical 自体のネイティブ対応についても GitHub で議論が進んでいます:

https://github.com/facebook/lexical/discussions/2410

もし対応が現実的になれば、その時は移行も簡単なのではと淡い期待を抱いています。

Lexical WebView 実装

では、ここからが本題です。
Lexical WebView の実装と、Web 版のカスタムノードを React Native で流用する実装を紹介します。

プロジェクト構成

src/components/lexical-editor/
├── lexical-editor.tsx      # RN側のコンポーネント
├── use-lexical-editor.ts   # WebViewとの通信フック
├── editor-html.ts          # ビルド済みHTMLを埋め込んだファイル
└── web/                    
    ├── lexical-editor.tsx  # WebView内で動くLexical ⭐
    └── build.mjs           # esbuildでバンドル

WebView 用のビルド

WebView に読み込ませる HTML は事前にバンドルしておく必要があります。

package.json
{
  "scripts": {
    "build:lexical": "node src/components/lexical-editor/web/build.mjs"
  }
}

esbuild などで単一の HTML ファイルにバンドルし、それを React Native 側で WebView に読み込みます。

ImageNode の実装

画像を扱うカスタムノードです。DecoratorNode を継承して実装します。

まず型定義とコマンドを用意します:

import {
  DecoratorNode,
  createCommand,
  $getNodeByKey,
  type LexicalCommand,
  type SerializedLexicalNode,
} from "lexical";

export type ImagePayload = {
  src: string;
  altText?: string;
  width?: number;
  height?: number;
};

export type SerializedImageNode = SerializedLexicalNode & {
  src: string;
  altText: string;
  width?: number;
  height?: number;
};

export const INSERT_IMAGE_COMMAND: LexicalCommand<ImagePayload> =
  createCommand("INSERT_IMAGE_COMMAND");

次に ImageNode クラス本体です。ポイントは importJSON / exportJSON で、これにより Lexical JSON との相互変換が可能になり、Web 版と同じデータを扱えます。

// グローバルにエディタ参照を保持(削除処理で使用)
let globalEditor: LexicalEditor | null = null;

export class ImageNode extends DecoratorNode<null> {
  __src: string;
  __altText: string;
  __width: number | undefined;
  __height: number | undefined;

  static getType(): string {
    return "image";
  }

  static clone(node: ImageNode): ImageNode {
    return new ImageNode(
      node.__src, node.__altText, node.__width, node.__height, node.__key
    );
  }

  constructor(src: string, altText?: string, width?: number, height?: number, key?: string) {
    super(key);
    this.__src = src;
    this.__altText = altText || "";
    this.__width = width;
    this.__height = height;
  }

  // Lexical JSON からノードを復元
  static importJSON(serializedNode: SerializedImageNode): ImageNode {
    return new ImageNode(
      serializedNode.src,
      serializedNode.altText,
      serializedNode.width,
      serializedNode.height,
    );
  }

  // Lexical JSON へシリアライズ
  exportJSON(): SerializedImageNode {
    return {
      type: "image",
      version: 1,
      src: this.__src,
      altText: this.__altText,
      width: this.__width,
      height: this.__height,
    };
  }

  // HTMLからのインポート(コピペ対応)
  static importDOM(): DOMConversionMap | null {
    return {
      img: () => ({
        conversion: (domNode: Node) => {
          const img = domNode as HTMLImageElement;
          return {
            node: new ImageNode(img.src, img.alt, img.width || undefined, img.height || undefined),
          };
        },
        priority: 0,
      }),
    };
  }

  // HTMLへのエクスポート
  exportDOM(): DOMExportOutput {
    const img = document.createElement("img");
    img.src = this.__src;
    img.alt = this.__altText;
    if (this.__width) img.width = this.__width;
    if (this.__height) img.height = this.__height;
    return { element: img };
  }

  // DOM要素の生成(次のセクションで詳しく解説)
  createDOM(): HTMLElement {
    // ...
  }

  updateDOM(): boolean {
    return false;
  }

  decorate(): null {
    return null;
  }
}

削除ボタン付きの createDOM 実装

弊プロジェクトの Web 版エディタでは、画像ノードに以下のような UI を実装しています:

画像ノードの選択UI
画像を選択すると、削除ボタン・編集ボタン・キャプション入力欄が表示されます

この 「画像を選択時に削除ボタンを表示する」 実装を React Native 版でも流用します。

createDOM(): HTMLElement {
  const wrapper = document.createElement("div");
  wrapper.style.position = "relative";
  wrapper.style.display = "inline-block";
  wrapper.style.maxWidth = "100%";

  // 画像要素
  const img = document.createElement("img");
  img.src = this.__src;
  img.alt = this.__altText;
  img.className = "article-image";
  if (this.__width) img.width = this.__width;
  if (this.__height) img.height = this.__height;

  // 削除ボタン
  const deleteButton = document.createElement("button");
  deleteButton.innerHTML = "×";
  deleteButton.className = "image-delete";
  deleteButton.setAttribute("contenteditable", "false");
  deleteButton.setAttribute("aria-label", "画像を削除");

  // クリックで削除
  const nodeKey = this.__key;
  deleteButton.addEventListener("click", (e) => {
    e.preventDefault();
    e.stopPropagation();
    globalEditor?.update(() => {
      $getNodeByKey(nodeKey)?.remove();
    });
  });

  // タッチデバイス対応
  wrapper.addEventListener("touchstart", () => {
    deleteButton.style.opacity = "1";
  });

  wrapper.appendChild(img);
  wrapper.appendChild(deleteButton);
  return wrapper;
}

実装のポイント:

  1. contenteditable="false" - ボタンがエディタのコンテンツとして扱われないようにする
  2. globalEditor.update() - Lexical のノード操作は必ず update() コンテキスト内で行う

コマンドの登録

画像挿入用のコマンドをエディタに登録します:

function registerImagePlugin(editor: LexicalEditor): () => void {
  return editor.registerCommand(
    INSERT_IMAGE_COMMAND,
    (payload: ImagePayload) => {
      const selection = $getSelection();
      if (!$isRangeSelection(selection)) {
        return false;
      }

      const imageNode = new ImageNode(
        payload.src, payload.altText, payload.width, payload.height
      );
      $insertNodes([imageNode]);

      // 画像の後にカーソルを移動
      const paragraphNode = $createParagraphNode();
      imageNode.insertAfter(paragraphNode);
      paragraphNode.select();

      return true;
    },
    COMMAND_PRIORITY_EDITOR,
  );
}

エディタの初期化

カスタムノードとプラグインを登録してエディタを初期化します:

const editor = createEditor({
  namespace: "PlaygroundEditor",
  nodes: [
    HeadingNode,
    ListNode,
    ListItemNode,
    LinkNode,
    DividerNode,  // カスタムノード
    ImageNode,    // カスタムノード
  ],
  onError: (error) => console.error("Lexical error:", error),
});

// グローバル参照を設定(ImageNodeの削除処理で使用)
globalEditor = editor;

// プラグイン登録
registerImagePlugin(editor);
registerDividerPlugin(editor);

WebView との通信

React Native と WebView 内の Lexical を連携させるためのメッセージパッシングです。

// WebView → React Native(コンテンツ変更を通知)
editor.registerUpdateListener(({ editorState }) => {
  editorState.read(() => {
    const json = editorState.toJSON();
    window.ReactNativeWebView?.postMessage(
      JSON.stringify({ type: "contentChange", payload: { state: JSON.stringify(json) } })
    );
  });
});

// React Native → WebView(コマンドを受け取って実行)
window.handleMessage = (data) => {
  const message = JSON.parse(data);
  switch (message.type) {
    case "insertImage":
      editor.dispatchCommand(INSERT_IMAGE_COMMAND, message.payload);
      break;
    case "setContent":
      editor.setEditorState(editor.parseEditorState(message.payload));
      break;
    // ...
  }
};

これらの実装を組み込むことで、以下のようなリッチエディタを提供できます。

詳細な実装はリポジトリを参照してください。
https://github.com/shoNagai/rich-text-editor-playground

まとめ

React Native のリッチエディタについて、弊プロジェクトでの検討過程と Lexical WebView 実装を紹介しました。

今回の選択:

  • Web 版との体験統一、カスタムノード流用を重視して Lexical WebView 実装 を採用
  • Vanilla DOM で実装することでバンドルサイズを抑制

将来の展望:

  • WebView ベースの課題(初期表示の遅さなど)は認識している
  • react-native-enriched などのネイティブ実装へ移行を検討
  • Lexical 自体のネイティブ対応にも期待

React Native でリッチエディタの選定で悩んでいる方の参考になれば幸いです。

参考リンク


#GauDev Advent Calendar 2025 明日の担当は 山口達輝Yamaguchi Tatsuki さんです!

Gaudiy Engineers' Blog

Discussion