3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Nihon UniversityAdvent Calendar 2023

Day 23

Lexical RichTextPluginの実装を少し覗く ~本編~

Posted at

はじめに

本記事はNihon University Advent Calendar 2023の23日目の記事になります。

本記事は、Lexical RichTextPluginの実装を少し覗く ~Lexical紹介編~の続きとなっております。
Lexicalの基本的な事を知らない方は一本目から見ていただければと思います。

本記事が引用している実装は重要な部分のみを説明するために、Lexicalの実装を一部省略してる場合があります。...等で省略されている部分は表すようにしていますので、承知いただければ幸いです。

本記事の目的

本記事では、LexicalのRichTextPluginがどのように太字や斜体に変換しているのかを解明していきます。具体的には、

  • Commandの登録・作成・発火
  • Node→HTMLのレンダリングのされ方
  • 太字や斜体等のフォーマットの仕方

の3点で追っていきます。

本記事を通して、Lexicalの内部実装に少し詳しくなること、またLexicalの拡張に対して一定の知識を得ることを目的とします。

RichTextPlugin 内部実装

大まかな概要から説明すると、Lexical RichTextPluginの実装を少し覗く ~Lexical紹介編~で先述した通りLexicalではEditorの内部状態をNodeというデータ構造で保存しています。そのため、ctrl+bで太字になる流れとしては、

  1. ctrl+b押下時、commandがdispatchされる
  2. 登録していたcommandが走り、対象のNodeのformatが変換される
  3. そのNodeのformatを元に、再描画が走る

という流れになっています。以下では、この流れに沿って実装を見ていきます。

commandの登録及びイベント発火

まず、エントリポイントのRichTextPluginを見ていきます。

LexicalRichTextPlugin.tsx
export function RichTextPlugin({
  contentEditable,
  placeholder,
  ErrorBoundary,
}: {
  contentEditable: JSX.Element;
  placeholder:
    | ((isEditable: boolean) => null | JSX.Element)
    | null
    | JSX.Element;
  ErrorBoundary: ErrorBoundaryType;
}): JSX.Element {
  const [editor] = useLexicalComposerContext();
  const decorators = useDecorators(editor, ErrorBoundary);
  useRichTextSetup(editor);

  return (
    <>
      {contentEditable}
      <Placeholder content={placeholder} />
      {decorators}
    </>
  );
}

この中のHooksである、useRichTextSetupで、コマンドの登録が行われています。

useRichTextSetup.ts
// 
export function useRichTextSetup(editor: LexicalEditor): void {
  useLayoutEffect(() => {
    return mergeRegister(
      registerRichText(editor),
      registerDragonSupport(editor),
    );

    // We only do this for init
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [editor]);
}

このような形で、レンダリング前に mergeRegister(registerRichText(editor),...)が走ることが分かります。useLayoutEffectに馴染みが無い方もいらっしゃると思いますが、レンダリング前に同期的に走る useEffectになります。ここでは、useEffectに読み替えてもらっても大きな問題はないです。気になる方は公式ドキュメントをご覧ください。

mergeRegister自体はこのような実装になっており、中身の関数をただ実行するだけの関数です。

mergeRegister.ts
function mergeRegister(...func) {
  return () => {
    func.forEach(f => f());
  };
}

registerRichTextの方に深堀していきましょう。

index.ts
const removeListener = mergeRegister(
    editor.registerCommand(
      CLICK_COMMAND,
      (payload) => {
        const selection = $getSelection();
        if ($isNodeSelection(selection)) {
          selection.clear();
          return true;
        }
        return false;
      },
      0,
    ),
    ... // 長い実装が続く
    editor.registerCommand<TextFormatType>(
        FORMAT_TEXT_COMMAND,
        (format) => {
          const selection = $getSelection();
          if (!$isRangeSelection(selection)) {
            return false;
          }
          selection.formatText(format);
          return true;
        },
        COMMAND_PRIORITY_EDITOR,
      ),
      ... // 長い実装が続く

$getSelectionでユーザの選択範囲を取得して、その選択範囲をformatする関数が登録されています。
selection.formatの中、及びこの関数が何をしているかは後述します。今は、RichTextPluginがマウントされた段階で、commandが登録されていることが分かればOKです。

では、登録されている関数を呼び出すところも見てみましょう。FORMAT_TEXT_COMMANDという名前で登録していたので、dispatchCommand(~, FORMAT_TEXT_COMMAND, ~)という部分が呼び出しに該当します。

LexicalEvents.ts
function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
  ...//長い実装
  } else if (isBold(keyCode, altKey, metaKey, ctrlKey)) {
    event.preventDefault();
    dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
  } else if (isUnderline(keyCode, altKey, metaKey, ctrlKey)) {
    event.preventDefault();
    dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
  } else if (isItalic(keyCode, altKey, metaKey, ctrlKey)) {
    event.preventDefault();
    dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
  ...//長い実装
}

このような形で、onKeyDowndispatchCommandが呼び出されることが分かります。
では、どのような時に onKeyDownが呼ばれるのでしょうか?

LexicalEvents.ts
const rootElementEvents: RootElementEvents = [
  ['keydown', onKeyDown],
  ['pointerdown', onPointerDown],
  ['compositionstart', onCompositionStart],
  ['compositionend', onCompositionEnd],
  ['input', onInput],
  ['click', onClick],
  ['cut', PASS_THROUGH_COMMAND],
  ['copy', PASS_THROUGH_COMMAND],
  ['dragstart', PASS_THROUGH_COMMAND],
  ['dragover', PASS_THROUGH_COMMAND],
  ['dragend', PASS_THROUGH_COMMAND],
  ['paste', PASS_THROUGH_COMMAND],
  ['focus', PASS_THROUGH_COMMAND],
  ['blur', PASS_THROUGH_COMMAND],
  ['drop', PASS_THROUGH_COMMAND],
];

LexicalEvents内で、このような配列が定義されています。この配列が、addRootElementEventsという関数を通して、ContentEditable要素がマウントされたときにイベントが登録されます。(コードが気になる方はそれぞれリンクを貼っておいたので見てみてください。本記事ではこれ以上説明しません。)

まとめとしては、RichTextPluginのマウント時にFORMAT_TEXT_COMMANDのコマンドが登録され、ContentEditablekeyDown時に登録したコマンドが走ります。

フォーマットの仕方

フォーマットのされ方についても見ていきましょう。先ほど調べた通り、dispatchCommandした際は、以下の関数が呼ばれます。

index.ts
(format) => {
  const selection = $getSelection();
  if (!$isRangeSelection(selection)) {
    return false;
  }
  selection.formatText(format);
  return true;
},

この関数は、selectionを取得し、そのselectionformatTextという関数を呼び出していますね。
formatTextには様々な分岐があって(選択ノードが無い場合、1個の場合、複数の場合)結構めんどくさいですが、全体的には、

  1. 選択された範囲に含まれるNodeに対して TextNode.setFormatを呼ぶ
  2. this.toggleFormatを呼ぶ

の2つの操作を行います。実際の関数はこちらです。
まずは、TextNode.setFormatから読んでいきましょう。

lexical.ts
...
* @param format - TextFormatType or 32-bit integer representing the node format.
...
setFormat(format) {
  const self = this.getWritable();
  self.__format = typeof format === 'string' ? TEXT_TYPE_TO_FORMAT[format] : format;
  return self;
}

このような実装になっています。DocStringに記載されてる通り、整数か文字列を受け取ります。
後述していますが、TEXT_TYPE_TO_FORMATは整数を返します。
そのため、TextNode.__formatに装飾の仕方を表す整数値が保存されていることが分かりました。

続いて、this.toggleFormatの方も見ていきましょう。

LexicalTextNode.ts
toggleFormat(type) {
  const formatFlag = TEXT_TYPE_TO_FORMAT[type];
  return this.setFormat(this.getFormat() ^ formatFlag);
}

このような実装になっています。どうやら現在のフォーマットと、今のフォーマットを演算して、新しいフォーマットとして設定しなおしていることがtoggleという命名と実装から読み取れます。
TEXT_TYPE_TO_FORMATこのように定義されています。

LexicalConstants.ts
const IS_BOLD = 1;
const IS_ITALIC = 1 << 1;
const IS_STRIKETHROUGH = 1 << 2;
const IS_UNDERLINE = 1 << 3;
const IS_CODE = 1 << 4;
const IS_SUBSCRIPT = 1 << 5;
const IS_SUPERSCRIPT = 1 << 6;
const IS_HIGHLIGHT = 1 << 7;
const IS_ALL_FORMATTING = IS_BOLD | IS_ITALIC | IS_STRIKETHROUGH | IS_UNDERLINE | IS_CODE | IS_SUBSCRIPT | IS_SUPERSCRIPT | IS_HIGHLIGHT;
// ...
const TEXT_TYPE_TO_FORMAT = {
  bold: IS_BOLD,
  code: IS_CODE,
  highlight: IS_HIGHLIGHT,
  italic: IS_ITALIC,
  strikethrough: IS_STRIKETHROUGH,
  subscript: IS_SUBSCRIPT,
  superscript: IS_SUPERSCRIPT,
  underline: IS_UNDERLINE
};

察しが良い方は分かったかもしれませんが、これらのフォーマットはbit演算により計算されています。
bit演算は、あらかじめどのような要素が来るか分かっているときに他のデータ構造よりも高速に要素の含有判定・追加・削除等が行えます。
簡単ですが速度の比較も行っていますので結果を載せておきます。

テスト
test.ts
let BIT = 0;
const SET = new Set();
let LIST: string[] = [];

const toggleFormatWithBit = () => {
  for (let i = 0; i < 10000000; i++) {
    const [format, _] = createRandomFormat();
    BIT = BIT ^ format;
  }
};

const toggleFormatWithSet = () => {
  for (let i = 0; i < 10000000; i++) {
    const [_, format] = createRandomFormat();
    if (SET.has(format)) {
      SET.delete(format);
    } else {
      SET.add(format);
    }
  }
};

const toggleFormatWithList = () => {
  for (let i = 0; i < 10000000; i++) {
    const [_, format] = createRandomFormat();
    if (LIST.includes(format)) {
      LIST = LIST.filter((v) => v != format);
    } else {
      LIST.push(format);
    }
  }
};

const createRandomFormat: () => [number, string] = () => {
  const formatIndex = Math.floor(Math.random() * 9);
  return [1 << formatIndex, formatIndex.toString()];
};

export { toggleFormatWithBit, toggleFormatWithSet, toggleFormatWithList };

bit-benchmark>npm run start

> start
> npx ts-node ./index.ts

Bit x 2.65 ops/sec ±3.50% (11 runs sampled)
List x 0.61 ops/sec ±2.50% (6 runs sampled)
Set x 0.58 ops/sec ±3.67% (6 runs sampled)
Fastest is Bit
bit演算はその性質からReactのレーンでも使われています。勉強になった記事をおいておきます。

ともあれ、bit演算を駆使しながらTextNodeの中でformatを整数で保持していることが分かりました。

まとめると、登録したコマンドは選択範囲に対して対象のTextNodeを求め、そのTextNodeに対して指定したフォーマットを当てる関数でした。

レンダリングのされ方

TextNodeは、DOMのレンダリングの仕方がこの__formatによって決まっています。
各ノードは、そのノードがどのようにレンダリングされるかをcreateDOMで実装する必要があります。TextNode.createDOMの例を見てみましょう。

LexicalTextNode.ts
createDOM(config: EditorConfig): HTMLElement {
  const format = this.__format;
  const outerTag = getElementOuterTag(this, format);
  const innerTag = getElementInnerTag(this, format);
  const tag = outerTag === null ? innerTag : outerTag;
  const dom = document.createElement(tag);
  let innerDOM = dom;
  if (this.hasFormat('code')) {
    dom.setAttribute('spellcheck', 'false');
  }
  if (outerTag !== null) {
    innerDOM = document.createElement(innerTag);
    dom.appendChild(innerDOM);
  }
  const text = this.__text;
  createTextInnerDOM(innerDOM, this, innerTag, format, text, config);
  const style = this.__style;
  if (style !== '') {
    dom.style.cssText = style;
  }
  return dom;
}

この実装を見ると、TextNode.__formatを見ながら、const dom = document.createElement(tag)で要素を作り、中にtextを入れ、最後にstyleを設定したdomを返していることが分かります。

どのようなtagTextNodeのDOMが作られているのかgetElementOuterTag, getElementInnerTagを見てみましょう。

LexicalTextNode.ts
function getElementOuterTag(node: TextNode, format: number): string | null {
  if (format & IS_CODE) {
    return 'code';
  }
  if (format & IS_HIGHLIGHT) {
    return 'mark';
  }
  if (format & IS_SUBSCRIPT) {
    return 'sub';
  }
  if (format & IS_SUPERSCRIPT) {
    return 'sup';
  }
  return null;
}

function getElementInnerTag(node: TextNode, format: number): string {
  if (format & IS_BOLD) {
    return 'strong';
  }
  if (format & IS_ITALIC) {
    return 'em';
  }
  return 'span';
}

この&は先ほども登場したbit演算になります。

このように、TextNode.__formatを見てtagを決めていることが分かります。

ちなみに公式の例にある色付き文字の拡張の実装を見ると、

ColoredNode.ts
export class ColoredNode extends TextNode {
  __color: string;

  constructor(text: string, color: string, key?: NodeKey): void {
    super(text, key);
    this.__color = color;
  }

  ... //

  createDOM(config: EditorConfig): HTMLElement {
    const element = super.createDOM(config);
    element.style.color = this.__color;
    return element;
  }

  updateDOM(
    prevNode: ColoredNode,
    dom: HTMLElement,
    config: EditorConfig,
  ): boolean {
    const isUpdated = super.updateDOM(prevNode, dom, config);
    if (prevNode.__color !== this.__color) {
      dom.style.color = this.__color;
    }
    return isUpdated;
  }
}

とあり、CSSを上手く当てることで簡単に色付けを行えることが分かります。ここまで理解すればNodeの拡張も容易に行えそうです。

まとめとしては、bitで保存しているTextNode.__formatを見ながらtagを決め、createElementでそのタグに対してDOMを作っています。このように、Node内で好きな値を持っておき、好きなDOMをレンダリングさせることが出来ます。ここまで書いてて思いましたが、Nodeの拡張をするときはDOMを意識しなきゃいけないですね。あれ?まあいいか。

最後に

Lexicalはいかがだったでしょうか?まだまだ公式ドキュメントやその他の記事が充実しているとは言い難いですが、直観的なデータ構造をしていて個人的にはとても気に入っています。

拡張性がある分はいいですが、DOMのあたりを意識せずにNodeを拡張できるようになると嬉しいですね。嬉しい人も勿論いると思いますが。

誤字脱字や、間違った情報や質問などありましたら気軽にコメントいただければと思います。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?