Lexicalを使ってリッチエディタを実現する(画像編)
はじめに
Lexicalを使ってリッチエディタを実現する(Link編)に引き続き、今回はMetaが開発するLexicalというライブラリを使って、リッチテキストエディタ上に画像を挿入する機能を実装したいと思います。具体的には、次のような内容を実現したいと思います。
- ツールバーからエディタ上に画像を挿入できる
- 画像をクリップボードから、エディタ上に貼り付けることができる
なお、これらの機能はLexical.playgroundの実装を参考にしています。
完成形は以下のデモページで確認が可能です。
実装方針
今回画像の挿入機能を実装するにあたり、下図の流れをとることとします。
- ツールバーもしくはクリップボードからの画像挿入を検知し、Lexicalのカスタムコマンドをdispatchする
- コマンドのListener側でコマンドを受け付け、今回作成するカスタムノード=ImageNodeによって画像をレンダリングする
これにあたって、実装は以下のような流れで行います。
- 事前準備編
1.1. Editorコンポーネントの実装
1.2. カスタムノードの実装
1.3. カスタムコマンドの実装 - 機能実装編
2.1. ツールバーから画像を挿入
2.2. 画像をクリップボードから貼り付けることができる
1. 事前準備編
事前準備編では、Lexicalの基礎となる部分の実装に加え、コマンドの受け渡しの仕組み、ImageNodeの作成を行います。
1.1. Editorコンポーネントの実装
今回も前回同様、React + Next.js + Tailwind CSSを使って開発を行います(これらの説明については割愛します)。Lexicalを公式に従いインストールした後、以下のようにLexicalComposerを含む、ベースとなるEditorコンポーネントを準備します。ここでは、Lexicalにて提供されているRichTextPluginとOnChangePlugin、HistoryPluginを最小限のプラグインとして利用します。
import { FC, ReactNode, useRef } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import type { Klass, LexicalNode } from "lexical";
import type { EditorState } from "lexical";
type Props = {
children: ReactNode,
toolbarItems?: ReactNode[],
customNodes?: Klass<LexicalNode>[]
}
const Editor: FC<Props> = ({
children,
toolbarItems = [],
customNodes = []
}) => {
const editorStateRef = useRef<EditorState>();
return (
<LexicalComposer initialConfig={{
namespace: 'example',
onError: () => console.error,
nodes: [...customNodes]
}}>
<div className='flex min-h-[512px] flex-col gap-2'>
<div className='flex items-center gap-1 border-b border-blue-100 pb-2'>
{/* ツールバー */}
{toolbarItems}
</div >
<div className='relative'>
<RichTextPlugin
ErrorBoundary={LexicalErrorBoundary}
contentEditable={<div>
<ContentEditable
className='outline-none' />
</div>}
placeholder={(
<div className='pointer-events-none absolute top-0 left-0 select-none text-gray-400'>
文字を入力してください
</div>)}
/>
</div>
</div>
<HistoryPlugin />
<OnChangePlugin onChange={(editorState) => {
editorStateRef.current = editorState;
}} />
<>
{/* 以下にカスタムプラグイン */}
{children}
</>
</LexicalComposer>
);
};
export default Editor;
このクラスをNext.jsのpage側では以下のように、カスタムノードやカスタムプラグインを差し込む形で使います。
import { FC } from 'react';
import LexicalEditor from '@/features/editor';
const LinkExample: FC = () => {
return (
<LexicalEditor
customNodes={[
// TODO : 画像挿入に必要なカスタムノードを指定する
]}
toolbarItems={[
// TODO : 画像挿入にToolbarアイテムを指定する
]}>
// TODO : 画像挿入のプラグインを指定する
</LexicalEditor>
);
};
export default LinkExample;
1.2. カスタムノードの実装
次に、エディタ上に画像を表示するためのカスタムノードを作成します。
今回、画像をReactコンポーネントで表示させたいため、これを実現できるカスタムノードであるDecoratorNodeを継承して作成することにします。少し長いですが、実装は以下のような形となります。
import type {
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
import { $applyNodeReplacement, DecoratorNode } from 'lexical';
import ImagePreview from './ImagePreview';
export interface ImagePayload {
altText: string;
height?: number;
key?: NodeKey;
src: string;
width?: number;
}
export type SerializedImageNode = Spread<
{
altText: string;
height?: number;
src: string;
width?: number;
type: 'image';
version: 1;
},
SerializedLexicalNode
>;
export class ImageNode extends DecoratorNode<JSX.Element> {
__src: string;
__altText: string;
__width: 'inherit' | number;
__height: 'inherit' | number;
...中略
constructor(
src: string,
altText: string,
width?: 'inherit' | number,
height?: 'inherit' | number,
key?: NodeKey,
) {
super(key);
this.__src = src;
this.__altText = altText;
this.__width = width || 'inherit';
this.__height = height || 'inherit';
}
createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const theme = config.theme;
const className = theme.image;
if (className !== undefined) {
span.className = className;
}
return span;
}
decorate(): JSX.Element {
return (
<ImagePreview
src={this.__src}
height={this.__height}
width={this.__width}
alt={this.__altText}
nodeKey={this.__key}
/>
);
}
}
export function $createImageNode({
altText,
height,
src,
width,
key,
}: ImagePayload): ImageNode {
return $applyNodeReplacement(
new ImageNode(
src,
altText,
width,
height,
key,
),
);
}
キーとなるのは、decorate
メソッドで、このメソッドはReactのノードを返すことができるため、任意のコンポーネントをレンダリングすることが可能となります。実際、この中で呼び出しているImagePreview
によってimgタグがレンダリングされます。ImagePreview
は以下のような実装になっています。
import { NodeKey } from 'lexical';
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents';
import Image from './Image';
import type { FC } from 'react';
type Props = {
alt: string;
height: 'inherit' | number;
src: string;
width: 'inherit' | number;
nodeKey: NodeKey;
}
const ImagePreview: FC<Props> = ({ nodeKey, alt, ...others }) => {
return (
<BlockWithAlignableContents
format={''}
nodeKey={nodeKey}
className={{
base: 'relative',
focus: 'relative outline outline-indigo-300'
}}>
<Image alt={alt} {...others} />
</BlockWithAlignableContents>
);
};
export default ImagePreview;
Image
については、画像を表示するのみコンポーネントのため、ここでは省略しますが、BlockWithAlignableContents
を用いることで、左右キーやマウスクリックで画像を選択し、deleteキーで削除等が可能になっています。
最後に、作成したImageNodeをLexicalにカスタムノードとして登録しておきます。
const ImageExample: NextPageWithLayout = () => {
return (
<LexicalEditor
customNodes={[
+ ImageNode
]}
...
</LexicalEditor>
);
};
これにて、画像をレンダリングするサイドの実装は概ね完了しました。次はカスタムコマンドによる連携部分を実装していきます。
1.3. カスタムコマンドの作成
ここから、先ほど作成したImageNodeをカスタムコマンド経由で作成できるようにします。
lexical
のcreateCommand
メソッドを用いて、以下のようなカスタムコマンドを定義します。
import type { LexicalCommand, NodeKey } from 'lexical';
import { createCommand } from 'lexical';
import { ImagePayload } from '../components/ImagePlugin/ImageNode';
export type InsertImagePayload = Readonly<ImagePayload>
export const INSERT_IMAGE_COMMAND: LexicalCommand<InsertImagePayload> =
createCommand('INSERT_IMAGE_COMMAND');
次に、このコマンドのdispatchを検知し、ImageNode
を作成する側の処理を実装します。
import {
$createParagraphNode, $insertNodes, $isRootOrShadowRoot, COMMAND_PRIORITY_EDITOR
} from 'lexical';
import { useEffect } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils';
import { $createImageNode, ImageNode } from './ImageNode';
import type { FC } from 'react';
import { InsertImagePayload, INSERT_IMAGE_COMMAND } from '../../utils/InsertImageCommand';
export const ImageRegister: FC = () => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([ImageNode])) {
throw new Error('ImageRegister: ImageNode not registered on editor');
}
return mergeRegister(
editor.registerCommand<InsertImagePayload>(
INSERT_IMAGE_COMMAND,
(payload) => {
const imageNode = $createImageNode(payload);
$insertNodes([imageNode]);
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd();
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
);
}, [editor]);
return null;
};
useEffect内のregisterCommandによって、コマンドがdispatchされた時にImageNodeを作成する処理を行っています。このImageRegisterをpage側で取り込むことで、カスタムコマンド発行時に画像を表示することが可能になります。
const ImageExample: NextPageWithLayout = () => {
return (
<LexicalEditor
customNodes={[
ImageNode
]}
toolbarItems={[]}>
+ <ImageRegister />
</LexicalEditor>
);
};
さて、残すはツールバーやクリップボードから画像の貼り付けをハンドルする側=コマンドをdispatchする側の処理のみとなります!
2. 機能実装編
ここからは機能実装編とありますが、カスタムノードなど概ねの必要な処理は前章までに殆ど終わっているため、ここからは、画像を受け取りカスタムコマンドをdispatchする処理が中心の内容となります。
2.1. ツールバーから画像を挿入
まず、ツールバーのコンポーネントを実装します。ツールバーの画像アイコンをクリックしたら、画像選択ダイアログを開き、そこで選択された画像の情報をListener側にdispatchCommandします。コードは次のようなものになります。
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import type { FC } from 'react';
import { INSERT_IMAGE_COMMAND } from '../../utils/InsertImageCommand';
import { uploadImage } from '../../utils/uploadImage';
import ImageIcon from './ImageIcon';
const ImageToolBarItem: FC = () => {
const [editor] = useLexicalComposerContext();
return (
<label className='...' aria-label='image upload'>
<ImageIcon className='...' />
<span className='...'>画像を挿入</span>
<input id="file-upload" type='file' className='hidden' onChange={async (e) => {
const file = e.target.files[0];
const { path } = await uploadImage(file);//画像をアップロード
editor.update(() => {
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
altText: file.name,
src: path
});
});
}} />
</label>
);
};
export default ImageToolBarItem;
INSERT_IMAGE_COMMAND
の受け側については既に1.3章にて実装しているため、これをpage側に以下のような形で含めることで画像の挿入が可能となります。
const ImageExample: NextPageWithLayout = () => {
return (
<LexicalEditor
customNodes={[
ImageNode
]}
toolbarItems={[
+ <ImageToolbarItem key='image' />
]}>
<ImageRegister />
</LexicalEditor>
);
};
2.2. 画像をクリップボードから貼り付ける
クリップボードの画像を扱うために、Lexical(@lexical/rich-text)で用意されているDRAG_DROP_PASTE
コマンドを用います。このコマンドをregisterCommand
に登録しておくと、ドラッグ&ドロップや貼り付け操作を行なった際に、登録したコールバックメソッドが呼び出されることになります。
editor.registerCommand(
DRAG_DROP_PASTE,
(files) => { // ドラッグ&ドロップや貼り付け操作を行なった際、コールされる
...任意の処理
},
COMMAND_PRIORITY_LOW,
);
このコールバックメソッド中で、引数のファイルを受け取って、ツールバーの時と同じくアップロードした画像をINSERT_IMAGE_COMMAND
経由でエディタ上に表示させることが可能となります。
コードは次のような形となります。
import { COMMAND_PRIORITY_LOW } from 'lexical';
import { FC, useEffect } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { DRAG_DROP_PASTE } from '@lexical/rich-text';
import { mediaFileReader } from '@lexical/utils';
import { INSERT_IMAGE_COMMAND } from '../../utils/InsertImageCommand';
import { uploadImage } from '../../utils/uploadImage';
const ACCEPTABLE_IMAGE_TYPES = [
'image/',
'image/heic',
'image/heif',
'image/gif',
'image/webp',
];
const ClipboardImageHandler: FC = () => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
DRAG_DROP_PASTE,
(files) => {
(async () => {
const filesResult = await mediaFileReader(
files,
[ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x),
);
for (const { file } of filesResult) {
const res = await uploadImage(file);
if (!res) {
console.error('upload failed');
continue;
}
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
altText: file.name,
src: res.path,
});
}
})();
return true;
},
COMMAND_PRIORITY_LOW,
);
}, [editor]);
return null;
};
export default ClipboardImageHandler;
このコンポーネントを、page側に含めることで、クリップボードからの貼り付けに対応が可能となります。
const ImageExample: NextPageWithLayout = () => {
return (
<LexicalEditor
customNodes={[
ImageNode
]}
toolbarItems={[
<ImageToolbarItem key='image' />
]}>
<ImageRegister />
+ <ClipboardImageHandler />
</LexicalEditor>
);
};
WEB上の画像を貼り付ける
ここまでの実装では、ローカルファイルをコピーしたものを貼り付け操作により、画像表示することができましたが、WEB上の画像をコピーして貼り付ける操作には対応していません。
任意のWEB上にある画像の貼り付けを行うには、別途ImageNode
に対して改修が必要となります。
具体的には、ImageNodeに以下の追加を行います。
export class ImageNode extends DecoratorNode<JSX.Element> {
__src: string;
__altText: string;
__width: 'inherit' | number;
__height: 'inherit' | number;
...
+ static importDOM(): DOMConversionMap | null {
+ return {
+ img: (node: Node) => ({
+ conversion: convertImageElement,
+ priority: 0,
+ }),
+ };
+ }
...
}
+function convertImageElement(domNode: Node): null | DOMConversionOutput {
+ if (domNode instanceof HTMLImageElement) {
+ const { alt: altText, src } = domNode;
+ const node = $createImageNode({ altText, src });
+ return { node };
+ }
+ return null;
+}
LexicalのRichTextPluginにおいて、貼り付け操作が行われた際に、WEB上の画像(=クリップボードの中身がtext/html)である場合、登録しているカスタムノードのimportDOM
が呼び出されます。この処理の中で、ImageNodeを作成する、といった流れとなります。
以上にて、画像をクリップボードから貼り付ける実装が完了となります🎉
最後に
最後まで読んでいただきありがとうございます。
今回実装したコードは以下となっています。
Discussion
最後のGitHubリンクが404でした
気づいていただきありがとうございます!
こちら、見れるように修正しました🙇