Lexical 是 Meta 开源的一个 Web 文本编辑器框架,它采用了与 React 虚拟 DOM 类似的思路,将编辑器状态与真实 DOM 解耦,大大降低了开发者的心智负担,并且 Lexical 有着很高的可扩展性和性能,开发者可以基于它开发完全符合自己业务需求的编辑器,可以是简单的纯文本编辑器,也可以是复杂的富文本编辑器,甚至是实时协作的文本编辑器。
因为 Lexical 的概念比较多和复杂,只是进行理论学习会比较枯燥和难懂,因此我们通过开发一个富文本编辑器来边实践边学习。由于内容较多,所以将会分为多期进行,这是第一期,另外需要你有一定的前端基础和 React 基础。
前置准备
框架方面,我们会基于 React 和 Lexical 官方的 React 集成包进行开发,不过 Lexical 本身是个框架无关的 JavaScript 框架,你也可以根据自己的需求使用其他框架。
样式方面,我们会使用 Tailwind CSS,另外 Lexical 没有提供任何 UI 组件,我们为了便于开发,会使用 Radix UI 作为基础组件,当然 Lexical 不限制你使用哪种 CSS 方案,可以根据自己的喜好换成其他的。
脚手架方面,我们使用 Rsbuild - 基于 Rspack 的 Web 构建工具,并且由于项目仅作为学习使用,不考虑将其发布为 npm 包,所以脚手架方面也不会做相关配置,如果你有相关需求,可以参考 Rspack 生态中的 Rslib 或社区其他方案。
创建项目
我们使用 Rsbuild 来创建一个空项目,并且使用 pnpm 作为包管理工具:
pnpm create rsbuild@latest
根据提示我们创建一个 React 和 TypeScript 的项目。
安装依赖
安装 Lexical、Lexical 的 React 集成包和其他必需的包:
pnpm add lexical @lexical/react @lexical/rich-text @lexical/selection @lexical/utils
安装 UI 相关依赖,如 Tailwind CSS、Radix UI 等:
# 作为 devDependencies
pnpm add tailwindcss @tailwindcss/typography tailwindcss-animate -D
# 作为 dependencies
pnpm add @radix-ui/react-select @radix-ui/react-toggle clsx lucide-react
依赖安装完成后需要进行简单的配置,Tailwind CSS 相关的配置方法参考 Rsbuild 的官方文档即可,另外要注意我们需要配置两个插件,@tailwindcss/typography
和 tailwindcss-animate
,前者用于富文本的样式,后者用于给基础组件添加一些简单的动画效果。
另外 clsx
用于拼接 className,lucide-react
是一个图标库,界面上用到的图表都用它导入。
项目结构
我们的项目结构如下,和编辑器相关的内容都放在 ./src/Editor
目录下,components
目录下是一些基础组件,如按钮、下拉框等,plugins
和 nodes
目录分别是 Lexical 中的对应概念的内容。
- src
| - Editor
| | - components
| | - plugins
| | - nodes
| | - index.tsx
| - App.tsx
| - index.tsx
开发基础组件
目前需要用到的 ToggleButton 和 Select 组件,用于工具栏上的按钮和下拉选择,我们基于 Radix UI 和 Tailwind CSS 进行简单的封装,都放在 ./src/Editor/components
目录下,这里就不做过多的介绍了,文章最后会提供代码链接,直接参考即可。
创建编辑器
我们先来创建一个最基本的编辑器,放在 ./src/Editor/index.tsx
中:
import { InitialConfigType, LexicalComposer } from '@lexical/react/LexicalComposer' ;
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' ;
import { ContentEditable } from '@lexical/react/LexicalContentEditable' ;
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' ;
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin' ;
const Editor = () => {
const initialConfig : InitialConfigType = {
namespace: 'editor' ,
theme: {},
nodes: [],
onError: console.error,
};
return (
< div >
< LexicalComposer initialConfig = {initialConfig}>
< div >
< RichTextPlugin
contentEditable = {< ContentEditable />}
placeholder = {< div >来写点什么吧</ div >}
ErrorBoundary = {LexicalErrorBoundary}
/>
</ div >
< AutoFocusPlugin />
</ LexicalComposer >
</ div >
);
};
export default Editor;
上面的代码就创建了一个基础的编辑器,现在编辑器是没有样式的,先来解释一下这段代码:
LexicalComposer 是编辑器的根组件,其他所有内容都要在它下面,它本质上一个 React 的 Context,在其他代码中可以通过 useLexicalComposerContext
获取到编辑器实例。
RichTextPlugin 是一个富文本插件,也是我们这个编辑器的起点,它提供了富文本编辑的相关功能,如文本输入、删除、格式化等。
ContentEditable 是实际可以编辑的区域,本质上是一个 contenteditable
的 div
,我们的编辑器是不需要 input
或 textarea
进行输入的,因此 placeholder 也是一个 HTML 元素,我们需要调整样式将它放在合适的位置,默认是在可编辑区域下方的。
AutoFocusPlugin 也是个 Lexical 插件,它可以将光标自动聚焦到可编辑区域,这样就可以直接输入,不需要鼠标点击一下了。
以上代码是需要添加一些基础的样式的,这里不做过多的介绍,直接参考代码即可。
插件
从上面最基础的编辑器就能看得出来,Lexical 的各个功能模块都是以插件为单位的,插件可以做任何事,Lexical 没有为插件提供特定的 API,而是可以使用任意 API 实现任意功能。
我们在基于 React 进行开发时,插件本质上也是一个 React 组件,直接包裹在 LexicalComposer 中。
开发工具栏
上面创建的编辑器是还没有任何用户可交互的内容的,富文本编辑器最常见的是交互形式是上方放一个工具栏,那我们也来开发一个。工具栏也是以插件的形式来实现,我们把代码放在 ./src/Editor/plugins/ToolbarPlugin
目录下。
工具栏的功能如下:
调整文本的类型,包含段落、一级标题到六级标题、引用块;
调整文本的样式,包括加粗、斜体、下划线、删除线、代码、高亮、上标和下标;
UI 部分
UI 部分比较简单,没有任何逻辑,直接看代码吧:
const ToolbarPlugin = () => {
const [ editor ] = useLexicalComposerContext ();
/** 文本类型 */
const [ blockType , setBlockType ] = useState ( 'paragraph' );
/** 文本格式 */
const [ isBold , setIsBold ] = useState ( false );
// 省略一些,如斜体、下划线等
const formatText = () => {
// ...
};
const handleBlockTypeChange = () => {
// ...
};
return (
< div >
< Select value = {blockType} onValueChange = {handleBlockTypeChange}>
< SelectItem value = "paragraph" label = "段落" icon = {ALargeSmallIcon} />
< SelectSeparator />
< SelectItem value = "h1" label = "一级标题" icon = {Heading1Icon} />
{ /* 省略一些 */ }
< SelectSeparator />
< SelectItem value = "quote" label = "引用块" icon = {QuoteIcon} />
</ Select >
< div >{ /* 来个分割线 */ }</ div >
< div >
< ToggleButton
icon = {BoldIcon}
aria-label = "加粗"
pressed = {isBold}
onPressedChange = {() => formatText ( 'bold' )}
/>
{ /* 省略一些 */ }
</ div >
</ div >
);
};
获取选中文本状态
在设置文本格式之前,我们需要先知道当前选中的文本的状态。在 Lexical 中可以通过给编辑器实例注册 Update 监听器来监听状态的更新,并且插件本质上是个 React 组件,所以我们可以通过 useEffect 来注册和取消注册监听器。
// 省略了上面已有的代码
const Editor = () => {
const [ editor ] = useLexicalComposerContext ();
useEffect (() => {
const unregister = editor. registerUpdateListener (({ editorState }) => {
editorState. read (() => {
// 编辑器状态更新后,都会执行到这里
});
});
// 不要忘了取消注册监听器
return unregister;
}, []);
};
然后我们在 editorState.read
的回调函数中就可以读取当前选中文本的状态:
// 这段代码放在 editorState.read 的回调函数中
const selection = $getSelection ();
if ( ! $isRangeSelection (selection)) {
return ;
}
/** 读取文本格式 */
setIsBold (selection. hasFormat ( 'bold' ));
/** 读取文本类型 */
const anchorNode = selection.anchor. getNode ();
let element =
anchorNode. getKey () === 'root'
? anchorNode
: $findMatchingParent (anchorNode, ( node : LexicalNode ) => {
const parent = node. getParent ();
return parent !== null && $isRootOrShadowRoot (parent);
});
if (element === null ) {
element = anchorNode. getTopLevelElementOrThrow ();
}
// 这里仅考虑了 Heading、Quote 和 Paragraph,还不够完善
const type = $isHeadingNode (element) ? element. getTag () : element. getType ();
setBlockType (type);
解释一下上面的代码:
$getSelection
是 Lexical 内置的方法,可以读取获取当前选中的内容,返回一个 Selection 实例,我们这里需要用到 RangeSelection 来进行后续的操作,RangeSelection 本质上是对浏览器 DOM 的 Selection 和 Range API 的封装。
通过 RangeSelection 实例上的 hasFormat
方法就可以获取到当前选中的文本是否包含相应的格式,同一个 Selection 是可以包含多种格式的,比如同时是加粗和斜体。
获取文本类型就没那么简单了,比如在一级标题上选中了部分文字,这时我们需要获取的是这个一级标题的信息,也就是选中部分的父级,而不是选中部分本身,上述代码就是先获取父级元素,然后再进行判断,按照目前的需求这里只判断了 $isHeadingNode
,后续还需要进行扩展。
设置文本格式
只需要调用 Lexical 内置的 FORMAT_TEXT_COMMAND
命令就可以设置文本格式了:
const formatText = ( type : TextFormatType ) => {
editor. dispatchCommand ( FORMAT_TEXT_COMMAND , type);
};
设置文本类型
设置文本类型也是一样的,不过不同的类型需要调用不同的内置方法,需要进行一一处理:
// 设置为段落
const formatParagraph = () => {
editor. update (() => {
const selection = $getSelection ();
if ( $isRangeSelection (selection)) {
$setBlocksType (selection, () => $createParagraphNode ());
}
});
};
// 设置为标题
const formatHeading = ( headingSize : HeadingTagType ) => {
editor. update (() => {
const selection = $getSelection ();
$setBlocksType (selection, () => $createHeadingNode (headingSize));
});
};
// 设置为引用
const formatQuote = () => {
editor. update (() => {
const selection = $getSelection ();
$setBlocksType (selection, () => $createQuoteNode ());
});
};
注意上面的操作都要放在 editor.update
回调函数中,这是唯一可以安全的修改编辑器状态的地方,可以把它理解为 React 的 setState
。
至此一个简单的工具栏就开发完成了,把上面的代码合并完善一下,再补充一下样式,就是下面这个样子了。
总结
上面我们实现的功能虽然还比较简陋,但已经涉及到了 Lexical 大部分的重要概念,比如插件、Nodes、Selection 和 Command,现在只知道了怎么用它们,但还不知道它们具体是什么,另外你能注意到了 Lexical 内置的 API 有些是以 $
开头的,而有些又不是,这是为什么呢?下期就来详细讲讲。
本项目的完整代码可以从 lexical-demo 仓库看到,本期的可以参考 feat: 01 commit,后续每期的更新也会是一个 commit。