Slate v2 Op-family 第二十一个切片:跨混合内联叶子的折叠删除(Collapsed Delete)实现解析 📅 发布时间:2026/9/15 20:13:25 👁 浏览次数: 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、当前折叠选区、reverse与distance等选项的完整处理链路理解源码中位置归一化、内联 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_node、remove_node、set_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 selectionreverse选项向后删除即退格方向distance选项删除多少个字符/单位在一个受支持的顶级块内部跨越兄弟文本叶子包括内联后代inline descendants。本切片明确不做的事仅保留折叠删除语义collapsed-delete only避免在本次切片中实现任意的显式多叶子Range删除即用户显式框选跨越多个叶子的展开选区删除不在本次范围内。这个只做最小诚实切片的取舍与第一个切片docs/plans/2026-04-07-slate-v2-op-family-first-slice.md中Keep the slice narrow保持切片狭窄的指导思想一脉相承不做虚假的宽泛 NodeOptions 奇偶性、不做选区繁重的变换语义、不引入更广泛的节点族。核心 APIDeleteTextOptions参数全解本切片落地的核心入口是editor.delete()与editor.tf.delete()其底层实现为deleteText选项类型定义在 packages/slate/src/interfaces/editor/editor-transforms.tsexport type DeleteTextOptions { distance?: number; hanging?: boolean; reverse?: boolean; unit?: TextUnit; } QueryAt QueryVoids QueryTextUnit;各参数语义与实现中的默认行为如下参数类型默认值语义distancenumber1删除的单位数量与unit组合决定删除跨度hangingbooleanfalse是否悬挂删除见下文实现说明reversebooleanfalsetrue表示向后删除退格方向false表示向前删除Delete 方向unitTextUnitcharacter删除单位character/word/line/blockvoidsbooleanfalse是否允许删除 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; } }这里有两个关键细节void 优先如果光标位于某个 void 节点内部且未开启voids直接将该 void 的路径作为删除目标交给后面的removeNodes分支处理整块删除而不是逐个字符删除构造悬挂 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 路径或用户显式传入路径则直接调用removeNodesif (PathApi.isPath(at)) { editor.tf.removeNodes({ at, voids }); return; }第四步展开 Range 删除 —— 跨块判断与内联 void 边界挤出如果 Range 并非折叠hanging或显式 RangedeleteText进入多叶子删除主流程。首先进行两个关键的结构判断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 outif (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.tsdelete: bindFirst(deleteText, editor),同时delete也被列入遗留方法白名单LEGACY_TRANSFORMS见 packages/slate/src/utils/assignLegacyTransforms.ts保证在带 legacy 方法同步的编辑器syncLegacyMethods上editor.delete()、editor.tf.delete()均可用。此外仓库中还保留了遗留版实现deleteMergepackages/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而非 voidisVoid做删除保护。从这些差异可以看出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-utils的jsxt语法编写通过cursor /、anchor /、focus /标记声明初始与期望的选区状态比较editor.children与editor.selection双重结果既验证文档树也验证光标位置。与其他操作族切片的关系本切片属于 op-family 系列的一部分同目录下还有针对insert_node/remove_node第一切片见 docs/plans/2026-04-07-slate-v2-op-family-first-slice.md等不同操作族的切片计划。每个切片遵循统一的五阶段执行模板确认确切的 API/行为缺口与当前代码接缝seam编写聚焦的失败测试实现最小的诚实核心/API 切片同步包/公共文档验证被触及的包与文档。这种小步、可验证、文档同步的模式保证了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、当前折叠选区、reverse与distance选项。其实现通过位置归一化、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),仅供参考