深入 Slate v2 占位符与 IME 合成输入的浏览器级验证:从 FEFF 零宽字符到渲染器所有权边界
2026/9/15 10:18:59 网站建设 项目流程

深入 Slate v2 占位符与 IME 合成输入的浏览器级验证:从 FEFF 零宽字符到渲染器所有权边界

【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate

导读:本文围绕 plate 仓库中docs/plans/2026-04-04-slate-v2-placeholder-ime.md这份支撑型执行计划,完整梳理 Slate v2(含slate-react-v2slate-dom-v2slate-browser)在真实 Chromium 浏览器中验证「空块占位符 + 输入法(IME)合成输入」的完整过程。读者将掌握:IME 合成期(composition)与提交期的正确提交策略、FEFF(BOM 零宽字符)占位符是否可被移除的判断依据、React 渲染器所有权边界如何决定 IME 崩溃与否,以及从零宽矩阵到混合内联、嵌套引用、复杂列表等碎片语义逐级被浏览器证明的演进路径。

背景:为什么占位符与 IME 需要真实浏览器证明

Slate v2 是 plate 项目中对编辑器内核与渲染层的一次架构级推进,其工作目录散落在slate-v2slate-browser两条线上。占位符(placeholder)与 IME 看似是两个小功能,但它们共同命中编辑器最脆弱的一段链路:

  • 空块的占位符路径需要在 DOM 中渲染「零宽内容」,否则空段落会塌陷、光标无处可放;
  • IME(中文、日文等输入法)合成输入期间,浏览器临时持有原生文本区域,并可能在提交前多次重写它;
  • 两者叠加时,任何「把 DOM 临时状态当作编辑器真值」的实现,都会污染编辑器模型。

该计划在开篇就明确了工作前提与纪律(原文档「Context」节):

  • 继续推进slate-browserslate-v2当前的状态;
  • 下一个活接缝(live seam)是真实slate-v2界面上的「零宽 / 渲染器输入策略」路径;
  • 全程采用 TDD:先建真实浏览器证明面(proof surface),让 Playwright IME 测试「因正确的原因」保持红,再修复最小的运行时接缝把它变绿。

这条「先红后绿、每次只修最小接缝」的纪律贯穿整个计划,也解释了为什么文档里 80 个阶段全部标记为 Complete——每一行进度都对应一条已收敛的证明链,而不是一次性大重构。

IME 合成输入的正确提交边界:compositionend

失败模式:把合成步骤逐条写进文档状态

计划对应的第一份真实浏览器证明是slate-v2-placeholder-ime.test.ts,目标是slate-v2-placeholder.tsx示例面。该示例最初挂载了一个最小slate-v2+slate-react-v2编辑器,占位符路径包含:FEFF 零宽 span、<br />、以及原生beforeinput/input对账层。

首轮红测试暴露出的问题非常微妙:Chromium 的 IME 步骤被一条一条当作文档文本提交,最终 DOM 变成所有合成步骤的拼接。即输入「すし」时,模型里可能出现的是「す」「すし」这样逐阶段叠加的文本,而不是最终提交的「すし」。

计划文档在「Findings」中给出了根因定位路径:

  • 最初在每次input时都从 DOM 对账编辑器——看起来合理,但把瞬态合成文本当成了已提交状态;
  • 桥接(bridge)归一化加强并不能修复它,桥不再是主要说谎者;
  • 等待「友好的」insertTextinput 事件也是不可靠的——一旦瞬态合成 input 停止触发重渲染,Chromium 在干净路径上以compositionend收尾,之后并不会再来一个可提交的input事件。

修复规则:把合成与提交当作两个阶段

对应解决方案文档 2026-04-04-slate-v2-placeholder-ime-proofs-must-commit-on-compositionend.md 记录了最终规则:

  • event.isComposing为 true 时,忽略该input事件;
  • 忽略insertCompositionText/deleteCompositionText这类瞬态合成事件;
  • compositionend时从 DOM 对账编辑器状态;
  • 对非 IME 的普通文本输入,仍走正常input对账。

这样处理后,DOM 内保留合成期的活文本,编辑器只在合成真正结束时提交一次。最终浏览器证明落点正是「すし」与选区0.0:2|0.0:2

为什么这是对的

合成期文本不是稳定的文档真值。合成期间浏览器持有临时原生区域,且可能多次重写;如果证明面每一步都把瞬态 DOM 拷回 Slate,就摧毁了它自己正要测量的合成生命周期。在 Chromium 这条路径上,compositionend是第一个可信点——最终提交文本已经在 DOM 里,可以无重复地翻译回编辑器模型。

这条规则在 plate 主仓库的编辑器 API 中同样能找到回音:slate包把isComposing作为编辑器实例方法公开,其实现位于 packages/slate/src/internal/dom-editor/isComposing.ts,直接委托给slate-domDOMEditor.isComposing,并在 packages/slate/src/create-editor.ts 通过bindFirst(isComposing, editor)绑定到编辑器。这意味着「合成中」是一个可以从业务代码直接查询的一等状态,而非隐藏在事件处理里的内部细节。

可复用的结论是:如果一个 IME 敏感的占位符证明面在每次合成 input 上都做对账,它测试的是自己糟糕的输入策略,而不是编辑器的接缝本身。

无 FEFF 的换行占位符:React 渲染器所有权边界

崩溃不是桥的问题,是渲染器所有权的问题

证明完 FEFF 支撑的占位符路径后,下一个诚实的证明切片直接抛出了策略问题:同一个占位符路径在 Chromium IME 下能否不依赖 FEFF 存活?

第一次 no-FEFF 红测试比「选区不对」更尖锐:React 直接以NotFoundError: Failed to execute 'removeChild' on 'Node'崩溃,可编辑界面整个消失。计划文档明确指出——这不是桥接债务,而是渲染器所有权债务(renderer ownership debt)。

为什么?浏览器在 IME 期间会改写空换行占位符子树。如果 React 恰好拥有那个会被浏览器重写的<br />子节点,React 之后对账时就会拿自己过期的子节点簿记去匹配一个已被浏览器改过的子树,removeChild崩溃由此而来。

最小修复:所有权止步于零宽包裹 span

对应解决方案文档 2026-04-04-slate-v2-no-feff-line-break-placeholders-need-dom-owned-br-interiors.md 记录的分界是:

  • FEFF 路径:React 渲染'\uFEFF'<br />
  • no-FEFF 路径:React 渲染零宽包裹 span,但内部<br />通过dangerouslySetInnerHTML注入,作为 DOM 自有内部节点,而不是 React 子 fiber。

边界确立后,no-FEFF 的 Chromium 证明通过:占位符形态保持{ hasBr: true, hasFEFF: false, kind: 'n' },IME 提交「すし」,最终选区落在0.0:2|0.0:2

由此得到的关键认知升级:FEFF 并非空换行占位符路径在 Chromium 下天然必需,但渲染器绝不能让 React 拥有被 IME 改写的<br />内部节点。React 所有权停在包裹边界后,浏览器可以自由改写内部,React 也能在提交文本到达时干净地替换整个空分支。

共享渲染接缝:ZeroWidthString 与零宽矩阵

证明收敛后,行为不能再停留在示例 markup 里,必须沉淀为共享渲染接缝。计划把当前拆分固化进slate-react-v2ZeroWidthString组件:

  • 换行占位符:默认无 FEFF,<br />内部 DOM 自有;
  • 非换行零宽占位符:仍保留 FEFF。

浏览器矩阵(slate-v2-zero-width-matrix.test.ts)钉住四种零宽形态:

形态默认策略Chromium 证明状态
换行路径(line-break)无 FEFF + DOM 自有<br />已证明
空块换行路径无 FEFF已证明
内联边缘(inline-edge)起点/终点FEFF 支撑已证明
类 void 零宽路径FEFF 支撑已证明(需镜像真实 void spacer 结构)

两条重要的「假阴性」教训也记录在案:

  • 内联边缘首轮红不是渲染策略 bug,而是测试设置粗糙——仅 root click 无法保证选区落在目标零宽 leaf 上;改为在合成前语义化设置 Slate 选区后,FEFF 支撑的内联边缘路径在 Chromium 通过;
  • 类 void 首轮红是证明面不足导致的假阴性——它把 void 外观与 spacer 折叠进同一个包裹;镜像真实 legacy void 接缝(data-slate-void="true"元素 + 不可编辑内容包裹 + 独立绝对定位 spacer leaf)后,FEFF 支撑的类 void 路径同样通过。

因此当前诚实的拆分是:换行路径的 no-FEFF 已在 Chromium 证明;内联边缘与类 void 路径的 FEFF 行为已证明;而非换行的 no-FEFF 路径仍未证明,因此保持保守

从零宽到完整渲染栈:TextString、SlatePlaceholder、EditableText、Editable

零宽矩阵证明后,重复出现的渲染/输入接缝逐步被收编为slate-react-v2的共享原语,证明面不再手搓 DOM:

  1. TextString:共享 DOM 文本边界,data-slate-stringspan 不再由各证明面手写,并带 DOM 文本修复行为(有包级测试);
  2. SlateElement/SlateSpacer/SlateText/SlateLeaf:最小渲染节点形态原语;
  3. SlatePlaceholder:占位符覆盖层的 attr 与样式由包统一拥有;
  4. EditableText:组合「叶子文本 / 零宽 / 占位符」的分支逻辑,支持从 projection 切片拆分多 leaf,并以path={[...]}绑定文本与runtimeId,零长度切片作为 mark 占位符渲染;
  5. EditableElement/VoidElement:组合普通元素与 void 包裹 + spacer 的元素层;
  6. Editable:根循环——挂载、选区同步、焦点初始化、DOM 提交、剪贴板桥接(ClipboardBridge)全部收编;
  7. EditableBlocks:第一个「诚实的」公开编辑器面向 v2 表面。

对应每个原语,计划都落地了聚焦的包级测试(集中在packages/slate-react-v2/test/runtime.tsx)与解决方案文档,例如2026-04-04-v2-editable-text-primitives-should-compose-leaf-text-zero-width-and-placeholder.md等。这条收编路径的实质是:从「示例冒充渲染器」走向「示例依赖最小真实渲染栈」

从零宽到文档级碎片:混合内联、嵌套容器与复杂列表

混合内联:Editable必须保留后代形态

首个「诚实的公开表面」之后的极限是混合内联编辑:顶层块同时有直接文本子节点与内联元素子节点。首个红不在剪贴板,而在编辑器真值——Editable仍以「把整个根拍平回单个文本块」的方式对账 DOM 编辑。这对单文本节点的证明面够用,但对真实混合内联表面是错的:输入或粘贴可能保留可见文本,却摧毁选区路径仍指向的节点结构。

持久修复是让Editable接受snapshotFromDom(...),并让EditableTextBlocks在只替换文本 leaf 内容的同时保留当前后代形态。Chromium 证明随后覆盖:跨内联边界的语义化选区、内联子文本节点内输入、混合内联选区的复制/粘贴往返。

嵌套与包装单元:复用已证明的几何模型

计划随后把碎片语义逐级推宽,但反复强调一个模式——不是发明新的变换族,而是复用已证明的几何模型

  • 多块混合内联:当每个触及的块仍匹配已证明形态时,让碎片提取跨多个顶层块;插入侧保持窄——多块混合内联碎片可粘贴进折叠或同块混合内联目标范围,但不假装解决任意嵌套块树;
  • 嵌套引用(quote + 段落):检测任意深度的简单文本块兄弟容器,嵌套时把选区提取为包裹碎片,粘贴时插回同一容器形态;显式at重定基通过「剥掉容器前缀 → 复用顶层文本块重定基逻辑 → 前缀贴回」实现;
  • 更丰富内联几何:把块几何从「直接子节点或一跳」推广为「块内递归文本 leaf 条目(leaf 路径 + 文本长度)」,其余变换栈(块相对偏移映射、碎片提取/插入、显式at选区与 range-ref 重定基、嵌套引用前缀剥离)全部继续工作;
  • 列表包装单元list-item碎片在目标是嵌套bulleted-list时作为兄弟单元处理而非拆成段落子节点;同一接缝随后推广到ordered-listcheck-list、兼容异构兄弟单元,以及「引用 → list-item → 内层引用 → 更丰富内联段落」这样的更深包装栈。

每条推广都在三层同时证明:slate-v2的核心契约测试(clipboard-contract.tsrange-ref-contract.ts)、slate-dom-v2的剪贴板边界测试(clipboard-boundary.ts)、以及 Playwright Chromium 浏览器证明。浏览器证明还沉淀了一条真值:真实剪贴板粘贴后,折叠选区落在最深插入后代,而不是第一个显眼的插入叶子

诚实的剩余极限

计划没有声称解决一切,而是不断把剩余极限收窄:

  • 任意嵌套树 range-ref 重定基仍需要更丰富的插入元数据,但同块顶层混合内联 refs 已不再被阻塞;
  • 嵌套容器中「非简单单文本子节点」或「嵌套混合内联容器」的子块仍需要更广的变换;
  • 无法归约为「递归文本 leaf 列表 + 包装前缀」的块后代几何,仍超出当前模型。

验证命令与测试拓扑

计划在「Progress」中沉淀了一套可复现的验证命令,三层依次收紧:

# 核心契约(slate-v2,bun 运行) bun test packages/slate-v2/test/clipboard-contract.ts bun test packages/slate-v2/test/range-ref-contract.ts # DOM 剪贴板边界(slate-dom-v2) yarn workspace slate-dom-v2 test clipboard-boundary # 渲染包 yarn workspace slate-react-v2 test # 类型与静态检查 yarn lint:typescript # 浏览器证明面(本地服务 + Playwright Chromium) bash ./scripts/run-slate-browser-local.sh 3100 /examples/slate-v2-placeholder \ "yarn build:slate-browser:playwright && yarn exec playwright test playwright/integration/examples/slate-v2-placeholder-ime.test.ts --project=chromium --workers=1" # 合并后的本地 IME 通道 yarn test:slate-browser:ime:local # 合并后的本地 e2e 通道 yarn test:slate-browser:e2e:local

三条值得注意的工程细节:

  • Playwright 构建通道必须同时重建slate-v2slate-react-v2等 v2 包,否则站点示例会消费陈旧的 dist 输出——计划中一次「浏览器假阴性」正是由此而来,而非运行时逻辑问题;
  • slate-browser的嵌套路径选区辅助函数需要把 handle key 显式序列化进 page-eval 快照,并等待 handle 水合后再回退到纯 DOM 选区路径,避免过早回退导致的假 null 选区;
  • 真实 Chromium IME 证明依赖yarn exec playwright install chromium安装浏览器二进制。

结论:占位符 IME 证明沉淀的可复用资产

这条证明链最终交付的不仅是「占位符在 Chromium IME 下可用」,而是一组可复用的技术结论:

  1. 提交边界:IME 合成期文本不是文档真值,compositionend才是 Chromium 路径上可信的提交点;对每次合成 input 做对账等于测试自己的坏输入策略(详见 2026-04-04-slate-v2-placeholder-ime-proofs-must-commit-on-compositionend.md);
  2. 渲染器所有权:FEFF 不是换行占位符在 Chromium 下的必需品,但 React 不能拥有被 IME 改写的<br />内部;遇到removeChild崩溃先查 React 是否拥有了错误的 DOM 节点(详见 2026-04-04-slate-v2-no-feff-line-break-placeholders-need-dom-owned-br-interiors.md);
  3. 共享接缝:策略拆分必须沉淀进slate-react-v2的共享原语(ZeroWidthStringTextStringSlatePlaceholderEditableTextEditableElementVoidElementEditableEditableBlocks),而非散落在示例 markup;
  4. 证明纪律:任何「浏览器专属」行为(FEFF 需求、void spacer 结构、粘贴后选区落点)都应以真实 Chromium 证明为准,jsdom 合成剧场不可作为 IME 证明(对应方案文档见 2026-04-03-jsdom-contenteditable-composition-is-not-a-trustworthy-ime-proof.md);
  5. 渐进推广:嵌套容器、列表包装单元、异构兄弟单元等更宽形态,优先复用已证明的「递归文本 leaf + 包装前缀剥离」几何模型,而不是每次发明新变换族。

对希望深入验证的读者,当前仓库中的相关实现与测试入口包括:slate包的编辑器创建与isComposing绑定、isComposing的 DOM 委托实现、slate-react-v2的组件与运行时测试、以及计划对应的完整进度台账 2026-04-04-slate-v2-placeholder-ime.md 与更上层的 slate-v2 总览。

【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate

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

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

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

立即咨询