🗒️

Lexicalを使ってリッチエディタを実現する(画像編)

2023/03/05に公開

はじめに

Lexicalを使ってリッチエディタを実現する(Link編)に引き続き、今回はMetaが開発するLexicalというライブラリを使って、リッチテキストエディタ上に画像を挿入する機能を実装したいと思います。具体的には、次のような内容を実現したいと思います。

  • ツールバーからエディタ上に画像を挿入できる
  • 画像をクリップボードから、エディタ上に貼り付けることができる

なお、これらの機能はLexical.playgroundの実装を参考にしています。
完成形は以下のデモページで確認が可能です。

https://lexical-traning.vercel.app/image-example

実装方針

今回画像の挿入機能を実装するにあたり、下図の流れをとることとします。

  1. ツールバーもしくはクリップボードからの画像挿入を検知し、Lexicalのカスタムコマンドをdispatchする
  2. コマンドのListener側でコマンドを受け付け、今回作成するカスタムノード=ImageNodeによって画像をレンダリングする

これにあたって、実装は以下のような流れで行います。

  1. 事前準備編
    1.1. Editorコンポーネントの実装
    1.2. カスタムノードの実装
    1.3. カスタムコマンドの実装
  2. 機能実装編
    2.1. ツールバーから画像を挿入
    2.2. 画像をクリップボードから貼り付けることができる

1. 事前準備編

事前準備編では、Lexicalの基礎となる部分の実装に加え、コマンドの受け渡しの仕組み、ImageNodeの作成を行います。

1.1. Editorコンポーネントの実装

今回も前回同様、React + Next.js + Tailwind CSSを使って開発を行います(これらの説明については割愛します)。Lexicalを公式に従いインストールした後、以下のようにLexicalComposerを含む、ベースとなるEditorコンポーネントを準備します。ここでは、Lexicalにて提供されているRichTextPluginOnChangePluginHistoryPluginを最小限のプラグインとして利用します。

Editor.tsx
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側では以下のように、カスタムノードやカスタムプラグインを差し込む形で使います。

page/image-example.tsx
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を継承して作成することにします。少し長いですが、実装は以下のような形となります。

ImageNode.tsx

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は以下のような実装になっています。

ImagePreview.tsx
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をカスタムコマンド経由で作成できるようにします。

lexicalcreateCommandメソッドを用いて、以下のようなカスタムコマンドを定義します。

InsertImageCommand.ts
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を作成する側の処理を実装します。

ImageRegister.tsx
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します。コードは次のようなものになります。

ImageToolBarItem.tsx
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に登録しておくと、ドラッグ&ドロップや貼り付け操作を行なった際に、登録したコールバックメソッドが呼び出されることになります。

DRAG_DROP_PASTEを使うイメージ
editor.registerCommand(
    DRAG_DROP_PASTE,
    (files) => { // ドラッグ&ドロップや貼り付け操作を行なった際、コールされる
	...任意の処理
    },
    COMMAND_PRIORITY_LOW,
);

このコールバックメソッド中で、引数のファイルを受け取って、ツールバーの時と同じくアップロードした画像をINSERT_IMAGE_COMMAND経由でエディタ上に表示させることが可能となります。

コードは次のような形となります。

ClipboardImageHandler.tsx
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を作成する、といった流れとなります。

以上にて、画像をクリップボードから貼り付ける実装が完了となります🎉

最後に

最後まで読んでいただきありがとうございます。
今回実装したコードは以下となっています。
https://github.com/mktu/lexical-traning

Discussion