Slate v2 Op-family 第二十一个切片:跨混合内联叶子的折叠删除(Collapsed Delete)实现解析
2026/9/15 20:13:17 网站建设 项目流程

Slate v2 Op-family 第二十一个切片:跨混合内联叶子的折叠删除(Collapsed Delete)实现解析

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

导读

本文围绕 Plate 仓库中 Slate v2 操作族(op-family)系列切片计划的第二十一个切片展开,聚焦于在单个受支持的顶级块(top-level block)内部、跨越多个兄弟文本叶子(sibling text leaves)及内联后代(inline descendants)的折叠删除(collapsed delete)语义。通过阅读本文,你将掌握editor.delete()/editor.tf.delete()对显式Point、当前折叠选区、reversedistance等选项的完整处理链路,理解源码中位置归一化、内联 void 边界挤出、pathRef/pointRef 引用管理与泰文脚本特殊处理等底层实现细节,并能对照测试用例验证每一个边界行为。

背景:op-family 切片机制与本次切片的定位

在 Slate v2 的重构进程中,核心包packages/slate的能力恢复被拆解为一系列"操作族切片"(op-family slices),每个切片只解决一个具体、可独立验证的 API/行为缺口。这种做法的执行纪律在 docs/slate-v2/master-roadmap.md 中明确为"测试背书的契约覆盖(test-backed contract coverage)优先于当前源码形态",并设定了奇偶性(parity)与 v2 北极星(north-star)两道不可协商的关卡。

操作族(op-family)这一概念在 docs/slate-v2-draft/references/slate-batch-engine.md 中有更完整的描述:批处理引擎(batch engine)之上允许叠加 op-family 优化执行器(executor),每一族操作(如insert_noderemove_nodeset_node)都可以有专门的优化路径,但未优化或无法合并的操作混合必须安全地降级(deopt)到通用 draft-root 执行器,绝不允许直接退化为对已提交树(committed tree)的突变。

本切片docs/plans/2026-04-07-slate-v2-op-family-twenty-first-slice.md正是在这条主线上的一次聚焦推进:把折叠删除从"单文本叶子内删除"拓宽为"单个顶级块内跨混合内联兄弟叶子删除"

切片目标与范围边界

根据计划文档,本切片的核心目标可以概括为一句话:

Broaden collapseddelete(...)across mixed-inline sibling leaves inside one supported top-level block —— 在一个受支持的顶级块内,拓宽折叠delete(...)使其能够跨越混合内联兄弟叶子。

其范围约束非常明确:

本切片要支持的能力:

  • 显式传入的Point位置;
  • 当前的折叠选区(collapsed selection);
  • reverse选项(向后删除,即退格方向);
  • distance选项(删除多少个字符/单位);
  • 在一个受支持的顶级块内部跨越兄弟文本叶子,包括内联后代(inline descendants)。

本切片明确不做的事:

  • 仅保留折叠删除语义(collapsed-delete only);
  • 避免在本次切片中实现任意的显式多叶子Range删除(即用户显式框选跨越多个叶子的展开选区删除,不在本次范围内)。

这个"只做最小诚实切片"的取舍,与第一个切片docs/plans/2026-04-07-slate-v2-op-family-first-slice.md中"Keep the slice narrow"(保持切片狭窄)的指导思想一脉相承:不做虚假的宽泛 NodeOptions 奇偶性、不做选区繁重的变换语义、不引入更广泛的节点族。

核心 API:DeleteTextOptions参数全解

本切片落地的核心入口是editor.delete()editor.tf.delete(),其底层实现为deleteText,选项类型定义在 packages/slate/src/interfaces/editor/editor-transforms.ts:

export type DeleteTextOptions = { distance?: number; hanging?: boolean; reverse?: boolean; unit?: TextUnit; } & QueryAt & QueryVoids & QueryTextUnit;

各参数语义与实现中的默认行为如下:

参数类型默认值语义
distancenumber1删除的单位数量,与unit组合决定删除跨度
hangingbooleanfalse是否"悬挂"删除(见下文实现说明)
reversebooleanfalsetrue表示向后删除(退格方向),false表示向前删除(Delete 方向)
unitTextUnit'character'删除单位:character/word/line/block
voidsbooleanfalse是否允许删除 void 节点(默认 false 时遇到 void 会整体处理)
atAtPoint/Range/Path当前选区显式指定删除位置,不传则使用editor.selection

从实现源码 packages/slate/src/internal/transforms/deleteText.ts 可以看到默认值解构逻辑:

const { distance = 1, reverse = false, unit = 'character', voids = false, } = options; let { hanging = false } = options;

而位置参数的默认取值则通过getAt解析后回退到当前选区:

let at: any = getAt(editor, options?.at) ?? editor.selection;

实现原理:deleteText源码级走读

deleteText的实现位于 packages/slate/src/internal/transforms/deleteText.ts,整体被包裹在editor.tf.withoutNormalizing(...)中,即整个删除过程作为一个原子批处理执行,避免中间态触发归一化。

第一步:位置归一化 —— 折叠 Range 等价于其锚点 Point

折叠删除的关键语义在于:任何折叠的 Range 都必须被当作其anchor点来处理。这是本切片"支持当前折叠选区"能力的实现基石:

let isCollapsed = false; if (RangeApi.isRange(at) && RangeApi.isCollapsed(at)) { isCollapsed = true; at = at.anchor; }

isCollapsed标记会被保留到函数尾部,用于泰文脚本的特殊处理(见下文)。

第二步:Point 分支 —— 构造待删除的目标 Range

当位置归一化为Point后,deleteText依据reverse方向计算目标点:

if (PointApi.isPoint(at)) { const furthestVoid = editor.api.void({ at, mode: 'highest' }); if (!voids && furthestVoid) { const [, voidPath] = furthestVoid; at = voidPath; } else { const opts = { distance, unit }; const target = reverse ? editor.api.before(at, opts) || editor.api.start([]) : editor.api.after(at, opts) || editor.api.end([]); at = { anchor: at, focus: target }; hanging = true; } }

这里有两个关键细节:

  1. void 优先:如果光标位于某个 void 节点内部(且未开启voids),直接将该 void 的路径作为删除目标,交给后面的removeNodes分支处理整块删除,而不是逐个字符删除;
  2. 构造悬挂 Range:正常路径下,deleteText通过editor.api.before/after配合{ distance, unit }计算目标点,构造{ anchor: at, focus: target }的临时 Range,并设置hanging = true——表示这个 Range 的端点位于文档/块边缘时不需要被"不悬挂"(unhang)修正。若before/after返回空(已到文档边界),则回退到editor.api.start([])editor.api.end([])

第三步:Path 分支 —— 直接删除节点

如果at解析为Path(例如上一步 void 处理产生的 void 路径,或用户显式传入路径),则直接调用removeNodes

if (PathApi.isPath(at)) { editor.tf.removeNodes({ at, voids }); return; }

第四步:展开 Range 删除 —— 跨块判断与内联 void 边界挤出

如果 Range 并非折叠(hanging或显式 Range),deleteText进入多叶子删除主流程。首先进行两个关键的结构判断:

const isAcrossBlocks = startBlock && endBlock && !PathApi.equals(startBlock[1], endBlock[1]); const isSingleText = PathApi.equals(start.path, end.path);
  • isAcrossBlocks:起止点是否落在不同的顶级块中(用于决定是否需要在最后合并块);
  • isSingleText:起止点是否在同一条文本路径上(决定末尾remove_text的 offset 计算方式)。

随后是本切片的核心新增语义——内联 void 边界挤出(nudge out)

if (startNonEditable) { const before = editor.api.before(start); if (before && startBlock && PathApi.isAncestor(startBlock[1], before.path)) { start = before; } } if (endNonEditable) { const after = editor.api.after(end); if (after && endBlock && PathApi.isAncestor(endBlock[1], after.path)) { end = after; } }

当删除范围的起/止点落在内联 void(如图片img)或只读内联元素(如mention)内部时,端点会被"挤出"到该 void 紧邻的可编辑文本位置上,且仅当挤出后的位置仍位于同一顶级块内PathApi.isAncestor(startBlock[1], before.path))才生效。这一机制保证了删除操作永远作用在可编辑文本上,而不是卡在不可编辑的内联节点内部。

第五步:收集完全覆盖的节点并安全删除

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; } }

这里收集的是完全落在删除范围内、且不包含起止点的最高层节点(以及只读元素)。收集到的路径通过editor.api.pathRef转为引用,起止点通过editor.api.pointRef转为引用——这是为了防止删除过程中路径/点位置因前面的操作而失效:

const pathRefs = Array.from(matches, ([, p]) => editor.api.pathRef(p)); const startRef = editor.api.pointRef(start); const endRef = editor.api.pointRef(end);

第六步:分三段执行删除与合并

删除按"起始叶子文本 → 中间节点 → 末尾叶子文本"三段进行,最后视跨块情况合并:

// 起始叶子:删除从 start.offset 到该叶子末尾的文本 if (!isSingleText && !startNonEditable) { const text = node.text.slice(offset); editor.tf.apply({ offset, path, text, type: 'remove_text' }); } // 中间节点:逆序移除(路径从后往前,避免索引失效) const paths = pathRefs.reverse().map((r) => r.unref()).filter(...); for (const p of paths) { editor.tf.removeNodes({ at: p, voids }); } // 末尾叶子:删除剩余文本 const offset = isSingleText ? start.offset : 0; const text = node.text.slice(offset, end.offset); editor.tf.apply({ offset, path, text, type: 'remove_text' }); // 跨块时合并 if (!isSingleText && isAcrossBlocks && endRef.current && startRef.current) { editor.tf.mergeNodes({ at: endRef.current, hanging: true, reverse: !reverse, voids }); }

注意一个细节:跨块删除时的mergeNodes调用带有reverse: !reverse—— 即删除方向与合并方向互补,确保删除后光标停留在正确一侧。这也是deleteText与遗留deleteMerge实现之间的一个差异点(见后文对比)。

第七步:泰文脚本的特殊处理

deleteText中内置了针对泰文(Thai script)的特殊规则:

const THAI_SCRIPT_REGEX = /[\u0E00-\u0E7F]+/; if ( isCollapsed && reverse && unit === 'character' && removedText.length > 1 && THAI_SCRIPT_REGEX.exec(removedText) ) { editor.tf.insertText(removedText.slice(0, removedText.length - distance)); }

泰文属于复杂文字(complex script),其"字符"边界与 Unicode 码点并不对齐。删除 N 个字符时,若按整个字素簇(grapheme cluster)删除会删多,因此实现会在删除后把多余的码点重新插入,保证向后删除 N 个字符恰好删除 N 个码点。对应的测试用例验证了delete({ distance: 2, reverse: true, unit: 'character' })在文本พี่上只删除两个码点、保留的行为(见 packages/slate/src/internal/transforms/deleteText.spec.tsx)。

第八步:选区恢复

删除完成后,若调用方未显式传入at,则把光标恢复到删除后的位置:

const point = reverse ? startUnref || endUnref : endUnref || startUnref; if (options?.at == null && point) { editor.tf.select(point); }

向后删除(退格)时光标留在start一侧,向前删除(Delete)时光标留在end一侧,符合主流编辑器交互直觉。

入口绑定与兼容层:editor.delete的两种暴露方式

deleteText作为核心变换被绑定到编辑器实例上,见 packages/slate/src/create-editor.ts:

delete: bindFirst(deleteText, editor),

同时delete也被列入遗留方法白名单LEGACY_TRANSFORMS(见 packages/slate/src/utils/assignLegacyTransforms.ts),保证在带 legacy 方法同步的编辑器(syncLegacyMethods)上editor.delete()editor.tf.delete()均可用。

此外,仓库中还保留了遗留版实现deleteMerge(packages/slate/src/utils/deleteMerge.ts),并从 packages/slate/src/utils/index.ts 导出。两套实现的骨架几乎一致(位置归一化、void 处理、nudge out、三段删除),主要差异包括:

  • deleteMerge使用getVoidNode/getPointBefore/getPointAfter等遗留内部函数,而deleteText使用editor.api.*统一命名空间;
  • 跨块mergeNodes时,deleteText额外传递reverse: !reversedeleteMerge不传递;
  • deleteText内置泰文脚本码点恢复逻辑,deleteMerge没有;
  • deleteText在收集 matches 时对只读元素(isElementReadOnly)而非 void(isVoid)做删除保护。

从这些差异可以看出,deleteText是面向 v2 API 形态(editor.api/editor.tf分离)的新实现,deleteMerge则是为兼容旧调用方保留的过渡层。

测试验证:聚焦的失败测试先行

本切片遵循"先写聚焦失败测试,再实现最小诚实核心"的流程,测试集集中在 packages/slate/src/internal/transforms/deleteText.spec.tsx。这些用例直接覆盖了本切片承诺的能力矩阵:

测试用例覆盖能力
折叠文本选区向前删除一个字符基础折叠删除 + 选区恢复
按路径删除节点Path位置支持
跨块展开选区删除并合并块跨块删除 +mergeNodes行为
从内联 void 前方向前删除(img内联 void 兄弟叶子跨越
从 void 内部点删除整个 voidPoint 位于 void 内部的整体删除
向后删除时挤出只读内联(mention内联只读元素边界挤出
泰文多码点字符删除后重新插入distance+ 复杂文字语义
文档末尾向前删除 no-op文档边界保护

配套的deleteMerge测试集(packages/slate/src/utils/deleteMerge.spec.tsx)还额外验证了:无选区且无显式位置时提前返回、显式折叠 Range 等价于其锚点、完全覆盖的中间块先移除再合并边缘、以及起/止点分别位于内联 void 内部时的双向挤出行为。

测试使用@platejs/test-utilsjsxt语法编写,通过<cursor /><anchor /><focus />标记声明初始与期望的选区状态,比较editor.childreneditor.selection双重结果,既验证文档树也验证光标位置。

与其他操作族切片的关系

本切片属于 op-family 系列的一部分,同目录下还有针对insert_node/remove_node(第一切片,见 docs/plans/2026-04-07-slate-v2-op-family-first-slice.md)等不同操作族的切片计划。每个切片遵循统一的五阶段执行模板:

  1. 确认确切的 API/行为缺口与当前代码接缝(seam);
  2. 编写聚焦的失败测试;
  3. 实现最小的诚实核心/API 切片;
  4. 同步包/公共文档;
  5. 验证被触及的包与文档。

这种"小步、可验证、文档同步"的模式,保证了packages/slate在能力恢复过程中每一步都有测试背书,且不会因为一次改动引入超出范围的语义漂移。

边界、限制与后续演进

需要特别强调的是本切片的范围纪律:

  • 仅限折叠删除:用户显式框选跨越多个叶子的展开 Range 删除不在本次范围内(虽然deleteText的实现已经具备处理展开 Range 的基础能力,但作为切片承诺,本次只对折叠场景做契约保证);
  • 仅限单个受支持的顶级块内:跨越多个顶级块的删除虽然实现上会触发mergeNodes合并,但本切片的核心验收场景是"单块内跨混合内联兄弟叶子";
  • void 语义保留:默认voids: false时,void 节点被整体删除而非逐个字符删除,内联 void 内部起点会被挤出到相邻可编辑文本。

从 docs/slate-v2-draft/references/slate-batch-engine.md 的架构愿景看,删除类操作未来还可能获得专门的 op-family 优化执行器(与已证明的 exact-pathset_node快路径并列),但前提是"基准测试证明其必要性",未优化前一律走通用 draft-root 执行器。

小结

第二十一个 op-family 切片以最小诚实改动,把deleteText的折叠删除能力从单叶子拓宽到了"单个顶级块内跨混合内联兄弟叶子(含内联后代)",并完整支持显式Point、当前折叠选区、reversedistance选项。其实现通过位置归一化、void 边界挤出、pathRef/pointRef 安全引用、三段删除与泰文码点补偿等一系列机制,确保了删除操作在复杂内联结构下的正确性与光标恢复的稳定性,全部行为均有对应的聚焦测试用例背书,相关实现可在 packages/slate/src/internal/transforms/deleteText.ts 及其配套测试中继续深入研读。

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

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

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

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

立即咨询