
Lexical 无障碍实践lexical/dragon 语音输入兼容包原理与接入指南【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical导读本文聚焦 Lexical 编辑器框架中的lexical/dragon包讲解如何让 Lexical 编辑器与 Dragon NaturallySpeaking 语音识别工具的 Web 扩展协同工作语音扩展通过window消息驱动可编辑区域而该包负责把消息转译为 Lexical API 编辑并阻止扩展绕开 Lexical 直接改 DOM。读完本文你将掌握DragonExtension、registerDragonSupport、installDragonSupport三者的区别与用法理解编辑被应用两次的竞态根因与抢先注册方案并能正确处理 iframe 与 SSR 场景。一、这个包解决什么问题lexical/dragon是一个体积很小、职责单一的兼容层包。Dragon NaturallySpeaking 是常见的语音识别与口述工具其浏览器 Web 扩展通过向页面window派发消息来驱动可编辑区域contenteditable完成听写、光标移动、选择与格式调整。问题在于如果让扩展的监听器直接编辑 DOM编辑动作就发生在 Lexical 之外——Lexical 对 DOM 有自己的管理与同步机制外部直接改 DOM 会造成状态不同步。lexical/dragon的处理方式是在window上注册一个共享的message监听器handleMessage截获 Dragon 扩展发来的消息把消息中的编辑意图转译为 Lexical 的节点 APIselection.setTextNodeRange、selection.insertRawText、selection.formatText在editor.update()事务内执行调用event.stopImmediatePropagation()阻止消息继续到达扩展自身的监听器避免扩展绕过 Lexical 直接操作 DOM。从源码注释看其目标正是stop the messages from reaching the extensions own handler, which would otherwise edit the DOM directly behind Lexicals back。二、两种注册方式扩展依赖与手动注册接入方式有两条路径见 README2.1 通过DragonExtension自动注册DragonExtension是lexical/rich-text与lexical/plain-text扩展的依赖项也就是说只要你的编辑器使用了这两个扩展之一Dragon 支持就会自动带上。可以在 LexicalRichTextExtension.ts 中确认dependencies: [ HeadingAnnounceExtension, DragonExtension, // ... ],DragonExtension本身的定义在 src/index.ts它带一个DragonConfig其中disabled的默认值为typeof window undefined——即非浏览器SSR环境下自动禁用避免在服务端注册 DOM 监听器。register阶段在disabled为 false 时调用registerDragonSupport(editor)。2.2 通过registerDragonSupport(editor)手动注册不依赖扩展体系时可以直接调用import {registerDragonSupport} from lexical/dragon; const unregister registerDragonSupport(editor); // 编辑器销毁时调用 unregister()它在 src/index.ts 中实现通过watchedSignal监听editor.getRootElement()的归属窗口rootElement.ownerDocument.defaultView一旦拿到窗口就为该窗口安装共享监听器并把当前编辑器登记进该窗口的状态表。React 侧的lexical/react正是用这条路径见 useRichTextSetup.ts 与 usePlainTextSetup.tsreturn mergeRegister( registerRichText(editor), registerDragonSupport(editor), );三、竞态问题为什么编辑会被应用两次README 明确描述了核心痛点这也是理解本包设计的关键Browsers invoke a windows message listeners in registration order, and the Dragon extensions content script registers its listener atdocument_endof the initial page load.即浏览器按注册顺序调用同一window上的message监听器而 Dragon 扩展的 content script 在页面初次加载的document_end阶段就已注册了自己的监听器。普通编辑器尤其晚挂载的编辑器注册监听器必然晚于扩展此时扩展的监听器先收到消息直接把 DOM 改了Lexical 的监听器后收到消息又通过 Lexical API 应用了一次编辑结果就是同一段语音输入被应用两次如 Howdy 变成 HowdyHowdy。哪些场景属于晚挂载README 列举了SPA 导航后挂载的编辑器、弹窗/对话框里的编辑器、任何懒加载视图中的编辑器。四、抢先注册installDragonSupport()要赢得这场注册竞速需要在页面入口处同步执行installDragonSupport()让共享监听器先于扩展的document_end监听器完成注册import {installDragonSupport} from lexical/dragon; installDragonSupport();调用位置应在每个可能懒渲染编辑器的入口点entrypoint同步执行例如应用的主入口脚本、路由入口、弹窗模块的顶层。4.1 返回 teardown 函数引用计数管理installDragonSupport()返回一个 teardown 函数。源码 src/index.ts 展示了底层机制每个window维护一个状态对象键为Symbol.for(lexical/dragon/WindowState)内部有installs: SetInstallKey记录所有安装方第一次安装时才真正addEventListener(message, boundHandleMessage, true)注意第三个参数true表示捕获阶段保证先于目标阶段的其他监听器每次installDragonSupport/registerDragonSupport都会向集合中加入自己的 key每个 teardown 只移除自己的 key只有当集合清空所有 teardown 都被调用包括registerDragonSupport返回的那些时才移除 window 上的监听器并删除窗口状态。也就是说即使入口处手动 install 了一次编辑器自身 register 产生的 install 没释放监听器也仍然存活反之亦然。多个编辑器、多次 install 可以安全共存互不干扰。相关逻辑见 removeInstall。4.2 iframe 内编辑器指定目标 window编辑器挂在 iframe 内部时消息发生在 iframe 的window上而非顶层窗口。此时把 iframe 的 window 传入即可监听器会安装到正确的窗口installDragonSupport(iframe.contentWindow);状态是按 window 独立维护的getOrCreateWindowState以目标 window 为键因此多个 iframe 甚至 iframe 与顶层窗口可以并存互不串扰。源码注释明确说明 State is kept per window, so multiple frames can coexist。同时消息处理函数校验event.origin targetWindow.location.originiframe 的语音消息不会泄漏到顶层窗口的监听器。五、消息处理协议源码级剖析handleMessagesrc/index.ts是整个包的核心它解析并执行 Dragon 扩展发来的消息处理流程如下5.1 消息结构与校验链Dragon 扩展的消息采用nuanria_messaging协议handleMessage按顺序校验event.origin必须等于当前窗口的location.origin找到当前聚焦的、且已注册的 Lexical 编辑器getFocusedEditor通过getActiveElementDeep找到实际活动元素再反查其归属编辑器见 getFocusedEditor没有聚焦编辑器则直接忽略消息体是字符串时先JSON.parse解析失败直接忽略校验parsedData.protocol nuanria_messaging且parsedData.type request校验payload.functionId makeChanges且payload.args是数组。5.2makeChanges的六个参数makeChanges的args是长度为 6 的数组含义如下表位置参数含义校验规则0elementStart替换区间起点必须为有限数值1elementLength替换区间长度必须为有限数值2text要插入的文本必须是字符串或数字-1表示仅改变选择不改文本3selStart最终选择起点必须为有限数值4selLength最终选择长度必须为有限数值5formatCommand可选的 execCommand 格式名字符串如bold参数不合法时整体忽略——测试用例ignores malformed makeChanges payloadsLexicalDragon.test.ts验证了garbage、缺少args的对象、非数字的elementStart、数字类型的text非 -1等非法负载均不会改变文档内容。5.3 执行编辑在editor.update()事务内对RangeSelection依次执行替换区间如果elementStart 0 elementLength 0用selection.setTextNodeRange(anchorNode, elementStart, elementStart elementLength)把选择设到待替换区间插入文本text为字符串且区间非空或文本非空时调用selection.insertRawText(text)——insertRawText会跳过部分词法触发逻辑正是语音听写所需的原始插入语义设置最终选择selStart会被钳制到[0, 文本长度]selStart selLength同理若selStart 0 || selLength 0则折叠选择setSelStart本身。源码注释解释协议中负偏移没有意义折叠而非保留区间可以避免下一次输入替换掉不该替换的内容格式命令formatCommand存在、selLength 0且选择未折叠时映射为selection.formatText(format)阻断扩展event.stopImmediatePropagation()阻止消息继续传到扩展自身的监听器只有命中分支才会阻断。5.4 特殊值 -1仅选择操作Dragon 用数字-1而非字符串作为text表示只改变选择不碰文本——典型场景是 Select-and-Say 纠正和语音光标移动。测试 LexicalDragon.test.ts 验证了发送[0, 5, -1, 6, 5]后文本保持 Hello world 不变而选择被移到 offset 6~11随后再发一条替换消息即可完成纠正。这保证了先选中再说出替换词的两步语音工作流可用。5.5 格式命令映射表formatCommand使用浏览器document.execCommand的命令名因为扩展原生通过 execCommand 施加格式lexical/dragon将其映射为 Lexical 的TextFormatTypeTEXT_FORMAT_BY_EXEC_COMMANDexecCommand 名称Lexical TextFormatTypeboldbolditalicitalicstrikeThroughstrikethroughsubscriptsubscriptsuperscriptsuperscriptunderlineunderline测试用例formats the final selection when formatCommand is presentLexicalDragon.test.ts验证了语音命令 bold that 的路径dispatchMakeChanges([0, 5, -1, 0, 5, bold])后 Hello 的hasFormat(bold)为 true。未知命令名如fontName、toString会被安全忽略选择已折叠时也不会误触发格式切换。六、聚焦编辑器分发与多编辑器共存handleMessage不会把消息广播给所有编辑器而是通过getFocusedEditor精确定位当前聚焦的那一个依据getActiveElementDeepgetEditorPropertyFromDOMNode反查。这意味着同一窗口下的多个编辑器可以并存语音消息只作用于当前获得焦点的编辑器。测试dispatches to the focused editorLexicalDragon.test.ts创建两个编辑器聚焦第二个后发消息只有第二个的内容从 Hello world 变成 Howdy world。同时窗口状态表会在编辑器销毁时通过 teardown 清理对应条目因此先销毁的编辑器不会阻塞后续编辑器的语音功能见测试keeps working for editors created after others are disposed。七、iframe 场景验证针对 iframe 场景测试 LexicalDragon.test.ts 完整演示了推荐做法创建 iframe写入基础 HTML 文档调用installDragonSupport(iframe.contentWindow)在 iframe 窗口抢先安装监听器在 iframe 文档内构建编辑器并聚焦向iframeWindow派发makeChanges消息编辑器正确得到 Howdy world同时顶层窗口的监听器从未收到该消息topEdits为空——验证了按窗口隔离的行为。八、接入清单与常见问题8.1 推荐接入步骤安装依赖lexical/dragon若通过pnpm使用 monorepo注意它与lexical、lexical/extension的 workspace 版本对应关系见 package.json若使用lexical/rich-text/lexical/plain-text扩展无需额外注册DragonExtension已被自动引入使用lexical/react的useRichTextSetup/usePlainTextSetup同理任何可能懒挂载编辑器的入口点同步调用installDragonSupport()抢先注册编辑器位于 iframe 时改为installDragonSupport(iframe.contentWindow)SSR 环境无需处理DragonExtension的disabled默认为typeof window undefined非浏览器环境自动禁用。8.2 常见疑问为什么还要手动 install因为registerDragonSupport是编辑器挂载后才注册的天然晚于扩展的document_end监听器只有入口处同步 install 才能赢下注册顺序。手动 install 与编辑器自带注册冲突吗不冲突。所有安装共用引用计数最后一次 teardown 才移除监听器。监听器在捕获阶段还是冒泡阶段源码使用addEventListener(message, boundHandleMessage, true)即捕获阶段确保先于扩展在目标阶段注册的监听器执行。九、总结lexical/dragon用约三百行源码解决了一个现实而棘手的无障碍兼容问题在不允许扩展绕过 Lexical 直接编辑 DOM 的前提下把 Dragon NaturallySpeaking 的nuanria_messaging/makeChanges消息完整转译为 Lexical 的选区与文本 API并通过入口同步抢先注册 捕获阶段监听 stopImmediatePropagation三重手段赢下监听器注册竞速同时以按窗口的引用计数状态管理支撑多编辑器、iframe、懒加载与 SSR 等复杂场景。对无障碍与语音输入有要求的 Lexical 应用直接按上文清单接入即可获得开箱即用的 Dragon 兼容能力。如果想深入底层建议精读以下文件核心实现 packages/lexical-dragon/src/index.ts、单元测试 packages/lexical-dragon/src/tests/unit/LexicalDragon.test.ts、以及集成方 packages/lexical-rich-text/src/LexicalRichTextExtension.ts 与 packages/lexical-react/src/shared/useRichTextSetup.ts。【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考