🗒️

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

2023/02/19に公開

1. はじめに

Metaが開発するLexicalというライブラリを使って、近頃のリッチエディタに備わっている機能について、実現方法を記事に残しておきたいと思います。Lexicalは機能が非常に多く、かつそれぞれの機能が非常に奥深いため、今回は特にリンクの機能にフォーカスして、次のような機能の実現を目指します。

  • ツールバーのボタンから、選択したテキストをリンクに変換できる
  • URLをテキストエディタに入力(コピペ)するとリンクに自動変換できる
  • URLはリンクカードに変換できる(今回ははてなブログカードを使用)

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

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

2. 事前準備

今回は、React + Next.js + Tailwind CSSを使って開発を行います(これらの説明については割愛します)。Lexicalを公式に従いインストールした後、以下のようにLexicalComposerを含む、ベースとなるEditorコンポーネントを準備します。

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;

このクラスをpage側では以下のように呼び出して使います。

page/link-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;

3. 基本機能

まず、エディタ上でリンクのノード(aタグ)をレンダリングするための準備を行います。
そこで、まず登場するのがLinkNodeです。このクラスは、Lexicalにて提供されているElement Nodeの一つになります。また、LexicalLinkPluginと組み合わせることで、ツールバーなどの任意のコンポーネントから、コマンド(dispatchCommand)経由でLinkNodeを生成することが可能になります。

...
+ import { LinkNode } from '@lexical/link';
+ import { LinkPlugin as LexicalLinkPlugin } from '@lexical/react/LexicalLinkPlugin';

const LinkExample: FC = () => {
    return (
        <LexicalEditor
	    customNodes={[
+		LinkNode // エディタ上にリンクノードを生成するために必要
	    ]}
	    ...
+	    <LexicalLinkPlugin validateUrl={validateUrl} /> //LexicalCommand経由でリンクノードを生成するために必要
	    ...
	</LexicalEditor>
    );
};

LexicalLinkPluginに指定しているvalidateUrlは、与えられた文字列がURLであることをバリデーションするメソッドになります。以下のようなものを用意します(省略は可能)。

util/url.ts
// 後ほど使用するためexport
export const urlRegex = /^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/\S*)?$/; 

export const validateUrl = (text: string) => {
    return urlRegex.test(text);
};

4. ツールバーのボタンからリンクに変換する

ではここからツールバーから、選択した文字列をリンクに変換する機能を実装していきます。具体的には以下のような使い方を想定します。

  1. テキストエディタ上の文字列を選択した状態で、ツールバーのボタンを押す
  2. URLを入力するためのPopoverが表示される。そこに任意のURLを入力する
  3. テキストエディタ上の文字列がリンクに変換される

まずは、ツールバーに表示する挿入ボタンですが、コードの全体像は以下のようなものとなります。
Popoverの実装はHeadless UIを用いています。

LinkToolbarItem.tsx
import { FC, useState } from 'react';

import { FocusTrap, Popover } from '@headlessui/react';
import { TOGGLE_LINK_COMMAND } from '@lexical/link';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';

import { validateUrl } from '../../utils/validateUrl'; // 前章で使ったバリデーションメソッド
import LinkIcon from './LinkIcon'; // 任意のリンクアイコン

const LinkToolbarItem: FC = () => {
    const [url, setUrl] = useState('');
    const [editor] = useLexicalComposerContext();
    return (
        <Popover className="relative">
            <Popover.Button className='...'>
                <LinkIcon className='...' />
                <span>リンクに変換</span>
            </Popover.Button>
            <Popover.Panel className="absolute ...">
                {({ close }) => (
                    <FocusTrap>
                        <div className='flex flex-col gap-1'>
                            <label htmlFor='url' className='text-xs'>URL(https://)</label>
                            <input id='url' onChange={(e) => { setUrl(e.target.value); }}
                                className='...' />
                            <div className='...'>
                                <button onClick={() => {
                                    if (validateUrl(url)) {
                                        editor.dispatchCommand(
                                            TOGGLE_LINK_COMMAND,
                                            url,
                                        );
                                    } else {
                                        console.error('invalid url');
                                    }
                                    close();
                                }} className='...'>挿入</button>
                            </div>
                        </div>
                    </FocusTrap>
                )}
            </Popover.Panel>
        </Popover>
    );
};

export default LinkToolbarItem;

キーとなるポイントはbuttonのonClick内で、editor.dispatchCommandを呼び出している点となります。コマンドが発行されると、LexicalLinkPlugin側でLinkNodeが生成され、レンダリングが行われます。このとき、引数に指定されたurlaタグのhref属性に設定されます。コマンドの受付側処理が気になる方は、LexicalLinkのコードに目を通してみても良いかと思います。
page側で先ほど作成したLinkToolbarItemを以下のように設定します。

 <LexicalEditor
    toolbarItems={[
+	<LinkToolbarItem key='link' /> 
    ]}>
    <LexicalLinkPlugin validateUrl={validateUrl} />
 </LexicalEditor>

これで、第一歩であるリンク変換の挙動が完成となります🎉

あれ、クリックしてもリンクにジャンプしない

リンクノードへの変換は実装できましたが、動作を確認すると、リンクに変換されたテキストをクリックしてもリンク先にジャンプすることができません。
エディタ上で要素を確認しても、正しくaタグがレンダリングされているにもかかわらず・・・。
Lexicalではエディタ上のクリックイベントをキャプチャし、ライブラリ内でハンドリングしています。そのためエディタ上のaタグをクリックしても何も起こらない仕組みになっています。
クリック時にリンク先にジャンプするには、以下のClickableLinkPluginのような実装を行います(以下、Playgroundのソースですが、ほぼそのまま流用することができます)。

https://github.com/facebook/lexical/blob/6e2982075cddc9132c7f093f100b4d151bfac92d/packages/lexical-playground/src/plugins/ClickableLinkPlugin/index.ts

こちらをpage側で、LexicalEditor下に含めてやります。

 <LexicalEditor
    toolbarItems={[
	<LinkToolbarItem key='link' /> 
    ]}>
    <LexicalLinkPlugin validateUrl={validateUrl} />
+   <ClickableLinkPlugin />
 </LexicalEditor>

これにて、エディタ上でリンクをクリックすると、リンク先にもジャンプできるようになります🎉

5. URLをテキストエディタに入力(コピペ)するとリンクに自動変換できる

さて、次はエディタ上に入力したURLが自動的にaタグにレンダリングされるように実装を行います。完成イメージは以下です。

こちらも実装の考え方は、基本と同じです。まず、aタグをレンダリングするためにAutoLinkNodeをカスタムノードに含めます。こちら、実装の詳細は以前の章でも扱ったLinkNodeを継承したものになります(機能的にはほぼLinkNodeと変わりません)。


+ import { LinkNode, AutoLinkNode } from '@lexical/link';

 <LexicalEditor
    customNodes={[
	LinkNode, // エディタ上にリンクノードを生成するために必要
+	AutoLinkNode // エディタ上のURLをリンクノードに変換するために必要
    ]}
    toolbarItems={[
	<LinkToolbarItem key='link' />
    ]}>
    ...
 </LexicalEditor>

また、エディタ上のテキストをAutoLinkNodeに変換するために、LexicalAutoLinkPluginを用意します。

LexicalAutoLinkPlugin.tsx
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin';
import { FC } from 'react';
import { urlRegex } from '../utils/url';

const LexicalAutoLinkPlugin: FC = () => (
    <AutoLinkPlugin matchers={[(text: string) => {
        const match = urlRegex.exec(text);
        if (match === null) {
            return null;
        }
        const fullMatch = match[0];
        return {
            index: match.index,
            length: fullMatch.length,
            text: fullMatch,
            url: fullMatch.startsWith('http') ? fullMatch : `https://${fullMatch}`,
        };
    }]} />
);

export default LexicalAutoLinkPlugin;

これをpage側のカスタムプラグインに含めます。


 <LexicalEditor
    customNodes={[
	LinkNode, // エディタ上にリンクノードを生成するために必要
	AutoLinkNode // エディタ上のURLをリンクノードに変換するために必要
    ]}
    toolbarItems={[
	<LinkToolbarItem key='link' />
    ]}>
    <LexicalLinkPlugin validateUrl={validateUrl} />
    ...
+    <AutolinkPlugin />
 </LexicalEditor>

これにて、エディタ上にURL形式の文字を入力すると、自動的にリンクに変換してくれます🎉

6. エディタに貼り付けたURLをリンクカードに変換する

さて、ここからは難易度が上がります。入力したURLがリンクカードに変換されるという、最近のエディタではお馴染みの機能ですが、ここでは以下のような使い方を想定します。

  1. テキストエディタ上でURLを入力(貼り付け)する
  2. プレビューを表示するかどうかのポップアップメニューが現れる
  3. プレビュー表示を選択すると、URL文字列がリンクカードに変わる

今まではリンクをレンダリングしてくれるノードがLexicalによって提供されていましたが、今回からはノードからこちらで実装する必要があります。カスタムノードの作成方法は公式にも記載されていますが、今回は複雑なパーツを描画するため、DecoratorNodeを拡張してリンクノードを作成することとします。DecoratorNodeではdecorateメソッドにおいてReactコンポーネントを返すことができるため、表示する内容はReactを使って自由に実装することができるようになります。
具体的な実装の手順は、以下のステップとなります。

  1. カスタムノードの実装
  2. カスタムコマンドの実装

6.1 カスタムノードの実装

カスタムノードはDecoratorBlockNodeを継承して作成します。全体像は次のようになります。

LinkPreviewNode.tsx
import type { ElementFormatType, LexicalNode, NodeKey, Spread } from "lexical";
import { $applyNodeReplacement } from 'lexical';

import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode';

import LinkPreview from './LinkPreview';

import type { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode';

type LinkPreviewAttributes = {
    url: string,
    type: 'link-preview',
}

type SerializedLinkPreviewNode = Spread<LinkPreviewAttributes, SerializedDecoratorBlockNode>

export class LinkPreviewNode extends DecoratorBlockNode {
    __url: string;
    static getType(): string {
        return 'link-preview';
    }

    constructor(url: string, format?: ElementFormatType, key?: NodeKey) {
        super(format, key);
        this.__url = url;
    }

    static clone(node: LinkPreviewNode): LinkPreviewNode {
        return new LinkPreviewNode(node.__url);
    }

    getUrl(): string {
        return this.__url;
    }

    createDOM(): HTMLElement {
        return document.createElement('div');
    }

    updateDOM(): false {
        return false;
    }

    decorate(): JSX.Element {
        return <LinkPreview url={this.__url} nodeKey={this.__key} />;
    }

    exportJSON(): SerializedLinkPreviewNode {
        return {
            ...super.exportJSON(),
            url: this.__url,
            type: 'link-preview',
            version: 1
        };
    }

    isInline(): false {
        return false;
    }

    static importJSON(serializedLinkPreviewNode: SerializedLinkPreviewNode): LinkPreviewNode {
        const node = $createLinkPreviewNode(serializedLinkPreviewNode.url);
        node.setFormat(serializedLinkPreviewNode.format);
        return node;
    }

}

export function $createLinkPreviewNode(url: string): LinkPreviewNode {
    return $applyNodeReplacement(new LinkPreviewNode(url));
}

export function $isLinkPreviewNode(node?: LexicalNode): node is LinkPreviewNode {
    return node instanceof LinkPreviewNode;
}

ポイントは、コンストラクタでurlを受け取り、それを使ってdecorateメソッドでLinkPreviewというコンポーネントをレンダリングしている点となります。LinkPreviewはリンクカードを表示させる本体となりますが、カード表示は今回の主目的ではないため、独自に実装せずにはてなブログカードを使用させていただきます🙇‍♂️

LinkPreview.tsx
import { NodeKey } from 'lexical';

import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents';

import type { FC } from 'react';

type Props = {
    url: string,
    nodeKey: NodeKey;
}
const LinkPreview: FC<Props> = ({ url, nodeKey }) => {
    return (
        <BlockWithAlignableContents
            format={''}
            nodeKey={nodeKey}
            className={{
                base: 'relative',
                focus: 'relative outline outline-indigo-300'
            }}>
            <iframe
                // eslint-disable-next-line tailwindcss/no-custom-classname
                className='hatenablogcard'
                style={{
                    width: '100%',
                    height: '155px',
                    maxWidth: '680px'
                }}
                src={`https://hatenablog-parts.com/embed?url=${url}`}
                width="300" height="150">
            </iframe>
        </BlockWithAlignableContents>
    );
};

export default LinkPreview;

iframeBlockWithAlignableContentsというコンポーネントでラップしていますが、こちらを使うことで左右キーやマウスクリックでリンクカードを選択し、deleteキーで削除等が可能になります

最後に忘れがちなのですが、このカスタムノードをLexicalEditorに登録する必要があります。pageの方にLinkNodeなどと同様に、LinkPreviewNodeを追加します。


 <LexicalEditor
    customNodes={[
	LinkNode, // エディタ上にリンクノードを生成するために必要
	AutoLinkNode, // エディタ上のURLをリンクノードに変換するために必要
+	LinkPreviewNode // 今回追加のカスタムノード
    ]}
    toolbarItems={[
	<LinkToolbarItem key='link' />
    ]}>
    <LexicalLinkPlugin validateUrl={validateUrl} />
    ...
    <LinkPreviewDispatcher />
 </LexicalEditor>

6.2 カスタムコマンドの実装

まずはコマンド自体を以下のように定義します。

LinkPreviewCommand.ts
import type { LexicalCommand } from 'lexical';
import { createCommand } from 'lexical';

export const INSERT_LINK_PREVIEW_COMMAND: LexicalCommand<string> = createCommand(
    'INSERT_LINK_PREVIEW_COMMAND',
);

次に、カスタムコマンドを使う側の処理を書いていきます。Lexicalには非常に便利なLexicalAutoEmbedPluginというプラグインが用意されており、こちらを使うことで、以下の挙動を実現することができます。

  • テキストエディタ上でURLを入力(貼り付け)したときに、プレビューを表示するかどうかのポップアップを表示する
    • プレビューメニューは実装が必要(menuRenderFnの引数に指定
  • ユーザーがプレビュー表示する場合に上記作成コマンドをdispatchする
    • dispatch処理も実装が必要(embedConfigsの引数に指定

このクラスを使うことで、コマンド発行側の全体像は以下のような実装となります。

LinkPreviewDispatcher.tsx
import type { FC } from 'react';
import type { EmbedConfig } from '@lexical/react/LexicalAutoEmbedPlugin';
import { AutoEmbedOption, LexicalAutoEmbedPlugin } from '@lexical/react/LexicalAutoEmbedPlugin';
import { LinkPreviewConfig } from './LinkPreveiwConfig';
import { createPortal } from 'react-dom';
import PreviewMenu from './PreviewMenu';


const LinkPreviewDispatcher: FC = () => {
    const getMenuOptions = (
        activeEmbedConfig: EmbedConfig,
        embedFn: () => void,
        dismissFn: () => void,
    ) => {
        return [
            new AutoEmbedOption('プレビューを表示する', {
                onSelect: embedFn,
            }),
            new AutoEmbedOption('閉じる', {
                onSelect: dismissFn,
            })
        ];
    };
    return (
        <LexicalAutoEmbedPlugin<EmbedConfig>
	    // コマンドをdispatchするために必要(後述)
            embedConfigs={[LinkPreviewConfig]}
	    // embedConfigsは複数指定することができ、その場合各embedConfigのparseUrlで、
	    // 最後にnullではない戻り値を返したconfigの`insertNode`がよばれる挙動になっている
            onOpenEmbedModalForConfig={() => { }} // 今回は使用しないが省略不可能なので、空メソッドを指定
            getMenuOptions={getMenuOptions}
	    // ポップアップメニューを表示するメソッド
            menuRenderFn={(anchorElementRef,
                { selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex }) => anchorElementRef.current && createPortal(
                    (<div style={{ marginTop: anchorElementRef.current.clientHeight }}>
                        <PreviewMenu
                            selectedIndex={selectedIndex}
                            options={options}
                            selectOptionAndCleanUp={selectOptionAndCleanUp}
                            setHighlightedIndex={setHighlightedIndex}
                        />
                    </div>), anchorElementRef.current
                )}
        />
    );
};

export default LinkPreviewDispatcher;

PreviewMenuについてはメニューを表示するだけのコンポーネントのためここでは説明を割愛します(詳細はリポジトリをご確認ください)。
embedConfigsに指定しているLinkPreviewConfigについては、以下のようなものを用意します。

LinkPreviewConfig.tsx
import type { EmbedConfig, EmbedMatchResult } from '@lexical/react/LexicalAutoEmbedPlugin';
import type { LexicalEditor } from 'lexical';
import { INSERT_LINK_PREVIEW_COMMAND } from './LinkPreviewCommand';

export const LinkPreviewConfig: EmbedConfig = {
    insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
        editor.dispatchCommand(INSERT_LINK_PREVIEW_COMMAND, result.url);
    },

    // 今回、URLから情報を抽出するわけではないのでそのままurlを返す
    parseUrl: async (url: string) => {
        return {
            url,
            id: ''
        };
    },

    type: 'link-preview',
};

LinkPreviewConfigはEmbedConfigのインタフェースを実装しており、この中のinsertNodeでdispatchCommandすることが可能になります。

LinkPreviewDispatcherをpage側に取り込むことで、URLを入力した際にポップアップのメニューが表示されるようになります(まだプレビューは表示されません


 <LexicalEditor
    customNodes={[
	LinkNode, // エディタ上にリンクノードを生成するために必要
	AutoLinkNode, // エディタ上のURLをリンクノードに変換するために必要
	LinkPreviewNode // 
    ]}
    toolbarItems={[
	<LinkToolbarItem key='link' />
    ]}>
    <LexicalLinkPlugin validateUrl={validateUrl} />
    ...
+    <LinkPreviewDispatcher />
 </LexicalEditor>

最後に、コマンドを受け付けてLinkPreviewNodeを作成する側の処理を実装します。全体像は以下のようなコンポーネントになります。

LinkPreviewRegister.tsx
import { COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical';
import { FC, useEffect } from 'react';

import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $insertNodeToNearestRoot } from '@lexical/utils';

import { INSERT_LINK_PREVIEW_COMMAND } from './LinkPreviewCommand';
import { $createLinkPreviewNode, LinkPreviewNode } from './LinkPreviewNode';

const LinkPreviewRegister: FC = () => {
    const [editor] = useLexicalComposerContext();

    useEffect(() => {
        if (!editor.hasNodes([LinkPreviewNode])) {
            throw new Error('LinkPreviewNode is not registered on editor');
        }
        return editor.registerCommand<string>(
            INSERT_LINK_PREVIEW_COMMAND,
            (payload) => {
                const node = $createLinkPreviewNode(payload);
                $insertNodeToNearestRoot(node);

                return true;
            },
            COMMAND_PRIORITY_EDITOR,
        );
    }, [editor]);

    return null;
};

export default LinkPreviewRegister;

useEffect内でregisterCommandによって、コマンドがdispatchされた時にLinkPreviewNodeを作成する処理を行っています。このLinkPreviewRegisterをpage側で取り込むことで、リンクカードを表示することが可能になります。

 <LexicalEditor
    customNodes={[
	LinkNode, // エディタ上にリンクノードを生成するために必要
	AutoLinkNode, // エディタ上のURLをリンクノードに変換するために必要
	LinkPreviewNode // 今回追加のカスタムノード
    ]}
    toolbarItems={[
	<LinkToolbarItem key='link' />
    ]}>
    <LexicalLinkPlugin validateUrl={validateUrl} />
    ...
    <LinkPreviewDispatcher />
+   <LinkPreviewRegister />
 </LexicalEditor>

さて、これにて全ての実装が完了しました。エディタ上でURLを貼り付けると、リンクカードに変換される挙動を確認することができます🎉

最後に

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

Discussion