🦄

LexixalのPlaygroundを深掘りながらデータの永続化について考えてみる

2023/05/22に公開

昨今のSaaSでも採用されているテキストエディタですが、その中でも今回は「サポート良し、拡張性良し、パフォーマンス良し」の三拍子揃ったライブラリ「Lexical」をご紹介します。

Lexicalとは何か?

そもそも、テキストエディタの主な目的はテキストの作成と編集です。これは文書の作成からプログラミングまで、あらゆる種類のテキスト作業に使用されます。また、コードレベルまで話を落とすと、自然言語の文章を構造化されたデータに変換すること、つまりテキストをトークン化し、それらを特定のエンティティや関連付けにマッピングすることにあります。

その中でもLexicalは、信頼性、アクセシビリティ、パフォーマンスに重点を置いた、拡張可能なJavaScriptのWebテキストエディタフレームワークです。

開発元はfacebookで今でも活発に開発が進んでいます。最新のリリースは2023年4月18日(2023年5月11日時点)となっており常にアップデートが行われています。
また、コアパッケージのサイズも28kb(min+gzip)とテキストエディタの中でも軽量であり、必要に応じてプラグインをインストールすることで機能を拡張できます。

そしてLexicalは開発者にとってとても嬉しいPlaygroundがとても充実しています。

https://github.com/facebook/lexical/tree/main/packages/lexical-playground
▲こちらはPlaygroundのソースコードです。このコードを丸コピして不要な箇所を削る方針で実装するのもいいですね。

また、現在はWebのみの対応になりますが、開発チームではAndroidやiOSでの実装を可能にするため試験的に開発が進んでいるようです。

Lexicalでできること

Lexicalを使って実装できる機能は

  • メンション、カスタム絵文字、リンク、ハッシュタグなどの機能を内包したテキストエディタ
    ▲いわゆるSlackDiscordといったチャットアプリケーションで使用されているインターフェース。

  • ブログ、ソーシャルメディア、メッセージングアプリケーションにコンテンツ
    TwitterFacebookといったSNSで使用されている投稿フォーム

  • CMSやリッチコンテンツエディターで使用できる本格的なWYSIWYG(What You See Is What You Get)エディター。
    microCMSWord PressといったCMSで入稿するコンテンツ作成フォーム

  • 上記を組み合わせた、リアルタイムの共同テキスト編集機能
    ▲いわゆるNotionのドキュメント作成フォーム

と様々です。

プラグインの紹介

前述したようにLexicalではプラグインが充実しているため、リッチテキストエディタのよなにツールバーを実装したい場合は都度、プラグインをインストールする必要があります。プラグインではロジックとUIが分離しておりデザインのカスタマイズもとても柔軟に対応することができます。いくつかのプラグインを紹介します。

@lexical/react

これはReactアプリケーションでテキスト編集を可能にするLexicalのコンポーネントとhooksを提供します。昨今のフロントエンド開発でベストプラクティスとなっているReactでLexicalを使用してテキストエディタを作成することができる様になります。

@lexical/reactの実装サンプルコード
import {$getRoot, $getSelection} from 'lexical';
import {useEffect} from 'react';

import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';

const theme = {
  ltr: 'ltr',
  rtl: 'rtl',
  placeholder: 'editor-placeholder',
  paragraph: 'editor-paragraph',
  quote: 'editor-quote',
  heading: {
    h1: 'editor-heading-h1',
    h2: 'editor-heading-h2',
    h3: 'editor-heading-h3',
    h4: 'editor-heading-h4',
    h5: 'editor-heading-h5',
  },
  ...
}

function onChange(editorState) {
  editorState.read(() => {
    const root = $getRoot();
    const selection = $getSelection();
    console.log(root, selection);
  });
}

function MyCustomAutoFocusPlugin() {
  const [editor] = useLexicalComposerContext();

  

useEffect(() => {
    editor.focus();
  }, [editor]);

  return null;
}

function onError(error) {
  throw error;
}

function Editor() {
  const initialConfig = {
    namespace: 'MyEditor',
    theme,
    onError,
  };

return (
    <LexicalComposer initialConfig={initialConfig}>
      <PlainTextPlugin
        contentEditable={<ContentEditable />}
        placeholder={<div>Enter some text...</div>}
      />
      <OnChangePlugin onChange={onChange} />
      <HistoryPlugin />
      <MyCustomAutoFocusPlugin />
    </LexicalComposer>
  );
}

theme: エディタのスタイリングを設定します。themeの型はこちらにあります。

onChange: エディタの状態が変化したときに呼び出されるコールバック関数です。

MyCustomAutoFocusPlugin: エディタがレンダリングされたときに自動的にフォーカスを当てる機能を提供します。

onError: エディタの編集中に発生したエラーを処理するための関数です。

Editor: 最終的なエディタコンポーネントを定義します。Lexicalのコンポーザー(LexicalComposer)をルートコンポーネントとし、その中にいくつかのプラグイン(PlainTextPlugin, OnChangePlugin, HistoryPlugin, MyCustomAutoFocusPlugin)を配置しています。このエディタは初期設定(initialConfig)としてテーマやエラーハンドリングの関数を受け取ります。

@lexical/history

これはエディタ内の履歴を更新するプラグインです。画面上のUIだけでなくキーボードのショートカットでも実行することができます。ボリュームのあるコンテンツを作成する際に役立ちます。
履歴操作
▲赤枠の部分

@lexical/historyの実装サンプルコード
import {UNDO_COMMAND, REDO_COMMAND} from 'lexical';

<Toolbar>
  <Button onClick={() => editor.dispatchCommand(UNDO_COMMAND)}>Undo</Button>
  <Button onClick={() => editor.dispatchCommand(REDO_COMMAND)}>Redo</Button>
</Toolbar>

このプラグインを使用する場合は上記のようにツールバーに埋め込んだりして実装するとリッチエディタっぽくなります。

@lexical/code

これはエディタ内のコードブロックの制御を行うプラグインです。ソースコードを共有するのに特化したプラグインです。言語を選択することでコードに適切なハイライトを表示してくれるので、可読性も高く実装できます。
code
▲赤枠の部分

@lexical/codeの実装サンプルコード
import { CodeNode, registerCodeHighlighting, $isCodeNode } from '@lexical/code';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils';
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_CRITICAL, createCommand, LexicalEditor } from 'lexical';
import { FC, useEffect } from 'react';

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

  useEffect(() => {
    return mergeRegister(registerCodeHighlighting(editor), registerCodeLanguageSelecting(editor));
  }, [editor]);

  return null;
};

export const CODE_LANGUAGE_COMMAND = createCommand<string>();

function registerCodeLanguageSelecting(editor: LexicalEditor): () => void {
  return editor.registerCommand(
    CODE_LANGUAGE_COMMAND,
    (language, editor) => {
      const selection = $getSelection();
      if (!$isRangeSelection(selection)) return false;

      const anchorNode = selection.anchor.getNode();
      const targetNode = $isCodeNode(anchorNode) ? anchorNode : $getNearestNodeOfType(anchorNode, CodeNode);
      if (!targetNode) return false;

      editor.update(() => {
        targetNode.setLanguage(language);
      });

      return true;
    },
    COMMAND_PRIORITY_CRITICAL
  );
}

CodeHighlightPlugin: Reactの関数コンポーネントで、Lexicalエディタのコンテキストを取得し、エディタに対してコードハイライトとコード言語の選択機能を登録します。登録処理はuseEffectで副作用として扱われ、エディタが更新されるたびに再登録が行われます。また、コンポーネントがアンマウントされるときには登録を解除します。

CODE_LANGUAGE_COMMAND: Lexicalエディタに登録するコマンドを作成しています。このコマンドは、エディタ内の選択範囲の言語を設定するために使用されます。

registerCodeLanguageSelecting: エディタにコードの言語の選択機能を登録します。具体的には、エディタの選択範囲のアンカーノード(選択範囲の始点)がコードノードである場合、またはコードノード内に存在する場合に、そのコードノードの言語を設定します。

@lexical/markdown

マークダウン記法を実装するプラグインです。Lexical用のマークダウンヘルパー(import、export、shortcuts)も含まれています。Zennの記事作成でもツールバーではなくマークダウンで実装されています。マークダウンを使い慣れている人には嬉しい機能です。

@lexical/markdownの実装サンプルコード(マークダウン文法のimportとexport)
import {
  $convertFromMarkdownString,
  $convertToMarkdownString,
  TRANSFORMERS,
} from '@lexical/markdown';

editor.update(() => {
  const markdown = $convertToMarkdownString(TRANSFORMERS);
  ...
});

editor.update(() => {
  $convertFromMarkdownString(markdown, TRANSFORMERS);
});

また、マークダウンのショートカットの設定もできます。
以下は一部のショートカットの設定になります。

@lexical/markdownの実装サンプルコード(マークダウンのショートカット)
export const HR: ElementTransformer = {
  dependencies: [HorizontalRuleNode],
  export: (node: LexicalNode) => {
    return $isHorizontalRuleNode(node) ? '***' : null;
  },
  regExp: /^(---|\*\*\*|___)\s?$/,
  replace: (parentNode, _1, _2, isImport) => {
    const line = $createHorizontalRuleNode();

    if (isImport || parentNode.getNextSibling() != null) {
      parentNode.replace(line);
    } else {
      parentNode.insertBefore(line);
    }

    line.selectNext();
  },
  type: 'element',
};

export const IMAGE: TextMatchTransformer = {
  dependencies: [ImageNode],
  export: (node, _exportChildren, _exportFormat) => {
    if (!$isImageNode(node)) {
      return null;
    }

    return `![${node.getAltText()}](${node.getSrc()})`;
  },
  importRegExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))/,
  regExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))$/,
  replace: (textNode, match) => {
    const [, altText, src] = match;
    const imageNode = $createImageNode({
      altText,
      maxWidth: 800,
      src,
    });
    textNode.replace(imageNode);
  },
  trigger: ')',
  type: 'text-match',
};

export const EQUATION: TextMatchTransformer = {
  dependencies: [EquationNode],
  export: (node, _exportChildren, _exportFormat) => {
    if (!$isEquationNode(node)) {
      return null;
    }

    return `$${node.getEquation()}$`;
  },
  importRegExp: /\$([^$].+?)\$/,
  regExp: /\$([^$].+?)\$$/,
  replace: (textNode, match) => {
    const [, equation] = match;
    const equationNode = $createEquationNode(equation, true);
    textNode.replace(equationNode);
  },
  trigger: '$',
  type: 'text-match',
};

export const TWEET: ElementTransformer = {
  dependencies: [TweetNode],
  export: (node) => {
    if (!$isTweetNode(node)) {
      return null;
    }

    return `<tweet id="${node.getId()}" />`;
  },
  regExp: /<tweet id="([^"]+?)"\s?\/>\s?$/,
  replace: (textNode, _1, match) => {
    const [, id] = match;
    const tweetNode = $createTweetNode(id);
    textNode.replace(tweetNode);
  },
  type: 'element',
};

const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/;

export const TABLE: ElementTransformer = {
  dependencies: [TableNode, TableRowNode, TableCellNode],
  export: (node: LexicalNode, exportChildren: (elementNode: ElementNode) => string) => {
    if (!$isTableNode(node)) {
      return null;
    }

    const output = [];

    for (const row of node.getChildren()) {
      const rowOutput = [];

      if ($isTableRowNode(row)) {
        for (const cell of row.getChildren()) {
          if ($isElementNode(cell)) {
            rowOutput.push(exportChildren(cell));
          }
        }
      }

      output.push(`| ${rowOutput.join(' | ')} |`);
    }

    return output.join('\n');
  },
  regExp: TABLE_ROW_REG_EXP,
  replace: (parentNode, _1, match) => {
    const matchCells = mapToTableCells(match[0]);

    if (matchCells == null) {
      return;
    }

    const rows = [matchCells];
    let sibling = parentNode.getPreviousSibling();
    let maxCells = matchCells.length;

    while (sibling) {
      if (!$isParagraphNode(sibling)) {
        break;
      }

      if (sibling.getChildrenSize() !== 1) {
        break;
      }

      const firstChild = sibling.getFirstChild();

      if (!$isTextNode(firstChild)) {
        break;
      }

      const cells = mapToTableCells(firstChild.getTextContent());

      if (cells == null) {
        break;
      }

      maxCells = Math.max(maxCells, cells.length);
      rows.unshift(cells);
      const previousSibling = sibling.getPreviousSibling();
      sibling.remove();
      sibling = previousSibling;
    }

    const table = $createTableNode();

    for (const cells of rows) {
      const tableRow = $createTableRowNode();
      table.append(tableRow);

      for (let i = 0; i < maxCells; i++) {
        tableRow.append(i < cells.length ? cells[i] : createTableCell(null));
      }
    }

    const previousSibling = parentNode.getPreviousSibling();
    if ($isTableNode(previousSibling) && getTableColumnsSize(previousSibling) === maxCells) {
      previousSibling.append(...table.getChildren());
      parentNode.remove();
    } else {
      parentNode.replace(table);
    }

    table.selectEnd();
  },
  type: 'element',
};

export const PLAYGROUND_TRANSFORMERS: Array<Transformer> = [
  TABLE,
  HR,
  IMAGE,
  EQUATION,
  TWEET,
  CHECK_LIST,
  ...ELEMENT_TRANSFORMERS,
  ...TEXT_FORMAT_TRANSFORMERS,
  ...TEXT_MATCH_TRANSFORMERS,
];

export const MarkdownPlugin: FC = () => {
  return <MarkdownShortcutPlugin transformers={PLAYGROUND_TRANSFORMERS} />;
};

HR(Horizontal Rule): ---, *** , ___ のいずれかの形式で表される水平線を扱います。exportメソッドは、NodeがHorizontalRuleNodeである場合、*** を返します。replaceメソッドは、新しいHorizontalRuleNodeを作成し、位置を調整します。

Image: Markdownのイメージ記法 ![altText](src) を扱います。exportメソッドは、NodeがImageNodeである場合にMarkdown形式の文字列を返します。replaceメソッドは、イメージ記法に一致するテキストをImageNodeに置き換えます。

Equation: テキスト中の数式 $equation$ を扱います。exportメソッドは、NodeがEquationNodeである場合に数式を返します。replaceメソッドは、数式に一致するテキストをEquationNodeに置き換えます。

Tweet: ツイートを埋め込むための独自の記法 <tweet id={tweetId} /> を扱います。exportメソッドは、NodeがTweetNodeである場合にこの記法の文字列を返します。replaceメソッドは、この記法に一致するテキストをTweetNodeに置き換えます。

Table: テーブルを扱います。TableNodeは行(TableRowNode)とそれに対応するセル(TableCellNode)を含みます。exportメソッドは、NodeがTableNodeである場合にMarkdown形式のテーブルを返します。replaceメソッドは、テーブルに一致するテキストをTableNodeに置き換えます。

小ネタ-Lexicalで機能実装-

LexicalのPlaygroundに実装されている実用的な機能をみてみましょう。

EmojiPicker✋

:(コロン)をprefixに置くと絵文字が自動で補完される機能です。Slackなどでよくみられる機能ですね。

EmojiPicker
▲赤枠の部分

EmojiPickerのソースコード
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
  LexicalTypeaheadMenuPlugin,
  MenuOption,
  useBasicTypeaheadTriggerMatch,
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import {
  $createTextNode,
  $getSelection,
  $isRangeSelection,
  TextNode,
} from 'lexical';
import * as React from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import * as ReactDOM from 'react-dom';

class EmojiOption extends MenuOption {
  title: string;
  emoji: string;
  keywords: Array<string>;

  constructor(
    title: string,
    emoji: string,
    options: {
      keywords?: Array<string>;
    },
  ) {
    super(title);
    this.title = title;
    this.emoji = emoji;
    this.keywords = options.keywords || [];
  }
}
function EmojiMenuItem({
  index,
  isSelected,
  onClick,
  onMouseEnter,
  option,
}: {
  index: number;
  isSelected: boolean;
  onClick: () => void;
  onMouseEnter: () => void;
  option: EmojiOption;
}) {
  let className = 'item';
  if (isSelected) {
    className += ' selected';
  }
  return (
    <li
      key={option.key}
      tabIndex={-1}
      className={className}
      ref={option.setRefElement}
      role="option"
      aria-selected={isSelected}
      id={'typeahead-item-' + index}
      onMouseEnter={onMouseEnter}
      onClick={onClick}>
      <span className="text">
        {option.emoji} {option.title}
      </span>
    </li>
  );
}

type Emoji = {
  emoji: string;
  description: string;
  category: string;
  aliases: Array<string>;
  tags: Array<string>;
  unicode_version: string;
  ios_version: string;
  skin_tones?: boolean;
};

const MAX_EMOJI_SUGGESTION_COUNT = 10;

export default function EmojiPickerPlugin() {
  const [editor] = useLexicalComposerContext();
  const [queryString, setQueryString] = useState<string | null>(null);
  const [emojis, setEmojis] = useState<Array<Emoji>>([]);

  useEffect(() => {
    import('../../utils/emoji-list.ts').then((file) => setEmojis(file.default));
  }, []);

  const emojiOptions = useMemo(
    () =>
      emojis != null
        ? emojis.map(
            ({emoji, aliases, tags}) =>
              new EmojiOption(aliases[0], emoji, {
                keywords: [...aliases, ...tags],
              }),
          )
        : [],
    [emojis],
  );

  const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(':', {
    minLength: 0,
  });

  const options: Array<EmojiOption> = useMemo(() => {
    return emojiOptions
      .filter((option: EmojiOption) => {
        return queryString != null
          ? new RegExp(queryString, 'gi').exec(option.title) ||
            option.keywords != null
            ? option.keywords.some((keyword: string) =>
                new RegExp(queryString, 'gi').exec(keyword),
              )
            : false
          : emojiOptions;
      })
      .slice(0, MAX_EMOJI_SUGGESTION_COUNT);
  }, [emojiOptions, queryString]);

  const onSelectOption = useCallback(
    (
      selectedOption: EmojiOption,
      nodeToRemove: TextNode | null,
      closeMenu: () => void,
    ) => {
      editor.update(() => {
        const selection = $getSelection();

        if (!$isRangeSelection(selection) || selectedOption == null) {
          return;
        }

        if (nodeToRemove) {
          nodeToRemove.remove();
        }

        selection.insertNodes([$createTextNode(selectedOption.emoji)]);

        closeMenu();
      });
    },
    [editor],
  );

  return (
    <LexicalTypeaheadMenuPlugin
      onQueryChange={setQueryString}
      onSelectOption={onSelectOption}
      triggerFn={checkForTriggerMatch}
      options={options}
      menuRenderFn={(
        anchorElementRef,
        {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex},
      ) => {
        if (anchorElementRef.current == null || options.length === 0) {
          return null;
        }

        return anchorElementRef.current && options.length
          ? ReactDOM.createPortal(
              <div className="typeahead-popover emoji-menu">
                <ul>
                  {options.map((option: EmojiOption, index) => (
                    <div key={option.key}>
                      <EmojiMenuItem
                        index={index}
                        isSelected={selectedIndex === index}
                        onClick={() => {
                          setHighlightedIndex(index);
                          selectOptionAndCleanUp(option);
                        }}
                        onMouseEnter={() => {
                          setHighlightedIndex(index);
                        }}
                        option={option}
                      />
                    </div>
                  ))}
                </ul>
              </div>,
              anchorElementRef.current,
            )
          : null;
      }}
    />
  );
}

EmojiOption: メニューオプションを表現します。各オプションにはタイトル、絵文字、そしてキーワードのリストが含まれています。

EmojiMenuItem: 個々の絵文字選択肢を表示するためのコンポーネントで、選択されたときの挙動などを含んでいます。

EmojiPickerPlugin: 絵文字ピッカーの全体的な動作を制御しています。絵文字のリストをステートとして保持し、useEffectを使用して絵文字のリストを非同期にインポートしています。絵文字のリストが得られたら、emojiOptionsというメモ化された値を計算します。この値は各絵文字をEmojiOptionインスタンスにマッピングしたものです。

checkForTriggerMatch: ユーザーがコロン(:)を入力したときに絵文字ピッカーが表示されるようにします。optionsというメモ化された値は、現在のクエリ文字列に基づいて絵文字オプションをフィルタリングします。

onSelectOption: ユーザーが絵文字を選択したときの挙動を制御します。特定の絵文字が選択されると、その絵文字がエディタに挿入されます。

EmojiPickerPlugin: LexicalTypeaheadMenuPluginコンポーネントをレンダリングします。このコンポーネントには、クエリの変更、オプションの選択、トリガーのチェック、オプション、そしてメニューのレンダリング方法に関するさまざまなプロパティが与えられます。

Memtion

@(アット)をprefixに置くと設定していた文字配列が自動で補完される機能です。Slackなどでよくみられる機能ですね。

Mention
▲赤枠の部分

Memtionのソースコード
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
  LexicalTypeaheadMenuPlugin,
  MenuOption,
  MenuTextMatch,
  useBasicTypeaheadTriggerMatch,
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import {TextNode} from 'lexical';
import {useCallback, useEffect, useMemo, useState} from 'react';
import * as React from 'react';
import * as ReactDOM from 'react-dom';

import {$createMentionNode} from '../../nodes/MentionNode';

const PUNCTUATION =
  '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';
const NAME = '\\b[A-Z][^\\s' + PUNCTUATION + ']';

const DocumentMentionsRegex = {
  NAME,
  PUNCTUATION,
};

const CapitalizedNameMentionsRegex = new RegExp(
  '(^|[^#])((?:' + DocumentMentionsRegex.NAME + '{' + 1 + ',})$)',
);

const PUNC = DocumentMentionsRegex.PUNCTUATION;

const TRIGGERS = ['@'].join('');

const VALID_CHARS = '[^' + TRIGGERS + PUNC + '\\s]';

const VALID_JOINS =
  '(?:' +
  '\\.[ |$]|' + // E.g. "r. " in "Mr. Smith"
  ' |' + // E.g. " " in "Josh Duck"
  '[' +
  PUNC +
  ']|' + // E.g. "-' in "Salier-Hellendag"
  ')';

const LENGTH_LIMIT = 75;

const AtSignMentionsRegex = new RegExp(
  '(^|\\s|\\()(' +
    '[' +
    TRIGGERS +
    ']' +
    '((?:' +
    VALID_CHARS +
    VALID_JOINS +
    '){0,' +
    LENGTH_LIMIT +
    '})' +
    ')$',
);

const ALIAS_LENGTH_LIMIT = 50;

const AtSignMentionsRegexAliasRegex = new RegExp(
  '(^|\\s|\\()(' +
    '[' +
    TRIGGERS +
    ']' +
    '((?:' +
    VALID_CHARS +
    '){0,' +
    ALIAS_LENGTH_LIMIT +
    '})' +
    ')$',
);

const SUGGESTION_LIST_LENGTH_LIMIT = 5;

const mentionsCache = new Map();

const dummyMentionsData = [
 ...
];

const dummyLookupService = {
  search(string: string, callback: (results: Array<string>) => void): void {
    setTimeout(() => {
      const results = dummyMentionsData.filter((mention) =>
        mention.toLowerCase().includes(string.toLowerCase()),
      );
      callback(results);
    }, 500);
  },
};

function useMentionLookupService(mentionString: string | null) {
  const [results, setResults] = useState<Array<string>>([]);

  useEffect(() => {
    const cachedResults = mentionsCache.get(mentionString);

    if (mentionString == null) {
      setResults([]);
      return;
    }

    if (cachedResults === null) {
      return;
    } else if (cachedResults !== undefined) {
      setResults(cachedResults);
      return;
    }

    mentionsCache.set(mentionString, null);
    dummyLookupService.search(mentionString, (newResults) => {
      mentionsCache.set(mentionString, newResults);
      setResults(newResults);
    });
  }, [mentionString]);

  return results;
}

function checkForCapitalizedNameMentions(
  text: string,
  minMatchLength: number,
): MenuTextMatch | null {
  const match = CapitalizedNameMentionsRegex.exec(text);
  if (match !== null) {
    const maybeLeadingWhitespace = match[1];

    const matchingString = match[2];
    if (matchingString != null && matchingString.length >= minMatchLength) {
      return {
        leadOffset: match.index + maybeLeadingWhitespace.length,
        matchingString,
        replaceableString: matchingString,
      };
    }
  }
  return null;
}

function checkForAtSignMentions(
  text: string,
  minMatchLength: number,
): MenuTextMatch | null {
  let match = AtSignMentionsRegex.exec(text);

  if (match === null) {
    match = AtSignMentionsRegexAliasRegex.exec(text);
  }
  if (match !== null) {
    const maybeLeadingWhitespace = match[1];

    const matchingString = match[3];
    if (matchingString.length >= minMatchLength) {
      return {
        leadOffset: match.index + maybeLeadingWhitespace.length,
        matchingString,
        replaceableString: match[2],
      };
    }
  }
  return null;
}

function getPossibleQueryMatch(text: string): MenuTextMatch | null {
  const match = checkForAtSignMentions(text, 1);
  return match === null ? checkForCapitalizedNameMentions(text, 3) : match;
}

class MentionTypeaheadOption extends MenuOption {
  name: string;
  picture: JSX.Element;

  constructor(name: string, picture: JSX.Element) {
    super(name);
    this.name = name;
    this.picture = picture;
  }
}

function MentionsTypeaheadMenuItem({
  index,
  isSelected,
  onClick,
  onMouseEnter,
  option,
}: {
  index: number;
  isSelected: boolean;
  onClick: () => void;
  onMouseEnter: () => void;
  option: MentionTypeaheadOption;
}) {
  let className = 'item';
  if (isSelected) {
    className += ' selected';
  }
  return (
    <li
      key={option.key}
      tabIndex={-1}
      className={className}
      ref={option.setRefElement}
      role="option"
      aria-selected={isSelected}
      id={'typeahead-item-' + index}
      onMouseEnter={onMouseEnter}
      onClick={onClick}>
      {option.picture}
      <span className="text">{option.name}</span>
    </li>
  );
}

export default function NewMentionsPlugin(): JSX.Element | null {
  const [editor] = useLexicalComposerContext();

  const [queryString, setQueryString] = useState<string | null>(null);

  const results = useMentionLookupService(queryString);

  const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
    minLength: 0,
  });

  const options = useMemo(
    () =>
      results
        .map(
          (result) =>
            new MentionTypeaheadOption(result, <i className="icon user" />),
        )
        .slice(0, SUGGESTION_LIST_LENGTH_LIMIT),
    [results],
  );

  const onSelectOption = useCallback(
    (
      selectedOption: MentionTypeaheadOption,
      nodeToReplace: TextNode | null,
      closeMenu: () => void,
    ) => {
      editor.update(() => {
        const mentionNode = $createMentionNode(selectedOption.name);
        if (nodeToReplace) {
          nodeToReplace.replace(mentionNode);
        }
        mentionNode.select();
        closeMenu();
      });
    },
    [editor],
  );

  const checkForMentionMatch = useCallback(
    (text: string) => {
      const slashMatch = checkForSlashTriggerMatch(text, editor);
      if (slashMatch !== null) {
        return null;
      }
      return getPossibleQueryMatch(text);
    },
    [checkForSlashTriggerMatch, editor],
  );

  return (
    <LexicalTypeaheadMenuPlugin<MentionTypeaheadOption>
      onQueryChange={setQueryString}
      onSelectOption={onSelectOption}
      triggerFn={checkForMentionMatch}
      options={options}
      menuRenderFn={(
        anchorElementRef,
        {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex},
      ) =>
        anchorElementRef.current && results.length
          ? ReactDOM.createPortal(
              <div className="typeahead-popover mentions-menu">
                <ul>
                  {options.map((option, i: number) => (
                    <MentionsTypeaheadMenuItem
                      index={i}
                      isSelected={selectedIndex === i}
                      onClick={() => {
                        setHighlightedIndex(i);
                        selectOptionAndCleanUp(option);
                      }}
                      onMouseEnter={() => {
                        setHighlightedIndex(i);
                      }}
                      key={option.key}
                      option={option}
                    />
                  ))}
                </ul>
              </div>,
              anchorElementRef.current,
            )
          : null
      }
    />
  );
}

useMentionLookupService: メンション文字列を引数として取り、メンション候補を検索し、結果を管理します。結果は状態変数resultsに保存されます。また、キャッシュはmentionsCacheというMapを使用して実装されています。これにより、同じメンション文字列の検索を繰り返すことなく、過去の検索結果を再利用することができます。

checkForCapitalizedNameMentions&checkForAtSignMentions: 入力テキストに対して正規表現を使用してメンションを探し、見つかったメンションとその位置を表すオブジェクトを返します。これらの関数は後で、型推論のトリガー関数内で使用されます。

NewMentionsPlugin: LexicalのTypeaheadプラグインを使用しています。具体的には、onQueryChangeプロパティでsetQueryStringを使用して現在のクエリ文字列を管理し、onSelectOptionプロパティで選択されたオプションに基づいてメンションノードを作成・置換します。triggerFnプロパティでは、先ほど定義したメンションのマッチング関数を使用して、メンションの型推論をトリガーします。そして、optionsプロパティで提案するメンションのリストを渡し、menuRenderFnプロパティで提案のメニューの描画方法を定義します。

その他テキストエディタライブラリ

Ace Editor

Ace Editor

Aceは、非常に強力で高性能なWebベースのコードエディタです。シンタックスハイライト、自動インデント、コード補完、複数のカーソルと選択範囲、などの機能があります。また、テーマと言語モードを自由に変更でき、カスタマイズが可能です。

CodeMirror

CodeMirror

CodeMirrorもまた、ブラウザ上で動作するコードエディタのライブラリです。多くのプログラミング言語とマークアップ言語のシンタックスハイライトをサポートしています。また、行番号の表示、コード折り畳み、検索と置換などの機能を提供します。

Quill

Quill

Quillは、リッチテキストエディタを提供するJavaScriptライブラリです。リッチテキストエディタは、テキストの書式設定(太字、斜体、下線など)やリンクの挿入、画像の挿入などを可能にします。Quillは、これらの機能を提供しつつ、拡張性とカスタマイズ性を持っています。(Slackでも使われているとか)

TinyMCE

TinyMCE

TinyMCEは、リッチテキストエディタを提供する別のJavaScriptライブラリです。非常に成熟しており、多くのオプションとプラグインを提供しています。このライブラリは、CMS(コンテンツ管理システム)やその他のWebアプリケーションでよく利用されています。

Monaco Editor

Monaco Editor

Monaco Editorは、Microsoftが開発し、Visual Studio Codeでも使用されているエディタです。非常に強力な機能を持ち、ブラウザ上での利用を想定しています。シンタックスハイライト、コード補完、エラー検出などの機能があります。

データの永続化

最後にテキストエディタのノードデータの永続化について考えてみました。
マークダウン形式のサービスであれば特に制約なく文字列としてDBに格納することができ、取り出すときも容易かと思います。しかしLexicalのようにエディター内のデータが複雑なノードになっている場合はどうするのが良いのでしょう。いくつか私なりに考えてみたので、皆さんのご意見もお聞かせください。

RDBでの格納

Lexicalではエディタ内に入力したデータはJSONとして出力することができます。その特性を活かしJSON.stringfyすることでLexicalのノード形式を崩すことなくDBに文字列として格納することができます。

  const onChange = useCallback(
    (editorState: EditorState) => {
      // editorの中身のJSON構造を文字列にシリアライズして値を保持する
      setValue(namespace, JSON.stringify(editorState.toJSON()));
    },
    [namespace, setValue]
  );

また、JSON型を許容するPostgresqlであればシリアライズすることなくそのまま格納することが可能です。

RDBの特性を活かすのであれば以下のような設計が可能で実現することもできます。

id nodeId parentNodeId nodeType content
主キー ノードのID ノードの親に当たるノードID ノードのタイプ(テキスト・ファイルなど) コンテンツ

JSONを文字列にシリアライズする方法は容易ですがその分デメリットもあります。ノードを正規化するのもパフォーマンス面ではやはりデメリットになってしまうでしょう。

下記にデメリットをまとめてみました。

クエリの複雑さ: JSONデータをクエリするためには、特殊なJSON関数や演算子が必要になる場合があり、SQLのクエリが複雑になる可能性があります。

パフォーマンス: 大量のJSONデータを処理する際には、パフォーマンスの問題が生じる可能性があります。特に、JSONデータ内の特定の属性を基に検索やソートを行う場合、通常のリレーショナルデータに比べてパフォーマンスが劣ることがあります。

データ整合性: RDBの主要な特性であるスキーマによるデータ整合性が、JSONデータでは保証されにくいです。つまり、JSONデータの形式が予期せぬ方法で変更されると、アプリケーションでエラーが発生する可能性があります。

ストレージの効率性: JSON形式は人間が読みやすい形式である一方で、そのメタデータ(キー名など)がデータ量を増加させ、ストレージ効率を低下させる可能性があります。

ドキュメント指向データベースでの格納

MongoDBやFirebaseのようなドキュメント指向データベースは、JSON形式でデータを保存することができます。これにより、ノードデータの階層的な構造をそのまま保存することができます。これらのデータベースは、階層的なデータ構造のクエリに対して良好なパフォーマンスを提供します。ただし、このタイプのデータベースはリレーショナルデータベースとは異なるデータ整合性の保証を持つため、アプリケーションの要件によっては適さない場合もあります。

グラフデータベースでの格納

ノード間の関係性が重要な場合、または階層構造が非常に複雑な場合、グラフデータベース(例えばNeo4j)を検討することもできます。グラフデータベースは、ノード間の関係性を第一級のエンティティとして扱うため、階層的なデータ構造や複雑な関連性を持つデータを効率的に表現することが可能です。


それぞれ要件次第にはなりますがドキュメント指向データベースやグラフデータベースで格納するのが構造としては良さそうな印象でした。

他にも様々な方法が考えられると思いますので、コメントなどでナレッジの共有をいただけますと幸いです。

参考文献

https://lexical.dev/docs/intro

https://playground.lexical.dev/

https://github.com/facebook/lexical/tree/main

https://github.com/facebook/lexical/tree/main/packages

https://github.com/facebook/lexical/tree/main/packages/lexical-playground

Discussion