Lexical で実用的なマークダウンエディタを作るまで
業務で使いそうなので、
https://playground.lexical.dev 最低限のマークダウンエディタを自力で作れるようにする
- EditorState の内容
- Plugin の知識
があるところから
すてぃん.tsx さんの記事がすごいので参考にする
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
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
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;
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ができるようになる
ツールバーの作成
ここからは 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;
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;
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;
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 バグってないか
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;
};
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;
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;