Plate Slate v2 Range 删除增强:Op-family 第二十三片——mixed-inline 内部子树删除语义与源码现状
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
本文围绕 Slate v2 核心包(@platejs/slate)中 Op-family 系列的第二十三片切片计划展开:该切片的目标是把"非空Range删除"扩展到完全覆盖单个受支持顶级块内部 inline 子树的跨度,同时把删除范围严格限制在一个顶级块内。读完后你会理解这一切片的动机、边界(明确做什么、不做什么)、当前deleteText删除管线在源码中的真实工作方式、既有测试已经覆盖的 mixed-inline 行为,以及该切片五个阶段的实际推进状态,从而判断这块能力在 Plate 仓库中处于"语义已确认、实现待落地"的哪个位置。
一、切片定位:从"相邻兄弟叶"到"内部子树"
Plate 的 Slate v2 工作被拆分成大量小的 op-family 切片,每片聚焦一类操作语义的收口。第二十三片计划文档 2026-04-07-slate-v2-op-family-twenty-third-slice.md 的 Goal 只有一句话:
把 mixed-inline 的非空
Range删除扩展到:完全覆盖单个受支持顶级块内部一个 inline 子树的跨度。
理解这句话的关键是它与前一片的关系。紧挨着它完成的第二十二片 2026-04-07-slate-v2-op-family-twenty-second-slice.md 的目标是:
把显式非空
Range删除扩展到单个受支持顶级块内相邻的 mixed-inline 兄弟叶子之间。
也就是说,第二十二片解决的是"起点叶和终点叶是相邻兄弟"这种最浅的跨叶删除(且五个阶段全部勾选完成),而第二十三片处理的是更深的结构:选区不仅跨兄弟叶,还要整体包住块内部的一个 inline 子树(例如 inline 元素嵌套在中间某层容器下)。两片的 Scope 都反复强调同一条底线——删除必须保持在"一个受支持的顶级块"之内。
计划文档开头声明自己是 Supporting plan,队列与路线图的"真相"以 master-roadmap.md 为准(原文档中该链接是作者本机绝对路径,仓库内对应文件为上述相对路径)。
二、Scope:这一片做什么、明确不做什么
原文档的 Scope 部分完整继承如下,这是判断该切片验收边界的核心依据:
约束(硬边界):
- 删除保持在一个受支持的顶级块内(Keeper:不跨块)。
- 支持两类输入:
- 显式的非空
Range(通过at传入); - 当
at省略时,使用当前非空 selection。
- 显式的非空
- 支持"起点边缘与终点边缘之间"被完全覆盖的内部后代节点。
明确排除(这一片不做):
- 跨块(cross-block)删除;
- 任意通用树范围(generic arbitrary tree ranges)删除。
这个"负面清单"值得强调:它说明该切片的语义收口是刻意保守的——先把"单块内、完整覆盖 inline 子树"这一最高频的场景做诚实、做完整,而不是趁机把任意树范围删除一次做完。这与整个 op-family 系列"smallest honest slice(最小诚实切片)"的命名风格一致。
三、现状底座:deleteText删除管线的源码拆解
第二十三片要改的正是@platejs/slate核心里的 Range 删除变换。当前实现位于 deleteText.ts,理解它才能理解"内部子树覆盖"为什么是一个独立的语义缺口。
3.1at解析:显式 Range 与 selection 回退
// packages/slate/src/internal/transforms/deleteText.ts#L24 let at: any = getAt(editor, options?.at) ?? editor.selection;getAt 负责把options.at解析为定位(支持 Point/Path/Range 等形态),解析不出时回退到editor.selection。这一行正是 Scope 中"显式非空Range"与"at省略时用当前 selection"两种入口的落点。随后若传入的是折叠 Range,会被降级为锚点走单点删除分支(L39-L43);若是 Path 则直接removeNodes(L61-L64);折叠 Range 直接返回(L66-L68)——所以"非空 Range"是进入后续区间删除逻辑的前提。
3.2 解悬挂(unhang)与跨块判定
进入区间删除后,L70-L77 对非 hanging 的选区调用editor.api.unhangRange把"悬挂"端点收回到字符边界(除非选区恰好结束在文档末尾):
// packages/slate/src/internal/transforms/deleteText.ts#L70-L77 if (!hanging) { const [, end] = RangeApi.edges(at); const endOfDoc = editor.api.end([])!; if (!PointApi.equals(end, endOfDoc)) { at = editor.api.unhangRange(at, { voids }); } }该 API 的包装在 unhangRange.ts 中:除委托上游unhangRange外,还提供一个character模式,当两端点不在同一路径上时,把端点 nudge 到"前一个字符/后一个字符",保证删除以完整字符为单位。
紧接着 L79-L91 用editor.api.above分别向上找两个端点所属的顶级块(匹配isBlock的元素),并用PathApi.equals比较得到isAcrossBlocks。第二十三片的"单块"边界,在源码层面就是由这组端点-块比对来支撑与约束的。
3.3 inline void / 只读 inline 的端点 nudge
L93-L121 处理端点落在不可编辑 inline(void 或 read-only)内部的情况:
// packages/slate/src/internal/transforms/deleteText.ts#L93-L121(节选) const startNonEditable = voids ? null : (editor.api.void({ at: start, mode: 'highest' }) ?? editor.api.void({ at: start, mode: 'highest' }) /* 实际为 elementReadOnly 回退 */); // 若起点在 void 内,且其 before 仍在同一顶级块内,则把起点外移到 before if (startNonEditable) { const before = editor.api.before(start); if (before && startBlock && PathApi.isAncestor(startBlock[1], before.path)) { start = before; } }(end 侧对称,用after。)这正是测试用例"从只读 inline 前方向后删,先把端点挪到它前面"的语义来源。
3.4 完全覆盖节点的收集与删除:第二十三片的关键接缝
删除的主体逻辑在 L125-L174:遍历editor.api.nodes({ at, voids }),收集两类节点——只读元素,或既不在起点路径公共前缀下、也不在终点路径公共前缀下的节点:
// packages/slate/src/internal/transforms/deleteText.ts#L125-L147(节选) for (const entry of editor.api.nodes({ at, voids })) { const [node, path] = entry; // 去重同路径 if (lastPath && PathApi.compare(path, lastPath) === 0) continue; if ( (!voids && ElementApi.isElement(node) && editor.api.isElementReadOnly(node)) || (!PathApi.isCommon(path, start.path) && !PathApi.isCommon(path, end.path)) ) { matches.push(entry); lastPath = path; } }收集到的节点先固化成pathRefs,再逆序逐个removeNodes(L167-L174,保证深层路径在后删除时依然有效)。同时 L155-L186 分别对起点叶、终点叶执行remove_text操作,削掉端点所在 Text 的尾部/头部残留;若跨块则补一次mergeNodes(L188-L195),最后 L210-L216 在options.at == null(即由 selection 驱动)时把选区收敛到删除后的落点。
从源码结构看,matches的判定条件依赖"节点路径与两端点路径不共享公共前缀"这一几何关系:当选区两端落在同一个叶路径的两侧、而某个 inline 子树恰好被完整罩在中间时,该子树路径与两端点路径的公共前缀关系并不满足上面这个"完全不相关"的收集条件——这正是 Goal 中"扩展到完全覆盖内部 inline 子树的跨度"所指出的语义缺口:这类节点今天不会稳定地进入matches而被删除。由于切片第一阶段(确认语义)刚完成、后续阶段未启动(见第六节),本文把这一点表述为从源码结构推断的缺口位置,而非已实现的结论。
四、已有测试基线:mixed-inline 删除目前证明到什么程度
与deleteText配套的测试在 deleteText.spec.tsx,使用@platejs/test-utils提供的jsxt自定义 JSX 转换(测试工具包)把编辑器结构写成<editor>/<hp>/<htext>等可断言的 JSX 文档。当前基线与 mixed-inline 直接相关的用例包括:
- 前向删除 inline void:块内结构为
空文本|cursor+<himg>(inline void)+ 尾文本,editor.delete()后整个<himg>被移除(L117-L150); - 从 void 内部删除:光标在
<himg>内,删除后 void 整体消失(L152-L181); - 绕过只读 inline 的后向删除:块内是
<hmention>read-only inline</hmention>+ 光标在后续文本,delete({ reverse: true })时先把端点 nudge 到 mention 前再删除整个 mention(L183-L214)。这两个用例分别通过withInlineVoid、withReadOnlyInline辅助函数注册isInline/isVoid/isElementReadOnly(L11-L31); - 其余用例覆盖单字符前删、按 Path 删除、跨块展开选区删除并合并块(L83-L115)、泰文反向字符删除的码点回填(L216-L241)、文档末尾删除 no-op(L243-L268)。
可以看出,现有证明行证明的是"单端点驱动的 inline 删除"与"跨块删除"两端;而"非空选区在单块内完整罩住一个 inline 子树"这组混合场景,正是第二十三片要补的测试缺口——这也解释了它的第二阶段"Write focused failing tests"必须先行:先用失败测试钉住目标语义,再动核心。
另外,Slate v2 的文档栈里对 mixed-inline 还有一条持续的证明线:live-shape 注册表 live-shape-register.md 中记录了"broad mixed-inline proof stays green"之类的 proof 行,说明仓库对 mixed-inline 剪贴板/运行时行为一直有专门的回归约束,第二十三片落地时不能破坏这些既有证明行(该文档内部引用的 proof 文件是其作者本机路径,仓库内对应于packages/slate的测试契约文件)。
五、五个阶段的推进状态
计划文档的 Phases 清单及其勾选状态(截至文档现状)如下:
| 阶段 | 内容 | 状态 |
|---|---|---|
| 1 | 确认更宽的 mixed-inline range-delete 语义与现有 helpers | 已完成 |
| 2 | 编写聚焦的失败测试(focused failing tests) | 未完成 |
| 3 | 实现最小的诚实核心/API 切片 | 未完成 |
| 4 | 同步包内/公开文档 | 未完成 |
| 5 | 验证所触及的包与文档 | 未完成 |
对比第二十二片的同名清单(五阶段全部勾选完成),可以推断第二十三片目前停在"语义已确认"节点:范围边界(单块、非空 Range、完整覆盖)已经定稿,但失败测试、最小实现、文档同步与验证都还没有落地。
六、如何继续跟进与验证
- 语义与验收边界:以 第二十三片计划 的 Goal/Scope 为准,队列顺序与后续切片见 master-roadmap.md;
- 核心实现接缝:deleteText.ts 中
matches收集(L125-L147)、逆序removeNodes(L167-L174)与端点文本削切(L155-L186),是"完全覆盖内部子树"最可能改动的三处; - 测试落点:deleteText.spec.tsx,沿用
jsxt+withInlineVoid/withReadOnlyInline的既有夹具风格新增用例; - 周边契约:unhangRange.ts(端点归位)与 operation.ts(
remove_text等操作类型)定义了该切片必须遵守的既有契约。
七、小结
第二十三片是 Plate Slate v2 在"单块内 mixed-inline 删除"这条能力线上的纵深一步:第二十二片解决了相邻兄弟叶,第二十三片把语义推进到"选区完整覆盖块内 inline 子树",同时用 Scope 的负面清单守住"不跨块、不做任意树范围"的边界。当前仓库中,deleteText管线已具备端点 nudge、完全覆盖节点收集与逆序删除等全部积木,缺的是针对"内部子树完整覆盖"的判定扩展;切片进度停在第一阶段完成处,后续以失败测试驱动、最小实现收口。对阅读者而言,这一文档的价值正在于:它给出了该语义的精确验收标准,而 deleteText.ts 的源码与 deleteText.spec.tsx 的测试基线则提供了验证这条标准的最小证据面。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考