はじめに
こんにちは。開発2部の富山です。
今回は、リッチテキストエディタフレームワーク「Lexical」の実践的な使い方を紹介していきます。
現在、私はCMS(コンテンツ管理システム)の開発プロジェクトに携わっています。このCMSでは記事を書く機能を提供するために「Lexical」を採用しています。
基本的な使い方は、ドキュメントを見たりPlaygroundを参考にすれば実装できますが、複雑な実装になるとドキュメントが不十分であるため、開発に苦労することがありました。
そこで、Lexicalの実践的な使い方の知見を共有できたらと考えています。
Lexicalの概要等を説明すると長くなってしまうので、省かせていただきます。
以下の記事がLexicalを理解する上で、とても参考になります。
EditorState, Editor, DOMでのLexicalのライフサイクルや、Node,Selectionといったデータ、Commandを使ったデータの更新フローなど、Lexicalの設計が紹介されています。
Lexicalで開発する上で躓いたこと
Lexicalでは様々な機能をプラグインとして提供しているので、それを使えば見出しや太字など基本的なリッチテキストエディタを作成することは、そこまで難しくありません。
そこから独自の機能を実装するのに試行錯誤したので3つの機能実装を紹介して、Lexicalの仕組みやメソッドの使い方などを共有させていただきます。
カスタムNodeの作成
Lexicalで用意されていないNodeの作成方法を紹介します。
Nodeの作成方法は、ドキュメントのCreating custom nodes という項目で紹介されています。
しかし、ドキュメントに従って作成したNodeを使った場合、エディタ上でコピー&ペーストしたり、データベースに保存するためエディタからデータを取得しようとすると、エラーが発生してしまいました。
エディタとして運用するためには、実装にもう少し手を加える必要がありました。
ですので具体的にどのような実装をしたのかを、背景色をつけることができるNode(ParagraphStyleNode
)の作成を例に説明します。
以下が必要な実装ステップになります。
これを基に骨組みを作成しました。
Custom Node
ParagraphStyleNode.tsx
// Custom Node // getType, cloneは必ず定義する必要がある export default class ParagraphStyleNode extends ParagraphNode { static getType() { return 'paragraphStyle' } static clone(node: ParagraphStyleNode): ParagraphStyleNode { return new ParagraphStyleNode(node.__key); } constructor(key?: NodeKey) { return new ParagraphStyleNode(key) } // カスタマイズしていく }
エディタにNodeを挿入するためのCommand
ParagraphStylePlugin.tsx
// ParagraphStyleNodeを適用するためのCommand // ParagraphStyleTypeは後ほど説明します。 export const PARAGRAPH_STYLE_COMMAND = createCommand<ParagraphStyleType>() // エディタにCommandを登録 const ParagraphStylePlugin = () => { const [editor] = useLexicalComposerContext() useEffect(() => { return editor.registerCommand<ParagraphStyleType>( PARAGRAPH_STYLE_COMMAND, () => { // Commandが実行されたときの処理を書く }, COMMAND_PRIORITY_EDITOR, ) }, [editor]) return null } export default ParagraphStylePlugin
Nodeを挿入できるボタンUI
ParagraphStyleTool.tsx
// Nodeの挿入を発火するためのボタンUI // 色を選択できるようにしたいので、今回はドロップダウンボタン const ParagraphStyleTool = () => { const changeStyle = () => { // Commandを実行する } return ( <ToolbarDropdown> <ToolbarDropdown.Button>スタイル</ToolbarDropdown.Button> <ToolbarDropdown.Menu> {Object.entries(ParagraphStyleTypes).map(([key, value]) => ( <ToolbarDropdown.Item onClick={() => changeStyle()} key={key} > {value} </ToolbarDropdown.Item> ))} </ToolbarDropdown.Menu> </ToolbarDropdown> ) } export default ParagraphStyleTool
エディタに作成したNodeを登録
Editor.tsx
const Editor = () => { const initialConfig: InitialConfigType = { namespace: 'Editor', onError, nodes: [ // Editorに作成したNodeの登録 ParagraphStyleNode, // ... 他にリッチテキストエディタで使うNodeが入っている ], theme: editorTheme, } return ( <LexicalComposer initialConfig={initialConfig}> <RichTextPlugin contentEditable={<ContentEditable />} placeholder={<div>Enter some text...</div>} ErrorBoundary={LexicalErrorBoundary} /> {/* プラグインをエディタに読み込ませる */} <ParagraphStylePlugin /> {/* ... */} </LexicalComposer> ) }
用意するファイルは以上になります。
あとは作りたいモノにあわせて肉付けをしていきます。
(最終的な完成コードは、この章の最後に貼ります)
Custom Nodeの作成 (ParagraphStyleNode)
Nodeにはエディタ上での見た目(DOMElement)や動作を実装します。
作成するのは、色付きのボックスといえば伝わるでしょうか。
テキストを囲う枠が作成するParagraphStyleNode
となっており、テキストはTextNode
という別のNodeになっています。
ステップ1 背景色をNodeに持たせる
背景色を持つNodeを作成したいので、
事前に背景色を用意(ParagraphStyleType
)しておきます。
export const ParagraphStyleTypes = { default: 'デフォルト', info: '補足説明', success: '補足説明(サクセス)', warning: '補足説明(警告)', error: '補足説明(注意)', } as const export type ParagraphStyleType = keyof typeof ParagraphStyleTypes
そして、背景色をParagraphStyleNode
が持てるようにすれば良いので、適当なプロパティ__styleType: ParagraphStyleType
を追加します。
export default class ParagraphStyleNode extends ParagraphNode { __styleType: ParagraphStyleType = 'default' static clone(node: ParagraphStyleNode) { return new ParagraphStyleNode(node.__styleType, node.__key) } constructor(styleType?: ParagraphStyleType, key?: NodeKey) { super(key) if (styleType) { this.__styleType = styleType } } // ... }
Lexicalのコードを見るとプロパティの先頭__
をつけるようにしていたので、それに倣います。
また、インスタンス化するときに、__styleType
が設定できるようにしました。
ステップ2 DOMへの出力設定を追加
次にParagraphStyleNode
がDOM上にどのように出力されるか(createDOM
)を実装します。
ParagraphNode
が出力する要素に加えて、スタイルを反映できるようにclass
とdata-paragraph-style
を追加で設定しました。
クラス名はDOM上でParagraphStyleNode
だと判別するために追加し、データ属性data-paragraph-style
は枠の色を判別するために追加しました。
export default class ParagraphStyleNode extends ParagraphNode { createDOM(config: EditorConfig) { const dom = super.createDOM(config) dom.dataset['paragraphStyle'] = this.__styleType addClassNamesToElement(dom, 'paragraphStyle') return dom } }
createDOMの返り値が出力されるElementとなり、ブラウザ上では以下のように出力されます。
<p data-paragraph-style="info" class="paragraphStyle" dir="ltr"> <span data-lexical-text="true">枠で囲まれたParagraphNode</span> </p> <!-- spanはTextNode部分の出力 -->
ステップ3 エディタで操作するために必要な設定を追加
ここまでの実装でエディタへの表示はできるようになりました。
しかし、ParagraphStyleNode
をエディタ上でコピー&ペーストしたり、エディタの内容をデータベースに保存するためeditorState.toJSON()
などと何かしらの操作をしようとするとエラーを吐いてしまいます。
こういった操作に対応するため、importJSON
とexportJSON
を実装する必要があります。
importJSON
は、JSON→Nodeに変換し、exportJSON
は、Node→JSONに変換するメソッドとなっています。
コピーする際は、exportJSON
が実行され、ペーストする際はimportJSON
が実行されることで、コピー&ペーストが可能になります。
また、ParagraphStyleNode
がJSONだとどのような型になるかをtype SerializedParagraphStyleNode
として定義しました。
export default class ParagraphStyleNode extends ParagraphNode { // extends元と型が違うと怒られるのでオーバーロードする static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode static importJSON( serializedNode: SerializedParagraphStyleNode, ): ParagraphStyleNode static importJSON( serializedNode: SerializedParagraphNode | SerializedParagraphStyleNode, ) { if (serializedNode.type === 'paragraph') { return $createParagraphNode() } else { const { styleType } = serializedNode const node = $createParagraphStyleNode(styleType) return node } } exportJSON(): SerializedParagraphStyleNode { return { ...super.exportJSON(), type: NAME, styleType: this.__styleType, version: 1, } } } export type SerializedParagraphStyleNode = Spread< { type: NAME styleType?: ParagraphStyleType version: 1 }, SerializedElementNode > // Lexicalのソースに倣って$をつける export const $createParagraphStyleNode = (styleType?: ParagraphStyleType) => { return $applyNodeReplacement<ParagraphStyleNode>( new ParagraphStyleNode(styleType), ) }
ステップ4 HTMLに変換するための設定を追加
エディタ上ではLexicalのデータとして扱うだけで良いのですが、エディタで書いた記事をユーザーに表示させるためにはHTML変換する必要があります。
HTMLに変換するにはNodeに、importDOM
, exportDOM
を実装する必要があります。
参考:https://lexical.dev/docs/concepts/serialization
export default class ParagraphStyleNode extends ParagraphNode { static importDOM(): DOMConversionMap | null { return { p: (node) => { if (!node.dataset['paragraphStyle']) { return null } return { conversion: convertParagraphStyleElement, priority: 1, } }, } } exportDOM(editor: LexicalEditor): DOMExportOutput { const { element } = super.exportDOM(editor) if (element) { element.classList.add(NAME) element.dataset[NAME] = this.__styleType } return { element, } } } function convertParagraphStyleElement(domNode: Node): DOMConversionOutput { let node = null if ( domNode instanceof HTMLParagraphElement && domNode.classList.contains(NAME) && isParagraphStyleType(domNode.dataset[NAME]) ) { node = $createParagraphStyleNode(domNode.dataset[NAME]) } return { node } } const isParagraphStyleType = (str?: string): str is ParagraphStyleType => { return Object.keys(ParagraphStyleTypes).includes(str || '') }
以上がカスタムNodeを作成する上で必要な実装になります。
エディタにNodeを挿入するためのCommandの作成
次は作成したParagraphStyleNode
をエディタ上に挿入するためのCommandの実装に入ります。
骨組みで作成したParagraphStylePlugin.tsx
に追記していきます。
Commandが実行されたら、現在のカーソルのある部分をParagraphStyleNode
に置換する処理をするように記述していきます。
useEffect(() => { return editor.registerCommand<ParagraphStyleType>( PARAGRAPH_STYLE_COMMAND, (style) => { const selection = $getSelection() if (!$isRangeSelection(selection)) { return false } if (style === 'default') { $setBlocksType_experimental(selection, () => $createParagraphNode()) } else { $setBlocksType_experimental(selection, () => $createParagraphStyleNode(style), ) } return true }, COMMAND_PRIORITY_EDITOR, ) }, [editor])
editor.registerCommand
は、PARAGRAPH_STYLE_COMMAND
が発火したときの処理をエディタに登録するためのメソッドです。
Selectionで置換可能かを判断
まず、現在のカーソル位置を取得して、ParagraphStyleNode
を挿れることができるのかを確認します。
const selection = $getSelection() if (!$isRangeSelection(selection)) { return false }
$getSelection
でカーソル情報を取得。
$isRangeSelection(selection)
でSelectionのタイプがRangeSelection
かを確認して、違うなら処理を終了させています。
Selectionはカーソル位置情報のことで、RangeSelection
、NodeSelection
、GridSelection
の3タイプあります。
テキストにカーソルがあたっていると
RangeSelection
になります。画像やTwitter埋め込み等の
DecoratorNode
を選択していると、NodeSelection
になります。表(
<table>
)を選択していると、GridSelection
になります。
なので、今回のParagraphStyleNode
は画像や表を選択しているときは適用したくないので、RangeSelection
以外は処理を終了させました。
ParagraphStyleNodeに変換
次に、selection部分にParagraphStyleNode
を適用させます。
style === 'default'
の場合は通常のParagraphNode
として動作してほしいので条件分岐させ、$setBlocksType_experimental
でParagraphStyleNode
を適用しています。
if (style === 'default') { $setBlocksType_experimental(selection, () => $createParagraphNode()) } else { $setBlocksType_experimental(selection, () => $createParagraphStyleNode(style), ) }
$setBlocksType_experimental
はLexicalが用意してくれているメソッドで、selectionのあるNodeを第2引数で指定したNodeに変換してくれます。なので、ParagraphStyleNode
を返すようにしています。
$setBlocksType_experimental
となっていますが、最新バージョンではexperimentalが外れていました。
priorityを付ける
適当なpriorityを付けてCommandは完成です。
COMMAND_PRIORITY_EDITOR
Lexicalのコード上でpriotiryは以下のように定義されており、0~4を指定できて、数字の大きい方が優先的に適応されます。
export declare type CommandListenerPriority = 0 | 1 | 2 | 3 | 4; export declare const COMMAND_PRIORITY_EDITOR = 0; export declare const COMMAND_PRIORITY_LOW = 1; export declare const COMMAND_PRIORITY_NORMAL = 2; export declare const COMMAND_PRIORITY_HIGH = 3; export declare const COMMAND_PRIORITY_CRITICAL = 4;
機能が重複したときに優先順位を考慮したpriorityを付ける必要がありますが、作業していて0か1を使っておけば問題ありませんでした。
Nodeを挿入できるボタンUIの作成
Lexicalにはボタンは用意されていないので、アプリケーションのUIに合ったボタンを各自で用意します。
そして、ボタンを押したときにCommandが発火するように、editor.dispatchCommand
で実行させます。
ParagraphStyleTool.tsx
const ParagraphStyleTool = () => { const changeStyle = () => { editor.dispatchCommand(PARAGRAPH_STYLE_COMMAND, style) } // ... }
エディタに作成したNodeを登録
作成したParagraphStyleNodeをエディタで使えるようにするためには、Nodeをエディタに登録する必要があるので、LexicalComposer
のinitialConfig
にNodeを追加します。
また、ParagraphStylePlugin.tsx
に書いたCommandをエディタで使えるようにするためにParagraphStylePlugin
を追加します。
const initialConfig: InitialConfigType = { // ... nodes: [ // Editorに作成したNodeの登録 ParagraphStyleNode, // ... 他にリッチテキストエディタで使うNodeが入っている ], } return ( <LexicalComposer initialConfig={initialConfig}> {/* ... */} {/* プラグインをエディタに読み込ませる */} <ParagraphStylePlugin /> {/* ... */} </LexicalComposer> )
ParagraphStyleNodeの完成コード
ParagraphStyleNode.tsx
export const ParagraphStyleTypes = { default: 'デフォルト', info: '補足説明', success: '補足説明(サクセス)', warning: '補足説明(警告)', error: '補足説明(注意)', } as const export type ParagraphStyleType = keyof typeof ParagraphStyleTypes const NAME = 'paragraphStyle' as const type NAME = typeof NAME export type SerializedParagraphStyleNode = Spread< { type: NAME styleType?: ParagraphStyleType version: 1 }, SerializedElementNode > export default class ParagraphStyleNode extends ParagraphNode { __styleType: ParagraphStyleType = 'default' static getType() { return NAME } static clone(node: ParagraphStyleNode) { return new ParagraphStyleNode(node.__styleType, node.__key) } static importDOM(): DOMConversionMap | null { return { p: (_node) => { return { conversion: convertParagraphStyleElement, priority: 0, } }, } } // extend元と型が違うと怒られるのでオーバーロードする static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode static importJSON( serializedNode: SerializedParagraphStyleNode, ): ParagraphStyleNode static importJSON( serializedNode: SerializedParagraphNode | SerializedParagraphStyleNode, ) { if (serializedNode.type === 'paragraph') { return $createParagraphNode() } else { const { styleType } = serializedNode const node = $createParagraphStyleNode(styleType) return node } } constructor(styleType?: ParagraphStyleType, key?: NodeKey) { super(key) if (styleType) { this.__styleType = styleType } } // DOMに表示されるElementを作成する createDOM(config: EditorConfig) { const dom = super.createDOM(config) dom.dataset[NAME] = this.__styleType addClassNamesToElement(dom, NAME) return dom } exportDOM(editor: LexicalEditor): DOMExportOutput { const { element } = super.exportDOM(editor) if (element) { element.classList.add(NAME) element.dataset[NAME] = this.__styleType } return { element, } } exportJSON(): SerializedParagraphStyleNode { return { ...super.exportJSON(), type: NAME, styleType: this.__styleType, version: 1, } } // 中のテキストをコピーしたときに、カラーボックスのスタイルも引き継ぐ // falseだとコピペしたときに、通常のParagraphNodeになってしまう extractWithChild(): boolean { return true } } export const $createParagraphStyleNode = (styleType?: ParagraphStyleType) => { return $applyNodeReplacement<ParagraphStyleNode>( new ParagraphStyleNode(styleType), ) } function convertParagraphStyleElement(domNode: Node): DOMConversionOutput { let node = null if ( domNode instanceof HTMLParagraphElement && domNode.classList.contains(NAME) && isParagraphStyleType(domNode.dataset[NAME]) ) { node = $createParagraphStyleNode(domNode.dataset[NAME]) } return { node } } const isParagraphStyleType = (str?: string): str is ParagraphStyleType => { return Object.keys(ParagraphStyleTypes).includes(str || '') }
ParagraphStyleTool.tsx
const ParagraphStyleTool = () => { const [editor] = useLexicalComposerContext() const currentBlockType = useCurrentBlockType() const changeStyle = (style: ParagraphStyleType) => { editor.dispatchCommand(PARAGRAPH_STYLE_COMMAND, style) } if (currentBlockType !== 'paragraph') { return null } return ( <ToolbarDropdown> <ToolbarDropdown.Button>スタイル</ToolbarDropdown.Button> <ToolbarDropdown.Menu> {Object.entries(ParagraphStyleTypes).map(([key, value]) => ( <ToolbarDropdown.Item onClick={() => changeStyle(key as ParagraphStyleType)} key={key} > {value} </ToolbarDropdown.Item> ))} </ToolbarDropdown.Menu> </ToolbarDropdown> ) }
ParagraphStylePlugin.tsx
export const PARAGRAPH_STYLE_COMMAND = createCommand<ParagraphStyleType>() const ParagraphStylePlugin = () => { const [editor] = useLexicalComposerContext() useEffect(() => { return editor.registerCommand<ParagraphStyleType>( PARAGRAPH_STYLE_COMMAND, (style) => { const selection = $getSelection() if (!$isRangeSelection(selection)) { return false } if (style === 'default') { $setBlocksType_experimental(selection, () => $createParagraphNode()) } else { $setBlocksType_experimental(selection, () => $createParagraphStyleNode(style), ) } return true }, COMMAND_PRIORITY_EDITOR, ) }, [editor]) return null }
Editor.tsx
const Editor = () => { const initialConfig: InitialConfigType = { namespace: 'Editor', onError, nodes: [ // Editorに作成したNodeの登録 ParagraphStyleNode, // ... 他にリッチテキストエディタで使うNodeが入っている ], theme: editorTheme, } return ( <LexicalComposer initialConfig={initialConfig}> <RichTextPlugin contentEditable={<ContentEditable />} placeholder={<div>Enter some text...</div>} ErrorBoundary={LexicalErrorBoundary} /> {/* プラグインをエディタに読み込ませる */} <ParagraphStylePlugin /> {/* ... */} </LexicalComposer> ) }
今回は省略しますが、class, attributeに合わせてcssを書けば画像のような色付きブロックをエディタで使えるようになります。
自動リンクプラグインAutoLinkの作成
続いては、URLを入力して改行すると自動的にリンクになるプラグインAutoLinkPluginの作成方法を紹介します。
以下は作成したAutoLinkPluginの動作です。
Lexicalでも自動的にリンクにしてくれるLexicalAutoLinkPluginを用意してくれています。
しかし、URLの文字列は強制的にリンクになってしまうし、リンクの解除もできません。
以下はLexicalAutoLinkPluginの動作例です。
このデメリットを解消するために、自動リンクのプラグインを自作しました。
Lexicalが用意している@lexical/linkとLinkPluginが導入されており、リンクを操作するボタン類も揃っている状態で説明を進めます(Playgroundでもリンク機能は実装されているので参考にしてください)。
作成する機能は、「改行したときに、文字列がURLならLinkNodeに変換する」です。
変換条件として以下を定めました。
- カーソルが文末にあること
- 範囲選択をしていないこと
- 既にリンクではないこと
- テキストがURL文字列であること
まずは完成コードをご覧ください。
const AutoLinkPlugin = () => { const [editor] = useLexicalComposerContext() useEffect(() => { editor.registerCommand( KEY_ENTER_COMMAND, () => { const selection = $getSelection() if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return false } const anchorNode = selection.anchor.getNode() if (!$isTextNode(anchorNode)) { return false } const parent = anchorNode.getParentOrThrow() // 行末にカーソルがいるか if ( !parent.getLastChild()?.is(anchorNode) || !$isAtNodeEnd(selection.anchor) ) { return false } // すでにリンクなら無視 if ( $isLinkNode(parent) || parent .getChildren() .some((child) => $isLinkNode(child)) ) { return false } // リンクに変換 const text = node.getTextContent() // URL文字列かバリデーションチェック if (!validateUrl(text)) { return } const linkNode = $createLinkNode(text) linkNode.append(...node.getChildren()) node.append(linkNode) return true }, COMMAND_PRIORITY_LOW, ) }, [editor]) return null }
ひとつずつ解説していきます。
Commandを使って改行を検知
まずは、実行タイミングとして、KEY_ENTER_COMMAND
を使っています。
editor.registerCommand( KEY_ENTER_COMMAND, () => { // ... }, COMMAND_PRIORITY_LOW, )
Lexicalではkeydownイベントで発火するCommandをいくつか用意しています。
KEY_ENTER_COMMAND
に対して処理を書くことで、Enterキーが押されたときに以下の処理を実行できます。
priorityはCOMMAND_PRIORITY_LOW
としているのは、改行される前にこの処理を実行してほしいので、0より大きいpriorityを指定しました。
リンクに変換しない条件
続いて、Enterキーを押されたときの処理部分ですが、最初の20行くらいは、リンクに変換しないパターンを書いています。
- RangeSelectionでない
- 範囲選択していない
- 行末にカーソルがない
- すでにリンクになっている
以上の場合は、処理を終了させています。
selection.isCollapsed()
は、選択範囲の始点と終点が同じ位置にあるかを返します。
selection.anchor
というのは、選択範囲の始点の情報を持つプロパティです。そしてgetNode()
で始点のNodeを取得して、行末にカーソルがないかとか、すでにリンクになっているかの確認をしています。
このanchorという考え方はLexical独自のものではなく、ブラウザ標準のSelectionAPIに似ています。
anchor, collapseといったプロパティの意味合いは、SelectionAPI関連の記事を読むと理解しやすくなるのでおすすめです。
リンクに変換する
そして、中断処理をしたあとに、テキストをリンクに変換する処理を書いています。
const text = node.getTextContent() // URL文字列かバリデーションチェック if (!validateUrl(text)) { return } const linkNode = $createLinkNode(text) linkNode.append(...node.getChildren()) node.append(linkNode)
node.getTextContent()
でNode内のテキストだけを取り出すことができます。
例えば以下のようにHTMLタグが乱立しているNodeだったとしても、文字列の部分だけを取得できます。
<div><b>https://</b><span>sample<strong>.com</strong></span></div> => https://sample.com
URL文字列かを確認しているvalidateUrl
は自分で作成していて、以下のようにしました。
export const validateUrl = (url: string) => { return ( url === 'https://' || z .string() .url() .refine((str) => { try { const url = new URL(str) return /^(https?|tel|mailto):$/.test(url.protocol) } catch { // silent // strがURL APIにとって不正な文字列だとエラー吐くのでtry..catchでエラーを無視する } }) .safeParse(url).success ) }
zodでのバリデーションに加えて、http, tel, mailto
で始まる文字に制限しています。
zodのurl
メソッドでURLチェックできると思いきや、javascript:...
といった文字列も通ってしまいXSS攻撃される危険性があるので、しっかりと対処しました。
バリデーションを通ったら、取り出したテキストを使ってLinkNode
に変換しました。
const linkNode = $createLinkNode(text) linkNode.append(...node.getChildren()) node.append(linkNode)
これが何をしているかはHTMLで見ると理解しやすいかもしれません。
変換前と変換後をHTMLで表現すると以下のようになります。
<!-- 変換前のnode --> <div><b>https://</b><span>sample<strong>.com</strong></span></div> <!-- 変換後のnode --> <div><a href="https://sample.com"><b>https://</b><span>sample<strong>.com</strong></span></a></div>
$createLinkNode
でLinkNodeを作成して、nodeとその子要素(TextNodeなど)の間にLinkNodeを挟むことで、テキストをリンクに変換しています。
以上で、改行した際にURL文字列をリンクに変換するプラグインを作成できました。
LexicalAutoLinkPluginと違って、こちらはただのLinkNodeなのでリンクを解除する機能を実装していれば、リンクにしたくない文字列の場合はリンクの解除も可能です。
HTMLへの変換
LexicalのデータをHTMLに変換する関数(generateHtml
)を紹介します。
エディタ上ではLexicalのデータ(EditorState)を扱うだけで良いのですが、エディタで書いた記事をユーザーに表示させるため、HTMLに変換する必要があります。
LexicalではHTML変換するために、@lexical/html
を提供しています。
その中の$generateHtmlFromNodes
を使えばLexicalEditorからHTMLの出力が可能です。
以下がHTMLに変換するコードです。
const generateHtml = (data: string) => { const editor = createEditor({ nodes: [...allNodes], onError: (error) => { throw error }, }) const editorState = editor.parseEditorState(data) const html = editorState.read(() => $generateHtmlFromNodes(editor)) return html }
いくつか躓いたポイントがあるので説明します。
まず、editorを新たに作成している点です。
なぜeditorを新たに作成しているかというと、どこからでもこのHTML生成関数を呼び出せるようにしたかったからです。
もし、editorを新たに作成せず既存のeditorを持ってくる場合は、const [editor] = useLexicalComposerContext()
といったhooksを使ってeditorを呼び出す方法があります。
しかし、<LexicalComposer>...</LexicalComposer>
で囲った範囲でしか使えなくなってしまいます。
そうなると、エディタが表示されている画面でしか変換処理が行えなくなり、限定的な機能になってしまうので、上記のように新しくeditorを作成する手段を取りました。
エディタ作成で重要なポイントは、利用する全てのNodeをオプションで設定する必要があります。
nodes
オプションに、自作したParagraphStyleNode
やLinkNode
など、他にもリッチテキストエディタに必要なNodeを読み込ませます。
createEditor({ nodes: [...allNodes] })
editorに読み込まれていないNodeが入力データに使われていると、そのNodeが何なのか、どう出力するかが不明でエラーになってしまうので、必ず全てのNodeを読み込ませてください。
$generateHtmlFromNodesは、editor.update
かeditorState.read
、editor.registerCommand
内でしか使えないことも注意してください。
それ以外のところで使おうとするとエラーになってしまいます。
まとめ
Lexicalの実践的な使い方として、カスタムNodeの作成、自動リンクプラグインAutoLinkの作成、HTMLへの変換を紹介させていただきました。
Lexicalではたくさんの機能を提供してくれてはいるのですが、メソッド使い方を理解するにはドキュメントに加えてコードから読み取る必要があり、開発に時間が掛かってしまいました。
たくさんある機能の一部ではありますが、作り方を通して、こんなメソッドあるんだとか・そうやって使うんだとか、少しでもLexicalの理解の役に立てたら幸いです。
最後まで読んでいただきありがとうございました。