基于 Lexical 的富文本输入框

这次先放 demo

富文本输入框

最近都在写后台项目,在项目中使用 富文本输入框 的频率很高。也使用过蛮多富文本编辑器,比如 tinymce, quill, jodit 等等。

因为一些特殊的业务需求,比如图片/视频的上传改成从后台库中选择图片,支持 youtube 视频嵌入等等,又不希望使用付费商业版,所以决定二次开发一个开源的高度自定义的富文本编辑器。在调研/使用了多个项目之后决定使用 lexical 开发。

Lexical

Lexical 是一个开源的富文本编辑器框架,由 Facebook 开发,旨在提供灵活和可扩展的文本编辑体验。最主要的优势在于 Lexical 的开发者体验一流。不需要过多与 DOM 的交互,通过声明性的 API 和插件就可以快速实现想要的功能。

在开始之前强烈建议先阅读完整的官方文档 ,后面的内容会从实践出发,不会过多介绍相关概念。

依赖安装

npm install --save lexical @lexical/react

基础使用

先看一个官方提供的基本例子:

app.tsx:

import React from 'react';
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';

interface RichTextEditorProps {}

const RichTextEditor: React.FC<RichTextEditorProps> = (props) => {
  const theme = {
    paragraph: 'paragraphClassName'
  }

  const initialConfig = {
    namespace: 'Lexical Demo',
    onError(error: Error) {
      throw error;
    },
    // 主题
    theme,
  };

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <RichTextPlugin
        contentEditable={<ContentEditable />}
        placeholder={<div>Enter some text...</div>}
        ErrorBoundary={LexicalErrorBoundary}
      />
      <HistoryPlugin />
      <AutoFocusPlugin />
    </LexicalComposer>
  );
};

export default RichTextEditor;

跑起来会发现似乎有点不对劲,为什么没有渲染出富文本编辑器?

run:
richtext1.png

这是因为 Lexical 本身不提供任何 UI,他是基于你的框架的编辑器,后面的样例都默认使用 tailwindcss 来添加样式,当然你也可以使用任何你熟悉或者喜欢的方式添加样式。

添加 CSS 样式

为了让 Lexical 编辑器正常工作,我们需要添加一些 CSS 样式。

···
 return (
    <div className="p-5">
      <LexicalComposer initialConfig={initialConfig}>
        <div className="rounded-lg border-[#e2e2e2] border-solid border w-full text-black relative text-left">
          <RichTextPlugin
            ErrorBoundary={LexicalErrorBoundary}
            contentEditable={
              <div className="min-h-[150px] border-none flex relative outline-none z-0 overflow-auto resize-y">
                <ContentEditable
                  aria-placeholder="Enter some rich text..."
                  className="min-h-[150px] w-full resize-none caret-[rgb(5,5,5)] relative outline-none py-4 px-6 editor-cell"
                  placeholder={
                    <div className="absolute overflow-hidden text-gray-400 truncate pointer-events-none select-none top-4 left-6">
                      Enter some rich text...
                    </div>
                  }
                />
              </div>
            }
          />
          <HistoryPlugin />
          <AutoFocusPlugin />
        </div>
      </LexicalComposer>
    </div>
  );
···

run:
richtext2.png

添加插件

现在编辑器有了样式,但目前只有输入这一项功能,所以需要添加一个 Toolbar 插件来支持更多功能。

ToolbarPlugin.tsx:(省略了一下组件样式,你可能需要自己实现你需要的样式)

const ToolbarPlugin: React.FC = () => {
  const [editor] = useLexicalComposerContext();
  const [activeEditor, setActiveEditor] = useState(editor);
  const toolbarRef = useRef(null);

  const [canUndo, setCanUndo] = useState(false);
    ··· 一些状态 ···
 
  return (
    <div
      ref={toolbarRef}
      className="flex items-center flex-wrap rounded-t-lg p-1 gap-0.5 border-b border-t-0 border-x-0 border-gray-300 border-solid bg-white"
    >
      {/* Undo/Redo */}
      <ToolbarButton disabled={!canUndo} >
        <IconUndo />
      </ToolbarButton>
      <ToolbarButton disabled={!canRedo} >
        <IconRedo />
      </ToolbarButton>
      <Divider />
      {/* font size */}
      <DropDownFontSize selectionFontSize={fontSize} editor={activeEditor} />
      <Divider />
      {/* font style */}
      <ToolbarButton active={isBold} >
        <IconTypeBold />
      </ToolbarButton>
      <ToolbarButton active={isItalic} >
        <IconTypeItalic />
      </ToolbarButton>
      <ToolbarButton active={isUnderline} >
        <IconTypeUnderline />
      </ToolbarButton>
      <ToolbarButton active={isStrikethrough} >
        <IconTypeStrikethrough />
      </ToolbarButton>
      <ToolbarButton active={isCode} >
        <IconCode />
      </ToolbarButton>

      {/* alignment */}
      <ToolbarButton active={elementFormat === 'left'} >
        <IconTextLeft />
      </ToolbarButton>
      <ToolbarButton active={elementFormat === 'center'} >
        <IconTextCenter />
      </ToolbarButton>
      <ToolbarButton active={elementFormat === 'right'} >
        <IconTextRight />
      </ToolbarButton>
      <ToolbarButton active={elementFormat === 'justify'} >
        <IconJustify />
      </ToolbarButton>

      <ToolbarButton >
        <IconOutdent />
      </ToolbarButton>
      <ToolbarButton >
        <IconIndent />
      </ToolbarButton>
    </div>
  );
}

接下来将添加一些基础功能

  • [ ] 撤销/重做
  • [ ] 修改字体大小 / 样式(加粗,斜体等)
  • [ ] 修改对齐方式
  • [ ] 添加缩进

首先通过 editor.registerCommand(...) 来注册命令 / 添加监听器。(ps:官方建议请在 effect 中使用)

ToolbarPlugin.tsx:

···
const ToolbarPlugin: React.FC = () => {
···
  useEffect(() => {
    return editor.registerCommand(
      SELECTION_CHANGE_COMMAND,//- 触发事件 当选择区域改变时
      (_payload, newEditor) => {
        setActiveEditor(newEditor); //- 更新编辑器实例
        $updateToolbar(); //- 更新工具栏状态
        return false;
      },
      COMMAND_PRIORITY_CRITICAL 
      //- 优先级有以下选择,根据需求选择
      //- COMMAND_PRIORITY_EDITOR
      //- COMMAND_PRIORITY_LOW
      //- COMMAND_PRIORITY_NORMAL
      //- COMMAND_PRIORITY_HIGH
      //- COMMAND_PRIORITY_EDITOR
    );
  }, [editor, $updateToolbar]);

  //- 监听器
  useEffect(() => {
    return mergeRegister(
      activeEditor.registerUpdateListener(({ editorState }) => {
        editorState.read(() => {
          $updateToolbar();
        });
      }),
      activeEditor.registerCommand<boolean>(
        CAN_UNDO_COMMAND,
        (payload) => {
          setCanUndo(payload);
          return false;
        },
        COMMAND_PRIORITY_CRITICAL
      ),
      activeEditor.registerCommand<boolean>(
        CAN_REDO_COMMAND,
        (payload) => {
          setCanRedo(payload);
          return false;
        },
        COMMAND_PRIORITY_CRITICAL
      )
    );
  }, [$updateToolbar, activeEditor]);
···
}
···

ToolbarPlugin.tsx: 根据选择区域更新工具栏状态,updateToolbar function:

  const $updateToolbar = useCallback(() => {
    const selection = $getSelection();//- 获取选择区域
    if ($isRangeSelection(selection)) {
        //- 判断段落信息
      const anchorNode = selection.anchor.getNode();
      let element =
        anchorNode.getKey() === 'root'
          ? anchorNode
          : $findMatchingParent(anchorNode, (e) => {
              const parent = e.getParent();
              return parent !== null && $isRootOrShadowRoot(parent);
            });

      if (element === null) {
        element = anchorNode.getTopLevelElementOrThrow();
      }

      const node = getSelectedNode(selection);
      const parent = node.getParent();

      let matchingParent;
      if ($isLinkNode(parent)) {
        //- 如果节点是 link ,我们需要获取父节点来设置格式
        matchingParent = $findMatchingParent(
          node,
          (parentNode) => $isElementNode(parentNode) && !parentNode.isInline()
        );
      }

      setElementFormat(
        $isElementNode(matchingParent)
          ? matchingParent.getFormatType()
          : $isElementNode(node)
          ? node.getFormatType()
          : parent?.getFormatType() || 'left'
      );
    }
    if ($isRangeSelection(selection)) {
      //- 判断字体样式
      setIsBold(selection.hasFormat('bold'));
      setIsItalic(selection.hasFormat('italic'));
      setIsUnderline(selection.hasFormat('underline'));
      setIsStrikethrough(selection.hasFormat('strikethrough'));

      setIsCode(selection.hasFormat('code'));

      setFontSize(
        $getSelectionStyleValueForProperty(selection, 'font-size', '16px')
      );
    }
  }, []);

最后使用 editor.dispatchCommand(command, payload) API 来发送命令。

ToolbarPlugin.tsx:

···
  <ToolbarButton
    disabled={!canUndo}
    onClick={() => {
      editor.dispatchCommand(UNDO_COMMAND, undefined);
    }}
  >
    <IconUndo />
  </ToolbarButton>
···

一些常用的命令:

editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'); //- 修改字体样式,第二个参数为样式
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left') //- 修改对齐方式
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); //- 增加缩进
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined); //- 减少缩进
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) //- 插入无序列表
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined) //- 插入有序列表
···

还记得一开始说的 "Lexical 本身不提供任何 UI" 吗,所以你可能需要添加一些样式才能正确显示。

app.tsx:

···
  const theme = {
    paragraph: 'paragraphClassName',
    text: {
      bold: 'font-bold',
      italic: 'italic',
      underline: 'underline',
      strikethrough: 'line-through', //- 这里用的是 tailwindcss 样式,你可以替换成你需要的 className
    },
  };
···

return (
    <div className="p-5">
      <LexicalComposer initialConfig={initialConfig}>
        <div className="rounded-lg border-[#e2e2e2] border-solid border w-full text-black relative text-left">
          {/* 在合适的位置放置工具栏 */}
          <ToolbarPlugin />
          <RichTextPlugin
            ErrorBoundary={LexicalErrorBoundary}
            contentEditable={
              <div className="min-h-[150px] border-none flex relative outline-none z-0 overflow-auto resize-y">
                <ContentEditable
                  aria-placeholder="Enter some rich text..."
                  className="min-h-[150px] w-full resize-none caret-[rgb(5,5,5)] relative outline-none py-4 px-6 editor-cell"
                  placeholder={
                    <div className="absolute overflow-hidden text-gray-400 truncate pointer-events-none select-none top-4 left-6">
                      Enter some rich text...
                    </div>
                  }
                />
              </div>
            }
          />
          <HistoryPlugin />
          <AutoFocusPlugin />
        </div>
      </LexicalComposer>
    </div>
)

run:
richtext3.png

到这已经完成了最基础的富文本编辑器,接下来将通过创建插件的方式,添加更为复杂的功能。

自定义插件(插入图片)

与其他编辑器框架不同,Lexical 并没有为插件定义提供任何接口。通过访问 LexicalEditor ,插件可以使用 Lexical 提供的任何 API 命令。这也是推荐 Lexical 作为富文本编辑器的原因之一,高度的自定义为开发提供了更多的舒适度。

  1. 创建节点 LexicalNode

Lexical 提供了一些基础节点,可以通过继承来快速自定义:

  • ElementNode – 父节点,可以是块级或者行内。
  • TextNode - 包含文本的叶子节点。(不能包含子元素)
  • DecoratorNode - 在编辑器中插入任意视图。

这里选用 DecoratorNode 来自定义插入节点,很简单,只需要继承 DecoratorNode 并实现一些基础方法。

imageNode.tsx:

import { Suspense } from 'react';
···

export interface ImagePayload {
  altText: string;
  height?: number;
  key?: NodeKey;
  maxWidth?: number;
  src: string;
  width?: number;
}

function $convertImageElement(domNode: Node): null | DOMConversionOutput {
  const img = domNode as HTMLImageElement;
  const { alt: altText, src, width, height } = img;
  const node = $createImageNode({ altText, height, src, width });
  return { node };
}

export type SerializedImageNode = Spread<
  {
    altText: string;
    height?: number;
    maxWidth: number;
    src: string;
    width?: number;
  },
  SerializedLexicalNode
>;

export class ImageNode extends DecoratorNode<JSX.Element> {
  //- img 节点属性,你可以按需添加
  __src: string;
  __altText: string;
  __width: 'inherit' | number;
  __height: 'inherit' | number;
  __maxWidth: number;

  //- 获取节点类型
  static getType(): string {
    return 'image';
  }

  //- 复制操作需要用到,拷贝一下即可
  static clone(node: ImageNode): ImageNode {
    return new ImageNode(
      node.__src,
      node.__altText,
      node.__maxWidth,
      node.__width,
      node.__height,
      node.__key
    );
  }

  //- 反序列化节点
  static importJSON(serializedNode: SerializedImageNode): ImageNode {
    const { altText, height, width, maxWidth, src } = serializedNode;
    const node = $createImageNode({
      altText,
      height,
      maxWidth,
      src,
      width,
    });
    return node;
  }

  //- 导出 dom 节点
  exportDOM(): DOMExportOutput {
    const element = document.createElement('img');
    element.setAttribute('src', this.__src);
    element.setAttribute('alt', this.__altText);
    element.setAttribute('width', this.__width.toString());
    element.setAttribute('height', this.__height.toString());
    return { element };
  }

  //- dom 节点序列化
  static importDOM(): DOMConversionMap | null {
    return {
      img: (node: Node) => ({
        conversion: $convertImageElement,
        priority: 0,
      }),
    };
  }

  constructor(
    src: string,
    altText: string,
    maxWidth: number,
    width?: 'inherit' | number,
    height?: 'inherit' | number,
    key?: NodeKey
  ) {
    super(key);
    this.__src = src;
    this.__altText = altText;
    this.__maxWidth = maxWidth;
    this.__width = width || 'inherit';
    this.__height = height || 'inherit';
  }

  //- 序列化节点
  exportJSON(): SerializedImageNode {
    return {
      altText: this.getAltText(),
      height: this.__height === 'inherit' ? 0 : this.__height,
      maxWidth: this.__maxWidth,
      src: this.getSrc(),
      type: 'image',
      version: 1,
      width: this.__width === 'inherit' ? 0 : this.__width,
    };
  }

  //- 这是拓展方法,image resize 会用到,可以忽略
  setWidthAndHeight(
    width: 'inherit' | number,
    height: 'inherit' | number
  ): void {
    const writable = this.getWritable();
    writable.__width = width;
    writable.__height = height;
  }

  //- 节点渲染
  createDOM(config: EditorConfig): HTMLElement {
    const span = document.createElement('span');
    const theme = config.theme;
    const className = theme.image;
    if (className !== undefined) {
      span.className = className;
    }
    return span;
  }

  updateDOM(): false {
    return false;
  }

  getSrc(): string {
    return this.__src;
  }

  getAltText(): string {
    return this.__altText;
  }

  decorate(): JSX.Element {
    return (
      <Suspense fallback={null}>
        <img
          src={this.__src}
          alt={this.__altText}
          style={{
            width: this.__width,
            height: this.__height,
            maxWidth: this.__maxWidth,
          }}
          key={this.getKey()}
          draggable={false}
        />
      </Suspense>
    );
  }
}

export function $createImageNode({
  altText,
  height,
  maxWidth = 500,
  src,
  width,
  key,
}: ImagePayload): ImageNode {
  return $applyNodeReplacement(
    new ImageNode(src, altText, maxWidth, width, height, key)
  );
}

export function $isImageNode(
  node: LexicalNode | null | undefined
): node is ImageNode {
  return node instanceof ImageNode;
}

  1. 注册插件 & 节点
···
  const [editor] = useLexicalComposerContext();

  const initialConfig = {
    namespace: 'Lexical Demo',
    nodes: [ImageNode], //- 注册节点
    onError(error: Error) {
      throw error;
    },
    theme,
  };

  useEffect(() => {
    if (!editor.hasNodes([ImageNode])) {
      throw new Error('ImagesPlugin: ImageNode not registered on editor');
    }

    return mergeRegister(
        //- 注册命令
      editor.registerCommand<InsertImagePayload>(
        INSERT_IMAGE_COMMAND,
        (payload) => {
          const imageNode = $createImageNode(payload);
          $insertNodes([imageNode]);
          if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
            $wrapNodeInElement(imageNode, $createParagraphNode).selectEnd();
          }

          return true;
        },
        COMMAND_PRIORITY_EDITOR
      ),
    );
  }, [captionsEnabled, editor]);
···

最后可以通过 INSERT_IMAGE_COMMAND 来插入图片

editor.dispatchCommand(INSERT_IMAGE_COMMAND, {altText, src, width, height})

LexicalEditor 完整文档
LexicalEditor Git