Tiptap 水平分割线扩展演进全解:从 changelog 到源码的 horizontalRule 实现剖析
【免费下载链接】tiptapThe headless rich text editor framework for web artisans.项目地址: https://gitcode.com/GitHub_Trending/ti/tiptap
水平分割线(horizontal rule)是富文本编辑器中一个看似简单、实现却暗藏不少细节的块级节点:它没有子内容,必须正确处理光标落点、空文档插入、文档末尾续写,以及“能插入才能执行”的命令校验。本文以 extension-horizontal-rule 的 CHANGELOG 为主线骨架,结合该扩展在@tiptap/extension-horizontal-rule包中的 核心实现、测试用例 与 官方示例,梳理 HorizontalRule 扩展从 1.0.0-alpha 到 3.30.3 的功能演变、配置项与底层命令行为。读完本文,你将理解setHorizontalRule命令的真实执行链路、nextNodeType选项的用途、输入规则(---快捷触发)的工作机制,以及历代版本修复的关键边界问题。
一、为什么一个<hr>需要专门的扩展
在 ProseMirror/Tiptap 的文档模型中,<hr>被建模为叶子块级节点(block leaf node):声明为group: 'block',自身不可包含子节点,也没有文本内容。这意味着编辑器的撤销栈、光标导航、序列化都要把它当作一个独立的结构单元处理。因此 HorizontalRule 扩展的核心职责不是“渲染一条线”,而是回答三个问题:
- 当前光标位置能不能插入这个节点(支撑
can()判断); - 插入后光标落在哪里(决定用户能否继续输入);
- 如果插入在文档末尾或空块附近,如何保证文档结构合法(ProseMirror 的 schema 通常不允许文档以
hr收尾,也不希望留下无意义的空文本块)。
从 CHANGELOG 可以看到,围绕这三个问题,历代版本进行了一连串针对性修复,如今全部沉淀在 horizontal-rule.ts 的命令逻辑中。
二、当前版本与包结构
该包发布的最新版本为3.30.3,这一点同时由 CHANGELOG 顶部 与 package.json 的"version": "3.30.3"确认。包的结构如下:
| 路径 | 作用 |
|---|---|
| src/horizontal-rule.ts | 扩展本体:Node 定义、命令、输入规则、Markdown 解析/序列化 |
| src/index.ts | 导出入口,默认导出HorizontalRule |
| CHANGELOG.md | 完整版本历史(覆盖 v1.0.0-alpha 至今,共约 1868 行) |
| README.md | 官方说明与文档入口 |
| __tests__/horizontalRule.spec.ts | 针对插入行为的单元测试 |
它通过 package.json 将@tiptap/core与@tiptap/pm声明为 peer 依赖,二者在当前仓库的 monorepo 中以workspace:*别名锁定同版本发布(这正是 v3 引入的版本钉扎策略,详见后文 v3.0.1 相关条目)。
三、配置项与源码级选项解析
扩展对外暴露两个选项,其类型定义在 horizontal-rule.ts 第 4–17 行,默认值在 addOptions(第 38–43 行):
| 选项 | 默认值 | 说明 |
|---|---|---|
HTMLAttributes | {} | 渲染<hr>时附加的 HTML 属性,例如{ class: 'foo' } |
nextNodeType | 'paragraph' | 分割线位于文档末尾、其后再无节点时,自动补入的下一节点类型名 |
nextNodeType是 3.6.5 才加入的选项
在 CHANGELOG 的 3.6.5 条目(commite6451b8)中明确记录:
Added
nextNodeTypeoption to horizontal-rule extension, allowing users to specify which node type should be inserted after a horizontal rule
即:当用户在文本末尾插入hr、其后没有节点时,编辑器会按nextNodeType创建空节点补在分割线之后,并把光标移进去,保证用户可以立即继续输入。之所以默认是paragraph,正是因为 ProseMirror 的文档 schema 通常要求正文以文本块结束。
在 horizontal-rule.ts 第 104–115 行 可以看到完整实现:当$to.nodeAfter为空(分割线位于文档末尾)时:
// add node after horizontal rule if it’s the end of the document const nodeType = chainState.schema.nodes[this.options.nextNodeType] || $to.parent.type.contentMatch.defaultType const node = nodeType?.create() if (node) { tr.insert(posAfter, node) tr.setSelection(TextSelection.create(tr.doc, posAfter + 1)) }值得注意的细节是兜底策略:如果用户配置的nextNodeType在当前 schema 中不存在,代码会回退到父节点的contentMatch.defaultType,保证在任何 schema 组合下都不会生成非法文档。
HTMLAttributes 与属性透传
选项通过 renderHTML(第 51–53 行) 的mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)合并到最终<hr>标签上;对应地,parseHTML(第 47–49 行) 只识别标签名为hr的元素。一个完整的最小配置示例:
HorizontalRule.configure({ HTMLAttributes: { class: 'my-rule' }, nextNodeType: 'heading', // 文档末尾自动补一个 heading 而非 paragraph })四、命令与插入流程:setHorizontalRule 如何工作
扩展通过 addCommands 注册唯一命令setHorizontalRule,并在 第 19–29 行 通过模块声明(module augmentation)把它挂到@tiptap/core的Commands类型上以获得完整类型提示:
editor.chain().focus().setHorizontalRule().run() // 或在工具栏做禁用态判断时: editor.can().chain().focus().setHorizontalRule().run()命令内部大致分三步:
可插入性预检。调用 core 的 canInsertNode 工具(对应 horizontal-rule.ts 第 71 行)。该工具对普通文本选区从
$from位置向上逐层用contentMatchAt(index).matchType(nodeType)探测;对 NodeSelection 则用parent.canReplaceWith(index, index + 1, nodeType)判断能否替换选中的节点——这解释了测试中“选中图片后插入分割线会把图片替换为hr”的行为。根据选区形态选择插入方式(第 80–86 行):如果是节点选区(如选中一张图片),使用
insertContentAt($originTo.pos, { type: this.name })原位替换;否则直接insertContent({ type: this.name })在光标处插入。安置光标并保证文档结构合法(第 88–122 行)。这里对
hr之后的节点做了分类处理:- 后随文本块:
TextSelection.create(tr.doc, $to.pos + 1),光标落在文本块开头; - 后随其他块节点:
NodeSelection.create选中它; - 后续没有节点(文档末尾):按
nextNodeType/ contentMatch 兜底补节点再放光标(见第三节)。
- 后随文本块:
最后tr.scrollIntoView()保证滚动跟随,整条链以.run()收尾并返回布尔结果,供can()与工具栏禁用态消费。
五、输入规则与 Markdown:---从哪来、到哪去
除了命令,扩展还支持“纯键盘”插入。在 addInputRules(第 128–135 行) 中注册了一个基于 core 的 nodeInputRule 的规则:
nodeInputRule({ find: /^(?:---|—-|___\s|\*\*\*\s)$/, type: this.type, })即在行首输入---(三个连字符)并满足整行匹配时,会自动替换为hr节点。历史上这个输入规则本身也修过 bug——见 CHANGELOG 2.1.4:commitffeefe2标题为replace the whole node in nodeInputRule(issue #4341),修正了触发时只替换部分文本而非整个文本块、留下残留字符的问题。而“把输入规则与粘贴规则并入 core 统一管理”则发生在更早的 2.0.0-beta.22(#1997)。
在 Markdown 方向,horizontal-rule.ts 第 55–63 行 声明了markdownTokenName: 'hr',parseMarkdown通过helpers.createNode('horizontalRule')建节点,renderMarkdown则固定输出---,与输入规则的触发串形成闭环——这也是 Tiptap 的 packages/markdown 做 Markdown 往返转换时分割线“进来是---、出去也是---”的原因。
六、从 changelog 回溯:历代修复背后的边界问题
把 CHANGELOG 中有实质内容的条目(排除大量仅“同步依赖版本”的条目)抽出,可以还原这条扩展在边界处理上的完整演进史。
1. 文档末尾与空文档的处理(最持久的主题)
| 版本 | 变更内容 |
|---|---|
| 2.0.0-beta.2 | improve handling of horizontal rule at document end,fix #248 |
| 2.1.7 | fix insertion being broken on empty docs,fix #4375 |
| 3.6.5 | 新增nextNodeType,让文档末尾的补节点类型可配置 |
这三条正好对应第三节展示的“末尾兜底补节点”代码:先在 beta.2 解决基本边界,再在 2.1.7 修掉空文档上的插入问题,最后用 3.6.5 的可配置选项把默认行为开放给用户。当前实现中nextNodeType优先、contentMatch.defaultType兜底的双保险,正是这些修复层层叠加的结果。
2. 空文本块清理与光标位置
| 版本 | 变更内容 |
|---|---|
| 2.0.0-beta.19 / beta.20 | remove node before hr if it’s an empty text block,fix #1665(若hr前是空文本块则先删掉,避免出现多余空行) |
| 2.0.0-beta.28 | Improve behavior when using insertContent,fix #2147(命令与通用insertContentAPI 行为的一致性) |
| 2.0.0-beta.31 | set cursor position in setHorizontalRule correctly,fix #2429 |
这些都能在第四节描述的光标安置分支中找到对应代码:isNodeSelection判断、$to.nodeAfter.isTextblock/.isBlock分类,以及对空块的考虑。
3. 命令可执行性判断(can())
| 版本 | 变更内容 |
|---|---|
| 3.0.0-beta.15 与 3.0.1 | commit087d114:修复setHorizontalRule通过can()检查时恒返回 true的 bug |
也就是说,v3 发布前修复了一个隐蔽问题:此前命令在can()预检场景下总是返回成功,导致工具栏按钮在不允许插入的上下文里错误地保持可用。现在 horizontal-rule.ts 第 70–73 行 把canInsertNode失败直接返回false,从根源上解决了该问题。
4. 构建与发布基建(v3 的破坏性变更)
| 版本 | 变更内容 |
|---|---|
| 3.0.1(Major) | commita92f4a6:改用 tsup 构建,不再产出 UMD 产物,需要 UMD 的用法需自行二次打包 |
| 3.0.1(Patch,同上条目) | 1b4c82b:改用 pnpm package alias 加强 monorepo 版本钉扎;89bd9c7:强制 type-only import,避免产物index.js带出 TS 类型引用;8c69002:将 beta 与稳定功能同步 |
| 2.5.4 | commitdd7f9ac:修复 cjs 产物 default export 的兼容问题 |
| 2.0.0-beta.215 | fix builds including prosemirror,避免把 ProseMirror 打进产物 |
| 2.0.0-beta.210 | 引入独立的@tiptap/pm包,统一 ProseMirror 依赖解析 |
这些条目解释了为什么 package.json 现在的exports字段同时提供import(ESMdist/index.js)与require(CJSdist/index.cjs)两个入口、类型声明落在.d.ts/.d.cts,以及 peerDependencies 为何以workspace:*引用 core 与 pm。
5. 其他零散修复
- 2.0.0-beta.9 / beta.12 / beta.14 / beta.15:历史上围绕
exports、type: module字段的反复调整(先加、回退、再加,最终在 v3 统一为模块化产物); - 2.0.1 / 2.1.0-rc.0:更新 peerDependencies 以修复 lerna 版本任务(#3914);
- 1.0.0-alpha.2:revert “use global namespace”,确立了模块化导出形态。
七、版本脉络一览表
汇总 CHANGELOG 中所有带实质说明的版本阶段(其余版本均为对@tiptap/core/@tiptap/pm的依赖同步):
| 版本区间 | 阶段特征 |
|---|---|
| 1.0.0-alpha → 2.0.0-beta.31 | 早期打磨:模块化导出、空文本块清理、光标修正(#1665、#2147、#2429 等) |
| 2.0.0-beta.200+ → 2.5.8 | 引入@tiptap/pm、接入输入规则与粘贴规则、构建修复(#3914、#4341、#4375) |
| 2.5.8 之后 → 2.12.0 | 工具链切换期(lerna 式记录过渡到 changesets),存在若干无实质说明的版本区间 |
| 3.0.0-next → 3.0.1 | 全面重构为 tsup/ESM 优先产物、修复can()恒真问题、pnpm alias 版本钉扎、强制 type-only import |
| 3.0.2 → 3.6.5 | 稳定性发布;3.6.5 新增nextNodeType选项 |
| 3.7.0 → 3.30.3 | 跟随 core/pm 迭代的常规同步发布,无扩展自身行为变更 |
说明:CHANGELOG 中存在版本号区间的工具链跳变,属于仓库由旧式发布记录切换为 changesets 自动化发布时的产物,因此“空条目”不代表功能回退;如需逐条核对某版本修改来源,可对照文件末尾Conventional Commits的说明与对应 commit 短哈希(如
087d114、e6451b8)。
八、测试与示例:行为如何被锁定
扩展的行为由单元测试与官方示例双重锁定:
- horizontalRule.spec.ts 构造了
image + paragraph文档,执行setTextSelection(2)选中图片后再调用setHorizontalRule(),断言最终 HTML 形态为<img><hr><p>Example Text</p>。这条用例直接验证了“NodeSelection 场景下用分割线替换选中节点”的代码路径(对应 horizontal-rule.ts 第 80–86 行)。 - 官方 demo 同时提供 React 与 Vue 实现:React/index.jsx 与 Vue/index.vue,内容以三段文字加两条
<hr>演示初始渲染,并提供 “Set horizontal rule” 按钮调用editor.chain().focus().setHorizontalRule().run(),对应的冒烟测试在 index.spec.ts。这是把扩展接入真实编辑器的最直接样板。
九、小结
在一个成熟的富文本引擎中,<hr>这类叶子块节点把“渲染”之外的复杂度集中在了命令的选区/光标处理与 schema 合法性维护上。@tiptap/extension-horizontal-rule的现状(horizontal-rule.ts)是十余个历史版本修复的汇聚:空文档插入、文档末尾补节点(nextNodeType)、NodeSelection 替换、can()可执行性、输入规则整块替换等边界问题都能在源码中找到对应实现。该包的 CHANGELOG 本身也是一部浓缩的“边界问题手册”:若你在 v3 迁移或排查hr插入后的光标/内容问题时感到困惑,本文梳理的条目与 horizontal-rule.ts 第 65–135 行 的命令实现,可以当作一份直接的调试索引。
【免费下载链接】tiptapThe headless rich text editor framework for web artisans.项目地址: https://gitcode.com/GitHub_Trending/ti/tiptap
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考