2022.11.21[Mon]
[シリーズ連載] Lexicalでリッチテキストエディターを自作する

Lexicalで自作プラグインを作りリッチテキストエディターをカスタマイズする

  • React
  • Javascript

目次

こんにちは、エンジニアチームのさらだです。

前編に続き、Lexicalについてのご紹介です。
前回はLexicalのコンセプトに触れつつ、リスト表記を切り替えるツールバーを用意するところまで行いました。

後編ではLexical既存のプラグインだけでは実装できない機能をカスタムプラグインという形で実装したり、見た目やデータの取り扱いなど実際にプロジェクトで利用できるところまでやっていきます。

  • カスタムプラグインの作り方
  • Youtube動画の埋め込み(カスタムプラグインを利用する)
  • スタイルを調整する
  • 外部で入力情報を管理する

前回のコード

前編で完成したコードから作業を進めます。
後編から進める方は、こちらのコードを参考にしてください。

// Editor.tsx
import React, { FC } from 'react';
import { EditorState } from 'lexical';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin';
import { ListNode, ListItemNode } from '@lexical/list';
import ToolbarPlugin from './ToolbarPlugin';
import { ToolProvider } from '../context/ToolContext';

export const Editor: FC = () => {

  const onChange = (editorState: EditorState) => {
    console.log(editorState);
  };
  
  const onError = (e: Error) => {
    console.log(e)
  };

  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;
// ToolContext.ts
import React, { createContext, Dispatch, SetStateAction, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $getSelection, $isRangeSelection, $isRootOrShadowRoot, EditorState, LexicalNode } from 'lexical';
import { $findMatchingParent, $getNearestNodeOfType } from '@lexical/utils';
import {
  ListNode,
  $isListNode,
  INSERT_CHECK_LIST_COMMAND,
  INSERT_ORDERED_LIST_COMMAND,
  INSERT_UNORDERED_LIST_COMMAND,
  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];

type Context = {
  isUnOrderList: boolean;
  isOrderList: boolean;
  isCheckList: boolean;
  toggleUnOrderList: () => void;
  toggleOrderList: () => void;
  toggleCheckList: () => void;
};

const ToolContext = createContext<Context>(
  {
    isUnOrderList: false,
    isOrderList: false,
    isCheckList: false,
    toggleUnOrderList: () => {},
    toggleOrderList: () => {},
    toggleCheckList: () => {},
  }
);

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(() => {
    console.log(editor);
  }, [editor]);

  useEffect(() => {
    editor.registerUpdateListener(({ editorState }) => {
      editorState.read(() => {
        updateToolCondition();
      });
    });
  }, [editor, updateToolCondition]);

  const toggleUnOrderList = useCallback(() => {
    if(blockType !== BLOCK_TYPES.UL) {
      editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
      return;
    }
    editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
  }, [editor, blockType]);

  const toggleOrderList = useCallback(() => {
    if(blockType !== BLOCK_TYPES.OL) {
      editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
      return;
    }
    editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
  }, [editor, blockType]);

  const toggleCheckList = useCallback(() => {
    if(blockType !== BLOCK_TYPES.CL) {
      editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
      return;
    }
    editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
  }, [editor, blockType]);

  const [
    isUnOrderList,
    isOrderList,
    isCheckList, 
  ] = useMemo(() => [
    blockType === BLOCK_TYPES.UL,
    blockType === BLOCK_TYPES.OL,
    blockType === BLOCK_TYPES.CL
  ], [blockType]);

  return {
    isUnOrderList,
    isOrderList,
    isCheckList, 
    toggleUnOrderList,
    toggleOrderList,
    toggleCheckList
  }
};

const getSecondRootNode = (targetNode: LexicalNode) => {
  return targetNode.getKey() === 'root'
  ? targetNode
  : $findMatchingParent(targetNode, (node: LexicalNode) => {
    const parentNode = node.getParent();
    return parentNode !== null && $isRootOrShadowRoot(parentNode);
  });
};

export const ToolProvider: React.FC = ({ children }) => {
  const value = useProvideTool();

  return (
    <ToolContext.Provider value={value}>
      {children}
    </ToolContext.Provider>
  )
};

export const useTool = () => {
  return useContext(ToolContext);
};
// 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 ToolbarPlugin;

カスタムプラグインの作り方

既存のNodeに手を加えるプラグインや、UIを提供するプラグインなど
カスタムプラグインを作ろうとすると色々な種類が考えられます。

本記事では、なるべく出来ることが増えるように自作のNodeを追加するプラグインについて解説していきます。

自作Nodeを追加するにはいくつか用意するものがあります。

  • カスタムプラグイン本体
  • カスタムNode
  • カスタムCommand

一つずつ見ていきましょう。

カスタムプラグイン本体

// SamplePlugin.tsx
export const SamplePlugin: FC = () => {
  const [ editor ] = useLexicalComposerContext();

  useEffect(() => {
    editor.registerCommand(
      INSERT_SAMPLE_COMMAND,
      (payload: string) => {
        // コマンドの中身
      },
      COMMAND_PRIORITY_EDITOR
    )
  }, [editor]);
  
  return null;
};

プラグイン本体はReact Componentとして作成します。
Componentにすることで、LexicalComposerの子コンポーネントとして指定することで、機能を追加することが出来ます。

内容としては、LexicalEditorに変更があった際に、あらかじめ作成したカスタムコマンドをLexicalEditor.registerCommand()で登録しておくだけになります。

今回のプラグインはUIを提供しないため、返り値はnullになります。

カスタムNode

追加したいDOMをNodeとして宣言します。
基本的にはLexicalNode、またはLexicalNodeを基底とした既存のNode(ElementNodeTextNodeDecoratorNode)を継承して自作のNodeを作成します。

// SampleNode.tsx
export class SampleNode extends LexicalNode {

  constructor(format?: ElementFormatType, key?: NodeKey) {
    super(format, key);
  }

  // Nodeの型を取得
  static getType(): string {
    return 'sample'
  }

  // JSONからNodeを作成するためのフォーマッター
  static importJSON(serializedNode: SerializedSampleNode): SampleNode {
    const node = $createSampleNode(serializedNode.videoId);
    node.setFormat(serializedNode.format);
    return node;
  }

  // NodeからJSONを作成するためのフォーマッター
  exportJSON(): SerializedSampleNode {
    return {
      ...super.exportJSON(),
      type: 'youtube',
      version: 1,
      videoId: this.__videoId
    }
  }
};

// カスタムNodeを作成する関数
export const $createSampleNode = (): SampleNode => {
  return new SampleNode();
}

// Nodeの型を判定する関数
export const  $isSampleNode = (
  node: SampleNode | LexicalNode | null | undefined,
): node is SampleNode => {
  return node instanceof SampleNode;
}

いくつかオーバーライドすべきメンバー関数があります。
上記のサンプルでは共通で最低限必須であろうメンバー関数をピックアップしています。

getType()はNodeの型名の取得に利用します。前編ではLexicalNode.getType()'root'であるかの判定を行なっていました。

importJSONexportJSONEditorState.toJSON()でのJSONへの変換や、$generateNodesFromSerializedNodesでのNodeの取得に利用します。

DecoratorNodeなどでは使わない場合もあるため、上記サンプルには記述しておりませんが、もちろんHTMLへの変換に関する関数、例えばcreateDOMimportDOMなども用意されています。

またカスタムNodeを作成する関数と型の判定を行う関数も用意しておきます。
これらの関数名は$から始まっています。
これは、前編でもありました特定のスコープのみで実行できることを示します。
(恐らく$をつけなくても問題はありませんが、他の既存プラグインに合わせております。)

カスタムCommand

上記のNodeを入力値などに合わせて、EditorStateに挿入するコマンドを用意します。

// SamplePlugin.tsx
export const INSERT_SAMPLE_COMMAND: LexicalCommand<string> = createCommand();

宣言は上記のようにcreateCommand()で行います。
どのように処理するかはLexicalEditor.registerCommand()の第二引数でLexicalEditorにCommandを登録する際に指定します。

カスタムプラグインを利用するYoutube動画の埋め込み

では上記のサンプルをもとにYouTubeの動画を埋め込むプラグインを作成し、エディターに実装していきます。

YouTubeNodeというNodeを作っていきますが、どのNodeを基底にするべきでしょうか。ここで5つある基本のNodeについてみていきます。

RootNode
EditorStateのNodeTreeで根となるNode
LineBreakNode
改行コードを示すNode
ElementNode
ブロック要素や、一部のインライン要素を示しますが、このNode自体にテキストは含まれません
TextNode
テキストを示すNodeです。NodeTreeの葉になります。
DecoratorNode
特定のビューを示すNodeです。

RootNodeは見た目には関係ないので対象外です。
LineBreakNodeも改行コードを示すので違いますね。

ElementNodeTextNodeDecoratorNodeの違いは少しややこしいです。
基本的にはElementNodeはタグ、TextNodeがテキストのみを表しているので<p>テスト</p>を表現するには"pタグ"を表すElementNodeと"テスト"を表すTextNodeが必要になります。

DecoratorNodeは特定のビューを返すということで、少ないパラメータで複雑なDOMを返したい時に利用します。

今回はYouTubeの動画IDを指定し、iframeで指定されたYouTubeの動画を描画する機能を作りたいと思います。
このケースの場合YouTubeの動画IDを指定するという特定の条件下で、iframeに規定の属性が付与されたものを出力することになります。
なのでDecoratorNodeが適していると考えられます。

YouTubeNodeの準備

まずはYouTube用のカスタムNodeを作っていきます。

// YouTubeNode.tsx
import React, { ComponentProps, FC } from 'react';
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents';
import { ElementFormatType, NodeKey, Spread, LexicalNode, LexicalEditor, EditorConfig } from 'lexical';
import {
  DecoratorBlockNode,
  SerializedDecoratorBlockNode,
} from '@lexical/react/LexicalDecoratorBlockNode';


export const YouTubeCompoonent: FC<
  { videoId: string; } & 
  Omit<
    ComponentProps<typeof BlockWithAlignableContents>,
    'children'
  >
> = ({
  videoId,
  className,
  format,
  nodeKey
}) => {
  return (
    <BlockWithAlignableContents
      className={className}
      format={format}
      nodeKey={nodeKey}
    >
      <iframe
        width="560px"
        height="315px"
        src={`https://www.youtube.com/embed/${videoId}`}
      />
    </BlockWithAlignableContents>
  );
}

export type SerializedYouTubeNode = Spread<
  {
    videoId: string;
    type: 'youtube';
    version: 1;
  },
  SerializedDecoratorBlockNode
>;

export class YouTubeNode extends DecoratorBlockNode {
  __videoId: string;

  constructor(videoId: string, format?: ElementFormatType, key?: NodeKey) {
    super(format, key);
    this.__videoId = videoId;
  }

  static getType(): string {
    return 'youtube'
  }

  static clone(node: YouTubeNode): YouTubeNode {
    return new YouTubeNode(node.__videoId, node.__format, node.__key);
  }

  static importJSON(serializedNode: SerializedYouTubeNode): YouTubeNode {
    const node = $createYouTubeNode(serializedNode.videoId);
    node.setFormat(serializedNode.format);
    return node;
  }

  exportJSON(): SerializedYouTubeNode {
    return {
      ...super.exportJSON(),
      type: 'youtube',
      version: 1,
      videoId: this.__videoId
    }
  }

  updateDOM(): false {
    return false
  }

  decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
    const embedBlockTheme = config.theme.embedBlock || {};
    const className = {
      base: embedBlockTheme.base || '',
      focus: embedBlockTheme.focus || ''
    };
    return (
      <YouTubeCompoonent
        className={className}
        format={this.__format}
        nodeKey={this.getKey()}
        videoId={this.__videoId}
      />
    )
  }
};

export const $createYouTubeNode = (videoID: string): YouTubeNode => {
  return new YouTubeNode(videoID);
}

export const  $isYouTubeNode = (
  node: YouTubeNode | LexicalNode | null | undefined,
): node is YouTubeNode => {
  return node instanceof YouTubeNode;
}

YouTubeComponentが実際のビューになっており、こちらをdecorator()で返すことで描画を行ないます。BlockWithAlignableContentsはその名の通り、text-alignが指定できるブロック要素です。

Propsにはそれぞれ、

className
指定の基本クラス名baseとフォーカス時のクラス名focusを指定できます
format
left、center、right、justifyからtest-alignを指定できます
nodeKey
Nodeを一意に示すためのkeyです

とあります。上記コードのように、EditorConfigなどから取得することになるかと思います。

SerializedYouTubeNodeはNodeをJSON形式で保存するための型です。
こちらで使われているSpreadは、Omit<T2, keyof T1> & T1です。T2T1で指定した型をオーバーライドして結合しています。
SerializedYouTubeNodeの例で言うと、versiontypeを指定値で固定しつつ、新たにvideoIdを追加した形になります。

typeはそのままYouTubeNodeであることを示すkeyです。
versionは恐らく、同じtypeで構造などを変更した場合に古いデータと不整合が起きないようにするためのkeyになります。
適宜versionは更新してください。

YouTubePluginの準備

次にYouTubePluginを用意します。こちらで一緒にYouTubeNodeを挿入するコマンドも用意します。

// YouTubePlugin.tsx
import React, { FC, useEffect } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from 'lexical';
import { $createYouTubeNode, YouTubeNode } from '../nodes/YoutubeNode';
import { $insertNodeToNearestRoot } from '@lexical/utils';

export const INSERT_YOUTUBE_COMMAND: LexicalCommand<string> = createCommand();

export const YouTubePlugin: FC = ({
}) => {
  const [ editor ] = useLexicalComposerContext();

  useEffect(() => {
    if (!editor.hasNodes([YouTubeNode])) {
      throw new Error('YouTubePlugin: YouTubeNode not registered on editor');
    }
    
    editor.registerCommand(
      INSERT_YOUTUBE_COMMAND,
      (payload: string) => {
        const youTubeNode = $createYouTubeNode(payload);
        $insertNodeToNearestRoot(youTubeNode);

        return true;
      },
      COMMAND_PRIORITY_EDITOR
    )
  }, [editor]);
  
  return null;
};

export default YouTubePlugin;

特筆する点は2つです。
一つはuseEffect内で、YouTubeNodeLexicalEditorに認識されているかの確認を行います。これは、LexicalComposerの初期化の際でYouTubeNodeを指定しているかの確認になります。

もう一つは、LexicalEditor.registerCommand()の部分です。第一引数でkeyとなるCommandを、第二引数でその実処理を、第三引数で、Commandの優先順位を指定しています。
実処理では、$createYouTubNodeYouTubeNodeを作成し、
$insertNodeToNearestRoot()でSelection内で最もRootNodeに近い部分に指定のNodeを挿入しています。

優先順位は同じコマンドが別のプラグインなどで登録されていた時にどちらを優先的に実行するかを表します。数値が高い方が優先され、指定可能な数値は定数で用意されています。(COMMAND_PRIORITY_EDITORは最低値の0です。)

ToolbarPluginへの処理の追加

最後にToolbarPluginに埋め込み用の処理を追加して完了です。

// ToolbarContext.tsx
import React, { createContext, Dispatch, SetStateAction, useCallback, useContext, useEffect, useMemo, useState } from 'react';

// 省略

type Context = {
  // 省略
  setYouTubeId: Dispatch<SetStateAction<string>>;
  embedYouTube: () => void;
};

const ToolContext = createContext<Context>(
  {
    // 省略
    setYouTubeId: () => {},
    embedYouTube: () => {},
  }
);

const useProvideTool = (): Context => {
  // 省略
  const [youTubeId, setYouTubeId] = useState<string>('');

  const embedYouTube = useCallback(() => {
    editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, youTubeId);
  }, [editor, youTubeId]);

  return {
    // 省略
    setYouTubeId,
    embedYouTube,
  }
};

// 省略
// ToolbarPlugin.tsx
import React, { ChangeEvent, FC, useCallback } from 'react';

// 省略

import { INSERT_YOUTUBE_COMMAND } from '../components/YoutubePlugin';

export const ToolbarPlugin: FC = ({
}) => {
  const tool = useTool();

  const onInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    tool.setYouTubeId(e.target.value);
  }, [tool.setYouTubeId]);
  
  return (
    <ul>
      // 省略
      <li>
        <input type="text" onChange={onInputChange} placeholder="YouTube ID" />
        <button onClick={tool.embedYouTube}>埋め込む</button>
      </li>
    </ul>
  );
};

export default ToolbarPlugin;

Playgroundではモーダルで入力フォームが出てくる形でしたが、
本記事では重要ではないので、IDを入力してボタンを押したら埋め込まれるようにします。

YouTubeの動画IDをuseStateで管理し、クリックされたらその動画IDでINSERT_YOUTUBE_COMMANDをdispatchするだけです。

// Editor.tsx
import React, { FC } from 'react';

// 省略
import YouTubePlugin from './YoutubePlugin';
import { YouTubeNode } from '../nodes/YoutubeNode';

export const Editor: FC = () => {

  // 省略

  return (
    <>
      <LexicalComposer initialConfig={{
        namespace: 'MyEditor',
        onError,
        nodes: [
          // 省略
          YouTubeNode
        ]
      }}>
        // 省略
        <YouTubePlugin />
      </LexicalComposer>
    </>
  );
};

export default Editor;

忘れずにLexicalComposerYouTubeNodeの指定と、YouTubePluginの指定をしましょう。

スタイルの調整

ここまでは機能の実装を行なってきました。
しかし今の見た目ではとても使いやすいとは言えないです。
そこでcssでスタイリングを行なっていきます。

エディター本体・プレースホルダーのスタイリング

出力されているエディターのDOMを見てみます。

<div contenteditable="true" spellcheck="true" data-lexical-editor="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" role="textbox" data-dl-input-translation="true">
  // エディターの入力内容
</div>
<p>入力してください</p>

このようなdivがあると思います。これがエディタの入力部分本体になります。そして下にプレースホルダーを表す部分があります。

この2点に関しては簡単で、RichTextPluginのProps、contentEditableplaceholderはどちらも型はJSX.Elementです。そのためクラス名を付与することもCSS in JSでシリアライズされたstyleを付与することも出来ます。

<RichTextPlugin
  contentEditable={
    <div className='editorMain'>
      <ContentEditable />
    </div>
  }
  placeholder={
    <p className="placeholder">
      入力してください
    </p>
  }
/>

入力内容のスタイリング

入力内容に関しては、エディター内で適用したいスタイルと、別のアプリケーションで入力内容を描画した際に適用したいスタイルの2つがあると思います。

そうなると、どのような環境でも自由にスタイリングできるよう各Nodeが描画するDOMにクラスを付与されているのが良いと思います。
(非HTML環境で描画するケースでは、JSONとしてデータを保存しビュー側の描画はSerializedLexicalNode.typeで判断することになります。)

Lexicalでは各Nodeにクラスを付与するThemingという機能があります。

const myTheme: EditorThemeClasses = {
  list: {
    ul: 'myUl',
    listitem: 'myLi',
    listitemChecked: 'myLiChecked',
    listitemUnchecked: 'myLiUnchecked',
    ol: 'myOl',
  }
}

return (
  <LexicalComposer initialConfig={{
    // 省略
    theme={myTheme}
  }}>
    // 省略
  </LexicalComposer>
)

クラスを当てたいNode、条件をkeyにしたEditorThemeClassesでクラス名を指定できます。どのような値を指定できるかは型の詳細を確認してください。
用意したオブジェクトはLexicalComposerinitialConfig.themeに指定します。

カスタムNodeに対して、Themeからクラスを付与したい場合があると思います。その場合はカスタムNode内のcreateDOM()decorator()で取得できるEditorConfig.themeから取得します。

const myTheme: EditorThemeClasses = {
  myCustomNode: 'myUniqueClassName'
}

// LexcicalNode内
createDom(config: EditorConfig) {
  const className = config.theme.myCustomNode;
}

EditorThemeClasses[key: string]: anyを持っているので独自のパラメータを用意することが出来ます。

外部で入力情報を管理する

ここまでの実装でリッチテキストエディター自体は完成しました。
しかし実際に利用するとなると、入力した内容をDBに保存するなど外部で入力情報を管理する必要が出てきます。

DBで入力内容を保存する場合はどのように保存するべきでしょうか。
色々条件はあると思いますが、

  • HTMLで保持し、描画側でそのまま描画できるようにする
  • JSONで保持し、非HTML環境でも扱えるようにする

の2点をご紹介すれば多くの場合困らないと思います。

HTMLでのインポート

HTMLでのインポートには、$generateHtmlFromNodes()を利用します。
例にならってカスタムプラグインとして用意していきます。

// ImportPlugin.tsx
React, { FC, useEffect } from 'react';
import { $generateNodesFromDOM } from '@lexical/html';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, $insertNodes } from 'lexical';

export const ImportPlugin: FC<{
  defaultContentAsHTML?: string;
}> = ({
  defaultContentAsHTML
}) => {
  const [ editor ] = useLexicalComposerContext();

  useEffect(() => {
    if(typeof defaultContentAsHTML === 'undefined') return;
    editor.update(() => {
      const parser = new DOMParser();
      const textHtmlMimeType: DOMParserSupportedType = 'text/html';
      const dom = parser.parseFromString(defaultContentAsHTML, textHtmlMimeType);
      const nodes = $generateNodesFromDOM(editor, dom);
      $getRoot().select();
      $insertNodes(nodes);
    });
  }, [editor, defaultContentAsHTML]);
  
  return null;
};

export default ImportPlugin;

defaultContentAsHTMLがインポートしたいHTML形式のstringです。
ブラウザ上でデフォルトで使えるDOMParserクラスでDocument型に変換します。

$generateNodesFromDOM()は第一引数にLexicalEditorを第二引数にDocumentを指定し、LexicalNode[]に変換して値を返します。

$getRoot().select()RootNodeを選択し、$insertNodeで先ほど生成したLexicalNodeを挿入します。
(挿入なので、このコードが複数回実行されると重複してしまうことに留意してください。)

// Editor.tsx
import React, { FC } from 'react';

// 省略

import ImportPlugin from './ImportPlugin'

export const Editor: FC<{
  defaultContentAsHTML?: string;
}> = ({
  defaultContentAsHTML
}) => {

  // 省略

  return (
    <>
      <LexicalComposer initialConfig={{
        // 省略
      }}>
        // 省略
        <ImportPlugin defaultContentAsHTML={defaultContentAsHTML} />
      </LexicalComposer>
    </>
  );
};

export default Editor;

エディターでImportPluginを呼び出し、PropsでインポートしたいHTMLを指定できるようにすれば完成です。
これで外部からHTMLを指定し、初期化することができました。

HTMLでのエクスポート

HTMLでのエクスポートは$generateHtmlFromNodes()を利用します。
同じくカスタムプラグインで実装していきます。

// ExportPlugin.tsx
import React, { FC, useEffect } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $generateHtmlFromNodes } from '@lexical/html';

export const ExportPlugin: FC<{
  exportAsHTML?: (contentAsHTML: string) => void;
}> = ({
  exportAsHTML
}) => {
  const [ editor ] = useLexicalComposerContext();

  useEffect(() => {
    if(exportAsHTML) {
      editor.registerUpdateListener(() => {
        editor.update(() => {
          const contentAsHTML = $generateHtmlFromNodes(editor);
          exportAsHTML(contentAsHTML);
        });
      });
    }
  }, [editor, exportAsHTML]);
  
  return null;
};

export default ExportPlugin;

実装方針として、LexicalEditor.registerUpdateListener()EditorStateが更新される度にLexlicalNode[]をHTMLに変換し、コールバック関数に渡すようにします。

$generateHtmlFromNodes()LexicalEditorからコンテンツをHTML形式のstringで取得します。
コールバック関数のexportAsHTMLはそのstringを引数に取り、その関数内で常に取得できる状態になります。

// Editor.tsx
import React, { FC } from 'react';

// 省略

import ExportPlugin from './ExportPlugin'

export const Editor: FC<{
  defaultContentAsHTML?: string;
}> = ({
  defaultContentAsHTML
}) => {

  // 省略

  const exportAsHTML = (contenAsHTML: string) => {
    console.log(contenAsHTML);
  };

  return (
    <>
      <LexicalComposer initialConfig={{
        // 省略
      }}>
        // 省略
        <ExportPlugin exportAsHTML={exportAsHTML} />
      </LexicalComposer>
    </>
  );
};

export default Editor;

エディターでExportPluginを呼び出し、コールバック関数をPropsに渡してあげれば完成です。
取得したHTML形式のstringはStateで管理したり、コールバック関数自体を外部から指定してあげることで、入力内容を保存する関数で利用できるようになります。

カスタムNodeをHTMLインポート・エクスポートに対応する

カスタムNodeを利用する場合は、上記の$generateNodesFromDOM()$generateHtmlFromNodes()で変換ができるように関数を用意します。
importDOM()exportDOM()の2つの関数を用意しますが、それぞれの入力と出力が相互変換可能になっている必要があります。

// YouTubeNode.tsx
import { DOMConversionMap, DOMConversionOutput } from 'lexical';

export class YouTubeNode extends DecoratorBlockNode {

  // 省略

  static importDOM(): DOMConversionMap {
    return {
      div: (element: HTMLDivElement) => {
        return {
          conversion: (element: HTMLDivElement) => {
            const videoId = element.getAttribute('data-youtube-id');
            if(!videoId) return null;
            const node = $createYouTubeNode(videoId);
            return {
              node
            }
          },
          priority: 0
        }
      }
    }
  }

  exportDOM(): DOMExportOutput {
    const element: HTMLDivElement = document.createElement('div');
    element.setAttribute('data-youtube-id', this.__videoId);
    return {
      element
    }
  }
};

importDOM()は少し複雑です。
返り値DOMConversionMapのkeyは変換したいHTMLElementnodeNameを指定します。上記の例では、HTMLElement.nodeNameが"div"、つまりdivタグの時に行う変換処理を関数で指定します。

conversionは実際のLexicalNodeへの変換関数です。
引数のHTMLElementから必要な情報、今回ではdata属性で埋め込んだYouTubeの動画IDを取得し、それを元に対応するLexicalNodeを生成して返します。
条件に合わない場合はnullを返すことで、変換を行わないようにします。

priorityは同じnodeNameで変換が指定されていた場合の優先順位になります。

exportDOM()は簡単です。
HTMLElementを用意し、それを含むDOMExportOutputを返します。importDOM()で独自のパラメータを取得できるようにattributeに値を含めておきます。

JSONでのインポート

JSONでのインポートはLexicalEditor.parseEditorState()を利用します。
前述のImportPluginをJSONにも対応できるよう変更していきましょう。

// ImportPlugin.tsx

// 省略

export const ImportPlugin: FC<{
  defaultContentAsHTML?: string;
  defaultContentAsJSON?: string;
}> = ({
  defaultContentAsHTML,
  defaultContentAsJSON
}) => {
  const [ editor ] = useLexicalComposerContext();

  // 省略

  useEffect(() => {
    if(typeof defaultContentAsJSON === 'undefined') return;
    editor.update(() => {
      const editorState = editor.parseEditorState(defaultContentAsJSON);
      editor.setEditorState(editorState);
    });
  }, [editor, defaultContentAsJSON]);
  
  return null;
};

export default ImportPlugin;

LexicalEditor.parseEditorState()でJSON形式でSerializeされたEditorStateをDeserializeします。
LexicalEditor.setEditorState()で、DeserializeしたEditorStateLexicalEditorに設定します。

JSONでのエクスポート

JSONでのエクスポートはEditorState.toJSON()を利用します。
同じくExportPluginもJSONに対応できるように変更してきましょう。

// ExportPlugin.tsx

// 省略

export const ExportPlugin: FC<{
  exportAsHTML?: (contentAsHTML: string) => void;
  exportAsJSON?: (contentAsJSON: string) => void;
}> = ({
  exportAsHTML,
  exportAsJSON
}) => {
  const [ editor ] = useLexicalComposerContext();

  useEffect(() => {
    if(exportAsJSON) {
      editor.registerUpdateListener(({ editorState }) => {
        editorState.read(() => {
          const contentAsJSON = JSON.stringify(editorState.toJSON());
          exportAsJSON(contentAsJSON);
        });
      });
    }
  }, [editor, exportAsJSON]);
  
  return null;
};

export default ExportPlugin;

EditorState.toJSON()EditorStateSerializeEditorState型に変換し、更にJSON.stringify()でstringに変換することでSerializeしていきます。

カスタムNodeをJSONインポート・エクスポートに対応する

もちろんJSONの場合もカスタムNodeを使う場合は対応が必要です。
カスタムNodeでも少し紹介しましたが、importJSON()exportJSON()を用意します。

// YouTubeNode.tsx

// 省略

export type SerializedYouTubeNode = Spread<
  {
    videoId: string;
    type: 'youtube';
    version: 1;
  },
  SerializedDecoratorBlockNode
>;

export class YouTubeNode extends DecoratorBlockNode {

  static importJSON(serializedNode: SerializedYouTubeNode): YouTubeNode {
    const node = $createYouTubeNode(serializedNode.videoId);
    node.setFormat(serializedNode.format);
    return node;
  }

  exportJSON(): SerializedYouTubeNode {
    return {
      ...super.exportJSON(),
      type: 'youtube',
      version: 1,
      videoId: this.__videoId
    }
  }

  updateDOM(): false {
    return false
  }

  decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
    const embedBlockTheme = config.theme.embedBlock || {};
    const className = {
      base: embedBlockTheme.base || '',
      focus: embedBlockTheme.focus || ''
    };
    return (
      <YouTubeCompoonent
        className={className}
        format={this.__format}
        nodeKey={this.getKey()}
        videoId={this.__videoId}
      />
    )
  }
};

export const $createYouTubeNode = (videoID: string): YouTubeNode => {
  return new YouTubeNode(videoID);
}

export const  $isYouTubeNode = (
  node: YouTubeNode | LexicalNode | null | undefined,
): node is YouTubeNode => {
  return node instanceof YouTubeNode;
}

SerializedYouTubeNodeYouTubeNodeをSerializeする際の型です。
基本的な型はSerializedDecoratorBlockNode型から引用し、typeversionの固定や追加パラメータの準備などをしております。

importJSON()はこのSerializedYouTubeNodeから必要な情報を取得し、YouTubeNodeを生成するだけです。
exportJSON()も簡単です。
メンバー変数を利用し、SerializedYouTubeNodeを生成するだけです。

まとめ

これで色々気の利くリッチテキストエディターが作れることと思います。
ドキュメントが少ないのでコードを読み解いていく必要がありますが、
他にも履歴機能やマークダウン機能などやれることが多くカスタマイズのしがいがあります。

リッチテキストエディターを作ろう!と思ったらぜひ一度触ってみて下さい。

Share

  1. 01 Lexicalでリッチテキストエディターのベースを作る >
  2. 02 Lexicalで自作プラグインを作りリッチテキストエディターをカスタマイズする
GCP Cloud Build で普通のVMにrsyncするLexicalでリッチテキストエディターのベースを作る