Open13

Lexical で実用的なマークダウンエディタを作るまで

ichigoichigo

PlainTextPlugin

本当にただのテキスト。段落とかの概念がない。
ref. https://lexical.dev/docs/react/plugins#lexicalplaintextplugin

コード
import { EditorState } from "lexical";

import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
// When the editor changes, you can get notified via the
// LexicalOnChangePlugin!
function onChange(editorState: EditorState) {
  console.log(editorState);
}

function onError(error: Error) {
  console.error(error);
}

function Editor() {
  const initialConfig = {
    namespace: "demo",
    onError,
  };

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

export default Editor;
EditorState
EditorState {_nodeMap: Map(7), _selection: RangeSelection, _flushSync: false, _readOnly: true}
_flushSync: false
_nodeMap: Map(7)
[[Entries]]
0: {"root" => RootNode}
key: "root"
value: RootNode {__type: 'root', __parent: null, __key: 'root', __children: Array(1), __format: 0,}
1: {"2" => ParagraphNode}
key: "2"
value: ParagraphNode {__type: 'paragraph', __parent: 'root', __key: '2', __children: Array(5), __format: 0,}
2: {"13" => TextNode}
key: "13"
value: TextNode {__type: 'text', __parent: '2', __key: '13', __text: 'hogehoge', __format: 0,}
3: {"14" => LineBreakNode}
key: "14"
value: LineBreakNode {__type: 'linebreak', __parent: '2', __key: '14'}
4: {"15" => TextNode}
key: "15"
value: TextNode {__type: 'text', __parent: '2', __key: '15', __text: 'fugafuga', __format: 0,}
5: {"16" => LineBreakNode}
key: "16"
value: LineBreakNode {__type: 'linebreak', __parent: '2', __key: '16'}
6: {"17" => TextNode}
key: "17"
value: TextNode {__type: 'text', __parent: '2', __key: '17', __text: 'boo', __format: 0,}
clear: () => {}
delete: () => {}
set: () => {}
size: 7
[[Prototype]]: Map
_readOnly: true
_selection: RangeSelection
anchor: Point {_selection: RangeSelection, key: '17', offset: 3, type: 'text'}
dirty: true
focus: Point {_selection: RangeSelection, key: '17', offset: 3, type: 'text'}
format: 0
_cachedNodes: null
[[Prototype]]: Object
[[Prototype]]: Object
ichigoichigo

RichTextPlugin

ref. https://lexical.dev/docs/react/plugins#lexicalrichtextplugin

  • indent/outdent
  • bold
  • italic
  • underline
  • strikethrough

ができるようになる

段落の概念が生まれた。(hogehoge → fuga は Enter 押した、fuga → fuga は shift + enter)

コード
import { EditorState } from "lexical";

import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
// When the editor changes, you can get notified via the
// LexicalOnChangePlugin!
function onChange(editorState: EditorState) {
  console.log(editorState);
}

function onError(error: Error) {
  console.error(error);
}

function Editor() {
  const initialConfig = {
    namespace: "demo",
    onError,
  };

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <RichTextPlugin
        contentEditable={
          <ContentEditable className="TableNode__contentEditable" />
        }
        placeholder={"something"}
      />
      <OnChangePlugin onChange={onChange} />
      <HistoryPlugin />
    </LexicalComposer>
  );
}

export default Editor;


EditorState
EditorState {_nodeMap: Map(7), _selection: RangeSelection, _flushSync: false, _readOnly: true}
_flushSync: false
_nodeMap: Map(7)
[[Entries]]
0: {"root" => RootNode}
1: {"1" => ParagraphNode}
2: {"15" => TextNode}
key: "15"
value: TextNode {__type: 'text', __parent: '1', __key: '15', __text: 'hogehoge', __format: 0,}
3: {"16" => ParagraphNode}
key: "16"
value: ParagraphNode {__type: 'paragraph', __parent: 'root', __key: '16', __children: Array(3), __format: 0,}
4: {"17" => TextNode}
key: "17"
value: TextNode {__type: 'text', __parent: '16', __key: '17', __text: 'fuga', __format: 0,}
5: {"18" => LineBreakNode}
6: {"19" => TextNode}
key: "19"
value: TextNode {__type: 'text', __parent: '16', __key: '19', __text: 'fuga', __format: 0,}
clear: () => {}
delete: () => {}
set: () => {}
size: 7
[[Prototype]]: Map
_readOnly: true
_selection: RangeSelection
anchor: Point {_selection: RangeSelection, key: '19', offset: 4, type: 'text'}
dirty: true
focus: Point {_selection: RangeSelection, key: '19', offset: 4, type: 'text'}
format: 0
_cachedNodes: null
[[Prototype]]: Object
[[Prototype]]: Object
ichigoichigo

AutoFocus

ページロード時に勝手にフォーカスが当たる

コード
import { EditorState } from "lexical";

import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";

// When the editor changes, you can get notified via the
// LexicalOnChangePlugin!
function onChange(editorState: EditorState) {
  console.log(editorState);
}

function onError(error: Error) {
  console.error(error);
}

function Editor() {
  const initialConfig = {
    namespace: "demo",
    onError,
  };
  return (
    <LexicalComposer initialConfig={initialConfig}>
      <RichTextPlugin
        contentEditable={
          <ContentEditable className="TableNode__contentEditable" />
        }
        placeholder={"something"}
      />
      <AutoFocusPlugin />
      <OnChangePlugin onChange={onChange} />
    </LexicalComposer>
  );
}

export default Editor;

ichigoichigo

LexicalOnChangePlugin

ref. https://lexical.dev/docs/react/plugins#lexicalonchangeplugin

Plugin that calls onChange whenever Lexical state is updated. Using ignoreInitialChange (true by default) and ignoreSelectionChange (false by default) can give more granular control over changes that are causing onChange call

<OnChangePlugin onChange={onChange} />

EditorState の更新にHookできる

LexicalHistoryPlugin

ref. https://lexical.dev/docs/react/plugins#lexicalhistoryplugin

React wrapper for @lexical/history that adds support for history stack management and undo / redo commands

<HistoryPlugin />

Undo/Redo コマンドを利用するのに必要(内部でHistoryStateというスタックをいい感じにしてくれる)
これをおくだけでctrl-zができるようになる

ichigoichigo

ツールバーの作成

ここからは heading, list などを扱っていくので、それらのノードを作り出すための button を用意する。
参考 https://zenn.dev/stin/articles/lexical-rich-editor-trial#見出し入力機能を実装する

コード
import {
  $getSelection,
  $isRangeSelection,
  EditorState,
  Klass,
  LexicalNode,
} from "lexical";

import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { HeadingNode } from "@lexical/rich-text";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { useCallback, useState } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $wrapLeafNodesInElements } from "@lexical/selection";
import { HeadingTagType, $createHeadingNode } from "@lexical/rich-text";

function ToolbarPlugin() {
  const SupportedBlockType = {
    paragraph: "Paragraph",
    h1: "Heading 1",
    h2: "Heading 2",
    h3: "Heading 3",
    h4: "Heading 4",
    h5: "Heading 5",
    h6: "Heading 6",
  } as const;
  type BlockType = keyof typeof SupportedBlockType;

  const [blockType, setBlockType] = useState<BlockType>("paragraph");
  const [editor] = useLexicalComposerContext();
  const formatHeading = useCallback(
    (type: HeadingTagType) => {
      if (blockType !== type) {
        editor.update(() => {
          const selection = $getSelection();
          if ($isRangeSelection(selection)) {
            $wrapLeafNodesInElements(selection, () => $createHeadingNode(type));
          }
        });
        setBlockType(type);
      }
    },
    [blockType, editor]
  );

  return (
    <div>
      <button type="button" onClick={() => formatHeading("h1")}>
        h1
      </button>
      <button type="button" onClick={() => formatHeading("h2")}>
        h2
      </button>
      <button type="button" onClick={() => formatHeading("h3")}>
        h3
      </button>
    </div>
  );
}

export default Editor;
ichigoichigo

HeadingNode

h1~h6 を描画する。
ref. https://lexical.dev/docs/concepts/nodes#elementnode

EditorState のもつ node tree に h1 用のNodeを入れる必要があるが、Lexical はデフォルトでは ElementNode, TextNode, DecoratorNode にしか対応していない。
ので、LexicalComposer の initialConfig に追加の node を渡してあげる必要がある。

import { HeadingNode } from "@lexical/rich-text";
const nodes: Klass<LexicalNode>[] = [HeadingNode];
コード
import {
  $getSelection,
  $isRangeSelection,
  EditorState,
  Klass,
  LexicalNode,
} from "lexical";

import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { HeadingNode } from "@lexical/rich-text";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { useCallback, useState } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $wrapLeafNodesInElements } from "@lexical/selection";
import { HeadingTagType, $createHeadingNode } from "@lexical/rich-text";

// When the editor changes, you can get notified via the
// LexicalOnChangePlugin!
function onChange(editorState: EditorState) {
  console.log(editorState);
}

function onError(error: Error) {
  console.error(error);
}

function Editor() {
  const nodes: Klass<LexicalNode>[] = [HeadingNode];

  const initialConfig = {
    namespace: "demo",
    nodes,
    onError,
  };
  return (
    <LexicalComposer initialConfig={initialConfig}>
      <ToolbarPlugin />

      <div>
        <RichTextPlugin
          contentEditable={
            <ContentEditable className="TableNode__contentEditable" />
          }
          placeholder={"something"}
        />
        <AutoFocusPlugin />
        <OnChangePlugin onChange={onChange} />
        <HistoryPlugin />
      </div>
    </LexicalComposer>
  );
}

function ToolbarPlugin() {
  const SupportedBlockType = {
    paragraph: "Paragraph",
    h1: "Heading 1",
    h2: "Heading 2",
    h3: "Heading 3",
    h4: "Heading 4",
    h5: "Heading 5",
    h6: "Heading 6",
  } as const;
  type BlockType = keyof typeof SupportedBlockType;

  const [blockType, setBlockType] = useState<BlockType>("paragraph");
  const [editor] = useLexicalComposerContext();
  const formatHeading = useCallback(
    (type: HeadingTagType) => {
      if (blockType !== type) {
        editor.update(() => {
          const selection = $getSelection();
          if ($isRangeSelection(selection)) {
            $wrapLeafNodesInElements(selection, () => $createHeadingNode(type));
          }
        });
        setBlockType(type);
      }
    },
    [blockType, editor]
  );

  return (
    <div>
      <button type="button" onClick={() => formatHeading("h1")}>
        h1
      </button>
      <button type="button" onClick={() => formatHeading("h2")}>
        h2
      </button>
      <button type="button" onClick={() => formatHeading("h3")}>
        h3
      </button>
    </div>
  );
}

export default Editor;


ichigoichigo

QuoteNode

HeadingNode 同様に QuoteNode を描画する。

コード

import {
$createParagraphNode,
$getSelection,
$isRangeSelection,
EditorState,
Klass,
LexicalNode,
} from "lexical";

import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { useCallback, useState } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $wrapLeafNodesInElements } from "@lexical/selection";
import {
HeadingNode,
QuoteNode,
HeadingTagType,
$createHeadingNode,
$createQuoteNode,
} from "@lexical/rich-text";

// When the editor changes, you can get notified via the
// LexicalOnChangePlugin!
function onChange(editorState: EditorState) {
console.log(editorState);
}

function onError(error: Error) {
console.error(error);
}

function Editor() {
const nodes: Klass<LexicalNode>[] = [HeadingNode, QuoteNode];

const initialConfig = {
namespace: "demo",
nodes,
onError,
};
return (
<LexicalComposer initialConfig={initialConfig}>
<ToolbarPlugin />

  <div>
    <RichTextPlugin
      contentEditable={
        <ContentEditable className="TableNode__contentEditable" />
      }
      placeholder={"something"}
    />
    <AutoFocusPlugin />
    <OnChangePlugin onChange={onChange} />
    <HistoryPlugin />
  </div>
</LexicalComposer>

);
}

function ToolbarPlugin() {
const SupportedBlockType = {
paragraph: "Paragraph",
h1: "Heading 1",
h2: "Heading 2",
h3: "Heading 3",
h4: "Heading 4",
h5: "Heading 5",
h6: "Heading 6",
quote: "Quate",
} as const;
type BlockType = keyof typeof SupportedBlockType;

const [blockType, setBlockType] = useState<BlockType>("paragraph");
const [editor] = useLexicalComposerContext();

const formatParagraph = useCallback(() => {
if (blockType !== "paragraph") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapLeafNodesInElements(selection, () => $createParagraphNode());
}
});

  setBlockType("paragraph");
}

}, [blockType, editor]);

const formatQuote = useCallback(() => {
if (blockType !== "quote") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapLeafNodesInElements(selection, () => $createQuoteNode());
}
});

  setBlockType("quote");
}

}, [blockType, editor]);

const formatHeading = useCallback(
(type: HeadingTagType) => {
if (blockType !== type) {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapLeafNodesInElements(selection, () => $createHeadingNode(type));
}
});
setBlockType(type);
}
},
[blockType, editor]
);

return (
<div>
<button type="button" onClick={() => formatHeading("h1")}>
h1
</button>
<button type="button" onClick={() => formatHeading("h2")}>
h2
</button>
<button type="button" onClick={() => formatHeading("h3")}>
h3
</button>
<button type="button" onClick={() => formatParagraph()}>
p
</button>
<button type="button" onClick={() => formatQuote()}>
quote
</button>
</div>
);
}

export default Editor;

ichigoichigo

List, Order List, CheckList

Heading 同様にリストを描画する。ただし一つ注意点がある。↓

こいつらは $createHeadingNode が用意されていない代わりに、editor.dispatchCommand から呼び出せる reducer 的な処理が公式で用意されている。それを

editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);

こんな感じに呼び出すことで、EditorState を更新することができる。

コード
import {
  $createParagraphNode,
  $getSelection,
  $isRangeSelection,
  EditorState,
  Klass,
  LexicalNode,
} from "lexical";

import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { useCallback, useState } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $wrapLeafNodesInElements } from "@lexical/selection";
import {
  HeadingNode,
  QuoteNode,
  HeadingTagType,
  $createHeadingNode,
  $createQuoteNode,
} from "@lexical/rich-text";
import {
  ListItemNode,
  ListNode,
  INSERT_ORDERED_LIST_COMMAND,
  INSERT_UNORDERED_LIST_COMMAND,
  INSERT_CHECK_LIST_COMMAND,
} from "@lexical/list";

// When the editor changes, you can get notified via the
// LexicalOnChangePlugin!
function onChange(editorState: EditorState) {
  console.log(editorState);
}

function onError(error: Error) {
  console.error(error);
}

function Editor() {
  const nodes: Klass<LexicalNode>[] = [
    HeadingNode,
    QuoteNode,
    ListItemNode,
    ListNode,
  ];

  const initialConfig = {
    namespace: "demo",
    nodes,
    onError,
  };
  return (
    <LexicalComposer initialConfig={initialConfig}>
      <ToolbarPlugin />

      <div>
        <RichTextPlugin
          contentEditable={
            <ContentEditable className="TableNode__contentEditable" />
          }
          placeholder={"something"}
        />
        <ListPlugin />
        <CheckListPlugin />
        <AutoFocusPlugin />
        <OnChangePlugin onChange={onChange} />
        <HistoryPlugin />
      </div>
    </LexicalComposer>
  );
}

function ToolbarPlugin() {
  const SupportedBlockType = {
    paragraph: "Paragraph",
    h1: "Heading 1",
    h2: "Heading 2",
    h3: "Heading 3",
    h4: "Heading 4",
    h5: "Heading 5",
    h6: "Heading 6",
    quote: "Quate",
    number: "Numbered List",
    bullet: "Bullet List",
    check: "Check List",
  } as const;
  type BlockType = keyof typeof SupportedBlockType;

  const [blockType, setBlockType] = useState<BlockType>("paragraph");
  const [editor] = useLexicalComposerContext();

  const formatParagraph = useCallback(() => {
    if (blockType !== "paragraph") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          $wrapLeafNodesInElements(selection, () => $createParagraphNode());
        }
      });

      setBlockType("paragraph");
    }
  }, [blockType, editor]);

  const formatQuote = useCallback(() => {
    if (blockType !== "quote") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          $wrapLeafNodesInElements(selection, () => $createQuoteNode());
        }
      });

      setBlockType("quote");
    }
  }, [blockType, editor]);

  const formatBulletList = useCallback(() => {
    if (blockType !== "bullet") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
        }
      });
      setBlockType("bullet");
    }
  }, [blockType, editor]);

  const formatNumberList = useCallback(() => {
    if (blockType !== "number") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
        }
      });
      setBlockType("number");
    }
  }, [blockType, editor]);

  const formatCheckList = useCallback(() => {
    if (blockType !== "check") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
        }
      });
      setBlockType("check");
    }
  }, [blockType, editor]);

  const formatHeading = useCallback(
    (type: HeadingTagType) => {
      if (blockType !== type) {
        editor.update(() => {
          const selection = $getSelection();
          if ($isRangeSelection(selection)) {
            $wrapLeafNodesInElements(selection, () => $createHeadingNode(type));
          }
        });
        setBlockType(type);
      }
    },
    [blockType, editor]
  );

  return (
    <div>
      <button type="button" onClick={() => formatHeading("h1")}>
        h1
      </button>
      <button type="button" onClick={() => formatHeading("h2")}>
        h2
      </button>
      <button type="button" onClick={() => formatHeading("h3")}>
        h3
      </button>
      <button type="button" onClick={() => formatParagraph()}>
        p
      </button>
      <button type="button" onClick={() => formatQuote()}>
        quote
      </button>
      <button type="button" onClick={() => formatBulletList()}>
        list
      </button>
      <button type="button" onClick={() => formatNumberList()}>
        number
      </button>
      <button type="button" onClick={() => formatCheckList()}>
        check
      </button>
    </div>
  );
}

export default Editor;
```

分かりにくいが、一番下はcheckboxになっている。

📝 nest バグってないか

ichigoichigo

Code Block

Quote 同様の手順で追加できる。ただし CodePlugin みたいなReact Componentを用意してくれていないので自分で書く必要がある。

またシンタックスハイライトがダルすぎるので省略した

コード

import {
$createParagraphNode,
$getSelection,
$isRangeSelection,
EditorState,
Klass,
LexicalNode,
} from "lexical";

import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { useCallback, useEffect, useState } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $wrapLeafNodesInElements } from "@lexical/selection";
import {
HeadingNode,
QuoteNode,
HeadingTagType,
$createHeadingNode,
$createQuoteNode,
} from "@lexical/rich-text";
import {
ListItemNode,
ListNode,
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
INSERT_CHECK_LIST_COMMAND,
} from "@lexical/list";
import {
registerCodeHighlighting,
$createCodeNode,
CodeNode,
CodeHighlightNode,
} from "@lexical/code";

// When the editor changes, you can get notified via the
// LexicalOnChangePlugin!
function onChange(editorState: EditorState) {
console.log(editorState);
}

function onError(error: Error) {
console.error(error);
}

function Editor() {
const nodes: Klass<LexicalNode>[] = [
HeadingNode,
QuoteNode,
ListItemNode,
ListNode,
CodeNode,
CodeHighlightNode,
];

const initialConfig = {
namespace: "demo",
nodes,
onError,
};
return (
<LexicalComposer initialConfig={initialConfig}>
<ToolbarPlugin />

  <div>
    <RichTextPlugin
      contentEditable={
        <ContentEditable className="TableNode__contentEditable" />
      }
      placeholder={"something"}
    />
    <ListPlugin />
    <CheckListPlugin />
    <CodeHighlightPlugin />
    <AutoFocusPlugin />
    <OnChangePlugin onChange={onChange} />
    <HistoryPlugin />
  </div>
</LexicalComposer>

);
}

function ToolbarPlugin() {
const SupportedBlockType = {
paragraph: "Paragraph",
h1: "Heading 1",
h2: "Heading 2",
h3: "Heading 3",
h4: "Heading 4",
h5: "Heading 5",
h6: "Heading 6",
quote: "Quate",
number: "Numbered List",
bullet: "Bullet List",
check: "Check List",
code: "Code Block",
} as const;
type BlockType = keyof typeof SupportedBlockType;

const [blockType, setBlockType] = useState<BlockType>("paragraph");
const [editor] = useLexicalComposerContext();

const formatParagraph = useCallback(() => {
if (blockType !== "paragraph") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapLeafNodesInElements(selection, () => $createParagraphNode());
}
});

  setBlockType("paragraph");
}

}, [blockType, editor]);

const formatQuote = useCallback(() => {
if (blockType !== "quote") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapLeafNodesInElements(selection, () => $createQuoteNode());
}
});

  setBlockType("quote");
}

}, [blockType, editor]);

const formatCode = useCallback(() => {
if (blockType !== "code") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapLeafNodesInElements(selection, () => $createCodeNode());
}
});

  setBlockType("code");
}

}, [blockType, editor]);

const formatBulletList = useCallback(() => {
if (blockType !== "bullet") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
}
});
setBlockType("bullet");
}
}, [blockType, editor]);

const formatNumberList = useCallback(() => {
if (blockType !== "number") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
}
});
setBlockType("number");
}
}, [blockType, editor]);

const formatCheckList = useCallback(() => {
if (blockType !== "check") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
}
});
setBlockType("check");
}
}, [blockType, editor]);

const formatHeading = useCallback(
(type: HeadingTagType) => {
if (blockType !== type) {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapLeafNodesInElements(selection, () => $createHeadingNode(type));
}
});
setBlockType(type);
}
},
[blockType, editor]
);

return (
<div>
<button type="button" onClick={() => formatHeading("h1")}>
h1
</button>
<button type="button" onClick={() => formatHeading("h2")}>
h2
</button>
<button type="button" onClick={() => formatHeading("h3")}>
h3
</button>
<button type="button" onClick={() => formatParagraph()}>
p
</button>
<button type="button" onClick={() => formatQuote()}>
quote
</button>
<button type="button" onClick={() => formatBulletList()}>
list
</button>
<button type="button" onClick={() => formatNumberList()}>
number
</button>
<button type="button" onClick={() => formatCheckList()}>
check
</button>
<button type="button" onClick={() => formatCode()}>
code
</button>
</div>
);
}

export default Editor;

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

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

return null;
};

ichigoichigo

Bold, Italic, ...

rich-text のおかげでできるようになっているので button で導線を出す。

コード
import {
  $createParagraphNode,
  $getSelection,
  $isRangeSelection,
  EditorState,
  FORMAT_TEXT_COMMAND,
  Klass,
  LexicalNode,
  TextFormatType,
} from "lexical";

import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { useCallback, useEffect, useState } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $wrapLeafNodesInElements } from "@lexical/selection";
import {
  HeadingNode,
  QuoteNode,
  HeadingTagType,
  $createHeadingNode,
  $createQuoteNode,
} from "@lexical/rich-text";
import {
  ListItemNode,
  ListNode,
  INSERT_ORDERED_LIST_COMMAND,
  INSERT_UNORDERED_LIST_COMMAND,
  INSERT_CHECK_LIST_COMMAND,
} from "@lexical/list";
import {
  registerCodeHighlighting,
  $createCodeNode,
  CodeNode,
  CodeHighlightNode,
} from "@lexical/code";

// When the editor changes, you can get notified via the
// LexicalOnChangePlugin!
function onChange(editorState: EditorState) {
  console.log(editorState);
}

function onError(error: Error) {
  console.error(error);
}

function Editor() {
  const nodes: Klass<LexicalNode>[] = [
    HeadingNode,
    QuoteNode,
    ListItemNode,
    ListNode,
    CodeNode,
    CodeHighlightNode,
  ];

  const initialConfig = {
    namespace: "demo",
    nodes,
    onError,
  };
  return (
    <LexicalComposer initialConfig={initialConfig}>
      <ToolbarPlugin />
      <InlineToolbarPlugin />

      <div>
        <RichTextPlugin
          contentEditable={
            <ContentEditable className="TableNode__contentEditable" />
          }
          placeholder={"something"}
        />
        <ListPlugin />
        <CheckListPlugin />
        <CodeHighlightPlugin />
        <AutoFocusPlugin />
        <OnChangePlugin onChange={onChange} />
        <HistoryPlugin />
      </div>
    </LexicalComposer>
  );
}

function ToolbarPlugin() {
  const SupportedBlockType = {
    paragraph: "Paragraph",
    h1: "Heading 1",
    h2: "Heading 2",
    h3: "Heading 3",
    h4: "Heading 4",
    h5: "Heading 5",
    h6: "Heading 6",
    quote: "Quate",
    number: "Numbered List",
    bullet: "Bullet List",
    check: "Check List",
    code: "Code Block",
  } as const;
  type BlockType = keyof typeof SupportedBlockType;

  const [blockType, setBlockType] = useState<BlockType>("paragraph");
  const [editor] = useLexicalComposerContext();

  const formatParagraph = useCallback(() => {
    if (blockType !== "paragraph") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          $wrapLeafNodesInElements(selection, () => $createParagraphNode());
        }
      });

      setBlockType("paragraph");
    }
  }, [blockType, editor]);

  const formatQuote = useCallback(() => {
    if (blockType !== "quote") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          $wrapLeafNodesInElements(selection, () => $createQuoteNode());
        }
      });

      setBlockType("quote");
    }
  }, [blockType, editor]);

  const formatCode = useCallback(() => {
    if (blockType !== "code") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          $wrapLeafNodesInElements(selection, () => $createCodeNode());
        }
      });

      setBlockType("code");
    }
  }, [blockType, editor]);

  const formatBulletList = useCallback(() => {
    if (blockType !== "bullet") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
        }
      });
      setBlockType("bullet");
    }
  }, [blockType, editor]);

  const formatNumberList = useCallback(() => {
    if (blockType !== "number") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
        }
      });
      setBlockType("number");
    }
  }, [blockType, editor]);

  const formatCheckList = useCallback(() => {
    if (blockType !== "check") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
        }
      });
      setBlockType("check");
    }
  }, [blockType, editor]);

  const formatHeading = useCallback(
    (type: HeadingTagType) => {
      if (blockType !== type) {
        editor.update(() => {
          const selection = $getSelection();
          if ($isRangeSelection(selection)) {
            $wrapLeafNodesInElements(selection, () => $createHeadingNode(type));
          }
        });
        setBlockType(type);
      }
    },
    [blockType, editor]
  );

  return (
    <div>
      <button type="button" onClick={() => formatHeading("h1")}>
        h1
      </button>
      <button type="button" onClick={() => formatHeading("h2")}>
        h2
      </button>
      <button type="button" onClick={() => formatHeading("h3")}>
        h3
      </button>
      <button type="button" onClick={() => formatParagraph()}>
        p
      </button>
      <button type="button" onClick={() => formatQuote()}>
        quote
      </button>
      <button type="button" onClick={() => formatBulletList()}>
        list
      </button>
      <button type="button" onClick={() => formatNumberList()}>
        number
      </button>
      <button type="button" onClick={() => formatCheckList()}>
        check
      </button>
      <button type="button" onClick={() => formatCode()}>
        code
      </button>
    </div>
  );
}

const textFormat = [
  "bold",
  "italic",
  "underline",
  "strikethrough",
  "code",
  "subscript",
  "superscript",
];

const InlineToolbarPlugin: React.FC = () => {
  const [editor] = useLexicalComposerContext();

  const formatText = useCallback(
    (format: TextFormatType) => {
      editor.update(() => {
        editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
      });
    },
    [editor]
  );

  return (
    <div>
      {textFormat.map((format) => (
        <button
          key={format}
          type="button"
          onClick={() => formatText(format as TextFormatType)}
        >
          {format}
        </button>
      ))}
    </div>
  );
};

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

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

  return null;
};

export default Editor;

ichigoichigo

Markdown shortcut

公式がいい感じのプラグインを提供してくれている。
変換ルール(TRANSFORMERS)は自分で拡張することもできる。

コード
import {
  $createParagraphNode,
  $getSelection,
  $isRangeSelection,
  EditorState,
  FORMAT_TEXT_COMMAND,
  Klass,
  LexicalNode,
  TextFormatType,
} from "lexical";

import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { useCallback, useEffect, useState } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $wrapLeafNodesInElements } from "@lexical/selection";
import { AutoLinkNode, LinkNode } from "@lexical/link";
import {
  HeadingNode,
  QuoteNode,
  HeadingTagType,
  $createHeadingNode,
  $createQuoteNode,
} from "@lexical/rich-text";
import {
  ListItemNode,
  ListNode,
  INSERT_ORDERED_LIST_COMMAND,
  INSERT_UNORDERED_LIST_COMMAND,
  INSERT_CHECK_LIST_COMMAND,
} from "@lexical/list";
import {
  registerCodeHighlighting,
  $createCodeNode,
  CodeNode,
  CodeHighlightNode,
} from "@lexical/code";
import { TRANSFORMERS } from "@lexical/markdown";
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";

// When the editor changes, you can get notified via the
// LexicalOnChangePlugin!
function onChange(editorState: EditorState) {
  console.log(editorState);
}

function onError(error: Error) {
  console.error(error);
}

function Editor() {
  const nodes: Array<Klass<LexicalNode>> = [
    HeadingNode,
    ListNode,
    ListItemNode,
    QuoteNode,
    CodeNode,
    CodeHighlightNode,
    AutoLinkNode,
    LinkNode,
  ];

  const initialConfig = {
    namespace: "demo",
    nodes,
    onError,
  };
  return (
    <LexicalComposer initialConfig={initialConfig}>
      <ToolbarPlugin />
      <InlineToolbarPlugin />

      <div>
        <RichTextPlugin
          contentEditable={
            <ContentEditable className="TableNode__contentEditable" />
          }
          placeholder={"something"}
        />
        <ListPlugin />
        <CheckListPlugin />
        <MarkdownPlugin />
        <CodeHighlightPlugin />
        <AutoFocusPlugin />
        <OnChangePlugin onChange={onChange} />
        <HistoryPlugin />
      </div>
    </LexicalComposer>
  );
}

function ToolbarPlugin() {
  const SupportedBlockType = {
    paragraph: "Paragraph",
    h1: "Heading 1",
    h2: "Heading 2",
    h3: "Heading 3",
    h4: "Heading 4",
    h5: "Heading 5",
    h6: "Heading 6",
    quote: "Quate",
    number: "Numbered List",
    bullet: "Bullet List",
    check: "Check List",
    code: "Code Block",
  } as const;
  type BlockType = keyof typeof SupportedBlockType;

  const [blockType, setBlockType] = useState<BlockType>("paragraph");
  const [editor] = useLexicalComposerContext();

  const formatParagraph = useCallback(() => {
    if (blockType !== "paragraph") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          $wrapLeafNodesInElements(selection, () => $createParagraphNode());
        }
      });

      setBlockType("paragraph");
    }
  }, [blockType, editor]);

  const formatQuote = useCallback(() => {
    if (blockType !== "quote") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          $wrapLeafNodesInElements(selection, () => $createQuoteNode());
        }
      });

      setBlockType("quote");
    }
  }, [blockType, editor]);

  const formatCode = useCallback(() => {
    if (blockType !== "code") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          $wrapLeafNodesInElements(selection, () => $createCodeNode());
        }
      });

      setBlockType("code");
    }
  }, [blockType, editor]);

  const formatBulletList = useCallback(() => {
    if (blockType !== "bullet") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
        }
      });
      setBlockType("bullet");
    }
  }, [blockType, editor]);

  const formatNumberList = useCallback(() => {
    if (blockType !== "number") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
        }
      });
      setBlockType("number");
    }
  }, [blockType, editor]);

  const formatCheckList = useCallback(() => {
    if (blockType !== "check") {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
        }
      });
      setBlockType("check");
    }
  }, [blockType, editor]);

  const formatHeading = useCallback(
    (type: HeadingTagType) => {
      if (blockType !== type) {
        editor.update(() => {
          const selection = $getSelection();
          if ($isRangeSelection(selection)) {
            $wrapLeafNodesInElements(selection, () => $createHeadingNode(type));
          }
        });
        setBlockType(type);
      }
    },
    [blockType, editor]
  );

  return (
    <div>
      <button type="button" onClick={() => formatHeading("h1")}>
        h1
      </button>
      <button type="button" onClick={() => formatHeading("h2")}>
        h2
      </button>
      <button type="button" onClick={() => formatHeading("h3")}>
        h3
      </button>
      <button type="button" onClick={() => formatParagraph()}>
        p
      </button>
      <button type="button" onClick={() => formatQuote()}>
        quote
      </button>
      <button type="button" onClick={() => formatBulletList()}>
        list
      </button>
      <button type="button" onClick={() => formatNumberList()}>
        number
      </button>
      <button type="button" onClick={() => formatCheckList()}>
        check
      </button>
      <button type="button" onClick={() => formatCode()}>
        code
      </button>
    </div>
  );
}

const textFormat = [
  "bold",
  "italic",
  "underline",
  "strikethrough",
  "code",
  "subscript",
  "superscript",
];

const InlineToolbarPlugin: React.FC = () => {
  const [editor] = useLexicalComposerContext();

  const formatText = useCallback(
    (format: TextFormatType) => {
      editor.update(() => {
        editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
      });
    },
    [editor]
  );

  return (
    <div>
      {textFormat.map((format) => (
        <button
          key={format}
          type="button"
          onClick={() => formatText(format as TextFormatType)}
        >
          {format}
        </button>
      ))}
    </div>
  );
};

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

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

  return null;
};

export const MarkdownPlugin: React.FC = () => {
  console.log(TRANSFORMERS);
  return <MarkdownShortcutPlugin transformers={TRANSFORMERS} />;
};

export default Editor;