はじめに
本記事は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
で太字になる流れとしては、
-
ctrl+b
押下時、commandがdispatchされる - 登録していたcommandが走り、対象のNodeのformatが変換される
- そのNodeのformatを元に、再描画が走る
という流れになっています。以下では、この流れに沿って実装を見ていきます。
commandの登録及びイベント発火
まず、エントリポイントのRichTextPluginを見ていきます。
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
で、コマンドの登録が行われています。
//
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
自体はこのような実装になっており、中身の関数をただ実行するだけの関数です。
function mergeRegister(...func) {
return () => {
func.forEach(f => f());
};
}
registerRichText
の方に深堀していきましょう。
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, ~)
という部分が呼び出しに該当します。
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');
...//長い実装
}
このような形で、onKeyDown
でdispatchCommand
が呼び出されることが分かります。
では、どのような時に onKeyDown
が呼ばれるのでしょうか?
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
のコマンドが登録され、ContentEditable
のkeyDown
時に登録したコマンドが走ります。
フォーマットの仕方
フォーマットのされ方についても見ていきましょう。先ほど調べた通り、dispatchCommand
した際は、以下の関数が呼ばれます。
(format) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
selection.formatText(format);
return true;
},
この関数は、selection
を取得し、そのselection
のformatText
という関数を呼び出していますね。
formatTextには様々な分岐があって(選択ノードが無い場合、1個の場合、複数の場合)結構めんどくさいですが、全体的には、
- 選択された範囲に含まれるNodeに対して
TextNode.setFormat
を呼ぶ -
this.toggleFormat
を呼ぶ
の2つの操作を行います。実際の関数はこちらです。
まずは、TextNode.setFormat
から読んでいきましょう。
...
* @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
の方も見ていきましょう。
toggleFormat(type) {
const formatFlag = TEXT_TYPE_TO_FORMAT[type];
return this.setFormat(this.getFormat() ^ formatFlag);
}
このような実装になっています。どうやら現在のフォーマットと、今のフォーマットを演算して、新しいフォーマットとして設定しなおしていることがtoggleという命名と実装から読み取れます。
TEXT_TYPE_TO_FORMAT
はこのように定義されています。
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演算は、あらかじめどのような要素が来るか分かっているときに他のデータ構造よりも高速に要素の含有判定・追加・削除等が行えます。
簡単ですが速度の比較も行っていますので結果を載せておきます。
テスト
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演算を駆使しながらTextNode
の中でformat
を整数で保持していることが分かりました。
まとめると、登録したコマンドは選択範囲に対して対象のTextNode
を求め、そのTextNode
に対して指定したフォーマットを当てる関数でした。
レンダリングのされ方
TextNode
は、DOMのレンダリングの仕方がこの__format
によって決まっています。
各ノードは、そのノードがどのようにレンダリングされるかをcreateDOM
で実装する必要があります。TextNode.createDOM
の例を見てみましょう。
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を返していることが分かります。
どのようなtag
でTextNode
のDOMが作られているのかgetElementOuterTag, getElementInnerTag
を見てみましょう。
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
を決めていることが分かります。
ちなみに公式の例にある色付き文字の拡張の実装を見ると、
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を拡張できるようになると嬉しいですね。嬉しい人も勿論いると思いますが。
誤字脱字や、間違った情報や質問などありましたら気軽にコメントいただければと思います。