深入理解 @lexical/rich-text:Lexical 富文本编辑器的命令集与内置扩展
2026/9/12 3:31:17 网站建设 项目流程

深入理解 @lexical/rich-text:Lexical 富文本编辑器的命令集与内置扩展

【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical

导读

@lexical/rich-text是 Lexical 生态中面向富文本场景的“开箱即用”包:它以一组基础命令监听器为起点,覆盖文本输入、字符删除、复制粘贴、方向键移动选区等基础编辑行为,并为标题、文本格式与块引用等富文本特性提供默认实现。本文将以 packages/lexical-rich-text/README.md 为主体,结合该包的源码、配置与测试,讲解它的设计定位、核心命令体系、可配置项以及可扩展方式,帮助你理解“何时选用它”“它替你做完了什么”“如何在它之上做定制”。


一、包定位:给编辑器一套“默认键位与行为”

在 Lexical 的架构中,核心包lexical只负责编辑器状态模型、节点树、命令分发与生命周期,它预设任何键盘行为或编辑习惯。具体的输入体验由各个功能包以“注册命令监听器”的方式提供。@lexical/rich-text正是这样一个“起始点”包:

它注册了一组基础命令的监听器,覆盖简单文本编辑行为——输入文字、删除字符、复制粘贴、用方向键改变选区;同时为富文本特性提供默认行为——标题、格式化文本与块引用。

这意味着:

  • 你不需要自己写KEY_BACKSPACE_COMMANDDELETE_CHARACTER_COMMAND等监听逻辑;
  • 你可以把该包当作地基,在此基础上追加自己的命令监听器来定制编辑器功能(追加监听器的优先级、顺序由你控制);
  • 如果你不需要富文本能力(纯文本输入即可),官方建议改用 @lexical/plain-text,它的体积和行为都更精简。

从仓库中packages/lexical-rich-text/package.jsondependencies可以看到它的能力来源:@lexical/clipboard(剪贴板)、@lexical/selection(选区运算)、@lexical/utils(工具函数)、@lexical/dragon(语音/听写输入支持)、@lexical/a11y(无障碍)以及核心包lexical


二、两种接入方式:扩展 API 与命令注册函数

当前仓库中的@lexical/rich-text同时提供两代接入方式。

2.1 扩展方式:RichTextExtension(推荐)

在基于扩展体系(buildEditorFromExtensions/defineExtension)构建编辑器时,直接声明依赖即可:

import {RichTextExtension} from '@lexical/rich-text'; import {HistoryExtension} from '@lexical/history'; import {buildEditorFromExtensions} from 'lexical'; const editor = buildEditorFromExtensions( { namespace: 'MyRichEditor', theme: {...}, }, [RichTextExtension, HistoryExtension], );

从 LexicalRichTextExtension.ts 的源码可以看到,RichTextExtension并非孤立扩展,它会自动拉起一组配套依赖:

  • HeadingAnnounceExtension:无障碍播报(详见下文第六节);
  • DragonExtension:Dragon 语音听写输入支持;
  • NormalizeInlineElementsExtension/NormalizeTripleClickSelectionExtension:行内元素规范与三击全选规范化;
  • CoreImportExtension+ 定制了规则的DOMImportExtension:通过RichTextImportRules支持从 HTML 导入标题与块引用(见第七节)。

扩展还声明了conflictsWith: ['@lexical/plain-text'],即纯文本包与富文本包互斥,二者不可同时挂载。

2.2 命令注册方式:registerRichText

对于基于createEditor()的传统写法,包导出了函数registerRichText(editor, escapeFormatTriggers?, shouldHandlePasteAsFiles?),它批量注册全部命令监听并返回一个清理函数:

import {createEditor} from 'lexical'; import {registerRichText} from '@lexical/rich-text'; const editor = createEditor({namespace: 'MyEditor'}); const removeListeners = registerRichText(editor); // 卸载时调用 removeListeners()

函数的第二、三个可选参数与扩展的配置一一对应(见第五节)。该函数的完整实现在 index.ts,一个大型mergeRegister(...)包裹了下面第三节列出的全部命令。


三、核心命令体系:包替你监听了什么

这是本包的技术核心。registerRichTextCOMMAND_PRIORITY_EDITOR优先级上注册了如下命令监听(以下命令常量均来自核心包lexical,完整实现见 index.ts):

命令默认行为说明
CLICK_COMMAND清空NodeSelection;按触发配置逃逸文本格式点击已选中节点内部视为“与节点交互”,不取消选中
DELETE_CHARACTER_COMMANDselection.deleteCharacter(isBackward)/deleteNodes()删除字符或节点选择
DELETE_WORD_COMMANDselection.deleteWord(isBackward)删除单词
DELETE_LINE_COMMANDselection.deleteLine(isBackward)删除整行
CONTROLLED_TEXT_INSERTION_COMMAND插入文本 / 富文本 DataTransfer受控文本插入(含 beforeinput 路径)
REMOVE_TEXT_COMMANDselection.removeText()删除选中文本
FORMAT_TEXT_COMMAND$formatText(selection, format)文本级格式:加粗、斜体等
SET_TEXT_FORMAT_COMMAND$setTextFormat(selection, formats)批量设置文本格式
FORMAT_ELEMENT_COMMAND对最近的块级祖先设置setFormat块级对齐:左/中/右/两端
INSERT_LINE_BREAK_COMMANDselection.insertLineBreak(selectStart)插入换行(Shift+Enter)
INSERT_PARAGRAPH_COMMANDselection.insertParagraph()插入段落(Enter)
INSERT_TAB_COMMAND插入TabNode插入制表符节点
INDENT_CONTENT_COMMAND/OUTDENT_CONTENT_COMMANDblock.setIndent(indent ± 1)块缩进 / 反缩进
KEY_ARROW_UP/DOWN/LEFT/RIGHT_COMMAND移动选区,处理 NodeSelection→RangeSelection 转换、RTL 方向、块光标、装饰器与行内网格导航方向键导航
KEY_BACKSPACE_COMMAND/KEY_DELETE_COMMAND转发为DELETE_CHARACTER_COMMAND;缩进块开头 Backspace 触发反缩进;iOS 特殊处理删除键
KEY_ENTER_COMMANDShift 判定后转发INSERT_LINE_BREAK_COMMANDINSERT_PARAGRAPH_COMMAND回车
KEY_ESCAPE_COMMANDeditor.blur()Esc 失焦
KEY_SPACE_COMMAND/KEY_TAB_COMMAND触发格式逃逸检查,然后交还默认行为空格 / Tab
DROP_COMMAND/DRAGSTART_COMMAND/DRAGOVER_COMMAND文件拖放转发为DRAG_DROP_PASTE;把 Lexical 自有序列化写入 DataTransfer拖放
SELECT_ALL_COMMAND全选(仅在具名插槽内有界)全选
COPY_COMMAND/CUT_COMMAND/PASTE_COMMAND复制 / 剪切 / 粘贴富文本剪贴板(详见下文)
MOVE_TO_END/MOVE_TO_START光标移动到块首/块尾,绕过 Chromium 对contenteditable=false行内装饰器的边界限制行首行尾

其中几个值得展开的细节:

剪贴板三件套。粘贴路径onPasteForRichText会调用$insertDataTransferForRichText把剪贴板中的 HTML/文本按富文本规则插入,并以PASTE_TAG作为撤销边界(来源注释说明这是让“撤销粘贴”不会连带撤销粘贴前的输入,见 index.ts)。剪切路径onCutForRichText会先把整文档选区扩展到块本身再复制,保证 Cmd+X 后 Cmd+V 能还原标题、引用或列表这类块结构而非只还原文本,同样用CUT_TAG标记为独立撤销条目(index.ts)。拖拽起点DRAGSTART_COMMAND会把 Lexical 自有序列化写入 DataTransfer,使自定义节点(图片、装饰器)在编辑器间拖放时不会降级为纯 HTML。

Backspace 的缩进语义:当光标位于缩进块的开头时,按下 Backspace 会先preventDefault并转发OUTDENT_CONTENT_COMMAND,即“先反缩进再删字符”,与主流编辑器的直觉一致(index.ts)。

平台兼容KEY_BACKSPACE_COMMAND在 iOS + beforeinput 环境下特意返回false不阻断 keydown,以免干扰系统键盘的自动更正建议栏;KEY_ENTER_COMMAND对 iOS/Safari/WebKit 同样放行默认行为,让自动完成、自动大写正常工作(源码注释引用了对应 issue 编号)。


四、富文本节点:HeadingNode 与 QuoteNode

包通过扩展注册了两个富文本核心节点(见 LexicalRichTextExtension.ts 的nodes: () => [HeadingNode, QuoteNode])。

4.1 HeadingNode:标题节点

HeadingNode对应<h1><h6>,其行为要点(见 index.ts):

  • 构造函数接受HeadingTagType = 'h1' | 'h2' | ... | 'h6',默认'h1'
  • createDOM根据__tag创建对应标签,并从主题中取theme.heading[tag]应用类名(updateDOM在标签变化时返回true触发 DOM 更新);
  • insertNewAfter实现了标题拆分语义:光标不在末尾时按 Enter 会把标题从中间拆成两段,后半段保留标题标签、格式与样式;光标在末尾时插入普通段落;
  • importDOMh1h6提供导入转换,并包含一个有趣的 Google Docs 标题启发式:当<p>首子节点或<span>具有font-size: 26pt时,将其视为来自 Google Docs 的文档标题并转换为h1(index.ts)。

程序化创建/判断:

import {$createHeadingNode, $isHeadingNode} from '@lexical/rich-text'; editor.update(() => { const h2 = $createHeadingNode('h2'); // 插入到根节点... });

4.2 QuoteNode:块引用节点

QuoteNode渲染为<blockquote>,并支持一个可选的“影子根(shadow root)”行为(见 index.ts):

  • 默认(shadowRoot: false)维持传统行为:引用内部持有行内内容;
  • 通过$createQuoteNode({shadowRoot: true})node.setIsShadowRoot(true)可选用影子根模式,此时引用像一个多块区域(类似表格单元格),内部持有段落、标题等块级子节点,从而让<blockquote>的 HTML/Markdown 导入导出保真;
  • 影子根模式下,光标在引用开头按 Backspace 会“解散”引用并把内部块提升为兄弟节点,而非合并成单个段落(collapseAtStart实现)。

程序化创建:

import {$createQuoteNode, $isQuoteNode} from '@lexical/rich-text'; editor.update(() => { const quote = $createQuoteNode(); // 或 $createQuoteNode({shadowRoot: true}) 启用影子根模式 });

对应的单元测试位于 LexicalQuoteNode.test.ts,验证了节点类型、createDOM生成<blockquote class="my-quote-class">的类名注入、updateDOM返回false(无需更新)等行为。

4.3 文本格式

FORMAT_TEXT_COMMAND/SET_TEXT_FORMAT_COMMAND覆盖的文本格式类型为TextFormatTypebolditalicunderlinestrikethroughcodesubscriptsuperscripthighlightlowercaseuppercasecapitalize等,由核心包定义)。工具栏按钮通常就是editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')


五、可配置项:格式逃逸与粘贴为文件

RichTextConfig提供了两个运行时可调配置,定义于 LexicalRichTextExtension.ts。

5.1escapeFormatTriggers:格式逃逸触发器

“格式逃逸”指:当光标带某种文本格式时,在某些用户交互(回车、点击、方向键、空格、Tab)下自动清除该格式,避免用户把格式“带入”下一段。

  • 触发类型:'enter' | 'click' | 'arrow' | 'space' | 'tab'
  • 每个格式可配onlyAtBoundary:为true时仅在光标位于格式化文本节点首/尾且该方向没有相邻兄弟时才逃逸;为false/缺省时无论光标位置都逃逸(对应历史$resetCapitalization行为);
  • 默认配置只对capitalizelowercaseuppercase三种格式生效:
{ capitalize: {enter: true, space: true, tab: true}, lowercase: {enter: true, space: true, tab: true}, uppercase: {enter: true, space: true, tab: true}, }

通过configExtension可追加其他格式的逃逸规则,例如让code格式在文本节点边界处随 Enter/点击/方向键逃逸:

import {RichTextExtension} from '@lexical/rich-text'; import {configExtension} from 'lexical'; configExtension(RichTextExtension, { escapeFormatTriggers: { code: {onlyAtBoundary: true, enter: true, click: true, arrow: true}, }, });

配置采用浅合并:mergeEscapeFormatTriggers会按格式逐项合并TriggerConfig,若某个格式传null则显式禁用该格式的逃逸(用于覆盖默认值)。逃逸的实际判定逻辑$escapeFormatsForTrigger在 index.ts:它判断选区是否为折叠的文本点、是否处于边界,然后对命中的格式执行selection.toggleFormat

5.2shouldHandlePasteAsFiles:粘贴文件判定

该回调决定:当剪贴板同时携带文件与文本时,粘贴事件是否优先走DRAG_DROP_PASTE文件通道。签名:

type ShouldHandlePasteAsFiles = ( files: File[], hasTextContent: boolean, ) => boolean;

默认实现defaultShouldHandlePasteAsFiles保持历史行为——仅当剪贴板完全不含文本内容时才按文件处理(index.ts)。源码注释特别指出:浏览器在“右键复制图片”时往往会在文件旁附带 text/html 兜底,因此默认规则下这类图片会走 HTML 导入器。需要“有文本也当文件粘贴”时,可自定义该回调。


六、无障碍扩展:HeadingAnnounceExtension

富文本编辑器里一个常见的可访问性痛点是:屏幕阅读器用户无法感知“这一块变成了标题”。HeadingAnnounceExtension(HeadingAnnounceExtension.ts)通过AriaLiveRegionExtension的实时区播报两类转换:

  • 块变为标题时播报created(默认文案'Heading level %s'%s替换为 1–6 的级别);
  • 标题被移除时播报destroyed(默认'Heading level %s removed')。

设计细节值得注意:

  • 只播报“变成标题/不再是标题”两种转换,光标在标题内移动、输入、删除不播报,避免每次按键都打断用户;
  • 级别变化会同时触发“移除+创建”,代码优先播报创建事件,避免播报旧级别;
  • 被移除节点的级别从prevEditorState读取;
  • 通过信号disabled可在运行时关闭,关闭时不注册任何监听器;
  • 文案模板支持运行时修改,且只在播报时读取,修改不会导致监听器重注册。

这一扩展由RichTextExtension自动依赖,无需单独配置。


七、HTML 导入规则:RichTextImportRules 与影子根引用

随着扩展体系引入,@lexical/rich-text额外导出了基于DOMImportExtension的导入规则集合RichTextImportRules(RichTextImportExtension.ts),包含四条规则:

规则匹配元素行为
HeadingRuleh1h6创建对应HeadingNode,还原缩进、格式与方向
QuoteRuleblockquote创建默认QuoteNode
GoogleDocsTitleParagraphRulep若首子节点是 26pt 的标题 span,则丢弃该段落包装
GoogleDocsTitleSpanRulespan26pt 的 span 提升为h1

这些规则由RichTextExtension自身(连同CoreImportExtension)注册,因此任何使用富文本扩展的编辑器都能直接通过DOMImportExtension管线导入这些标签,无需额外配置(标注为@experimental)。

此外还有一个可选的ShadowRootQuoteRule:它把<blockquote>导入为影子根QuoteNode,并用BlockSchema保留块级子节点,使结构化 blockquote 的 HTML 往返导入导出不被打平成行内内容。它默认不启用;启用方式是在规则编译顺序上“压过”默认规则,例如:

buildEditorFromExtensions( MyExtension, configExtension(DOMImportExtension, {rules: [ShadowRootQuoteRule]}), );

八、典型使用示例:一个富文本编辑器的完整骨架

参考仓库中 examples/website-toolbar/src/Editor.tsx 的实际用法,一个带工具栏的富文本编辑器可以这样组织扩展:

import {RichTextExtension} from '@lexical/rich-text'; import {HistoryExtension} from '@lexical/history'; import {TabIndentationExtension} from '@lexical/tab-indentation'; import {buildEditorFromExtensions} from 'lexical'; const editor = buildEditorFromExtensions( { namespace: 'RichTextDemo', theme: { heading: {h1: 'text-3xl font-bold', h2: 'text-2xl font-semibold'}, quote: 'border-l-4 pl-4 text-gray-600', }, }, [ RichTextExtension, // 富文本默认行为 + Heading/Quote 节点 HistoryExtension, // 撤销/重做 TabIndentationExtension, // Tab 缩进 // 再追加你自己的业务扩展…… ], );

之后,工具栏按钮通过editor.dispatchCommand(...)触发富文本行为:

editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'); editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center'); editor.dispatchCommand(OUTDENT_CONTENT_COMMAND);

在 examples/website-toolbar/src/tests/browser/editor.test.ts 中可以看到其浏览器级测试同样以dependencies: [RichTextExtension]构建测试编辑器,验证默认行为。


九、与 @lexical/plain-text 的选择

@lexical/rich-text与 @lexical/plain-text 共享同一套“命令起始点”设计哲学,区别在于:

  • rich-text额外提供标题、块引用、块级格式化等富文本语义,并注册FORMAT_ELEMENT_COMMANDINSERT_TAB_COMMAND等富文本相关命令;
  • plain-text只保留纯文本输入体验,不注册富文本命令,体积与行为更克制;
  • 两者在扩展体系中互斥(conflictsWith),同一编辑器不可同时挂载。

选择依据很简单:需要标题/引用/块格式就用 rich-text,只需要像<textarea>一样的输入体验就用 plain-text。


十、从源码结构看包的演进与边界

最后从源码结构总结一下这个包的边界与演进方向:

  • 包根导出位于 src/index.ts,统一导出节点类、工厂函数($createHeadingNode$createQuoteNode)、类型守卫($isHeadingNode$isQuoteNode)、命令注册函数registerRichText、配置类型RichTextConfig、扩展RichTextExtension/RichTextImportExtension以及导入规则;
  • 新增的扩展层代码独立成 LexicalRichTextExtension.ts 与 HeadingAnnounceExtension.ts,体现了仓库正在把“散落的注册逻辑”收敛为声明式扩展的趋势;
  • 测试覆盖相当完整:src/__tests__/unit/下有LexicalHeadingNodeLexicalQuoteNodeLexicalTabNodeEscapeFormatTriggersQuoteInsertNewAfter等单元测试,src/__tests__/browser/下有针对方向键、Backspace、Enter、粘贴文件等交互的浏览器测试,可作为理解各命令边界行为的“行为说明书”。

使用建议:新项目优先走RichTextExtension接入;需要精细控制优先级或不想引入扩展体系时,退回到registerRichText(editor)手动注册;在富文本之上做业务扩展时,继续追加你自己的editor.registerCommand(..., COMMAND_PRIORITY_EDITOR)监听即可——这正符合该包“起始点”的设计定位。

【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询