iT邦幫忙

0

[React + TS] lexical文字編輯器套件使用心得與教學

  • 分享至 

  • xImage
  •  

[React + TS] lexical文字編輯器套件使用心得與教學

前言

最近寒假寫專案寫著發閒,發現React一些套件的教學很少,例如本篇提到的lexical,因此想記錄一下自己的學習心得和想法,如果可以幫助到各位的話就太好啦,第一次寫文章,算是現學現賣,如果有錯誤或可優化的地方,歡迎提出!

使用環境: "@lexical/react": "0.12.5", "@mui/material": "5.14.17",
本篇算是將自己專案的東西複製過來,這裡會提到的範例程式碼都在這裡

Lexical簡介

Lexical 是基於JS的富文字編輯器,個人認為,對React使用者而言,非常方便,有以下優缺點

  • 優點:可擴充性高、客製化程度高
  • 缺點:上手困難

相較於其他文字編輯器的套件(例如: Quill, Draft等),不受限於套件本身的樣式與服務限制,他提供更客製化的服務,他可以套用任何自己已經寫好的ui樣式,然後再加上lexical的一些功能,就可以做出符合自己網頁的風格樣式編輯器,同時,又提供許多擴充功能( 例如youtube, latex公式等,可以到他的Playground看),然而如此自製的缺點就是上手有點困難,要產生出一個能用基礎編輯器功能,相較於其他套件就會十分困難。

Lexical核心架構

Node

Lexical中,會使用Node來呈現元件,並且為編譯成html,以下舉常見的例子

  • HeadingNode: <h1>, <h1>,
  • ListNode:<li>
  • QuoteNode: <blockquote>

不只有以上Node,lexical甚至提供開發者可以創建Node,提供更多新花樣,但本文不會提到這裡

Plugin

前面有提到,Lexical的高可擴充性,是用LexicalComposer將一個個Plugin加入而來的,例如一個基礎的編輯器就需要有以下功能

  • InitialPlugin: 用於初始化內容
  • ToolbarPlugin: 自定義功能,產生文字的樣式功能列,例如:設定粗體、大小、排列等
  • OnChangePlugin: 更新時的事件
  • RichTextPlugin: 富文字編輯器本身

除了以上Plugin,還有許多功能,例如清單、Markdown、歷史紀錄(ctrl+z)等功能都是可以自己依照需求增減

lexical上手困難的在於,本身文檔對於這些Plugin敘述很少,大多數時候都是我自己去Playground的原始碼看才知道怎麼達到想要的功能

LexicalComposer

編輯本身的控制器,可以設定文字編輯器的css class名稱,風格、功能等

<LexicalComposer
    initialConfig={editorConfig}> 
 {/* Plugins here*/}
</LexicalComposer>
  • initialConfig : 設定文字編輯
    • namespace: 編輯器名稱
    • theme: 用來設定編輯器Node的classname,讓你可以自訂一每個Node的樣式
    • nodes: 加入會使用到的Node (注意!!! 一定要加入會用到的Node)
    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
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

自訂義功能列,麻煩在於,需要手動註冊使用者變更選取範圍、更新時的事件,這裡將文字編輯器內的事件區分成三種:

  1. Text format: 文字本身的樣式,例如: 粗體、斜體
  2. Block format: 影響一整行的樣式,例如: 標題、Quote
  3. Align format: 影響文字對其的位置,例如: 置中、置右

Text Format

變更文字本身的樣式(例如粗體、斜體等)流程如下
https://ithelp.ithome.com.tw/upload/images/20240128/201651775d9Jp4g4um.png

  1. Text format Button Group: Lexical沒有定義控制列所需要的原件,因此可以將專案使用的ui框架(此處使用mui),加上變更text fortmat onClick上,接著再定義後續的動作就可以了
// 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>
  1. handleTextFormatClick: FORMAT_TEXT_COMMAND是lexical開給這個事件用的命令常數,可以輸入value
// import {FORMAT_TEXT_COMMAND} from 'lexical';
 /** Handle text formatting */
  const handleTextFormatClick = (
    event: React.MouseEvent,
    value: TextFormatType
  ) => {
    editor.dispatchCommand(FORMAT_TEXT_COMMAND, value);
  };
  1. useEffect
// 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]);
  1. updateToolbar:使用@lexical/selection偵測範圍內的textFormat狀態,然後更新
// 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類似,如下:

graph TD;
    A[Select: 使用者點擊樣式更新按鈕] --> B[handleBlockFormat:在編輯器更新文字樣式,並註冊變更樣式的命令]
    B --> C[useEffect: 聆聽 editor 變更命令]
    C --> D[updateToolbar: 變更控制列狀態與編輯器元件]
  1. Select:
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>
</>
)}
  1. handleBlockFormat:此處與TextFormat比較不一樣,有以下幾點差異:
  • 需要根據不同的型態來create Node
  • List 因為有縮行(order)的情況,所以直接使用內建的COMMAND來更新
  • 為了避免重複的程式碼,所以我將create Node的方法寫成常數,記得要加useMemo
  • 除了ParagraphNode不須設定,其他Node,例如HeadingNode, QuoteNode,要再LexicalComposer.initialConfig中設定nodes,否則改了也不會變格式

  /** 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);
      }
    }
  };
  1. useEffect:與Textformat一樣
  2. updateToolbar:ListNode需要特別找他的type,因為會有縮行問題
/** 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:
{/* 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>
  1. handleElementClick
  /** Handle element align formatting */
  const handleElementFormatClick = (
    event: React.MouseEvent,
    value: ElementFormatType
  ) => {
    editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, value);
  };
  1. useEffect
  2. updateToolbar
/** 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

顧名思義,就是初始化編輯器內容

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才可以讓清單能夠縮行與限制縮行次數

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縮行

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會長這樣

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年多一點,菜雞輸出,有任何疏漏歡迎提出,希望能幫助到各位,感謝!


圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言