Plate 项目 Slate v2 op-family 第一切片实战:以 insert_node / remove_node 补齐核心操作族 API
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
本文基于仓库文档 2026-04-07-slate-v2-op-family-first-slice.md 展开。该文档记录了一次典型的"最小诚实切片"(smallest honest slice)执行:当
slate包的公开文档已经暗示了insert_node/remove_node操作与Transforms.insertNodes/Transforms.removeNodes变换,而实际包内只实现了四个操作与四个变换辅助函数时,如何用一次窄范围的切片补齐缺口。本文以该计划为骨架,结合当前仓库packages/slate中的类型定义、变换实现与测试用例,还原从缺口确认、失败测试、实现到验证的完整闭环,并给出下一个切片(path-basedset_node/Transforms.setNodes)的前瞻。
一、背景:op-family 缺口从何而来
在 Slate 的架构里,Operation是编辑器改变内部状态时使用的底层指令。把一切变更统一表示为操作,是 Slate 编辑器实现 history(撤销/重做)、协同编辑等功能的前提——这一点在 operation.ts 的类型注释中写得很明确:
Operationobjects define the low-level instructions that Slate editors use to apply changes to their internal state. Representing all changes as operations is what allows Slate editors to easily implement history, collaboration, and other features.
计划文档(Finding 一节)指出的核心问题是:文档与实现之间存在错位。当时的slate文档已经暗示了insert_node/remove_node两个操作以及Transforms.insertNodes(...)/Transforms.removeNodes(...)两个变换辅助函数的存在,但包内实际只实现了四个操作和四个变换辅助函数,即公开 API 承诺了一部分能力,而底层操作族(op-family)尚未跟上。
这种"文档先行、实现滞后"的缺口在大型编辑器重构中非常典型:先有 API 面(surface)的契约,再逐步用操作族实现填充。从当前仓库看,该切片已经落地——operation.ts 中定义了完整的InsertNodeOperation,operation.ts 定义了完整的RemoveNodeOperation,两者都已被纳入 NodeOperation 联合类型。
二、目标与范围:坚持"最小诚实切片"原则
计划文档的 Scope 一节给出了三个明确的边界:
- 从最小的、已被文档暗示的 op-family / API 错位开始,不贪多、不铺开;
- 优先实现
insert_node/remove_node及配套的Transforms包装,前提是核心(core)能够干净地支撑它们; - 保持切片窄小,明确排除以下内容:
- 不做虚假的、泛化的
NodeOptions兼容(no fake broad NodeOptions parity); - 不做选择语义繁重的变换(no selection-heavy transform semantics);
- 不扩展更宽的节点族(no broader node families yet)。
- 不做虚假的、泛化的
这条"最小诚实切片"原则的价值在于:每次只交付一个可独立验证、可回归、不会引入大规模副作用的增量,而不是一次性重写整个操作层。它同时为后续切片留下了清晰的接缝(seam)。
三、五阶段执行流程:从缺口确认到验证闭环
计划文档以勾选清单形式记录了五个执行阶段,全部完成。这套流程本身就是一个可复用的 op-family 切片方法论:
| 阶段 | 内容 | 状态 |
|---|---|---|
| 1 | 确认确切的缺口与当前代码接缝(Confirm the exact mismatch and current code seams) | 已完成 |
| 2 | 编写聚焦的失败测试(Write focused failing tests) | 已完成 |
| 3 | 实现最小诚实的核心 / API 切片(Implement the smallest honest core/API slice) | 已完成 |
| 4 | 同步包与公开文档(Sync package/public docs) | 已完成 |
| 5 | 验证受影响的包与文档(Verify the touched package/docs) | 已完成 |
每个阶段的产物在当前仓库中都能找到对应证据:
- 阶段 2 的失败测试沉淀为 insertNodes.spec.tsx 与 removeNodes.spec.tsx;
- 阶段 3 的核心实现对应 operation.ts 的类型层与 insertNodes.ts、removeNodes.ts 的变换层;
- 阶段 4 的文档同步指向仓库的 roadmap 真相来源 master-roadmap.md(计划文档开头的引用即为该文件)。
四、核心实现(一):Operation 类型层
切片首先补齐的是操作类型的静态定义。当前仓库中 operation.ts 已经完整定义了本次切片涉及的类型:
export type InsertNodeOperation<N extends Descendant = Descendant> = { [key: string]: unknown; node: N; path: Path; type: 'insert_node'; }; export type RemoveNodeOperation<N extends Descendant = Descendant> = { [key: string]: unknown; node: N; path: Path; type: 'remove_node'; }; export type NodeOperation<N extends Descendant = Descendant> = | InsertNodeOperation<N> | MergeNodeOperation<N> | MoveNodeOperation | RemoveNodeOperation<N> | SetNodeOperation<N> | SplitNodeOperation<N>;几个值得注意的类型设计细节:
- 两个操作都携带
node(被插入/被移除的节点快照)与path(操作发生的路径),并带有[key: string]: unknown索引签名以保持向前兼容; Operation顶层联合类型由NodeOperation | SelectionOperation | TextOperation组成(operation.ts),本次切片完全落在NodeOperation分支内;OperationApi提供了isNodeOperation、isOperation、isOperationList等运行时判别方法(operation.ts),这些判别函数是 history、协同等上层模块对操作进行分派的基础。
从源码结构看,当前仓库的NodeOperation已包含六种节点操作(insert、merge、move、remove、set、split),说明该切片完成后,后续的set_node/merge_node/move_node/split_node等操作族成员也已在类型层面逐步就位——这印证了计划文档"Follow-on"一节所述路线图的方向。
五、核心实现(二):Transforms 变换层
操作类型之外,切片还需要提供面向使用者的变换 API。当前仓库中 insertNodes.ts 是对slate基础insertNodes的包装,其实现要点如下:
export const insertNodes = <N extends ElementOrTextOf<E>, E extends Editor = Editor>( editor: E, nodes: N | N[], { nextBlock, removeEmpty, ...options }: InsertNodesOptions<ValueOf<E>> = {} ) => { options = getQueryOptions(editor, options); editor.tf.withoutNormalizing(() => { if (removeEmpty) { const blockEntry = editor.api.above({ at: options.at }); // ...查询匹配后,若为空块则先 editor.tf.removeNodes({ at: blockEntry[1] }) } if (nextBlock) { // ...将插入位置改为当前块的后一个兄弟路径 PathApi.next(blockEntry[1]) } insertNodesBase(editor as any, nodes, options as any); }); };对应地,removeNodes.ts 处理了children与previousEmptyBlock两个扩展选项:
previousEmptyBlock: true时,先定位目标位置前一个兄弟块,若为空则删除它;children: true时,用NodeApi.children(editor, options.at, { reverse: true })以逆序遍历子节点并逐个删除——逆序是为了避免删除过程中路径偏移导致下标错乱,这一点在 removeNodes.spec.tsx 的嵌套列表用例中有直接验证;- 默认情况则委托给基础
removeNodesBase。
两条变换都统一包裹在editor.tf.withoutNormalizing(...)中,保证批量操作期间不触发中间态的归一化(normalization)。
5.1 选项类型与默认值
变换层的能力边界由 editor-transforms.ts 中的选项类型刻画:
InsertNodesOptions<V>(editor-transforms.ts):
| 选项 | 类型 | 说明 |
|---|---|---|
batchDirty | boolean | 是否批量标记脏状态 |
hanging | boolean | 是否允许悬空范围 |
nextBlock | boolean | 在当前块之后插入节点;若removeEmpty已导致当前块被移除则不生效 |
removeEmpty | QueryNodeOptions \| boolean | 插入前若当前块为空则移除;默认仅针对段落(allow: ['p']),可传入QueryNodeOptions定制 |
select | boolean | 若为 true,插入后选中插入的节点 |
| 继承 | — | QueryOptions、QueryMode、QueryVoids(位置/匹配/void 查询约束) |
RemoveNodesOptions<V>(editor-transforms.ts):
| 选项 | 类型 | 说明 |
|---|---|---|
children | boolean | 为 true 时删除指定位置节点的全部子节点 |
event | { type: 'mergeNodes' } | 事件标注(供deleteBackward等内部路径使用) |
hanging | boolean | 是否允许悬空范围 |
previousEmptyBlock | boolean | 为 true 时若前一个块为空则先删除它 |
| 继承 | — | QueryOptions、QueryMode、QueryVoids |
5.2 变换的入口包装
值得注意,editor.tf.insertNodes之上还有一层薄封装 insertNode.ts,它直接把单节点委托给变换层:
export const insertNode = <N extends DescendantOf<E>, E extends Editor = Editor>( editor: E, node: N, options?: InsertNodesOptions<ValueOf<E>> ) => editor.tf.insertNodes(node, options);这形成了editor.api.insertNode→editor.tf.insertNodes→ 基础slate实现的调用链,从源码结构看,这是该仓库为 Editor API 与 Transforms API 双入口保持一致而设计的模式。
六、测试驱动:spec 如何锁定行为
计划文档明确要求"编写聚焦的失败测试"。两个变换的测试文件展示了不同但互补的验证风格:
- insertNodes.spec.tsx 使用
createEditor构建输入,覆盖四条关键行为路径:removeEmpty: true时先移除空段落再插入(第 4-24 行);removeEmpty传QueryNodeOptions(如{ allow: ['blockquote'] })时按过滤器决定是否移除空块(第 26-47 行);nextBlock: true时插入到当前块之后(第 49-70 行);- 无选择、无显式目标时追加到文档末尾;显式
at优先于当前选择(第 72-111 行)。
- removeNodes.spec.tsx 使用
@platejs/test-utils的jsxt语法构造编辑器状态,分三个describe组覆盖:previousEmptyBlock为 true/false 时对前驱空块的处理;- 指定路径的节点删除与未指定路径时的幂等行为;
children: true时对嵌套列表的逆序递归删除(含"路径无子节点时保持不变"的边界用例)。
此外,history 层也有对insert_node/remove_node操作语义的直接依赖:在 with-history.spec.tsx 中,手动向editor.history.undos压入一段由remove_node+insert_node组成的操作序列,随后调用editor.undo()验证文档内容与 selection 均能按预期回滚。这说明本次切片不只是类型补全,而是真正参与了 history 的撤销/重做回路。
七、验证命令:如何确认切片健康
计划文档的 Verification 一节列出了完整的验证矩阵,可直接在当前仓库中执行:
# 1. 运行 snapshot-contract 测试(需要 babel register 作为 loader) yarn mocha --require ./config/babel/register.cjs ./packages/slate/test/snapshot-contract.ts # 2. 运行整个 mocha 测试套件 yarn test:mocha # 3. 运行 slate-react 工作区测试(验证下游 React 绑定未回归) yarn workspace slate-react run test # 4. 对改动的 packages/slate 文件做定向 tsc 诊断:期望 0 错误 # 5. 对改动的 packages/slate 文件运行 eslint yarn exec eslint # 6. 对改动的 slate-v2 文件运行 prettier 检查 yarn prettier --check # 7. 对改动的 plate-2 文档运行 prettier 检查 pnpm exec prettier --check7.1 两个诚实的工程信号
计划文档的 Notes 一节记录了仓库级检查的真实状况,值得每一位在大型 monorepo 中做定向改动的人借鉴:
- 仓库级
yarn lint:typescript仍会命中该切片之外的过期 workspace*-v2tsconfig 引用,因此全仓类型检查在此阶段不能作为该切片的有效信号; - 仓库级
yarn lint:eslint仍会报告数千条与本切片无关的既有问题,所以对改动文件的定向 eslint 才是诚实(honest)的回归信号。
换言之:当全仓检查被历史遗留噪音污染时,收敛到改动文件的定向检查 + 聚焦测试是更可靠的验证策略——这也是本计划将验证范围精确限定在"改动的 packages/slate 文件"的原因。
八、已知约束与后续路线
计划文档明确记录了本次切片不做的事,这些约束既是验收边界,也是下一步的路线图:
- 不做选择语义繁重的变换:本切片只保证基于路径(path-based)的插入/删除语义,不承诺复杂的选区扩散、悬空范围折叠等行为;
- 不做泛化的 NodeOptions 兼容:避免为了"看起来完整"而伪造与上游 Slate 完全一致的选项面;
- Follow-on 明确指向下一个切片:路径化
set_node/Transforms.setNodes(...)。从当前仓库看,setNodes.ts 与其 spec 已经存在,说明这条路线已在后续切片中持续推进。
九、关联文件速查
| 角色 | 路径 |
|---|---|
| 本文依据的计划文档 | 2026-04-07-slate-v2-op-family-first-slice.md |
| 路线图真相来源 | master-roadmap.md |
| Operation 类型定义 | operation.ts |
| Transform 选项类型 | editor-transforms.ts |
| insertNodes 变换实现 | insertNodes.ts |
| removeNodes 变换实现 | removeNodes.ts |
| insertNode 入口包装 | insertNode.ts |
| insertNodes 测试 | insertNodes.spec.tsx |
| removeNodes 测试 | removeNodes.spec.tsx |
| history 层操作依赖验证 | with-history.spec.tsx |
十、小结
这份计划文档示范了一种可复制的操作族补齐方法论:先以文档契约为基准确认缺口,用失败测试锁定最小行为,实现时严格收窄范围、拒绝伪兼容,最后用定向测试与定向 lint 对抗 monorepo 的历史噪音。对读者而言,insert_node/remove_node及其Transforms包装不仅是两个 API,更是一条"从契约到实现再到验证"的完整切片范式,可直接套用于set_node、merge_node、move_node、split_node等后续操作族成员。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考