バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

リッチテキストエディタフレームワークLexicalの実践的な使い方

はじめに

こんにちは。開発2部の富山です。

今回は、リッチテキストエディタフレームワーク「Lexical」の実践的な使い方を紹介していきます。

現在、私はCMS(コンテンツ管理システム)の開発プロジェクトに携わっています。このCMSでは記事を書く機能を提供するために「Lexical」を採用しています。

基本的な使い方は、ドキュメントを見たりPlaygroundを参考にすれば実装できますが、複雑な実装になるとドキュメントが不十分であるため、開発に苦労することがありました。

そこで、Lexicalの実践的な使い方の知見を共有できたらと考えています。

Lexicalの概要等を説明すると長くなってしまうので、省かせていただきます。
以下の記事がLexicalを理解する上で、とても参考になります。

zenn.dev

EditorState, Editor, DOMでのLexicalのライフサイクルや、Node,Selectionといったデータ、Commandを使ったデータの更新フローなど、Lexicalの設計が紹介されています。

Lexicalで開発する上で躓いたこと

Lexicalでは様々な機能をプラグインとして提供しているので、それを使えば見出しや太字など基本的なリッチテキストエディタを作成することは、そこまで難しくありません。

そこから独自の機能を実装するのに試行錯誤したので3つの機能実装を紹介して、Lexicalの仕組みやメソッドの使い方などを共有させていただきます。

カスタムNodeの作成

Lexicalで用意されていないNodeの作成方法を紹介します。

Nodeの作成方法は、ドキュメントのCreating custom nodes という項目で紹介されています。

しかし、ドキュメントに従って作成したNodeを使った場合、エディタ上でコピー&ペーストしたり、データベースに保存するためエディタからデータを取得しようとすると、エラーが発生してしまいました。

エディタとして運用するためには、実装にもう少し手を加える必要がありました。

ですので具体的にどのような実装をしたのかを、背景色をつけることができるNode(ParagraphStyleNode)の作成を例に説明します。

カスタムNode

以下が必要な実装ステップになります。

これを基に骨組みを作成しました。

Custom Node
ParagraphStyleNode.tsx

// Custom Node
// getType, cloneは必ず定義する必要がある
export default class ParagraphStyleNode extends ParagraphNode {
  static getType() {
    return 'paragraphStyle'
  }

  static clone(node: ParagraphStyleNode): ParagraphStyleNode {
    return new ParagraphStyleNode(node.__key);
  }

  constructor(key?: NodeKey) {
    return new ParagraphStyleNode(key)
  }
  // カスタマイズしていく
}

エディタにNodeを挿入するためのCommand
ParagraphStylePlugin.tsx

// ParagraphStyleNodeを適用するためのCommand
// ParagraphStyleTypeは後ほど説明します。
export const PARAGRAPH_STYLE_COMMAND = createCommand<ParagraphStyleType>()

// エディタにCommandを登録
const ParagraphStylePlugin = () => {
  const [editor] = useLexicalComposerContext()

  useEffect(() => {
    return editor.registerCommand<ParagraphStyleType>(
      PARAGRAPH_STYLE_COMMAND,
      () => {
        // Commandが実行されたときの処理を書く
      },
      COMMAND_PRIORITY_EDITOR,
    )
  }, [editor])

  return null
}

export default ParagraphStylePlugin

Nodeを挿入できるボタンUI
ParagraphStyleTool.tsx

// Nodeの挿入を発火するためのボタンUI
// 色を選択できるようにしたいので、今回はドロップダウンボタン
const ParagraphStyleTool = () => {
  const changeStyle = () => {
    // Commandを実行する
  }

  return (
    <ToolbarDropdown>
      <ToolbarDropdown.Button>スタイル</ToolbarDropdown.Button>
      <ToolbarDropdown.Menu>
        {Object.entries(ParagraphStyleTypes).map(([key, value]) => (
          <ToolbarDropdown.Item
            onClick={() => changeStyle()}
            key={key}
          >
            {value}
          </ToolbarDropdown.Item>
        ))}
      </ToolbarDropdown.Menu>
    </ToolbarDropdown>
  )
}

export default ParagraphStyleTool

エディタに作成したNodeを登録
Editor.tsx

const Editor = () => {
  const initialConfig: InitialConfigType = {
    namespace: 'Editor',
    onError,
    nodes: [
      // Editorに作成したNodeの登録
      ParagraphStyleNode,
      // ... 他にリッチテキストエディタで使うNodeが入っている
    ],
    theme: editorTheme,
  }
  return (
    <LexicalComposer initialConfig={initialConfig}>
      <RichTextPlugin
        contentEditable={<ContentEditable />}
        placeholder={<div>Enter some text...</div>}
        ErrorBoundary={LexicalErrorBoundary}
      />
      {/* プラグインをエディタに読み込ませる */}
      <ParagraphStylePlugin />
      {/* ... */}
    </LexicalComposer>
  )
}

用意するファイルは以上になります。
あとは作りたいモノにあわせて肉付けをしていきます。

(最終的な完成コードは、この章の最後に貼ります)

Custom Nodeの作成 (ParagraphStyleNode)

Nodeにはエディタ上での見た目(DOMElement)や動作を実装します。

作成するのは、色付きのボックスといえば伝わるでしょうか。
テキストを囲う枠が作成するParagraphStyleNodeとなっており、テキストはTextNodeという別のNodeになっています。

テキストを囲う枠が作成するParagraphStyleNode

ステップ1 背景色をNodeに持たせる

背景色を持つNodeを作成したいので、
事前に背景色を用意(ParagraphStyleType)しておきます。

export const ParagraphStyleTypes = {
  default: 'デフォルト',
  info: '補足説明',
  success: '補足説明(サクセス)',
  warning: '補足説明(警告)',
  error: '補足説明(注意)',
} as const
export type ParagraphStyleType = keyof typeof ParagraphStyleTypes

そして、背景色をParagraphStyleNodeが持てるようにすれば良いので、適当なプロパティ__styleType: ParagraphStyleTypeを追加します。

export default class ParagraphStyleNode extends ParagraphNode {
  __styleType: ParagraphStyleType = 'default'

  static clone(node: ParagraphStyleNode) {
    return new ParagraphStyleNode(node.__styleType, node.__key)
  }

  constructor(styleType?: ParagraphStyleType, key?: NodeKey) {
    super(key)
    if (styleType) {
      this.__styleType = styleType
    }
  }
  // ...
}

Lexicalのコードを見るとプロパティの先頭__をつけるようにしていたので、それに倣います。

また、インスタンス化するときに、__styleTypeが設定できるようにしました。

ステップ2 DOMへの出力設定を追加

次にParagraphStyleNodeがDOM上にどのように出力されるか(createDOM)を実装します。

ParagraphNodeが出力する要素に加えて、スタイルを反映できるようにclassdata-paragraph-styleを追加で設定しました。

クラス名はDOM上でParagraphStyleNodeだと判別するために追加し、データ属性data-paragraph-styleは枠の色を判別するために追加しました。

export default class ParagraphStyleNode extends ParagraphNode {
  createDOM(config: EditorConfig) {
    const dom = super.createDOM(config)
    dom.dataset['paragraphStyle'] = this.__styleType
    addClassNamesToElement(dom, 'paragraphStyle')

    return dom
  }
}

createDOMの返り値が出力されるElementとなり、ブラウザ上では以下のように出力されます。

<p data-paragraph-style="info" class="paragraphStyle" dir="ltr">
  <span data-lexical-text="true">枠で囲まれたParagraphNode</span>
</p>
<!-- spanはTextNode部分の出力 -->

ステップ3 エディタで操作するために必要な設定を追加

ここまでの実装でエディタへの表示はできるようになりました。

しかし、ParagraphStyleNodeをエディタ上でコピー&ペーストしたり、エディタの内容をデータベースに保存するためeditorState.toJSON()などと何かしらの操作をしようとするとエラーを吐いてしまいます。

ParagraphStyleNodeをコピペしようとするとエラー

こういった操作に対応するため、importJSONexportJSONを実装する必要があります。

importJSONは、JSON→Nodeに変換し、exportJSONは、Node→JSONに変換するメソッドとなっています。
コピーする際は、exportJSONが実行され、ペーストする際はimportJSONが実行されることで、コピー&ペーストが可能になります。

また、ParagraphStyleNodeがJSONだとどのような型になるかをtype SerializedParagraphStyleNodeとして定義しました。

export default class ParagraphStyleNode extends ParagraphNode {
  // extends元と型が違うと怒られるのでオーバーロードする
  static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode
  static importJSON(
    serializedNode: SerializedParagraphStyleNode,
  ): ParagraphStyleNode
  static importJSON(
    serializedNode: SerializedParagraphNode | SerializedParagraphStyleNode,
  ) {
    if (serializedNode.type === 'paragraph') {
      return $createParagraphNode()
    } else {
      const { styleType } = serializedNode
      const node = $createParagraphStyleNode(styleType)
      return node
    }
  }

  exportJSON(): SerializedParagraphStyleNode {
    return {
      ...super.exportJSON(),
      type: NAME,
      styleType: this.__styleType,
      version: 1,
    }
  }
}

export type SerializedParagraphStyleNode = Spread<
  {
    type: NAME
    styleType?: ParagraphStyleType
    version: 1
  },
  SerializedElementNode
>

// Lexicalのソースに倣って$をつける
export const $createParagraphStyleNode = (styleType?: ParagraphStyleType) => {
  return $applyNodeReplacement<ParagraphStyleNode>(
    new ParagraphStyleNode(styleType),
  )
}

ステップ4 HTMLに変換するための設定を追加

エディタ上ではLexicalのデータとして扱うだけで良いのですが、エディタで書いた記事をユーザーに表示させるためにはHTML変換する必要があります。
HTMLに変換するにはNodeに、importDOM, exportDOMを実装する必要があります。

参考:https://lexical.dev/docs/concepts/serialization

export default class ParagraphStyleNode extends ParagraphNode {
  static importDOM(): DOMConversionMap | null {
    return {
      p: (node) => {
        if (!node.dataset['paragraphStyle']) {
          return null
        }
        return {
          conversion: convertParagraphStyleElement,
          priority: 1,
        }
      },
    }
  }

  exportDOM(editor: LexicalEditor): DOMExportOutput {
    const { element } = super.exportDOM(editor)
    if (element) {
      element.classList.add(NAME)
      element.dataset[NAME] = this.__styleType
    }

    return {
      element,
    }
  }
}

function convertParagraphStyleElement(domNode: Node): DOMConversionOutput {
  let node = null
  if (
    domNode instanceof HTMLParagraphElement &&
    domNode.classList.contains(NAME) &&
    isParagraphStyleType(domNode.dataset[NAME])
  ) {
    node = $createParagraphStyleNode(domNode.dataset[NAME])
  }
  return { node }
}

const isParagraphStyleType = (str?: string): str is ParagraphStyleType => {
  return Object.keys(ParagraphStyleTypes).includes(str || '')
}

以上がカスタムNodeを作成する上で必要な実装になります。

エディタにNodeを挿入するためのCommandの作成

次は作成したParagraphStyleNodeをエディタ上に挿入するためのCommandの実装に入ります。

骨組みで作成したParagraphStylePlugin.tsxに追記していきます。

Commandが実行されたら、現在のカーソルのある部分をParagraphStyleNodeに置換する処理をするように記述していきます。

  useEffect(() => {
    return editor.registerCommand<ParagraphStyleType>(
      PARAGRAPH_STYLE_COMMAND,
      (style) => {
        const selection = $getSelection()
        if (!$isRangeSelection(selection)) {
          return false
        }
        if (style === 'default') {
          $setBlocksType_experimental(selection, () => $createParagraphNode())
        } else {
          $setBlocksType_experimental(selection, () =>
            $createParagraphStyleNode(style),
          )
        }
        return true
      },
      COMMAND_PRIORITY_EDITOR,
    )
  }, [editor])

editor.registerCommandは、PARAGRAPH_STYLE_COMMANDが発火したときの処理をエディタに登録するためのメソッドです。

Selectionで置換可能かを判断

まず、現在のカーソル位置を取得して、ParagraphStyleNodeを挿れることができるのかを確認します。

const selection = $getSelection()
if (!$isRangeSelection(selection)) {
  return false
}

$getSelectionでカーソル情報を取得。
$isRangeSelection(selection)でSelectionのタイプがRangeSelectionかを確認して、違うなら処理を終了させています。

Selectionはカーソル位置情報のことで、RangeSelectionNodeSelectionGridSelectionの3タイプあります。

  • テキストにカーソルがあたっているとRangeSelectionになります。

  • 画像やTwitter埋め込み等のDecoratorNodeを選択していると、NodeSelectionになります。 NodeSelectionを選択

  • 表(<table>)を選択していると、GridSelectionになります。
    GridSelectionを選択

なので、今回のParagraphStyleNodeは画像や表を選択しているときは適用したくないので、RangeSelection以外は処理を終了させました。

ParagraphStyleNodeに変換

次に、selection部分にParagraphStyleNodeを適用させます。

style === 'default'の場合は通常のParagraphNodeとして動作してほしいので条件分岐させ、$setBlocksType_experimentalParagraphStyleNodeを適用しています。

if (style === 'default') {
  $setBlocksType_experimental(selection, () => $createParagraphNode())
} else {
  $setBlocksType_experimental(selection, () =>
    $createParagraphStyleNode(style),
  )
}

$setBlocksType_experimentalはLexicalが用意してくれているメソッドで、selectionのあるNodeを第2引数で指定したNodeに変換してくれます。なので、ParagraphStyleNodeを返すようにしています。

$setBlocksType_experimentalとなっていますが、最新バージョンではexperimentalが外れていました。

priorityを付ける

適当なpriorityを付けてCommandは完成です。

COMMAND_PRIORITY_EDITOR

Lexicalのコード上でpriotiryは以下のように定義されており、0~4を指定できて、数字の大きい方が優先的に適応されます。

export declare type CommandListenerPriority = 0 | 1 | 2 | 3 | 4;
export declare const COMMAND_PRIORITY_EDITOR = 0;
export declare const COMMAND_PRIORITY_LOW = 1;
export declare const COMMAND_PRIORITY_NORMAL = 2;
export declare const COMMAND_PRIORITY_HIGH = 3;
export declare const COMMAND_PRIORITY_CRITICAL = 4;

機能が重複したときに優先順位を考慮したpriorityを付ける必要がありますが、作業していて0か1を使っておけば問題ありませんでした。

Nodeを挿入できるボタンUIの作成

Lexicalにはボタンは用意されていないので、アプリケーションのUIに合ったボタンを各自で用意します。

そして、ボタンを押したときにCommandが発火するように、editor.dispatchCommandで実行させます。

ParagraphStyleTool.tsx

const ParagraphStyleTool = () => {
  const changeStyle = () => {
    editor.dispatchCommand(PARAGRAPH_STYLE_COMMAND, style)
  }
  // ...
}

エディタに作成したNodeを登録

作成したParagraphStyleNodeをエディタで使えるようにするためには、Nodeをエディタに登録する必要があるので、LexicalComposerinitialConfigにNodeを追加します。

また、ParagraphStylePlugin.tsxに書いたCommandをエディタで使えるようにするためにParagraphStylePluginを追加します。

const initialConfig: InitialConfigType = {
  // ...
  nodes: [
    // Editorに作成したNodeの登録
    ParagraphStyleNode,
    // ... 他にリッチテキストエディタで使うNodeが入っている
  ],
}
return (
  <LexicalComposer initialConfig={initialConfig}>
    {/* ... */}
    {/* プラグインをエディタに読み込ませる */}
    <ParagraphStylePlugin />
    {/* ... */}
  </LexicalComposer>
)

ParagraphStyleNodeの完成コード

ParagraphStyleNode.tsx

export const ParagraphStyleTypes = {
  default: 'デフォルト',
  info: '補足説明',
  success: '補足説明(サクセス)',
  warning: '補足説明(警告)',
  error: '補足説明(注意)',
} as const
export type ParagraphStyleType = keyof typeof ParagraphStyleTypes

const NAME = 'paragraphStyle' as const
type NAME = typeof NAME

export type SerializedParagraphStyleNode = Spread<
  {
    type: NAME
    styleType?: ParagraphStyleType
    version: 1
  },
  SerializedElementNode
>

export default class ParagraphStyleNode extends ParagraphNode {
  __styleType: ParagraphStyleType = 'default'

  static getType() {
    return NAME
  }

  static clone(node: ParagraphStyleNode) {
    return new ParagraphStyleNode(node.__styleType, node.__key)
  }

  static importDOM(): DOMConversionMap | null {
    return {
      p: (_node) => {
        return {
          conversion: convertParagraphStyleElement,
          priority: 0,
        }
      },
    }
  }

  // extend元と型が違うと怒られるのでオーバーロードする
  static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode
  static importJSON(
    serializedNode: SerializedParagraphStyleNode,
  ): ParagraphStyleNode
  static importJSON(
    serializedNode: SerializedParagraphNode | SerializedParagraphStyleNode,
  ) {
    if (serializedNode.type === 'paragraph') {
      return $createParagraphNode()
    } else {
      const { styleType } = serializedNode
      const node = $createParagraphStyleNode(styleType)
      return node
    }
  }

  constructor(styleType?: ParagraphStyleType, key?: NodeKey) {
    super(key)
    if (styleType) {
      this.__styleType = styleType
    }
  }

  // DOMに表示されるElementを作成する
  createDOM(config: EditorConfig) {
    const dom = super.createDOM(config)
    dom.dataset[NAME] = this.__styleType
    addClassNamesToElement(dom, NAME)

    return dom
  }

  exportDOM(editor: LexicalEditor): DOMExportOutput {
    const { element } = super.exportDOM(editor)
    if (element) {
      element.classList.add(NAME)
      element.dataset[NAME] = this.__styleType
    }

    return {
      element,
    }
  }

  exportJSON(): SerializedParagraphStyleNode {
    return {
      ...super.exportJSON(),
      type: NAME,
      styleType: this.__styleType,
      version: 1,
    }
  }

  // 中のテキストをコピーしたときに、カラーボックスのスタイルも引き継ぐ
  // falseだとコピペしたときに、通常のParagraphNodeになってしまう
  extractWithChild(): boolean {
    return true
  }
}

export const $createParagraphStyleNode = (styleType?: ParagraphStyleType) => {
  return $applyNodeReplacement<ParagraphStyleNode>(
    new ParagraphStyleNode(styleType),
  )
}

function convertParagraphStyleElement(domNode: Node): DOMConversionOutput {
  let node = null
  if (
    domNode instanceof HTMLParagraphElement &&
    domNode.classList.contains(NAME) &&
    isParagraphStyleType(domNode.dataset[NAME])
  ) {
    node = $createParagraphStyleNode(domNode.dataset[NAME])
  }
  return { node }
}

const isParagraphStyleType = (str?: string): str is ParagraphStyleType => {
  return Object.keys(ParagraphStyleTypes).includes(str || '')
}

ParagraphStyleTool.tsx

const ParagraphStyleTool = () => {
  const [editor] = useLexicalComposerContext()
  const currentBlockType = useCurrentBlockType()

  const changeStyle = (style: ParagraphStyleType) => {
    editor.dispatchCommand(PARAGRAPH_STYLE_COMMAND, style)
  }

  if (currentBlockType !== 'paragraph') {
    return null
  }

  return (
    <ToolbarDropdown>
      <ToolbarDropdown.Button>スタイル</ToolbarDropdown.Button>
      <ToolbarDropdown.Menu>
        {Object.entries(ParagraphStyleTypes).map(([key, value]) => (
          <ToolbarDropdown.Item
            onClick={() => changeStyle(key as ParagraphStyleType)}
            key={key}
          >
            {value}
          </ToolbarDropdown.Item>
        ))}
      </ToolbarDropdown.Menu>
    </ToolbarDropdown>
  )
}

ParagraphStylePlugin.tsx

export const PARAGRAPH_STYLE_COMMAND = createCommand<ParagraphStyleType>()

const ParagraphStylePlugin = () => {
  const [editor] = useLexicalComposerContext()

  useEffect(() => {
    return editor.registerCommand<ParagraphStyleType>(
      PARAGRAPH_STYLE_COMMAND,
      (style) => {
        const selection = $getSelection()
        if (!$isRangeSelection(selection)) {
          return false
        }
        if (style === 'default') {
          $setBlocksType_experimental(selection, () => $createParagraphNode())
        } else {
          $setBlocksType_experimental(selection, () =>
            $createParagraphStyleNode(style),
          )
        }
        return true
      },
      COMMAND_PRIORITY_EDITOR,
    )
  }, [editor])

  return null
}

Editor.tsx

const Editor = () => {
  const initialConfig: InitialConfigType = {
    namespace: 'Editor',
    onError,
    nodes: [
      // Editorに作成したNodeの登録
      ParagraphStyleNode,
      // ... 他にリッチテキストエディタで使うNodeが入っている
    ],
    theme: editorTheme,
  }
  return (
    <LexicalComposer initialConfig={initialConfig}>
      <RichTextPlugin
        contentEditable={<ContentEditable />}
        placeholder={<div>Enter some text...</div>}
        ErrorBoundary={LexicalErrorBoundary}
      />
      {/* プラグインをエディタに読み込ませる */}
      <ParagraphStylePlugin />
      {/* ... */}
    </LexicalComposer>
  )
}

今回は省略しますが、class, attributeに合わせてcssを書けば画像のような色付きブロックをエディタで使えるようになります。

ParagraphStyleNode

自動リンクプラグインAutoLinkの作成

続いては、URLを入力して改行すると自動的にリンクになるプラグインAutoLinkPluginの作成方法を紹介します。

以下は作成したAutoLinkPluginの動作です。

AutoLinkPluginの動作

Lexicalでも自動的にリンクにしてくれるLexicalAutoLinkPluginを用意してくれています。
しかし、URLの文字列は強制的にリンクになってしまうし、リンクの解除もできません。

以下はLexicalAutoLinkPluginの動作例です。

LexicalAutoLinkPluginの動作例

このデメリットを解消するために、自動リンクのプラグインを自作しました。

Lexicalが用意している@lexical/linkLinkPluginが導入されており、リンクを操作するボタン類も揃っている状態で説明を進めます(Playgroundでもリンク機能は実装されているので参考にしてください)。

作成する機能は、「改行したときに、文字列がURLならLinkNodeに変換する」です。

変換条件として以下を定めました。

  • カーソルが文末にあること
  • 範囲選択をしていないこと
  • 既にリンクではないこと
  • テキストがURL文字列であること

まずは完成コードをご覧ください。

const AutoLinkPlugin = () => {
  const [editor] = useLexicalComposerContext()

  useEffect(() => {
    editor.registerCommand(
      KEY_ENTER_COMMAND,
      () => {
        const selection = $getSelection()
        if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
          return false
        }
        const anchorNode = selection.anchor.getNode()
        if (!$isTextNode(anchorNode)) {
          return false
        }
        const parent = anchorNode.getParentOrThrow()
        // 行末にカーソルがいるか
        if (
          !parent.getLastChild()?.is(anchorNode) ||
          !$isAtNodeEnd(selection.anchor)
        ) {
          return false
        }

        // すでにリンクなら無視
        if (
          $isLinkNode(parent) ||
          parent
            .getChildren()
            .some((child) => $isLinkNode(child))
        ) {
          return false
        }

        // リンクに変換
        const text = node.getTextContent()
        // URL文字列かバリデーションチェック
        if (!validateUrl(text)) {
          return
        }
        const linkNode = $createLinkNode(text)
        linkNode.append(...node.getChildren())
        node.append(linkNode)
        return true
      },
      COMMAND_PRIORITY_LOW,
    )
  }, [editor])

  return null
}

ひとつずつ解説していきます。

Commandを使って改行を検知

まずは、実行タイミングとして、KEY_ENTER_COMMANDを使っています。

editor.registerCommand(
  KEY_ENTER_COMMAND,
  () => {
    // ...
  },
  COMMAND_PRIORITY_LOW,
)

Lexicalではkeydownイベントで発火するCommandをいくつか用意しています。

KEY_ENTER_COMMANDに対して処理を書くことで、Enterキーが押されたときに以下の処理を実行できます。

priorityはCOMMAND_PRIORITY_LOWとしているのは、改行される前にこの処理を実行してほしいので、0より大きいpriorityを指定しました。

リンクに変換しない条件

続いて、Enterキーを押されたときの処理部分ですが、最初の20行くらいは、リンクに変換しないパターンを書いています。

  • RangeSelectionでない
  • 範囲選択していない
  • 行末にカーソルがない
  • すでにリンクになっている

以上の場合は、処理を終了させています。

selection.isCollapsed()は、選択範囲の始点と終点が同じ位置にあるかを返します。

selection.anchorというのは、選択範囲の始点の情報を持つプロパティです。そしてgetNode()で始点のNodeを取得して、行末にカーソルがないかとか、すでにリンクになっているかの確認をしています。

このanchorという考え方はLexical独自のものではなく、ブラウザ標準のSelectionAPIに似ています。
anchor, collapseといったプロパティの意味合いは、SelectionAPI関連の記事を読むと理解しやすくなるのでおすすめです。

リンクに変換する

そして、中断処理をしたあとに、テキストをリンクに変換する処理を書いています。

const text = node.getTextContent()
// URL文字列かバリデーションチェック
if (!validateUrl(text)) {
  return
}
const linkNode = $createLinkNode(text)
linkNode.append(...node.getChildren())
node.append(linkNode)

node.getTextContent()でNode内のテキストだけを取り出すことができます。

例えば以下のようにHTMLタグが乱立しているNodeだったとしても、文字列の部分だけを取得できます。

<div><b>https://</b><span>sample<strong>.com</strong></span></div>
=> https://sample.com

URL文字列かを確認しているvalidateUrlは自分で作成していて、以下のようにしました。

export const validateUrl = (url: string) => {
  return (
    url === 'https://' ||
    z
      .string()
      .url()
      .refine((str) => {
        try {
          const url = new URL(str)
          return /^(https?|tel|mailto):$/.test(url.protocol)
        } catch {
          // silent
          // strがURL APIにとって不正な文字列だとエラー吐くのでtry..catchでエラーを無視する
        }
      })
      .safeParse(url).success
  )
}

zodでのバリデーションに加えて、http, tel, mailtoで始まる文字に制限しています。

zodのurlメソッドでURLチェックできると思いきや、javascript:...といった文字列も通ってしまいXSS攻撃される危険性があるので、しっかりと対処しました。

バリデーションを通ったら、取り出したテキストを使ってLinkNodeに変換しました。

const linkNode = $createLinkNode(text)
linkNode.append(...node.getChildren())
node.append(linkNode)

これが何をしているかはHTMLで見ると理解しやすいかもしれません。
変換前と変換後をHTMLで表現すると以下のようになります。

<!-- 変換前のnode -->
<div><b>https://</b><span>sample<strong>.com</strong></span></div>
<!-- 変換後のnode -->
<div><a href="https://sample.com"><b>https://</b><span>sample<strong>.com</strong></span></a></div>

$createLinkNodeでLinkNodeを作成して、nodeとその子要素(TextNodeなど)の間にLinkNodeを挟むことで、テキストをリンクに変換しています。

以上で、改行した際にURL文字列をリンクに変換するプラグインを作成できました。

LexicalAutoLinkPluginと違って、こちらはただのLinkNodeなのでリンクを解除する機能を実装していれば、リンクにしたくない文字列の場合はリンクの解除も可能です。

リンクを解除することも可能

HTMLへの変換

LexicalのデータをHTMLに変換する関数(generateHtml)を紹介します。

エディタ上ではLexicalのデータ(EditorState)を扱うだけで良いのですが、エディタで書いた記事をユーザーに表示させるため、HTMLに変換する必要があります。

エディタで書いた記事をユーザーに表示させるため、HTMLに変換する必要がある

LexicalではHTML変換するために、@lexical/htmlを提供しています。
その中の$generateHtmlFromNodesを使えばLexicalEditorからHTMLの出力が可能です。

以下がHTMLに変換するコードです。

const generateHtml = (data: string) => {
  const editor = createEditor({
    nodes: [...allNodes],
    onError: (error) => {
      throw error
    },
  })
  const editorState = editor.parseEditorState(data)
  const html = editorState.read(() => $generateHtmlFromNodes(editor))
  return html
}

いくつか躓いたポイントがあるので説明します。

まず、editorを新たに作成している点です。

なぜeditorを新たに作成しているかというと、どこからでもこのHTML生成関数を呼び出せるようにしたかったからです。

もし、editorを新たに作成せず既存のeditorを持ってくる場合は、const [editor] = useLexicalComposerContext()といったhooksを使ってeditorを呼び出す方法があります。
しかし、<LexicalComposer>...</LexicalComposer>で囲った範囲でしか使えなくなってしまいます。

そうなると、エディタが表示されている画面でしか変換処理が行えなくなり、限定的な機能になってしまうので、上記のように新しくeditorを作成する手段を取りました。

エディタ作成で重要なポイントは、利用する全てのNodeをオプションで設定する必要があります。

nodesオプションに、自作したParagraphStyleNodeLinkNodeなど、他にもリッチテキストエディタに必要なNodeを読み込ませます。

createEditor({
  nodes: [...allNodes]
})

editorに読み込まれていないNodeが入力データに使われていると、そのNodeが何なのか、どう出力するかが不明でエラーになってしまうので、必ず全てのNodeを読み込ませてください。

editorに読み込まれていないNodeがあるとエラーになる

$generateHtmlFromNodesは、editor.updateeditorState.readeditor.registerCommand内でしか使えないことも注意してください。

それ以外のところで使おうとするとエラーになってしまいます。

まとめ

Lexicalの実践的な使い方として、カスタムNodeの作成、自動リンクプラグインAutoLinkの作成、HTMLへの変換を紹介させていただきました。

Lexicalではたくさんの機能を提供してくれてはいるのですが、メソッド使い方を理解するにはドキュメントに加えてコードから読み取る必要があり、開発に時間が掛かってしまいました。

ドキュメントにプロパティ・メソッド名は紹介されているが、説明は書かれていない。

たくさんある機能の一部ではありますが、作り方を通して、こんなメソッドあるんだとか・そうやって使うんだとか、少しでもLexicalの理解の役に立てたら幸いです。

最後まで読んでいただきありがとうございました。