# [React + TS] lexical文字編輯器套件使用心得與教學 ## 前言 最近寒假寫專案寫著發閒,發現React一些套件的教學很少,例如本篇提到的lexical,因此想記錄一下自己的學習心得和想法,如果可以幫助到各位的話就太好啦,第一次寫文章,算是現學現賣,如果有錯誤或可優化的地方,歡迎提出! > 使用環境: "@lexical/react": "0.12.5", "@mui/material": "5.14.17", > 本篇算是將自己專案的東西複製過來,這裡會提到的範例程式碼都在[這裡](https://codesandbox.io/p/devbox/knh4w6?file=%2Fsrc%2FRichTextEditor%2Findex.tsx%3A13%2C27) ## Lexical簡介 Lexical 是基於JS的富文字編輯器,個人認為,對React使用者而言,非常方便,有以下優缺點 * 優點:可擴充性高、客製化程度高 * 缺點:上手困難 相較於其他文字編輯器的套件(例如: Quill, Draft等),不受限於套件本身的樣式與服務限制,他提供更客製化的服務,他可以套用任何自己已經寫好的ui樣式,然後再加上lexical的一些功能,就可以做出符合自己網頁的風格樣式編輯器,同時,又提供許多擴充功能( 例如youtube, latex公式等,可以到他的[Playground](https://playground.lexical.dev/)看),然而如此自製的缺點就是上手有點困難,要產生出一個能用基礎編輯器功能,相較於其他套件就會十分困難。 ## Lexical核心架構 ### Node Lexical中,會使用Node來呈現元件,並且為編譯成html,以下舉常見的例子 * HeadingNode: ```<h1>```, ```<h1>```, * ListNode:```<li>``` * QuoteNode: ```<blockquote>``` ### Plugin 前面有提到,Lexical的高可擴充性,是用`LexicalComposer`將一個個Plugin加入而來的,例如一個基礎的編輯器就需要有以下功能 * InitialPlugin: 用於初始化內容 * ToolbarPlugin: 自定義功能,產生文字的樣式功能列,例如:設定粗體、大小、排列等 * OnChangePlugin: 更新時的事件 * RichTextPlugin: 富文字編輯器本身 除了以上Plugin,還有許多功能,例如清單、Markdown、歷史紀錄(ctrl+z)等功能都是可以自己依照需求增減 > lexical上手困難的在於,本身文檔對於這些Plugin敘述很少,大多數時候都是我自己去[Playground的原始碼](https://github.com/facebook/lexical/tree/main/packages/lexical-playground)看才知道怎麼達到想要的功能 ### LexicalComposer 編輯本身的控制器,可以設定文字編輯器的css class名稱,風格、功能等 ```jsx <LexicalComposer initialConfig={editorConfig}> {/* Plugins here*/} </LexicalComposer> ``` * initialConfig : 設定文字編輯 * namespace: 編輯器名稱 * theme: 用來設定編輯器Node的classname,讓你可以自訂一每個Node的樣式 * nodes: 加入會使用到的Node (**注意!!! 一定要加入會用到的Node**) ```javascript const editorConfig = { namespace: "MyEditor", theme: { ltr: "ltr", rtl: "rtl", placeholder: "editor-placeholder", paragraph: "editor-paragraph", quote: "editor-quote", heading: { h1: "editor-heading-h1", h2: "editor-heading-h2", h3: "editor-heading-h3", h4: "editor-heading-h4", h5: "editor-heading-h5", }, // ... other classname }, nodes: [ HeadingNode, ListNode, ListItemNode, QuoteNode, CodeNode, // ...other node ] } ## RichTextPlugin 富文字功能 * contentEditable:傳入ContentEditoable的元件,可在此改變編輯器的外框、大小等 * ErrorBoundary: 錯誤處理 * placeholder: 空白提示字元,可以直接傳入JSX.Element ```jsx import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; <RichTextPlugin contentEditable={ <ContentEditable style={{ padding: "0 8px", minHeight: "300px", border: "1px solid #0f0f0f" borderRadius: "0.3em", }} /> } ErrorBoundary={LexicalErrorBoundary} placeholder={null} /> ``` ## ToolbarPlugin ![image](https://hackmd.io/_uploads/r1gVUjyb9p.png) 自訂義功能列,麻煩在於,需要手動註冊使用者變更選取範圍、更新時的事件,這裡將文字編輯器內的事件區分成三種: 1. Text format: 文字本身的樣式,例如: 粗體、斜體 2. Block format: 影響一整行的樣式,例如: 標題、Quote 3. Align format: 影響文字對其的位置,例如: 置中、置右 ### Text Format 變更文字本身的樣式(例如粗體、斜體等)流程如下 ```mermaid graph TD; A[Button: 使用者點擊樣式更新按鈕] --> B[handleTextFormatClick:在編輯器更新文字樣式,並註冊變更樣式的命令] B --> C[useEffect: 聆聽 editor 變更命令] C --> D[updateToolbar: 變更控制列狀態] ``` 1. Text format Button Group: Lexical沒有定義控制列所需要的原件,因此可以將專案使用的ui框架(此處使用mui),加上變更text fortmat onClick上,接著再定義後續的動作就可以了 ```jsx // import {TextFormatType} from 'lexical'; // ... // const [textFormats, setTextFormats] = useState<TextFormatType[]>([]); // ... <ToggleButtonGroup size="small" aria-label="text formatting" //使用useState儲存format值 value={textFormats} > <ToggleButton value="bold" aria-label="bold" onClick={handleTextFormatClick} > <FormatBoldIcon /> </ToggleButton> <ToggleButton value="italic" aria-label="italic" onClick={handleTextFormatClick} > <FormatItalicIcon /> </ToggleButton> {/* ... */} </ToggleButtonGroup> ``` 2. handleTextFormatClick: `FORMAT_TEXT_COMMAND`是lexical開給這個事件用的命令常數,可以輸入`value` ```jsx // import {FORMAT_TEXT_COMMAND} from 'lexical'; /** Handle text formatting */ const handleTextFormatClick = ( event: React.MouseEvent, value: TextFormatType ) => { editor.dispatchCommand(FORMAT_TEXT_COMMAND, value); }; ``` 3. useEffect ``` jsx // import {SELECTION_CHANGE_COMMAND} from "lexical" // import {mergeRegister} from "@lexical/utils" /** Regist command **/ useEffect(() => { return mergeRegister( // 將一般command 註冊到editor中 editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { updateToolbar(); }); }), // 當使用者選取範圍變更時,也需要更新控制列 editor.registerCommand( SELECTION_CHANGE_COMMAND, (_payload, newEditor) => { updateToolbar(); return false; }, LowPriority ) ); }, [editor, updateToolbar]); ``` 4. updateToolbar:使用@lexical/selection偵測範圍內的textFormat狀態,然後更新 ```jsx // import { $getSelection, $isRangeSelection} from "@lexical/selection"; /** Toolbar state update */ const updateToolbar = useCallback(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { // Set text Format const currentFormats: TextFormatType[] = []; selection.hasFormat("bold") && currentFormats.push("bold"); selection.hasFormat("italic") && currentFormats.push("italic"); selection.hasFormat("underline") && currentFormats.push("underline"); selection.hasFormat("code") && currentFormats.push("code"); selection.hasFormat("strikethrough") && currentFormats.push("strikethrough"); setTextFormats(currentFormats); } }, [editor]); ``` #### 小結 目前大致完成基礎的變更樣式功能,我認為,使用lexcial很像在使用jQuery,開發者可以在自己的原件上,附加lexical提供的一些函數,雖然許多功能實現需要自己慢慢加上,但提供了更高客製化的服務。 ### Block Format 影響一整行的樣式,例如: 標題、Quote,其流程與textFormat類似,如下: ```mermaid graph TD; A[Select: 使用者點擊樣式更新按鈕] --> B[handleBlockFormat:在編輯器更新文字樣式,並註冊變更樣式的命令] B --> C[useEffect: 聆聽 editor 變更命令] C --> D[updateToolbar: 變更控制列狀態與編輯器元件] ``` 1. Select: ```jsx const supportedBlockTypes = [ "paragraph", "quote", "code", "h1", "h2", "ul", "ol", ]; {supportedBlockTypes.includes(blockType) && ( <> <FormControl sx={{ m: 1, minWidth: 120 }} size="small"> <Select value={blockType} onChange={handleBlockFormat} displayEmpty inputProps={{ "aria-label": "Without label" }} > {supportedBlockTypes.map((s) => ( <MenuItem value={s} key={`blockType-${s}`}> { blockTypeToBlockName[ s as keyof typeof blockTypeToBlockName ] } </MenuItem> ))} </Select> </FormControl> </> )} ``` 2. handleBlockFormat:此處與TextFormat比較不一樣,有以下幾點差異: * 需要根據不同的型態來create Node * List 因為有縮行(order)的情況,所以直接使用內建的COMMAND來更新 * 為了避免重複的程式碼,所以我將create Node的方法寫成常數,記得要加useMemo * 除了ParagraphNode不須設定,其他Node,例如HeadingNode, QuoteNode,要再LexicalComposer.initialConfig中設定nodes,否則改了也不會變格式 ```jsx /** Handle block formatting */ const blockHandlers = { paragraph: () => $createParagraphNode(), h1: () => $createHeadingNode("h1"), h2: () => $createHeadingNode("h2"), quote: () => $createQuoteNode(), code: () => $createCodeNode(), }; type blockHandlersType = keyof typeof blockHandlers; const listHandlers = { ul: INSERT_UNORDERED_LIST_COMMAND, ol: INSERT_ORDERED_LIST_COMMAND, }; type listHandlersType = keyof typeof listHandlers; const handleBlockFormat = (event: SelectChangeEvent) => { const type = event?.target.value as blockHandlersType | listHandlersType; if (Object.keys(blockHandlers).includes(type) && blockType !== type) { editor.update(() => { const blockHandler = blockHandlers[type as blockHandlersType]; const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, blockHandler as () => ElementNode); } }); } else if (Object.keys(listHandlers).includes(type)) { if (blockType !== type) { editor.dispatchCommand( listHandlers[type as listHandlersType], undefined ); } else { editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); } } }; ``` 3. useEffect:與Textformat一樣 4. updateToolbar:ListNode需要特別找他的type,因為會有縮行問題 ```jsx /** Toolbar state update */ const updateToolbar = useCallback(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { // Set Block Format const anchorNode = selection.anchor.getNode(); const element = anchorNode.getKey() === "root" ? anchorNode : anchorNode.getTopLevelElementOrThrow(); const elementKey = element.getKey(); const elementDOM = editor.getElementByKey(elementKey); if (elementDOM !== null) { if ($isListNode(element)) { const parentList = $getNearestNodeOfType(anchorNode, ListNode); const type = parentList ? parentList.getTag() : element.getTag(); setBlockType(type); } else { const type = $isHeadingNode(element) ? element.getTag() : element.getType(); setBlockType(type); } } //... } } ``` ### Align Format 與上述兩種流程類似,這裡快速帶過 1. Button Group: ``` jsx {/* Elemnet Align format */} <ControlButtonGroup size="small" value={elementFormat} exclusive onChange={handleElementFormatClick} aria-label="text alignment" > <ToggleButton value="left" aria-label="left aligned"> <FormatAlignLeftIcon /> </ToggleButton> <ToggleButton value="center" aria-label="centered"> <FormatAlignCenterIcon /> </ToggleButton> <ToggleButton value="right" aria-label="right aligned"> <FormatAlignRightIcon /> </ToggleButton> <ToggleButton value="justify" aria-label="justified"> <FormatAlignJustifyIcon /> </ToggleButton> </ControlButtonGroup> ``` 2. handleElementClick ```jsx /** Handle element align formatting */ const handleElementFormatClick = ( event: React.MouseEvent, value: ElementFormatType ) => { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, value); }; ``` 3. useEffect 4. updateToolbar ```jsx /** Toolbar state update */ export function getSelectedNode( selection: RangeSelection ): TextNode | ElementNode { const anchor = selection.anchor; const focus = selection.focus; const anchorNode = selection.anchor.getNode(); const focusNode = selection.focus.getNode(); if (anchorNode === focusNode) { return anchorNode; } const isBackward = selection.isBackward(); if (isBackward) { return $isAtNodeEnd(focus) ? anchorNode : focusNode; } else { return $isAtNodeEnd(anchor) ? anchorNode : focusNode; } } const updateToolbar = useCallback(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { // Set Algin Format const node = getSelectedNode(selection); const parent = node.getParent(); let matchingParent; if ($isLinkNode(parent)) { // If node is a link, we need to fetch the parent paragraph node to set format matchingParent = $findMatchingParent( node, (parentNode) => $isElementNode(parentNode) && !parentNode.isInline() ); } setElementFormat( $isElementNode(matchingParent) ? matchingParent.getFormatType() : $isElementNode(node) ? node.getFormatType() : parent?.getFormatType() || "left" ); //... } } ``` ### 小結 至此,lexical最難處理的部分已經完成,剩下的就是加上一些Plugins,大部分的Plugin lexical都有提供,但少部分可以從lexical的Playground中直接複製而來,接下來我會提到一些我專案中有用到,但無法直接import的Plugin,都是~~抄來的~~ ## InitialPlugin 顧名思義,就是初始化編輯器內容 ```jsx import { InitialEditorStateType } from "@lexical/react/LexicalComposer"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import React from "react"; const HISTORY_MERGE_OPTIONS = { tag: "history-merge" }; type InitialPluginProps = { initialEditorState?: InitialEditorStateType; }; export default function InitialPlugin({ initialEditorState, }: InitialPluginProps) { const [editor] = useLexicalComposerContext(); React.useLayoutEffect(() => { if (initialEditorState !== null) { try { switch (typeof initialEditorState) { case "string": { const parsedEditorState = editor.parseEditorState(initialEditorState); editor.setEditorState(parsedEditorState, HISTORY_MERGE_OPTIONS); break; } case "object": { editor.setEditorState(initialEditorState, HISTORY_MERGE_OPTIONS); break; } } } catch (e) { console.error(e); } } }, [initialEditorState, editor]); return null; } ``` ## ListMaxIndentLevelPlugin 需要加入這個Plugin才可以讓清單能夠縮行與限制縮行次數 ```jsx import type { RangeSelection } from "lexical"; import { $getListDepth, $isListItemNode, $isListNode } from "@lexical/list"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $getSelection, $isElementNode, $isRangeSelection, COMMAND_PRIORITY_CRITICAL, ElementNode, INDENT_CONTENT_COMMAND, } from "lexical"; import { useEffect } from "react"; type Props = Readonly<{ maxDepth: number | null | undefined; }>; function getElementNodesInSelection( selection: RangeSelection ): Set<ElementNode> { const nodesInSelection = selection.getNodes(); if (nodesInSelection.length === 0) { return new Set([ selection.anchor.getNode().getParentOrThrow(), selection.focus.getNode().getParentOrThrow(), ]); } return new Set( nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())) ); } function isIndentPermitted(maxDepth: number): boolean { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } const elementNodesInSelection: Set<ElementNode> = getElementNodesInSelection(selection); let totalDepth = 0; for (const elementNode of elementNodesInSelection) { if ($isListNode(elementNode)) { totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth); } else if ($isListItemNode(elementNode)) { const parent = elementNode.getParent(); if (!$isListNode(parent)) { throw new Error( "ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent." ); } totalDepth = Math.max($getListDepth(parent) + 1, totalDepth); } } return totalDepth <= maxDepth; } export default function ListMaxIndentLevelPlugin({ maxDepth }: Props): null { const [editor] = useLexicalComposerContext(); useEffect(() => { return editor.registerCommand( INDENT_CONTENT_COMMAND, () => !isIndentPermitted(maxDepth ?? 7), COMMAND_PRIORITY_CRITICAL ); }, [editor, maxDepth]); return null; } ``` ## TabFocusPlugin 避免Tabout出編輯器,基本上如果要讓清單縮行,一定要加這個才能用Tab縮行 ``` jsx import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { $getSelection, $isRangeSelection, $setSelection, FOCUS_COMMAND, } from 'lexical'; import {useEffect} from 'react'; const COMMAND_PRIORITY_LOW = 1; const TAB_TO_FOCUS_INTERVAL = 100; let lastTabKeyDownTimestamp = 0; let hasRegisteredKeyDownListener = false; function registerKeyTimeStampTracker() { window.addEventListener( 'keydown', (event: KeyboardEvent) => { // Tab if (event.key === 'Tab') { lastTabKeyDownTimestamp = event.timeStamp; } }, true, ); } export default function TabFocusPlugin(): null { const [editor] = useLexicalComposerContext(); useEffect(() => { if (!hasRegisteredKeyDownListener) { registerKeyTimeStampTracker(); hasRegisteredKeyDownListener = true; } return editor.registerCommand( FOCUS_COMMAND, (event: FocusEvent) => { const selection = $getSelection(); if ($isRangeSelection(selection)) { if ( lastTabKeyDownTimestamp + TAB_TO_FOCUS_INTERVAL > event.timeStamp ) { $setSelection(selection.clone()); } } return false; }, COMMAND_PRIORITY_LOW, ); }, [editor]); return null; } ``` ## Full Plugins 最後編輯器再加入一些雜七雜八的內建Plugin會長這樣 ``` jsx import { CodeHighlightNode, CodeNode } from "@lexical/code"; import { AutoLinkNode, LinkNode } from "@lexical/link"; import { ListItemNode, ListNode } from "@lexical/list"; import { MarkNode } from "@lexical/mark"; import { TRANSFORMERS } from "@lexical/markdown"; import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; import { InitialEditorStateType, LexicalComposer, } from "@lexical/react/LexicalComposer"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; import { ListPlugin } from "@lexical/react/LexicalListPlugin"; import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; import { HeadingNode, QuoteNode } from "@lexical/rich-text"; import { Box, useTheme } from "@mui/material"; import lexicalTheme from "./theme"; import { EditorState, LexicalEditor } from "lexical"; import React from "react"; import InitialPlugin from "./InitialPlugin"; import ListMaxIndentLevelPlugin from "./ListMaxIndentLevelPlugin"; import TabFocusPlugin from "./TabFocusPlugin"; import ToolbarPlugin from "./ToolbarPlugin"; const editorConfig = { // The editor theme namespace: "MyEditor", theme: lexicalTheme, // Handling of errors during update onError(error: any) { throw error; }, // Any custom nodes go here nodes: [ HeadingNode, ListNode, ListItemNode, QuoteNode, CodeNode, CodeHighlightNode, MarkNode, LinkNode, AutoLinkNode, ], }; type RichTextEditorProps = { controllable?: boolean; onChange?: ( editorState: EditorState, editor: LexicalEditor, tags: Set<string>, ) => void; initialEditorState?: InitialEditorStateType; }; function RichTextEditor({ controllable = true, onChange, initialEditorState, }: RichTextEditorProps) { const theme = useTheme(); return ( <Box sx={{ position: "relative" }}> <LexicalComposer initialConfig={{ ...editorConfig, editable: controllable, }} > <InitialPlugin initialEditorState={initialEditorState} /> {controllable ? ( <> <ToolbarPlugin /> <LinkPlugin /> <HistoryPlugin /> <TabIndentationPlugin /> <ListPlugin /> <AutoFocusPlugin /> <TabFocusPlugin /> <ListMaxIndentLevelPlugin maxDepth={3} /> {onChange ? ( <OnChangePlugin onChange={onChange} ignoreSelectionChange ></OnChangePlugin> ) : ( <React.Fragment /> )} <MarkdownShortcutPlugin transformers={TRANSFORMERS} /> </> ) : ( <React.Fragment /> )} <RichTextPlugin contentEditable={ <ContentEditable style={{ padding: "0 8px", minHeight: controllable ? "300px" : "auto", border: controllable ? `1px solid ${theme.palette.divider}` : "", borderRadius: "0.3em", fontFamily: theme.typography.fontFamily, }} /> } ErrorBoundary={LexicalErrorBoundary} placeholder={null} /> </LexicalComposer> </Box> ); } export default RichTextEditor; ``` # 結語 首先,感謝看到這裡的人,這是我第一次寫文章,本人寫React只有1年多一點,菜雞輸出,有任何疏漏歡迎提出,希望能幫助到各位,感謝!