很多个人博客的编辑器,最后都会卡在一个很尴尬的位置:Markdown 足够轻,但一旦开始认真处理图片、表格、代码块、局部修订和 AI 协作,它就不再只是一个 textarea 的问题;富文本编辑器看起来能力更强,但如果不能控制文档模型和扩展边界,很快又会变成一套难以维护的黑盒。
后来寻找,了解,对 Tiptap 编辑器眼前一亮,在官网https://tiptap.dev/中,呈现了非常多种组合,并且还拥有 AI 模式 不过很可惜,AI模式需要进行付费购买,但是通过文档的了解过程中,收费功能,其实可以自行实现,那这款插件已经达到了需求。
这篇文章是一篇工程复盘:从选型原因开始,拆开编辑器、图片节点、AI 修订和服务端渲染几层实现,也记录一些在 LLM 接入富文本时容易踩到的坑。
一、为什么不是继续堆 Markdown
Markdown 的优点很明确:简单、稳定、可读、适合代码和文档。但 ZarkOne 的写作场景,随着许许多多的灵感逐渐超过了纯文本。
比如一张图片不只是 src,它可能有 caption、对齐、圆角、阴影、边框、旋转、链接、上下留白,甚至可能从 AI 图像创作链路里被替换或插入。再比如 AI 润色不是“把全文发给模型”,而是用户选中某一段,模型只修改这一段,然后系统让用户确认采用还是拒绝。

这些能力都要求编辑器内部有一个结构化文档模型。Tiptap 的价值就在这里:它不是一个预设 UI 的编辑器,而是基于 ProseMirror 的 headless rich text editor framework。官方文档强调它通过 extension 扩展能力,并允许使用 NodeView 构建可交互的自定义节点。这正好符合我的需求:UI 可以按 ZarkOne 的设计重写,底层文档模型和扩展系统又足够稳定。
我参考 Tiptap 官方文档和一些把 AI copilot 接入编辑器的工程文章后,最后确定文章编辑器不应只做“输入框 + AI 按钮”,而应该做成写作工作台:文章元信息和正文呈现主要视觉,AI 助手、模型选择、历史模式、附件和版本确认在页面底部、侧边进行搭配。

二、Tiptap 究竟要怎么存
如果只是写 Markdown,存储方式很直观:数据库里保存一段 Markdown 字符串,展示时再转换成 HTML。这个方案并不是不能用,甚至对很多博客来说非常合适。
Tiptap 本身也可以接收 Markdown 风格的输入,编辑器里同样可以支持 Markdown 到富文本的转换。问题在于,ZarkOne 后来要处理的内容已经不只是一段文本。
比如图片要有 caption、对齐、链接、圆角、阴影和不同布局;代码块要能高亮和复制;AI 修改要知道原文在哪里、新内容放在哪里、哪些内容是待确认的建议。到了这个阶段,如果只把正文当成一段 Markdown 字符串,很多信息就只能塞进临时 HTML 或额外字段里,后续编辑、回显和 AI 修订都会越来越别扭。
所以这里的关键取舍不是“Markdown 和 Tiptap 谁更好”,而是:Markdown 适合表达文本,Tiptap JSON 更适合保存一篇文章的结构。
我后来采用的是“双层思路”:编辑过程里以 Tiptap JSON 作为主要存储格式,因为它能保留文档树、节点属性和自定义内容块;展示时再把这份 JSON 渲染成安全的 HTML,给博客文章页使用。这样做的好处是,编辑器和文章页看到的是同一份内容,只是处在不同形态。
Markdown 可以继续作为输入方式:用户可以粘贴 Markdown,AI 也可以返回 Markdown。
Tiptap JSON 负责保存结构:标题、段落、列表、代码块、图片节点、AI 标记都能进入同一棵文档树。
HTML 更适合最终展示:文章发布后需要服务端渲染、代码高亮、目录生成和安全清理。
文章显示页则独立承担访问路径:它不应该依赖标题临时变化,也不应该和正文内容耦合。
Tiptap 官方文档里有两块内容对这个判断很有帮助:一块是编辑器基础介绍,说明它是基于 ProseMirror 的 headless 编辑器框架;另一块是 Markdown 相关能力,说明 Markdown 可以作为内容输入和转换的一部分,但编辑器内部仍然围绕结构化文档工作。可以参考 Tiptap 官方文档:https://tiptap.dev/docs/editor/getting-started/overview,以及 Markdown 扩展相关介绍:https://tiptap.dev/docs/editor/markdown。
// 这不是完整代码,只是存储思路的简化表达
type ArticleDraft = {
title: string
slug: string
contentJson: TiptapDocument
contentHtml?: string
status: 'draft' | 'published'
}这一步想清楚以后,后面的图片、AI 修订、版本确认才有了落点。因为它们不是外挂在文章旁边的零散状态,而是尽量回到同一份结构化内容里。
三、把图片当成文章内容块

我在早期最容易低估的是图片。普通博客经常把图片当成 img 标签,但写作过程中图片其实是一种内容块:它有说明文字,有排版位置,有边距,有链接,有视觉样式,也可能需要从块级图切换成内联图。如左图的实现例子所示。
在编辑器里,一张图片更像是正文里的一个小组件:它可以有说明文字,可以选择居中或浮动,可以加链接,也可以调整圆角、阴影、留白和尺寸。
搭配 Tiptap 官方的 Bubble Menu。Bubble Menu 的核心用途,就是在用户选中某段内容时,在内容附近显示一个上下文菜单。官方文档里也提到,可以通过 shouldShow 控制菜单只在特定内容上出现,例如只在图片或链接被选中时显示。
参考文档:https://tiptap.dev/docs/editor/extensions/functionality/bubble-menu
也就是说,图片菜单不是单独凭空造出来的功能,它本质上是“选中内容后出现上下文工具”的延伸。区别只是文字选区通常弹出加粗、链接、引用,而图片选区更需要弹出布局、说明、替换和 AI 创作。
// 思路示意:图片不是普通 <img>,而是带状态的文档节点
const imageNode = {
type: 'image',
attrs: {
src: imageUrl,
caption: '图片说明',
align: 'center',
rotation: 0,
},
}
// 点击图片时显示菜单;点击菜单按钮时,只更新图片节点属性
editor
.chain()
.focus()
.updateAttributes('image', {
rotation: 90,
caption: '新的图片说明',
})
.run()上面这段不是完整实现,但能说明关键思路:菜单是否出现,由当前选中的内容决定;菜单里的按钮,不是直接操作 DOM,而是把图片的状态写回编辑器文档。这样图片的布局和说明就能跟正文一起保存,而不是散落成临时样式。
沿着这个方向继续扩展。图片点击后出现的是更贴近写作场景的菜单:可以做基础排版,也可以进入 AI 配图。用户看到某张图不合适时,不需要跳出编辑器重新打开另一个工具,而是直接以当前图片为参考生成新图,再选择替换原图或插入一张新图。
四、AI 不应该只是聊天,而应该变成编辑动作
接入 AI 写作以后,我最早遇到的不是模型能不能写,而是它写完以后怎么进入文章。很多产品会把 AI 做成一个聊天窗:用户问一句,模型答一段,然后用户自己复制到正文里。这个方式能用,但它没有真正理解“我正在编辑哪一篇文章、哪一段内容、这次到底想改什么”。
所以后来我更倾向于把 AI 当作一次编辑动作,而不是一次聊天回答。润色、扩写、精简、翻译这些常见操作,可以理解为几种典型的编辑意图;而 Agent 模式则更进一步,它不再只是执行某个固定按钮背后的逻辑,而是会先读懂当前文章和用户目标,再结合项目中专为 LLM 准备的联网搜索、知识库、网页解析等工具,自主判断该在哪里替换、插入或删除,真正做到由 AI 替你动手完成事情。

这里最重要的变化是:AI 不直接把一整篇“改好后的正文”扔回来。它应该提交一组可定位、可预览、可拒绝的修改建议。这样作者仍然是最后做决定的人,AI 只是把可能的改法摆到编辑器里。
我理解的 Agent,不是“更会聊天的模型”,而是“能把自然语言需求拆成可执行编辑建议的中间层”。
// 思路示意:Agent 不直接覆盖文章,而是提交一条可审阅的编辑建议
type EditProposal = {
action: 'replace' | 'insert' | 'delete'
anchorText?: string // 用来定位原文
insertAfter?: string // 用来决定插入位置
content?: string // 建议写入的新内容
}
const flow = [
'用户提出目标',
'Agent 理解文章上下文',
'生成一组编辑建议',
'编辑器定位原文并显示差异',
'作者确认后写回文章',
]这个结构看起来比“直接生成全文”麻烦,但它解决了一个核心问题:AI 的输出必须能被文章系统理解。只要建议能定位到原文,编辑器就可以把旧内容和新内容并排展示;只要建议没有被确认,它就不应该悄悄变成正式正文。
五、旧内容和新建议为什么能同时存在
这里真正起作用的底层概念,是 ProseMirror。Tiptap 可以理解成更易用的一层封装,但文档模型、选区、事务更新、位置映射这些能力,底下都来自 ProseMirror。
ProseMirror 不把正文当成一整段 HTML 字符串,而是把文章看成一棵文档树。标题、段落、列表、图片、代码块,都是树上的节点;加粗、链接、AI 建议这类状态,可以理解成附着在内容上的标记。正因为内容是结构化的,编辑器才知道“这是一段旧内容”“这是一段新建议”“这张图片有自己的属性”。
AI 修订要做的事情,就是不要立刻把旧内容删除。更稳的方式是:旧内容先被标记为“可能要删”,新内容插入到旁边并标记为“建议新增”。这样作者看到的不是一个被 AI 直接覆盖后的结果,而是一组还没确认的变化。
换句话说,旧内容和新建议能同时存在,不是因为页面上硬画了两个 div,而是因为它们都还在同一棵 ProseMirror 文档树里,只是带着不同的状态。
这也是为什么它比普通 textarea 难很多。textarea 里只有字符串,替换就是替换;ProseMirror 里每一次修改都更像一次 transaction:它描述这次文档发生了什么变化,再由编辑器把变化应用到文档状态里。
// 简化示意:一次 AI 修改不是直接覆盖,而是一次文档事务
const tr = editor.state.tr
// 1. 先把原文标记为“待删除”
tr.addMark(from, to, changeMark({ kind: 'old' }))
// 2. 再把 AI 建议插入到旁边,并标记为“待采用”
tr.insertText(aiSuggestion, to)
tr.addMark(to, to + aiSuggestion.length, changeMark({ kind: 'new' }))
// 3. 只有用户确认后,再把旧内容真正移除,把新内容转成正式正文
editor.view.dispatch(tr)上面这段只是原理示意,但它说明了关键点:AI 建议不是直接改 DOM,也不是单纯替换字符串,而是通过编辑器的事务系统进入文档。旧内容、新内容、是否待确认,都可以成为文档状态的一部分。
还有一个很重要的细节叫 Mapping。用户在 AI 建议出现以后,可能继续在附近打字、删字、调整段落。原来记录的位置会变化,如果只记一个固定下标,很容易找不到原文。ProseMirror 的位置映射机制可以把旧位置跟着事务变化往后推或往前移,让“这条建议原本指向哪里”尽量保持准确。
Mark 更适合保存到文档里的状态,例如“这段是旧内容”“这段是新建议”。
Decoration 更适合临时视觉效果,例如只是在屏幕上高亮某一段,不一定写入正文。
Transaction 负责描述一次修改,让编辑器按规则更新文档树。
Mapping 负责在文档继续变化时追踪位置,避免建议漂移。
理解了这一层以后,AI 编辑器的逻辑就清楚很多:模型生成的不是最终正文,而是一组可以进入 ProseMirror 文档树的修改建议。编辑器先把旧内容和新建议都展示出来,等作者确认后,再把这次建议真正合并进正文。
这一章可以参考 ProseMirror 官方文档里的 Guide 和 Reference。尤其是 document、transaction、decoration、mapping 这些概念,是理解 Tiptap 复杂编辑能力的底层钥匙:https://prosemirror.net/docs/guide/ 和 https://prosemirror.net/docs/ref/。
六、保存后才算真正闭环
编辑器里能显示,不代表文章页就能稳定展示。我的取舍是保留两种形态:一份给编辑器继续编辑,一份给文章页阅读。
结构化内容保存图片、代码块和 AI 建议这些状态;展示内容只留下读者需要看到的结果。这样重新打开草稿时能继续改,发布后页面也不会带着编辑器里的临时痕迹。
// 简化理解:保存时保留两种形态
const article = {
editableContent, // 给编辑器继续使用
readableHtml, // 给文章页展示使用
}关键不是多存一份数据,而是把“创作状态”和“公开内容”分开。
七、这次最值得记住的几个坑
这套编辑器真正容易踩坑的地方,不是按钮多,而是同一篇内容要在写作、保存、展示、AI 修改、再次编辑之间来回流动。
最终 HTML 适合展示,不适合作为唯一存储。
图片要作为内容块保存,不能只剩图片地址。
AI 建议先进入可审阅状态,不要直接覆盖正文。
定位不能只靠固定下标,用户继续编辑后位置会移动。
重要样式要能保存和回显,不能只留在页面临时状态里。
内容模型打稳以后,AI、图片和文章页渲染都能围绕同一份结构扩展;反过来,模型一开始糊了,后面每个功能都会补洞。
八、后续方向
引用资料管理,将页面文章联动引用,官方文档中的功能还有许多还未引入。
AI 的功能还尚未开发完毕,还有更有趣的玩法,如:AI 撰写自定义组件并且展示在文章,让文章呈现交互性。
整理工具的多样化,让写作过程更顺,但不是把编辑器做成更重的工具。
参考资料
Tiptap 官方文档:https://tiptap.dev/docs/editor/getting-started/overview
Tiptap Markdown 文档:https://tiptap.dev/docs/editor/markdown
Tiptap Bubble Menu 文档:https://tiptap.dev/docs/editor/extensions/functionality/bubble-menu
ProseMirror Guide:https://prosemirror.net/docs/guide/
ProseMirror Reference:https://prosemirror.net/docs/ref/
Liveblocks 的 Tiptap AI Copilot 工程文章:https://liveblocks.io/blog/building-an-ai-copilot-inside-your-tiptap-text-editor