[シリーズ連載] Lexicalでリッチテキストエディターを自作する
Lexicalで自作プラグインを作りリッチテキストエディターをカスタマイズする
目次
- - 前回のコード
- - カスタムプラグインの作り方
- - カスタムプラグイン本体
- - カスタムNode
- - カスタムCommand
- - カスタムプラグインを利用するYoutube動画の埋め込み
- - YouTubeNodeの準備
- - YouTubePluginの準備
- - ToolbarPluginへの処理の追加
- - スタイルの調整
- - エディター本体・プレースホルダーのスタイリング
- - 入力内容のスタイリング
- - 外部で入力情報を管理する
- - HTMLでのインポート
- - HTMLでのエクスポート
- - カスタムNodeをHTMLインポート・エクスポートに対応する
- - JSONでのインポート
- - JSONでのエクスポート
- - カスタムNodeをJSONインポート・エクスポートに対応する
- - まとめ
こんにちは、エンジニアチームのさらだです。
前編に続き、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(ElementNode
、TextNode
、DecoratorNode
)を継承して自作の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'
であるかの判定を行なっていました。
importJSON
、exportJSON
はEditorState.toJSON()
でのJSONへの変換や、$generateNodesFromSerializedNodes
でのNodeの取得に利用します。
DecoratorNode
などでは使わない場合もあるため、上記サンプルには記述しておりませんが、もちろんHTMLへの変換に関する関数、例えばcreateDOM
、importDOM
なども用意されています。
またカスタム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
も改行コードを示すので違いますね。
ElementNode
、TextNode
とDecoratorNode
の違いは少しややこしいです。
基本的には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
です。T2
にT1
で指定した型をオーバーライドして結合しています。SerializedYouTubeNode
の例で言うと、version
とtype
を指定値で固定しつつ、新たに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
内で、YouTubeNode
がLexicalEditor
に認識されているかの確認を行います。これは、LexicalComposer
の初期化の際でYouTubeNode
を指定しているかの確認になります。
もう一つは、LexicalEditor.registerCommand()
の部分です。第一引数でkeyとなるCommandを、第二引数でその実処理を、第三引数で、Commandの優先順位を指定しています。
実処理では、$createYouTubNode
でYouTubeNode
を作成し、$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;
忘れずにLexicalComposer
にYouTubeNode
の指定と、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、contentEditable
とplaceholder
はどちらも型は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
でクラス名を指定できます。どのような値を指定できるかは型の詳細を確認してください。
用意したオブジェクトはLexicalComposer
のinitialConfig.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は変換したい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.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したEditorState
をLexicalEditor
に設定します。
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()
でEditorState
をSerializeEditorState型
に変換し、更に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;
}
SerializedYouTubeNode
はYouTubeNode
をSerializeする際の型です。
基本的な型はSerializedDecoratorBlockNode
型から引用し、type
やversion
の固定や追加パラメータの準備などをしております。
importJSON()
はこのSerializedYouTubeNode
から必要な情報を取得し、YouTubeNode
を生成するだけです。exportJSON()
も簡単です。
メンバー変数を利用し、SerializedYouTubeNode
を生成するだけです。
まとめ
これで色々気の利くリッチテキストエディターが作れることと思います。
ドキュメントが少ないのでコードを読み解いていく必要がありますが、
他にも履歴機能やマークダウン機能などやれることが多くカスタマイズのしがいがあります。
リッチテキストエディターを作ろう!と思ったらぜひ一度触ってみて下さい。
- 01 Lexicalでリッチテキストエディターのベースを作る >
- 02 Lexicalで自作プラグインを作りリッチテキストエディターをカスタマイズする