快速打造你自己的富文本编辑器

2,746 阅读6分钟

作为一款文本编辑器框架,lexical通过灵活且丰富的底层能力,让创建属于自己的富文本编辑器成为了可能。本文将利用lexical框架从0到1搭建一款富文本编辑器,带领大家领略lexical框架的基础用法

阅读本文需要有一定的基础,如果未接触过,请先阅读《新一代富文本编辑器框架lexical入门》,本文的基础代码在此处,不再做冗余介绍

image.png

在这篇文章中,我们要实际采用lexical编写一个富文本编辑器,效果如上图所示。该文本编辑器具备以下能力:

  • 撤销&回撤
  • 格式清除
  • 正文-标题切换
  • 加粗
  • 斜体
  • 删除线
  • 无序/有序列表
  • 引用
  • 字体
  • 文字颜色
  • 文字大小

接下来,我们就一一加以介绍

核心功能实现原理

撤销&回撤

lexical有一个历史记录的能力,每一个操作都会被记录,因此实现撤销以及回撤的操作非常简单,只需要触发lexical包的UNDO_COMMAND命令和REDO_COMMAND即可。

import {
    REDO_COMMAND, UNDO_COMMAND
} from 'lexical';

<button disabled={!canUndo} onClick={() => editor.dispatchCommand(UNDO_COMMAND)}></button>
<button disabled={!canRedo} onClick={() => editor.dispatchCommand(REDO_COMMAND)}></button>

快捷键如ctrl+z做撤销,是lexical自带的,不需要任何写代码

这里需要注意的细节是,因为只有在有了操作后才能点击撤销,点击过撤销后才能点击回撤,所以默认刚打开页面的时候,撤销和回撤操作都是不可用的。lexical提供了相应的命令:CAN_REDO_COMMANDCAN_UNDO_COMMAND,我们只需要监听即可:

import {
    CAN_REDO_COMMAND, CAN_UNDO_COMMAND
} from 'lexical';

const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);

editor.registerCommand(
    CAN_UNDO_COMMAND,
    payload => {
        setCanUndo(payload);
        return false;
    },
    COMMAND_PRIORITY_CRITICAL,
);
editor.registerCommand(
    CAN_REDO_COMMAND,
    payload => {
        setCanRedo(payload);
        return false;
    },
    COMMAND_PRIORITY_CRITICAL,
);

正文-标题切换

image.png

正文-标题切换功能允许我们将选中的文字在正文与不同级别标题之间相互做切换,涉及到节点操作,核心代码如下:

import { $setBlocksType } from '@lexical/selection';
import { $createParagraphNode } from 'lexical';

// 选中文字改为正文格式
$setBlocksType(selection, () => $createParagraphNode());
// 选中文字改为标题格式
$setBlocksType(selection, () => $createHeadingNode(headingSize));

headingSize的取值是"h1" | "h2" | "h3" | "h4" | "h5" | "h6"

加粗&斜体&删除线

这三个功能的实现,都是依赖于FORMAT_TEXT_COMMAND命令(command),通过调用editor.dispatchCommand(FORMAT_TEXT_COMMAND, payload);函数实现:

editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');

需要注意的是,对于加粗其html元素会替换为<strong>,对于斜体其html元素会替换为em,单纯选择其中一个样式,都能生效,但要想两者共同生效,需要在主题里配置css,通过css来控制。对于下划线来说,同样需要css来控制。添加样式配置如下:

theme: {
    text: {
        bold: 'editor_text_old',
        italic: 'editor_text_italic',
        strikethrough: 'editor_text_strikethrough'
    }
}

然后在css文件中添加样式:

.editor_text_old {
    font-weight: bold;
}
.editor_text_italic {
    font-style: italic;
}
.editor_text_strikethrough {
    text-decoration: line-through;
}

列表(有序/无序)

官方提供了@lexical/list开发包,提供了列表所需的ListNode和命令:

editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);

// 注册事件监听
editorInstance.registerCommand(
    INSERT_ORDERED_LIST_COMMAND,
    () => {
        insertList(editorInstance, 'number');
        return true;
    },
    COMMAND_PRIORITY_LOW,
);
editorInstance.registerCommand(
    INSERT_UNORDERED_LIST_COMMAND,
    () => {
        insertList(editorInstance, 'bullet');
        return true;
    },
    COMMAND_PRIORITY_LOW,
);

需要注意的是,同撤销/撤回一样,@lexical/list开发包并不提供对INSERT_ORDERED_LIST_COMMANDINSERT_UNORDERED_LIST_COMMAND命令的处理,需要用户自己注册并调用insertListremoveList函数:

editorInstance.registerCommand(
    INSERT_ORDERED_LIST_COMMAND,
    () => {
        const selection = $getSelection();
        const anchorNode = selection.anchor.getNode();
        const element = anchorNode.getKey() === 'root'
            ? anchorNode
            : $findMatchingParent(anchorNode, e => {
                const parent = e.getParent();
                return parent !== null && $isRootOrShadowRoot(parent);
            });
        if ($isListNode(element)) {
            removeList(editorInstance);
        } else {
            insertList(editorInstance, 'number');
        }
        return true;
    },
    COMMAND_PRIORITY_LOW,
);
editorInstance.registerCommand(
    INSERT_UNORDERED_LIST_COMMAND,
    () => {
        const selection = $getSelection();
        const anchorNode = selection.anchor.getNode();
        const element = anchorNode.getKey() === 'root'
            ? anchorNode
            : $findMatchingParent(anchorNode, e => {
                const parent = e.getParent();
                return parent !== null && $isRootOrShadowRoot(parent);
            });
        if ($isListNode(element)) {
            removeList(editorInstance);
        } else {
            insertList(editorInstance, 'bullet');
        }
        return true;
    },
    COMMAND_PRIORITY_LOW,
);

这里需要注意removeList函数的用法,上述代码使我们可以在点击列表按钮的时候,对当前文案做添加/撤销列表的操作。

但上述代码依旧不完整,会出现敲击回车键,会一直拓展列表,而不会跳出列表,事实上一般而言,连续敲击两次回车,将会跳出列表格式,变为正文(paragraph)。另外我们还需要支持列表的缩进。

回车

@lexical/rich-text中监听了KEY_ENTER_COMMAND事件,当KEY_ENTER_COMMAND事件触发时会接着触发INSERT_PARAGRAPH_COMMAND事件,而引入列表后,需要监听INSERT_PARAGRAPH_COMMAND事件并做额外的处理,以便两次回车可以跳出列表操作:

editorInstance.registerCommand(
    INSERT_PARAGRAPH_COMMAND,
    () => {
        const hasHandledInsertParagraph = $handleListInsertParagraph();
        if (hasHandledInsertParagraph) {
            return true;
        }
        return false;
    },
    COMMAND_PRIORITY_LOW,
);

这里的核心是来自@lexical/list包的 $handleListInsertParagraph函数,会自动完成列表跳出的判断及跳出(INSERT_PARAGRAPH_COMMAND事件的监听函数如果返回true,则会阻止paragraph的插入)

缩进

默认情况下,lexical不提供INSERT_TAB_COMMAND能力,按tab会没有反应,但是列表本身支持嵌套,缩进是最基本的操作,需要支持。我们开发一个tabPlugin插件:

import {
    $normalizeSelection__EXPERIMENTAL,
    $createRangeSelection,
    $isBlockElementNode,
    $getSelection,
    $isRangeSelection,
    COMMAND_PRIORITY_EDITOR,
    INDENT_CONTENT_COMMAND,
    INSERT_TAB_COMMAND,
    KEY_TAB_COMMAND,
    OUTDENT_CONTENT_COMMAND
} from 'lexical';
import { $filter, $getNearestBlockElementAncestorOrThrow } from '@lexical/utils';

function indentOverTab(selection) {
    // const handled = new Set();
    const nodes = selection.getNodes();
    const canIndentBlockNodes = $filter(nodes, node => {
        if ($isBlockElementNode(node) && node.canIndent()) {
            return node;
        }
        return null;
    });
    // 1. If selection spans across canIndent block nodes: indent
    if (canIndentBlockNodes.length > 0) {
        return true;
    }
    // 2. If first (anchor/focus) is at block start: indent
    const anchor = selection.anchor;
    const focus = selection.focus;
    const first = focus.isBefore(anchor) ? focus : anchor;
    const firstNode = first.getNode();
    const firstBlock = $getNearestBlockElementAncestorOrThrow(firstNode);
    if (firstBlock.canIndent()) {
        const firstBlockKey = firstBlock.getKey();
        let selectionAtStart = $createRangeSelection();
        selectionAtStart.anchor.set(firstBlockKey, 0, 'element');
        selectionAtStart.focus.set(firstBlockKey, 0, 'element');
        selectionAtStart = $normalizeSelection__EXPERIMENTAL(selectionAtStart);
        if (selectionAtStart.anchor.is(first)) {
            return true;
        }
    }
    // 3. Else: tab
    return false;
}

export const registerTab = editor => {
    editor.registerCommand(
        KEY_TAB_COMMAND,
        event => {
            const selection = $getSelection();
            if (!$isRangeSelection(selection)) {
                return false;
            }

            event.preventDefault();
            const command = indentOverTab(selection)
                ? event.shiftKey
                    ? OUTDENT_CONTENT_COMMAND
                    : INDENT_CONTENT_COMMAND
                : INSERT_TAB_COMMAND;
            return editor.dispatchCommand(command, undefined);
        },
        COMMAND_PRIORITY_EDITOR,
    );
};

这里注意INDENT_CONTENT_COMMANDINSERT_TAB_COMMAND的区别,一个是缩进,一个是插入tab

引用

依赖@lexical/rich-text包,需要导入QuoteNode,实现的时候需要注意添加引用和去掉引用:

editor.update(() => {
    const selection = $getSelection();
    // 判断是否有引用节点,有的话清理掉,没有的话,整体变成引用
    const nodes = selection.getNodes();
    let hasQuoteNode = false;
    // 处理选中区域跨节点的情况
    nodes.forEach(node => {
        if ($isQuoteNode(node)) {
            hasQuoteNode = true;
            node.replace($createParagraphNode(), true);
        }
    });
    // 遍历其父节点
    const parentQuoteNode = $findMatchingParent(selection.anchor.getNode(), $isQuoteNode);
    if (parentQuoteNode) {
        hasQuoteNode = true;
        parentQuoteNode.replace($createParagraphNode(), true);
    }
    if (!hasQuoteNode) {
        // 转换为引用格式
        $setBlocksType(selection, () => $createQuoteNode());
    }
});

文字颜色

实现比较简单,本质是修改css样式,调用@lexical/selection包里的$patchStyleText即可:

editor.update(
    () => {
        const selection = $getSelection();
        if (selection !== null) {
            $patchStyleText(selection, styles);
        }
    },
    skipHistoryStack ? { tag: 'historic' } : {},
);

字号

字号的修改同文字颜色,,本质是修改css样式,调用@lexical/selection包里的$patchStyleText即可:

editor.update(() => {
    if (editor.isEditable()) {
        const selection = $getSelection();
        if (selection !== null) {
            $patchStyleText(selection, {
                'font-size': newFontSize
            });
        }
    }
});

字体

同文字颜色及字号,略

超链接

当点击工具栏上超链接按钮后,触发TOGGLE_LINK_COMMAND事件,然后需要自己监听TOGGLE_LINK_COMMAND事件并调用,我们将监听过程抽离为一个plugin

import { TOGGLE_LINK_COMMAND, toggleLink } from '@lexical/link';
import { COMMAND_PRIORITY_LOW } from 'lexical';
import { validateUrl } from '../utils/url';

export const registerLink = editor => {
    editor.registerCommand(
        TOGGLE_LINK_COMMAND,
        payload => {
            if (payload === null) {
                toggleLink(payload);
                return true;
            } if (typeof payload === 'string') {
                if (validateUrl === undefined || validateUrl(payload)) {
                    toggleLink(payload);
                    return true;
                }
                return false;
            }
            const {
                url, target, rel, title
            } = payload;
            toggleLink(url, { rel, target, title });
            return true;
        },
        COMMAND_PRIORITY_LOW
    );
};

因为超链接至少有文本和url两个变量需要编辑,文本在编辑器里修改即可,但是url还需要一个单的编辑器,这里我们设计成这个样子

image.png

url编辑

核心思路是,当选中一个链接或者点击添加链接按钮时,打开编辑浮框

import {
    useCallback, useEffect, useRef, useState
} from 'react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { $findMatchingParent, mergeRegister } from '@lexical/utils';
import {
    $createLinkNode,
    $isAutoLinkNode,
    $isLinkNode,
    TOGGLE_LINK_COMMAND
} from '@lexical/link';
import {
    $getSelection,
    $isLineBreakNode,
    $isRangeSelection,
    $setSelection,
    CLICK_COMMAND,
    COMMAND_PRIORITY_CRITICAL,
    COMMAND_PRIORITY_HIGH,
    COMMAND_PRIORITY_LOW,
    KEY_ESCAPE_COMMAND,
    SELECTION_CHANGE_COMMAND
} from 'lexical';
import { sanitizeUrl } from '../../utils/url';
import getSelectedNode from '../../utils/getSelectedNode';
import setFloatingElemPositionForLinkEditor from '../../utils/setFloatingElemPositionForLinkEditor';
import './index.css';

const FloatingLinkEditor = ({
    editor,
    isLink,
    setIsLink,
    anchorElem,
    isLinkEditMode,
    setIsLinkEditMode
}) => {
    const editorRef = useRef(null);
    const inputRef = useRef(null);
    const [linkUrl, setLinkUrl] = useState('');
    const [editedLinkUrl, setEditedLinkUrl] = useState('https://');
    const [lastSelection, setLastSelection] = useState(null);

    const updateLinkEditor = useCallback(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
            const node = getSelectedNode(selection);
            const linkParent = $findMatchingParent(node, $isLinkNode);

            if (linkParent) {
                setLinkUrl(linkParent.getURL());
            } else if ($isLinkNode(node)) {
                setLinkUrl(node.getURL());
            } else {
                setLinkUrl('');
            }
            if (isLinkEditMode) {
                setEditedLinkUrl(linkUrl);
            }
        }
        const editorElem = editorRef.current;
        // eslint-disable-next-line no-undef
        const nativeSelection = window.getSelection();
        const activeElement = document.activeElement;

        if (editorElem === null) {
            return;
        }

        const rootElement = editor.getRootElement();

        if (
            selection !== null
            && nativeSelection !== null
            && rootElement !== null
            && rootElement.contains(nativeSelection.anchorNode)
            && editor.isEditable()
        ) {
            const domRect = nativeSelection.focusNode?.parentElement?.getBoundingClientRect();
            if (domRect) {
                domRect.y += 40;
                setFloatingElemPositionForLinkEditor(domRect, editorElem, anchorElem);
            }
            setLastSelection(selection);
        } else if (!activeElement || activeElement.className !== 'link-input') {
            if (rootElement !== null) {
                setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem);
            }
            setLastSelection(null);
            setIsLinkEditMode(false);
            setLinkUrl('');
        }

        // eslint-disable-next-line consistent-return
        return true;
    }, [anchorElem, editor, setIsLinkEditMode, isLinkEditMode, linkUrl]);

    useEffect(() => {
        const scrollerElem = anchorElem.parentElement;

        const update = () => {
            editor.getEditorState().read(() => {
                updateLinkEditor();
            });
        };

        // eslint-disable-next-line no-undef
        window.addEventListener('resize', update);

        if (scrollerElem) {
            scrollerElem.addEventListener('scroll', update);
        }

        return () => {
            // eslint-disable-next-line no-undef
            window.removeEventListener('resize', update);

            if (scrollerElem) {
                scrollerElem.removeEventListener('scroll', update);
            }
        };
    }, [anchorElem.parentElement, editor, updateLinkEditor]);

    useEffect(() => mergeRegister(
        editor.registerUpdateListener(({ editorState }) => {
            editorState.read(() => {
                updateLinkEditor();
            });
        }),

        editor.registerCommand(
            SELECTION_CHANGE_COMMAND,
            () => {
                updateLinkEditor();
                return true;
            },
            COMMAND_PRIORITY_LOW,
        ),
        editor.registerCommand(
            KEY_ESCAPE_COMMAND,
            () => {
                if (isLink) {
                    setIsLink(false);
                    return true;
                }
                return false;
            },
            COMMAND_PRIORITY_HIGH,
        ),
    ), [editor, updateLinkEditor, setIsLink, isLink]);

    useEffect(() => {
        editor.getEditorState().read(() => {
            updateLinkEditor();
        });
    }, [editor, updateLinkEditor]);

    useEffect(() => {
        if (isLinkEditMode && inputRef.current) {
            inputRef.current.focus();
        }
    }, [isLinkEditMode, isLink]);
    const handleLinkSubmission = () => {
        if (lastSelection !== null) {
            if (linkUrl !== '') {
                editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(editedLinkUrl));
                editor.update(() => {
                    const selection = $getSelection();
                    if ($isRangeSelection(selection)) {
                        const parent = getSelectedNode(selection).getParent();
                        if ($isAutoLinkNode(parent)) {
                            const linkNode = $createLinkNode(parent.getURL(), {
                                rel: parent.__rel,
                                target: parent.__target,
                                title: parent.__title
                            });
                            parent.replace(linkNode, true);
                        }
                    }
                    $setSelection(null);
                });
            }
            setIsLinkEditMode(false);
        }
    };
    const monitorInputInteraction = event => {
        if (event.key === 'Enter') {
            event.preventDefault();
            handleLinkSubmission();
        } else if (event.key === 'Escape') {
            event.preventDefault();
            setIsLinkEditMode(false);
        }
    };

    return (
        <div ref={editorRef} className='link-editor'>
            {!isLinkEditMode ? null : (
                <>
                    <input
                        ref={inputRef}
                        className='link-input'
                        value={editedLinkUrl}
                        onChange={event => {
                            setEditedLinkUrl(event.target.value);
                        }}
                        onKeyDown={event => {
                            monitorInputInteraction(event);
                        }}
                    />
                    <div>
                        <div
                            className='link-cancel'
                            role='button'
                            tabIndex={0}
                            onMouseDown={event => event.preventDefault()}
                            onClick={() => {
                                setIsLinkEditMode(false);
                            }}
                        />
                        <div
                            className='link-confirm'
                            role='button'
                            tabIndex={0}
                            onMouseDown={event => event.preventDefault()}
                            onClick={handleLinkSubmission}
                        />
                    </div>
                </>
            )}
        </div>
    );
};

function useFloatingLinkEditorToolbar(
    editor,
    anchorElem,
    isLinkEditMode,
    setIsLinkEditMode,
) {
    const [activeEditor, setActiveEditor] = useState(editor);
    const [isLink, setIsLink] = useState(false);

    useEffect(() => {
        function updateToolbar() {
            const selection = $getSelection();
            if ($isRangeSelection(selection)) {
                const focusNode = getSelectedNode(selection);
                const focusLinkNode = $findMatchingParent(focusNode, $isLinkNode);
                const focusAutoLinkNode = $findMatchingParent(
                    focusNode,
                    $isAutoLinkNode,
                );
                if (!(focusLinkNode || focusAutoLinkNode)) {
                    setIsLink(false);
                    setIsLinkEditMode(false);
                    return;
                }
                const badNode = selection
                    .getNodes()
                    .filter(node => !$isLineBreakNode(node))
                    .find(node => {
                        const linkNode = $findMatchingParent(node, $isLinkNode);
                        const autoLinkNode = $findMatchingParent(node, $isAutoLinkNode);
                        return (
                            (focusLinkNode && !focusLinkNode.is(linkNode))
                            || (linkNode && !linkNode.is(focusLinkNode))
                            || (focusAutoLinkNode && !focusAutoLinkNode.is(autoLinkNode))
                            || (autoLinkNode && !autoLinkNode.is(focusAutoLinkNode))
                        );
                    });
                if (!badNode) {
                    setIsLink(true);
                    setIsLinkEditMode(true);
                } else {
                    setIsLink(false);
                    setIsLinkEditMode(false);
                }
            }
        }
        return mergeRegister(
            editor.registerUpdateListener(({ editorState }) => {
                editorState.read(() => {
                    updateToolbar();
                });
            }),
            editor.registerCommand(
                SELECTION_CHANGE_COMMAND,
                (_payload, newEditor) => {
                    updateToolbar();
                    setActiveEditor(newEditor);
                    return false;
                },
                COMMAND_PRIORITY_CRITICAL,
            ),
            editor.registerCommand(
                CLICK_COMMAND,
                payload => {
                    const selection = $getSelection();
                    if ($isRangeSelection(selection)) {
                        const node = getSelectedNode(selection);
                        const linkNode = $findMatchingParent(node, $isLinkNode);
                        if ($isLinkNode(linkNode) && (payload.metaKey || payload.ctrlKey)) {
                            // eslint-disable-next-line no-undef
                            window.open(linkNode.getURL(), '_blank');
                            return true;
                        }
                    }
                    return false;
                },
                COMMAND_PRIORITY_LOW,
            ),
        );
    }, [editor, setIsLinkEditMode]);

    return createPortal(
        <FloatingLinkEditor
            editor={activeEditor}
            isLink={isLink}
            anchorElem={anchorElem}
            setIsLink={setIsLink}
            isLinkEditMode={isLinkEditMode}
            setIsLinkEditMode={setIsLinkEditMode}
        />,
        anchorElem,
    );
}

export default ({
    anchorElem = document.body,
    isLinkEditMode,
    setIsLinkEditMode,
    editor
}) => useFloatingLinkEditorToolbar(
    editor,
    anchorElem,
    isLinkEditMode,
    setIsLinkEditMode,
);

代码链接

实现细节以及使用效果可参考以下两个资源: