Slate v2 React 编辑器中状态字段写入与浏览器焦点的边界:Document State 示例可用性修复实践
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
Plate(基于 Slate v2 的富文本编辑器框架)的 Document State 示例演示了"编辑器正文 + 独立标题输入框"共用同一份 Slate 文档状态的典型形态。这篇文章基于仓库中的修复计划文档 Document State 示例可用性计划 与沉淀的方案文档 Slate React 状态字段 setter 必须保留外部焦点,完整还原这次修复的目标、根因、方案与验证方式。读完你可以掌握三类实战能力:如何为浏览器交互行为编写"诚实"的 Playwright 测试(真实点击而非模型选择辅助)、如何用selection元数据策略防止状态字段写入抢占编辑器的焦点与选区、以及如何让DOMEditor.focus的重试机制"失败即安全"(fail closed)。
一、目标与问题背景
计划文档明确给出的目标是:修复 Document State 示例,使正文编辑器可以稳定地用鼠标点击进入编辑,且标题输入框中的打字永远不会把选区或焦点"偷"回编辑器。
这是一个 contenteditable 应用开发中极具代表性的问题类别。文档状态(标题、元数据、设置等)通常由 contenteditable 之外的原生控件编辑,但它们在模型层面仍是 Slate 状态的一部分。这类"外部控件写 Slate 状态"的场景,如果实现不当,会产生两类典型故障:
- 正文编辑器无法被可靠地点击编辑。报告的复现路径是:选中文本编辑器 → 点击标题输入框 → 在标题中输入 → 再点回编辑器 → 输入,此时编辑器的选区/焦点行为错乱。
- 标题输入框打字后焦点被抢回编辑器。在标题输入框聚焦并输入后,
document.activeElement会变成编辑器根节点,浏览器 DOM 选区也会跳回编辑器文本内;对标题历史批次做 undo 时同样会发生,甚至可能抛出Could not set focus, editor seems stuck with pending operations到运行时错误层。
二、根因分析:四个相互叠加的缺陷
计划文档的 "Current Finding" 一节给出了当时的核心发现,结合方案文档可以整理为四条根因,每一条都有对应的症状证据。
2.1 测试覆盖"虚绿":模型选择辅助无法证明浏览器行为
既有的 Playwright 覆盖只有在editor.selection.select(...)之后才插入正文文本,因此即使鼠标编辑是坏的,测试依然能通过。方案文档对此总结为:"基于editor.selection.select(...)的 Playwright 覆盖在真实鼠标点击失败时依然保持绿色。"这是本次修复最重要的方法论教训之一:模型层的选区辅助不是浏览器所有文本输入与选区行为的充分证据——Playwright 曾明确报告父级元素拦截了对 contenteditable 根节点的点击。
2.2 默认z-index: -1导致点击命中测试落在祖先元素上
Slate 的可编辑根节点默认样式包含z-index: -1。当示例为了测试定位(test scoping)用普通 wrapper 包住带样式的<Editable>时,wrapper 会在点击命中测试中拦截本应落在编辑器上的点击。视觉上页面看起来可编辑,但点击实际落在了祖先元素上——这正是 "Playwright 报告父级元素拦截点击" 症状的直接原因。
2.3useSetStateField的状态写入携带了默认选区副作用
useSetStateField的状态写入使用了默认的选区副作用:状态写入后,React 渲染会把(可能过期的)模型选区导出回 DOM,从而让编辑器重新获得焦点与选区。
2.4selection.dom: 'preserve'策略未被 React 选区桥真正执行
仅仅向状态更新传入metadata.selection.dom = 'preserve'是不够的,直到React 的 DOM 选区桥真正在把模型选区导出到 DOM 之前检查该策略,否则该元数据契约形同虚设。方案文档将其归纳为:"任何类似selection.dom: 'preserve'的元数据契约都需要直接的单元测试断言,以及一行能证明 DOM 确实被保留的浏览器测试。"
三、修复方案:分层修复,每一层都有对应代码
方案文档将修复拆为五个层次,下面逐一展开。
3.1 让 wrapper 不拦截点击,并显式覆盖可编辑根节点的 zIndex
当示例给编辑器加了边框/背景/内边距时,带样式的可编辑根节点必须位于其父元素之前(在层叠顺序上)。修复后的结构:
<div className={editorSurfaceCss} id="document-state-editor-surface"> <Editable className={editorCss} id="document-state" spellCheck={spellcheckEnabled} style={{ zIndex: 0 }} /> </div>要点是style={{ zIndex: 0 }}覆盖了 Slate 默认的z-index: -1,使点击命中测试正确落在 contenteditable 根节点上,而不是被外层 wrapper 截胡。
3.2 让useSetStateField默认对外部控件安全
状态字段写入应携带"保留 DOM 选区、不抢焦点、不触发滚动"的元数据:
editor.update( (tx) => { tx.setField(field, value) }, { metadata: { selection: { dom: 'preserve', focus: false, scroll: false }, }, } )这三个字段各自的职责:
dom: 'preserve':不把模型选区导出到浏览器 DOM,焦点留在当前输入控件上;focus: false:避免写入触发焦点副作用;scroll: false:避免写入触发滚动副作用。
3.3 让 React 选区桥真正遵守selection.dom: 'preserve'
这是本次修复中最容易遗漏的一层:元数据契约必须由消费端(React 的 DOM 选区桥)在修改浏览器选区之前实际检查。只有桥逻辑修复后,3.2 中的元数据才真正生效。
3.4 状态专属历史回放:不恢复保存的编辑器选区
"仅状态变更"(state-only)的历史回放——比如撤销一次标题字段的变更——从浏览器视角看仍是状态字段写入,同样有焦点抢占风险。正确的做法是走同样的选区保留策略,且不恢复保存的编辑器选区:
editor.update(fn, { metadata: { history: { mode: 'skip' }, selection: { dom: 'preserve', focus: false, scroll: false }, }, tag: 'historic', })关键规则是:只有操作支撑的(operation-backed)历史批次才应该恢复selectionBefore。
3.5DOMEditor.focus重试耗尽时失败即安全
聚焦修复请求可能在 DOM 节点映射(node map)稳定之前被外部标题输入取代;如果重试耗尽后继续抛出异常,会把应用直接打进运行时错误覆盖层。修复是当重试预算耗尽时直接返回,保持当前的外部焦点拥有者不变:
if (options.retries <= 0) { return }方案文档解释了为什么这是对的:"重试耗尽并不是一个模型不变量,它是渲染间隙期间尽力而为的 DOM 修复路径。返回可以保留当前生效的外部焦点拥有者,让后续的选区同步按正常路径工作。"
3.6 标题输入框接管 undo/redo 快捷键
由 Slate 状态字段支撑的受控输入框不应让浏览器的原生输入历史创建出第二个普通状态补丁。标题输入框应在浏览器使用原生输入历史之前拦截 undo/redo 快捷键,若存在 Slate 历史批次则以选区保留元数据执行它:
event.preventDefault() event.stopPropagation() if (hasHistoryBatch) { editor.update( (tx) => tx.history.undo(), { metadata: { selection: { dom: 'preserve', focus: false, scroll: false }, }, } ) } restoreTitleFocus()这样连续 undo/redo 与编辑器走的是同一条历史栈,标题字段依然是文档状态/历史模型的一部分,而活动 DOM 拥有者始终是标题输入框。
四、验证矩阵:每个结论都要有可执行的证据
计划文档的 "Plan" 部分按三步执行并全部标记为 done:(1) 先加一条会失败的 Playwright 交互行(点正文 → 输入 → 点标题 → 输入 → 点正文 → 输入);(2) 修复归属层(若点击目标有问题则修示例布局,若状态写入抢占焦点则修状态字段 setter / 更新选项);(3) 用聚焦的 Playwright、站点/根 typecheck、lint 与真实浏览器交互路径验证。
"Verification" 一节列出了完整的通过项:
| 验证命令 | 验证目标 |
|---|---|
PLAYWRIGHT_RETRIES=0 bun playwright playwright/integration/examples/document-state.test.ts --project=chromium | 文档状态示例的浏览器交互契约(真实点击与输入) |
bun test ./packages/slate-react/test/selection-side-effect-policy-contract.ts | selection.dom: 'preserve'等元数据策略的单元测试断言 |
bun --filter slate-react typecheck | slate-react 包的类型检查 |
bun typecheck:site/bun typecheck:root | 站点与仓库根类型检查 |
bun lint:fix | 静态检查 |
dev-browser --connect http://127.0.0.1:9222访问http://localhost:3100/examples/document-state | 真实浏览器手动验证:点编辑器输入 → 点标题输入 → 点编辑器输入 |
值得注意的是测试文件路径中明确区分了两种覆盖:浏览器交互行(真实点击)与selection-side-effect-policy-contract契约测试(策略元数据的直接单元断言)。这对应了计划文档中"Playwright 覆盖应使用真实页面交互,而不只是编辑器 harness 的选区辅助"这一要求。
五、沉淀下来的预防清单
方案文档的 "Prevention" 一节给出了可复用到所有"编辑器 + 外部控件"示例的预防规则,其中每条都能直接映射到一条测试要求:
- 真实交互测试:带外部控件的交互示例,Playwright 行必须使用真实点击和
page.keyboard.insertText,而不是只有模型选区辅助; - wrapper 不制造点击目标:若只是为了测试定位而包裹
<Editable>,wrapper 不应在编辑器之上创建点击目标(结合zIndex覆盖); - 契约测试双保险:任何元数据契约(如
selection.dom: 'preserve')都需要"直接单元测试断言 + 一行证明 DOM 确实被保留的浏览器行"; - 状态专属 undo 单独测试:setter 可以是正确的,而
tx.history.undo()仍会把过期的编辑器选区导出回 DOM——这两条路径必须分开测; - 显式的脏节点映射焦点测试:浏览器示例未必能复现每个重试时序,但底层契约应保证不产生运行时异常;
- 断言
tags:historic:标题的键盘 undo/redo 若绕过了 Slate 历史,"焦点没动"是不够的,必须断言提交是 historic 的; - 重复 undo 行:从编辑器开始,在标题中打字,然后从标题输入框连按两次 undo——第二次 undo 必须修改编辑器的模型与 DOM 文本,且不聚焦编辑器。
六、小结
这次修复的核心洞察可以浓缩为三句话:
- 模型层证据不能替代浏览器层证据——
editor.selection.select(...)全绿的测试套件与"鼠标点不进去"的编辑器完全可以并存,交互示例必须用真实点击与真实键盘输入验证。 - 状态字段写入必须声明选区策略并被消费端真正执行——
selection: { dom: 'preserve', focus: false, scroll: false }只有在 React 选区桥实际检查该策略后才成为契约,而不是摆设;仅状态的历史回放同理,且不恢复保存的编辑器选区。 - DOM 修复路径必须失败即安全——焦点重试耗尽时静默返回而非抛错,让当前的外部焦点拥有者保持权威。
这三条原则适用于任何"Slate 文档状态 + contenteditable 之外的表单控件"共存的场景,也解释了为何仓库把该问题的结论沉淀为独立的 ui-bug 方案文档而非仅修好示例本身。
(注:计划文档中引用的示例源码路径.tmp/slate-v2/site/examples/ts/document-state.tsx、Playwright 测试playwright/integration/examples/document-state.test.ts与单元测试packages/slate-react/test/selection-side-effect-policy-contract.ts属于该仓库当时.tmp实验区与 slate-v2 工作分支的产物;当前仓库检出中以 packages 下各功能包与docs/solutions/ui-bugs/下的沉淀文档为准。文中引用的仓库文档为 计划文档 与 方案文档,同目录下的 Document State undo 选区 bug 计划 记录了同一时期的关联问题。)
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考