まとめこんにちは、エンジニアチームのさらだです。前編に続き、Lexicalについてのご紹介です。前回はLexicalのコンセプトに触れつつ、リスト表記を切り替えるツールバーを用意するところまで行いました。後編ではLexical既存のプラグインだけでは実装できない機能をカスタムプラグインという形で実装したり、見た目やデータの取り扱いなど実際にプロジェクトで利用できるところまでやっていきます。カスタムプラグインの作り方Youtube動画の埋め込み(カスタムプラグインを利用する)スタイルを調整する外部で入力情報を管理する前回のコード前編で完成したコードから作業を進めます。後編から進める方は、こちらのコードを参考にしてください。既存のNodeに手を加えるプラグインや、UIを提供するプラグインなどカスタムプラグインを作ろうとすると色々な種類が考えられます。本記事では、なるべく出来ることが増えるように自作のNodeを追加するプラグインについて解説していきます。自作Nodeを追加するにはいくつか用意するものがあります。カスタムプラグイン本体カスタムNodeカスタムCommand一つずつ見ていきましょう。Componentとして作成します。Componentにすることで、LexicalComposerの子コンポーネントとして指定することで、機能を追加することが出来ます。内容としては、LexicalEditorに変更があった際に、あらかじめ作成したカスタムコマンドをLexicalEditor.registerCommand()で登録しておくだけになります。今回のプラグインはUIを提供しないため、返り値はnullになります。カスタムNode追加したいDOMをNodeとして宣言します。基本的にはLexicalNode、またはLexicalNodeを基底とした既存のNode(ElementNode、TextNode、DecoratorNode)を継承して自作のNodeを作成します。SampleNode;}いくつかオーバーライドすべきメンバー関数があります。上記のサンプルでは共通で最低限必須であろうメンバー関数をピックアップしています。getType()はNodeの型名の取得に利用します。前編ではLexicalNode.getType()が'root'であるかの判定を行なっていました。importJSON、exportJSONはEditorState.toJSON()でのJSONへの変換や、$generateNodesFromSerializedNodesでのNodeの取得に利用します。DecoratorNodeなどでは使わない場合もあるため、上記サンプルには記述しておりませんが、もちろんHTMLへの変換に関する関数、例えばcreateDOM、importDOMなども用意されています。カスタムNodeを作成する関数と型の判定を行う関数も用意しておきます。これらの関数名は$から始まっています。これは、前編でもありました特定のスコープのみで実行できることを示します。(恐らく$をつけなくても問題はありませんが、他の既存プラグインに合わせております。)カスタムCommand上記のNodeを入力値などに合わせて、EditorStateに挿入するコマンドを用意します。createCommand();宣言は上記のようにcreateCommand()で行います。どのように処理するかはLexicalEditor.registerCommand()の第二引数でLexicalEditorにCommandを登録する際に指定します。カスタムプラグインを利用するYoutube動画の埋め込みでは上記のサンプルをもとにYouTubeの動画を埋め込むプラグインを作成し、エディターに実装していきます。YouTubeNodeというNodeを作っていきますが、どのNodeを基底にするべきでしょうか。ここで5つある基本のNodeについてみていきます。RootNodeEditorStateのNodeTreeで根となるNodeLineBreakNode改行コードを示すNodeElementNodeブロック要素や、一部のインライン要素を示しますが、このNode自体にテキストは含まれませんTextNodeテキストを示すNodeです。NodeTreeの葉になります。DecoratorNode特定のビューを示すNodeです。RootNodeは見た目には関係ないので対象外です。LineBreakNodeも改行コードを示すので違いますね。ElementNode、TextNodeとDecoratorNodeの違いは少しややこしいです。基本的にはElementNodeはタグ、TextNodeがテキストのみを表しているので<p>テスト</p>を表現するには"pタグ"を表すElementNodeと"テスト"を表すTextNodeが必要になります。DecoratorNodeは特定のビューを返すということで、少ないパラメータで複雑なDOMを返したい時に利用します。今回はYouTubeの動画IDを指定し、iframeで指定されたYouTubeの動画を描画する機能を作りたいと思います。このケースの場合YouTubeの動画IDを指定するという特定の条件下で、iframeに規定の属性が付与されたものを出力することになります。DecoratorNodeが適していると考えられます。YouTubeNodeの準備まずはYouTube用のカスタムNodeを作っていきます。YouTubeNode;}YouTubeComponentが実際のビューになっており、こちらをdecorator()で返すことで描画を行ないます。BlockWithAlignableContentsはその名の通り、text-alignが指定できるブロック要素です。Propsにはそれぞれ、className指定の基本クラス名baseとフォーカス時のクラス名focusを指定できますformatleft、center、right、justifyからtest-alignを指定できますnodeKeyNodeを一意に示すためのkeyですとあります。上記コードのように、EditorConfigなどから取得することになるかと思います。SerializedYouTubeNodeはNodeをJSON形式で保存するための型です。T2にT1で指定した型をオーバーライドして結合しています。SerializedYouTubeNodeの例で言うと、versionとtypeを指定値で固定しつつ、新たにvideoIdを追加した形になります。typeはそのままYouTubeNodeであることを示すkeyです。versionは恐らく、同じtypeで構造などを変更した場合に古いデータと不整合が起きないようにするためのkeyになります。versionは更新してください。次にYouTubePluginを用意します。こちらで一緒にYouTubeNodeを挿入するコマンドも用意します。YouTubePlugin;特筆する点は2つです。一つはuseEffect内で、YouTubeNodeがLexicalEditorに認識されているかの確認を行います。これは、LexicalComposerの初期化の際でYouTubeNodeを指定しているかの確認になります。一つは、LexicalEditor.registerCommand()の部分です。引数でkeyとなるCommandを、第二引数でその実処理を、第三引数で、Commandの優先順位を指定しています。実処理では、$createYouTubNodeでYouTubeNodeを作成し、$insertNodeToNearestRoot()でSelection内で最もRootNodeに近い部分に指定のNodeを挿入しています。優先順位は同じコマンドが別のプラグインなどで登録されていた時にどちらを優先的に実行するかを表します。数値が高い方が優先され、指定可能な数値は定数で用意されています。(COMMAND_PRIORITY_EDITORは最低値の0です。最後にToolbarPluginに埋め込み用の処理を追加して完了です。本記事では重要ではないので、IDを入力してボタンを押したら埋め込まれるようにします。YouTubeの動画IDをuseStateで管理し、クリックされたらその動画IDでINSERT_YOUTUBE_COMMANDをdispatchするだけです。Editor;忘れずにLexicalComposerにYouTubeNodeの指定と、YouTubePluginの指定をしましょう。スタイルの調整ここまでは機能の実装を行なってきました。今の見た目ではとても使いやすいとは言えないです。そこでcssでスタイリングを行なっていきます。エディター本体・プレースホルダーのスタイリング出力されているエディターのDOMを見てみます。エディターの入力内容</div><p>入力してください</p>このようなdivがあると思います。これがエディタの入力部分本体になります。下にプレースホルダーを表す部分があります。この2点に関しては簡単で、RichTextPluginのProps、contentEditableとplaceholderはどちらも型はJSX.Elementです。JSでシリアライズされたstyleを付与することも出来ます。}/>入力内容のスタイリング入力内容に関しては、エディター内で適用したいスタイルと、別のアプリケーションで入力内容を描画した際に適用したいスタイルの2つがあると思います。なると、どのような環境でも自由にスタイリングできるよう各Nodeが描画するDOMにクラスを付与されているのが良いと思います。(非HTML環境で描画するケースでは、JSONとしてデータを保存しビュー側の描画はSerializedLexicalNode.typeで判断することになります。)Lexicalでは各Nodeにクラスを付与するThemingという機能があります。</LexicalComposer>)クラスを当てたいNode、条件をkeyにしたEditorThemeClassesでクラス名を指定できます。どのような値を指定できるかは型の詳細を確認してください。用意したオブジェクトはLexicalComposerのinitialConfig.themeに指定します。カスタムNodeに対して、Themeからクラスを付与したい場合があると思います。その場合はカスタムNode内のcreateDOM()やdecorator()で取得できるEditorConfig.themeから取得します。anyを持っているので独自のパラメータを用意することが出来ます。外部で入力情報を管理するここまでの実装でリッチテキストエディター自体は完成しました。実際に利用するとなると、入力した内容をDBに保存するなど外部で入力情報を管理する必要が出てきます。DBで入力内容を保存する場合はどのように保存するべきでしょうか。条件はあると思いますが、HTMLで保持し、描画側でそのまま描画できるようにするJSONで保持し、非HTML環境でも扱えるようにするの2点をご紹介すれば多くの場合困らないと思います。HTMLでのインポートHTMLでのインポートには、$generateHtmlFromNodes()を利用します。例にならってカスタムプラグインとして用意していきます。ImportPlugin;defaultContentAsHTMLがインポートしたいHTML形式のstringです。ブラウザ上でデフォルトで使えるDOMParserクラスでDocument型に変換します。$generateNodesFromDOM()は第一引数にLexicalEditorを第二引数にDocumentを指定し、LexicalNode[]に変換して値を返します。$getRoot().select()でRootNodeを選択し、$insertNodeで先ほど生成したLexicalNodeを挿入します。(挿入なので、このコードが複数回実行されると重複してしまうことに留意してください。Editor;エディターでImportPluginを呼び出し、PropsでインポートしたいHTMLを指定できるようにすれば完成です。これで外部からHTMLを指定し、初期化することができました。HTMLでのエクスポートHTMLでのエクスポートは$generateHtmlFromNodes()を利用します。カスタムプラグインで実装していきます。ExportPlugin;実装方針として、LexicalEditor.registerUpdateListener()でEditorStateが更新される度にLexlicalNode[]をHTMLに変換し、コールバック関数に渡すようにします。$generateHtmlFromNodes()でLexicalEditorからコンテンツをHTML形式のstringで取得します。コールバック関数のexportAsHTMLはそのstringを引数に取り、その関数内で常に取得できる状態になります。Editor;エディターでExportPluginを呼び出し、コールバック関数をPropsに渡してあげれば完成です。取得したHTML形式のstringはStateで管理したり、コールバック関数自体を外部から指定してあげることで、入力内容を保存する関数で利用できるようになります。カスタムNodeをHTMLインポート・エクスポートに対応するカスタムNodeを利用する場合は、上記の$generateNodesFromDOM()や$generateHtmlFromNodes()で変換ができるように関数を用意します。importDOM()とexportDOM()の2つの関数を用意しますが、それぞれの入力と出力が相互変換可能になっている必要があります。}};importDOM()は少し複雑です。返り値DOMConversionMapのkeyは変換したいHTMLElementのnodeNameを指定します。上記の例では、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;LexicalEditor.parseEditorState()でJSON形式でSerializeされたEditorStateをDeserializeします。LexicalEditor.setEditorState()で、DeserializeしたEditorStateをLexicalEditorに設定します。JSONでのエクスポートJSONでのエクスポートはEditorState.toJSON()を利用します。ExportPluginもJSONに対応できるように変更してきましょう。ExportPlugin;EditorState.toJSON()でEditorStateをSerializeEditorState型に変換し、更にJSON.stringify()でstringに変換することでSerializeしていきます。カスタムNodeをJSONインポート・エクスポートに対応するもちろんJSONの場合もカスタムNodeを使う場合は対応が必要です。カスタムNodeでも少し紹介しましたが、importJSON()とexportJSON()を用意します。YouTubeNode;}SerializedYouTubeNodeはYouTubeNodeをSerializeする際の型です。基本的な型はSerializedDecoratorBlockNode型から引用し、typeやversionの固定や追加パラメータの準備などをしております。importJSON()はこのSerializedYouTubeNodeから必要な情報を取得し、YouTubeNodeを生成するだけです。exportJSON()も簡単です。メンバー変数を利用し、SerializedYouTubeNodeを生成するだけです。まとめこれで色々気の利くリッチテキストエディターが作れることと思います。他にも履歴機能やマークダウン機能などやれることが多くカスタマイズのしがいがあります。リッチテキストエディターを作ろう!と思ったらぜひ一度触ってみて下さい。ExtensionカテゴリーHTMLJavascriptNode.jsNext.jsReactTypeScriptパフォーマンスShopifyNewRelicDockerCI/CDGitStrapiJestテストDatabaseAWSTechQLab(テックラボ)とは?株式会社キューのWeb技術者によるWeb技術・ノウハウを発信するブログです。