简介:Umo Editor是一款基于Vue3和Tiptap的本土化开源文档编辑器,具备分页模式、Markdown语法、富文本编辑、AI创作及页面样式自定义等能力,代码完全开源且支持私有部署,重点解决国内用户对编辑器安全性、可控性与本地化体验的需求。包内为完整的项目源码,共454个文件,核心以Vue单文件组件、TypeScript逻辑代码、SVG图标资源和JSON配置文件构成,辅以Less样式与PNG图片,压缩包整体仅392KB,结构目录清晰便于快速启动和二次开发。目前已有492人学习下载,适合有前端基础的技术人员、博客作者或需要集成在线文档能力的团队学习使用。通过阅读源码,可以深入掌握Tiptap扩展开发、Markdown与富文本协同编辑、分页排版、文档导出打印以及暗色主题切换等核心模块的实现思路,同时可直接将项目私有化部署到自己的服务器,获得完全自主可控的文档编辑方案。
1. 为什么是 Vue3 + Tiptap:Umo Editor 的编辑内核与选型逻辑
很多人看到 Umo Editor 的第一反应是「又一个 Notion 克隆」,但它真正解决的痛点是:在私有部署环境里,把 Markdown 快捷输入、富文本排版、分页预览、AI 续写和 PDF 导出同时放进同一个编辑内核,自己从零搭至少要两个月。Umo Editor 基于 Vue3 和 Tiptap,底层文档模型由 ProseMirror 管理,UI 和应用层由 Vue 组件接管。所以它既能像 Typora 一样用 Markdown 语法直接转格式,又能像 Word 一样分页和设置页面样式,还保留 Tiptap 扩展机制,方便接入自己的 AI 接口和存储。适合正在做在线文档、知识库、CMS 富文本模块的团队二开,也适合想研究 Tiptap 插件机制的前端工程师。
2. Tiptap 扩展机制与富文本编辑器的初始化搭建
2.1 ProseMirror 文档模型与 Tiptap 的封装边界
先理清这一层关系,后面改需求时才知道往哪儿下手。Tiptap 不是从 DOM 上直接读写内容的编辑器,它是 ProseMirror 之上的 Vue 封装。ProseMirror 维护的是一棵符合 schema 约束的文档树,每一次编辑都是一个 transaction:从旧 state 派生出新 state,再同步渲染到 DOM。这个模型带来的直接好处是,撤销、协作、AI 批量写入这类操作都有了稳定的事务层接口,而不是靠对比 DOM diff 去猜用户改了啥。
Umo Editor 选 Tiptap 而不是自己写 contenteditable,核心原因是 schema。比如你允许插入图片节点,那图片的 src、alt、width 这些属性就在 schema 里被声明过了,粘贴外部 HTML 时不符合 schema 的标签会被剥离或降级,从结构上避免了 XSS 和脏数据。这在国内内容管理场景里特别实用,因为从公众号、Word 复制过来的 HTML 经常带大量内联样式和危险标签。
Tiptap 的封装边界也很清晰:文档状态归 ProseMirror,扩展逻辑通过 Extension 注册,界面层完全用 Vue 组件写。也就是说工具栏、气泡菜单、斜杠菜单这些全是 Vue 组件,不涉及 ProseMirror 内部 API。这正好是 Umo Editor 这类项目能快速做本土化的原因——改按钮、改面板、加 AI 对话框,都是在 Vue 层做事,不碰内核。
2.2 从零初始化一个 Vue3 + Tiptap 编辑器实例
如果你已经装好了 Vue3 环境,直接建一个空白组件,下面是最小可用的编辑器实例。
<script setup lang="ts"> import { useEditor, EditorContent } from '@tiptap/vue-3' import StarterKit from '@tiptap/starter-kit' import { onBeforeUnmount } from 'vue' const editor = useEditor({ content: '<h2>从这里开始写</h2><p></p>', extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, codeBlock: { languageClassPrefix: 'language-' }, }), // Umo Editor 的分页、AI、表格等扩展在这里统一注册 ], editorProps: { attributes: { class: 'umo-content', }, }, }) onBeforeUnmount(() => { editor.value?.destroy() }) </script> <template> <EditorContent :editor="editor" /> </template>content是初始文档内容,Tiptap 会按已注册扩展的 schema 解析它。extensions数组决定了编辑器的能力边界,没注册的节点即使 HTML 里有也会被剥掉。editorProps.attributes.class会挂到可编辑区域的 DOM 上,分页样式的容器选择器就是用它来锚定的。组件卸载时调用destroy()释放编辑器事件监听,否则在路由切换或热更新场景下会出现事件泄漏。
拿到 Umo Editor 源码后,你会发现它的扩展集合已经被组织成一层聚合配置,而不是让你自己逐个拼 StarterKit。实际集成时一般只需要替换上面代码里的 extensions 数组,再传入 Umo 暴露的默认配置项,比如工具栏按钮开关、AI 接口地址、分页模式默认值。每个配置项都有默认值,这意味着开箱即用,不需要理解 ProseMirror 细节就能先跑起来。
2.3 StarterKit 裁剪与 Umo 扩展注册的参数对照
StarterKit 是 Tiptap 官方聚合包,里面包含 heading、bulletList、codeBlock、history 等常用扩展。但直接全量引入会有问题:六级标题、链接点击跳转、历史深度这些行为不一定符合中文文档编辑习惯。我在做这类编辑器时习惯先列一张参数对照表再决定去留。
| 配置项 | 默认值 | 场景建议 | 说明 |
|---|---|---|---|
| heading.levels | [1,2,3,4,5,6] | 文档类只留 1-3 级 | 层级太深会导致目录和分页导航混乱 |
| history.depth | 100 | 长文档调到 200 | 单位是事务数,不是步数 |
| codeBlock.languageClassPrefix | language- | 与高亮插件保持一致 | 换成 Shiki 时前缀必须匹配 |
| link.openOnClick | false | 编辑器内禁止跳转 | 防止误点导致文档内容丢失 |
| paragraph | 默认启用 | 保留 | 整个 schema 的根基节点 |
表格里的每一项都有实际意义。比如把 heading 限制在 1-3 级,是因为国内在线文档产品基本只暴露三级标题,超出三级一般用有序列表或加粗替代。link.openOnClick默认关闭是安全考虑,用户正在编辑时误点链接会直接跳走,体验很差,Umo Editor 这类编辑器通常会让用户按住 Ctrl 再点击才跳转。
另一个需要关注的是history.depth。分页模式下,每次图片加载、页面重排都可能产生额外事务,撤销栈会消耗得比普通编辑器快。如果文档里大量插入截图,100 的深度可能不够用。这里可以按团队实际测试结果去调,不需要迷信默认值。
3. Markdown 实时语法、分页模式与复杂节点插入的实现
3.1 input rules 里的 Markdown 语法与表格粘贴处理
Umo Editor 支持 Markdown 语法,工作方式不是「先输 Markdown 再转换」,而是输入过程中实时把语法标记替换为富文本节点。这个机制在 Tiptap 里叫 input rules。它监听每次字符输入,用正则匹配当前行尾的文本,命中后替换为对应节点。
import { Mark, markInputRule } from '@tiptap/core' // 示例:输入 **粗体** 时实时加粗 const BoldMark = Mark.create({ name: 'bold', parseHTML() { return [{ tag: 'strong' }, { tag: 'b' }] }, renderHTML() { return ['strong'] }, addInputRules() { return [ markInputRule({ find: /\*\*([^*]+)\*\*$/, type: this.type, }), ] }, })正则末尾的$是必须的,它把匹配范围锚定到光标所在行尾,避免把正文中间已经存在的**也误判成语法标记。比如一段代码里写了a ** b,如果没有$锚定会触发意外的加粗。中文输入法选词时偶尔会触发 input rule 误匹配,所以实际产品里会做一层「composition 期间不执行 input rules」的判断,防止输入法候选词被编辑器吞掉。
表格的粘贴逻辑比输入规则复杂得多。从 Excel 或网页复制的表格,在剪贴板里是以text/html形式存在的,里面可能是 table 标签,也可能是内联样式堆出来的假表格。只走 Markdown parser 会丢掉合并单元格、宽度等关键信息。常见做法是给 table 节点单独注册 paste rules,优先解析text/html里的真实 table 结构,解析失败再降级为 Markdown 文本。
3.2 分页模式的内核思路与页面样式设置
分页模式是这个编辑器区别于普通 Markdown 编辑器的关键能力。这里的实现难点不在 CSS,而在于内容如何在页面容器之间流动。目前两种主流路线:paged.js 这类真实分页媒体渲染,或者基于 block 切片的软分页。我的建议是软分页,它和编辑器光标的兼容性最好。
分页模式下每个页面容器是一个固定 A4 尺寸的 block。内容按 block 节点切分,逐个测量高度,超过页面阈值就放进下一页容器。图片加载完成后 block 高度会变化,所以必须监听ResizeObserver再触发一次重排,否则会出现图片溢出页面边界或大面积留白。
.umo-page { width: 210mm; min-height: 297mm; margin: 0 auto 16px; padding: 25.4mm 31.7mm; background: var(--umo-surface); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); box-sizing: border-box; break-inside: avoid; }这里有几个参数值得解释。210mm和297mm是 A4 纸的标准尺寸,25.4mm是 Word 默认的一英寸页边距,左右31.7mm则对应常见的 1.25 英寸页边距设置。break-inside: avoid是告诉浏览器不要在段落或列表中间强行断页,这对中文长段落特别重要。页面之间用margin-bottom拉开间距,让编辑态看起来像是纸页悬浮在灰色画布上。
页面样式设置功能对应的就是这些参数的可配置化。用户改页边距,实际改的是渲染层的 padding;用户改纸张大小,改的是 page 容器的宽高。实现时把这几个值抽成响应式配置对象,通过 Vue 的 reactive 注入到样式绑定里,不需要动 ProseMirror 层任何代码。
3.3 数学公式、图片和表格节点的注册与渲染
数学公式是技术文档的刚需。Tiptap 里数学公式需要实现成独立节点,而不是存成纯文本,这样导出时才能区分「这是公式」和「这是一段包含美元符号的文字」。常见约定是$...$表示行内公式,$$...$$表示块级公式。
import { Node, InputRule } from '@tiptap/core' export const InlineMath = Node.create({ name: 'inlineMath', inline: true, group: 'inline', atom: true, addAttributes() { return { expression: { default: '', parseHTML: (el) => el.getAttribute('data-math'), renderHTML: (attrs) => ({ 'data-math': attrs.expression }), }, } }, parseHTML() { return [{ tag: 'span[data-math]' }] }, renderHTML({ node }) { return ['span', { 'data-math': node.attrs.expression }, node.attrs.expression] }, addInputRules() { return [ new InputRule({ find: /\$([^$\s][^$]*)\$$/, handler: ({ state, range, match }) => { const expression = match[1] // dispatch 一个替换事务,把匹配文本替换为 math 节点 }, }), ] }, })渲染公式时通常接 KaTeX,编辑态展示源码还是渲染结果取决于产品偏好。我一般建议编辑态显示源码,预览态渲染公式,因为公式源码里有大量反斜杠,实时渲染的视觉反馈是滞后的,也容易让光标定位产生偏移。
图片节点的坑在粘贴。中国用户习惯直接截图后 Ctrl+V 粘到文档里,但 Tiptap 默认不处理剪贴板文件。需要在扩展里注册handlePaste,读取event.clipboardData.files,将图片转成 base64 或上传得到 URL 后再插入 image 节点。直接粘贴 base64 有个隐患:一张截图可能 2MB,一旦超过浏览器存储配额,文档就打不开了。所以私有部署场景下,图片应当优先走上传接口。
4. AI 创作功能的插件化设计与私有部署链路
4.1 为什么 AI 功能要放进 Tiptap 插件而不是普通 Vue 组件
AI 创作功能最容易做错的地方,是把生成结果作为字符串塞回编辑器。这样做会丢掉光标位置、破坏撤销栈、遇到正在选区替换时还会出现内容错位。问题的根源是:普通 Vue 组件拿不到编辑器的事务层权限。
AI 创作这个场景需要三个能力:读取当前光标附近的上下文、在指定位置插入生成内容、在生成过程中保持用户可以撤销。这三个能力都依赖 ProseMirror 的 state 和 transaction,所以 AI 功能必须做成 Tiptap 插件。界面层仍然是 Vue 组件,但组件只负责展示对话流和触发命令,真正读写文档的是插件里的 command。
组件、命令、插件三者的分工是:Vue 组件渲染 AI 面板;command 接收 prompt 并触发请求;ProseMirror Plugin 负责在生成期间对文档加 loading 标记。这样设计还有一个好处,AI 功能的入口可以有很多个——工具栏按钮、斜杠菜单、快捷键——但底层逻辑只有一份。
4.2 一个最小可用的 AI 续写扩展实现
下面是一个能跑通的 AI 续写 Extension 骨架。它做的事情是:读取光标前面的文本作为上下文,请求后端接口,然后把返回内容逐段插入到当前光标位置。
import { Extension } from '@tiptap/core' import { Plugin, PluginKey } from '@tiptap/pm/state' export interface AiWriteOptions { apiUrl: string locale: 'zh-CN' | 'en-US' maxContextChars: number } export const AiWrite = Extension.create<AiWriteOptions>({ name: 'aiWrite', addOptions() { return { apiUrl: '/api/ai/complete', locale: 'zh-CN', maxContextChars: 600, } }, addCommands() { return { aiWrite: () => ({ editor, tr, dispatch }) => { const { from } = editor.state.selection const start = Math.max(0, from - this.options.maxContextChars) const context = editor.state.doc.textBetween(start, from, '\n') const apiUrl = this.options.apiUrl queueAiCompletion( { prompt: context, locale: this.options.locale }, apiUrl, (chunk) => { editor.chain().focus().insertContent(chunk).run() }, ) return dispatch ? dispatch(tr) : true }, } }, addProseMirrorPlugins() { return [ new Plugin({ key: new PluginKey('aiWrite'), // 生成期间可通过 decorations 在光标处显示 loading 状态 }), ] }, })apiUrl指向后端代理接口,而不是直接请求模型服务商,因为模型密钥不能出现在前端代码里。locale参数会传给后端,由后端决定用中文还是英文 prompt 模板。maxContextChars控制上下文窗口,600 字约等于一般模型单轮输入的性价比区间,太长会拖慢响应,太短则生成内容缺乏上下文连贯性。
流式回填是另一个关键点。常见的做法是用fetch读取流式响应,每拿到一段文本就用insertContent插入一次。
async function queueAiCompletion( payload: { prompt: string; locale: string }, apiUrl: string, onChunk: (text: string) => void, ) { const res = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) const reader = res.body?.getReader() const decoder = new TextDecoder() if (!reader) return while (true) { const { done, value } = await reader.read() if (done) break onChunk(decoder.decode(value, { stream: true })) } }逐段插入比一次性插入体验好得多,用户能看到文字依次生成,心理上更容易接受 AI 输出的节奏。但要注意事务频率,每段都发起独立事务会拖慢渲染。实际处理时可以在前端做缓冲,大概 50ms 内的多个 chunk 合并成一次insertContent,既保持流畅度又不至于压垮编辑器的渲染循环。
4.3 私有部署构建与 API 代理配置
Umo Editor 支持私有部署,这个特性在企业场景里很重要,文档数据不出内网,AI 请求也在内网完成。构建产物是纯静态文件,部署方式和普通 Vue3 项目没有区别。构建命令通常会输出 dist 目录,里面是打包好的静态资源。接下来需要一个 Web 服务器托管这些文件,同时把 AI 和上传接口做反向代理。
server { listen 80; server_name docs.example.local; root /var/www/umo; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html是 SPA 部署的标配,它保证前端路由在刷新时不会 404。proxy_pass把/api/路径下的请求转发给本地后端服务,AI 密钥、文件存储密钥都只存在于后端环境变量里,前端永远接触不到。
这里有一个容易踩的坑:如果后端服务返回的响应体很大,或者是流式响应,nginx 默认配置下可能会缓冲整个响应,导致 AI 输出的第一个字迟迟不出现。碰到这种情况,可以在 location /api/ 里关掉代理缓冲,或者调大缓冲阈值。另外所有走代理的请求都要确认超时时间,AI 生成可能超过默认的 60 秒,需要把proxy_read_timeout调长。
5. 文档导出 PDF、打印样式与 Markdown 迁移的坑
5.1 导出链路的取舍:Markdown 与 HTML
Umo Editor 支持多种导出格式,但要理解这些导出不是同一条代码链路。Markdown 导出是把 ProseMirror 文档树序列化成 AST,再转成 Markdown 文本;HTML 导出是直接调用编辑器实例的getHTML();PDF 导出走的是打印样式加浏览器打印引擎。
// 导出 Markdown 文件 const md = editor.getMarkdown() const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `export-${Date.now()}.md` link.click() URL.revokeObjectURL(url)Blob的 MIME 类型里必须带charset=utf-8,否则 Windows 记事本打开时中文会乱码。URL.revokeObjectURL要在点击下载后立即调用,及时释放内存。editor.getMarkdown()依赖编辑器实例上注册的 Markdown 序列化扩展,如果 Markdown 语法在输入规则里生效但序列化扩展缺失,导出的 Markdown 会丢失表格或公式。
三种导出链路各有适用场景,做选型时按这个思路判断。
| 导出格式 | 生成链路 | 适用场景 | 主要限制 |
|---|---|---|---|
| Markdown | doc 转 AST 再转文本 | Git 管理、二次编辑 | 公式和表格依赖专用序列化器 |
| HTML | editor.getHTML() | 邮件正文、嵌入页面 | 样式依赖编辑器自带 CSS |
| 打印样式 + window.print() | 存档、打印 | 分页控制受浏览器引擎影响 |
5.2 打印样式与分页控制
PDF 导出最省事的路径是把编辑器切到分页模式,然后用window.print()打印当前页面。但默认打印会把编辑器的工具栏、气泡菜单、页面阴影全部打进去,所以必须针对打印媒介单独写一套覆盖样式。
@media print { body { background: #fff !important; } .umo-toolbar, .umo-ai-panel, .umo-sidebar { display: none !important; } .umo-page { width: auto; min-height: auto; margin: 0; box-shadow: none; break-inside: avoid; page-break-after: always; } pre, blockquote, table, img { break-inside: avoid; page-break-inside: avoid; } }display: none把编辑态的交互组件全部隐藏,用户在打印对话框中看到的就是干净的文档。.umo-page去掉了固定宽高和阴影,让它回归正常文档流的页面。page-break-after: always是旧语法,break-inside: avoid是新语法,两者共存是为了兼容不同版本的浏览器内核。图片和表格的break-inside: avoid防止一个元素被截断打印到两页。
打印样式里有一个经常被忽略的点:背景色。编辑器在暗色主题下文字是浅色的,直接打印会把一整页深色背景也打出来,耗费大量墨粉。所以打印样式里必须强制把背景设为白色,文字颜色恢复成深色。如果产品需要支持「打印时保留代码块背景色」,那要加上print-color-adjust: exact,并让用户明确勾选浏览器的背景图形选项。
5.3 导出踩坑:图片路径、中文字体与外部工具依赖
导出 Markdown 时最常踩的坑是图片路径。用户在编辑态用的是相对路径或本地 base64,导出后放到别的地方就打不开。相对路径的处理逻辑一般是:导入时把图片转成标准 URL 或 base64,导出前检查一遍。
const md = editor.getMarkdown() const fixed = md.replace(/!\[(.*?)\]\((.*?)\)/g, (raw, alt, src) => { return src.startsWith('http') ? raw : `})` })这个正则只对 Markdown 导出有效,HTML 导出需要遍历文档里的 img 节点改src属性。toAbsoluteUrl的具体实现取决于你的资源存储位置,私有部署一般指向文件服务域名。注意正则在这里是兜底方案,如果编辑器内部有更好的序列化钩子,建议在序列化阶段就处理好,而不是事后用字符串替换。
中文文档导出的另一个痛点是字体。浏览器打印时中文字体用的是系统字体,不同操作系统的字体回退规则不一样,导致同一个 PDF 在 Windows 和 macOS 上打开字体不一致。做法是在打印样式中显式声明中文字体栈,比如font-family: "Source Han Sans SC", "Microsoft YaHei", sans-serif,同时避免在 CSS 里使用过细的 font-weight,很多中文字体在 300 字重下会糊成一团。
相比在 VSCode 里导出 PDF 需要额外安装 PrinceXML 等外部渲染工具,Umo Editor 这类网页编辑器直接在编辑态做分页渲染,打印时把页面容器交给浏览器,整个链路少了一层外部依赖,部署环境更干净。
6. 多语言资源切换与暗色主题的进阶扩展技巧
6.1 语言包动态加载与业务词条拆分
Umo Editor 的仓库里能看到 zh-CN.json、en-US.json、ru-RU.json 这样的语言包文件,还有 bo.json 这类业务词条配置。多语言切换如果全部用静态 import,会把所有语言的文案一次性打进主包,对编辑器这种需要快速加载首屏的场景不划算。所以语言包要走动态加载。
const locales = import.meta.glob('../locales/*.json') export async function loadLocale(lang: string) { const mod = await locales[`../locales/${lang}.json`]() const messages = mod.default ?? mod document.documentElement.lang = lang return messages }import.meta.glob是 Vite 提供的按需加载机制,它会把匹配到的每个 JSON 文件拆成独立 chunk,只有在loadLocale('ru-RU')被调用时才加载对应的资源。这里的mod.default是处理 JSON 模块的默认导出差异,不同构建工具和配置下 JSON 模块的导出结构不完全一致。
语言包拆分的逻辑也值得注意。bo.json 这类业务词条和 zh-CN.json 这类界面文案分离是有实际好处的:接手团队可以只覆盖业务词条文件完成品牌化定制,而不需要动编辑器核心的界面文案;同时业务词条通常变化频繁,拆分后单独发版不会污染主要语言包。切换语言时还要注意编辑器历史记录里已有的节点属性,比如有些节点渲染时会读取语言包的文案,旧文档需要触发一次重渲染才能生效。
6.2 用 CSS 变量做暗色主题,浮层才能不翻车
暗色主题如果只做在编辑器页面上,AI 对话框、工具栏下拉菜单、气泡菜单等浮层会原形毕露——因为它们通常挂在 body 或编辑器根节点之外的容器里,页面级主题类名无法覆盖到。正确做法是把主题作用域收敛到编辑器根节点,并且所有浮层都挂在根节点内部,这样 CSS 变量才能统一传递。
[data-theme='dark'] { --umo-bg: #1e1e1e; --umo-surface: #252525; --umo-text: rgba(255, 255, 255, 0.86); --umo-border: #3a3a3a; --umo-accent: #4f8cff; } .umo-editor { background: var(--umo-bg); color: var(--umo-text); border-color: var(--umo-border); } .umo-editor .ProseMirror-selectednode { outline-color: var(--umo-accent); }使用style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />