Vue.js - 富文本编辑器Tiptap使用详解5(自定义扩展2:上传附件)
作者:hangge | 2026-02-16 10:03
前文我演示了如何实现一个 Mark 类型的 Highlight 扩展,本文接着以实现一个上传附件扩展为例,演示如何实现 Node + Vue NodeView 类型的扩展。具体来说,创建一个 attachment Node,用来在编辑器中插入文件(非图片)条目。这个 Node 通过 VueNodeViewRenderer 渲染一个 Vue 组件作为 NodeView,从而做到“富交互”,不仅可以显示文件名、大小信息,还可以进行选择文件、显示上传状态、打开文件、删除文件等操作。
(2)接着我们创建一个名为 AttachmentNode.vueVue NodeView 组件,其负责显示附件、上传、删除、进度。注意:
(2)运行程序,我们点击工具栏的“添加附件”按钮即可在目标位置添加一个附件占位条目。

五、自定义实现附件上传扩展
1,基本介绍
(1)首先我们需要熟悉如下一些概念:
- Extension:是 Tiptap 的扩展单元,底层基于 ProseMirror。Extension 可是 Node(块或内联内容)、Mark(文本内的格式/注释)或通用扩展(插件/命令/键盘等)。创建扩展时会实现 parseHTML、renderHTML、addCommands()、addAttributes()、addNodeView() 等钩子。
- Mark:用于内联文本样式(加粗、斜体、链接、下划线、highlight 等)。适合“标记”或注释文本。
- Node:文档树的节点(段落、图片、附件、表格、引用、嵌入组件等)。当需要可交互或包含子内容(content)时用 Node。
(2)开发扩展惯用步骤如下:
- 决定类型:Mark(inline)或 Node(block/inline、可含子内容)。
- 设计 schema(属性/attrs、content、group、inline/atom/defining 等)。
- 实现 parseHTML()(如何从 HTML 恢复)与 renderHTML()(如何输出 HTML)。可用 mergeAttributes() 帮助合并 attrs。
- 提供 addCommands() 将常用操作封装成命令,便于在 UI 中调用(editor.chain().focus().yourCommand().run())。
- 如需交互/复杂 UI,使用 NodeView,并在 Vue 中通过 VueNodeViewRenderer 渲染组件。
- 测试:editor.getJSON() / editor.getHTML() 验证文档模型及序列化结果。
2,准备工作
为方便演示和使用,首先我们封装一个用于发起上传请求的 upload.js,其代码如下:
提示:把 useFake 改为 true 可在没有后端时用 base64 模拟上传(方便本地测试)。否则请确保后端 /api/upload 接口返回 { url: 'https://...' }。
// src/services/upload.js
import axios from 'axios'
/**
* uploadFile(file, onProgress)
* - file: File
* - onProgress: (percent:number) => void
* 返回 Promise<{ url, name, size, mime }>
*/
async function uploadFile(file, onProgress) {
// 如果没有后端接口,可把这里替换为 fake upload(演示)
// 判定是否存在 /api/upload(你可改为实际路径)
const useFake = false // 改为 true 可在本地快速测试(不用后端)
if (useFake) {
// fake upload: 等待并返回一个 data url (仅用于 demo)
return new Promise((resolve) => {
const reader = new FileReader()
reader.onload = () => {
// simulate progress
let p = 0
const t = setInterval(() => {
p += 20
onProgress && onProgress(Math.min(100, p))
if (p >= 100) {
clearInterval(t)
resolve({ url: reader.result, name: file.name, size: file.size, mime: file.type })
}
}, 200)
}
reader.readAsDataURL(file)
})
}
const form = new FormData()
form.append('file', file)
const res = await axios.post('/api/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress(progressEvent) {
if (progressEvent.lengthComputable && typeof onProgress === 'function') {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percent)
}
},
})
// 期望后端返回 { url }
if (!res || !res.data) throw new Error('上传接口无响应')
const url = res.data.url || (res.data.data && res.data.data.url)
if (!url) throw new Error('上传未返回 URL,请检查后端')
return { url, name: file.name, size: file.size, mime: file.type }
}
export default { uploadFile }
3,扩展实现
(1)首先我们创建一个名为 attachment.js 的自定义 Node,其内部含 addNodeView() 返回 VueNodeViewRenderer。
import { Node, mergeAttributes } from '@tiptap/core'
import { VueNodeViewRenderer } from '@tiptap/vue-2'
import AttachmentNode from './AttachmentNode.vue' // NodeView 的 Vue 组件
export default Node.create({
name: 'attachment',
group: 'inline', // inline 或 block,视需求调整
inline: true,
atom: true, // 作为一个原子节点(不可编辑内部内容)
selectable: true,
draggable: true,
addAttributes() {
return {
url: { default: null },
name: { default: null },
size: { default: null },
mime: { default: null },
uploaded: { default: true }, // false 表示占位 / 待上传
uploadId: { default: null }, // 可选:临时 id
}
},
parseHTML() {
return [
{
tag: 'a[data-attachment]',
},
]
},
renderHTML({ HTMLAttributes }) {
// 使用 data-attachment 标识,方便 parseHTML 恢复
return [
'a',
mergeAttributes({ 'data-attachment': 'true', href: HTMLAttributes.url
|| '#', target: '_blank', rel: 'noopener noreferrer' }, HTMLAttributes),
0,
]
},
addCommands() {
return {
insertAttachment:
attrs =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs,
})
},
}
},
addNodeView() {
// 使用 VueNodeViewRenderer 渲染 AttachmentNode 组件
return VueNodeViewRenderer(AttachmentNode)
},
})
(2)接着我们创建一个名为 AttachmentNode.vueVue NodeView 组件,其负责显示附件、上传、删除、进度。注意:
- NodeView 组件通过 updateAttributes 修改节点 attrs(这是 NodeView Renderer 提供的回调)。
- deleteNode 有时由渲染器传入;若没有,我们实现了回退删除逻辑。
- 上传使用 uploadService.uploadFile(file, onProgress)。
<template>
<!-- NodeViewWrapper 帮助保证样式和可选的编辑行为 -->
<node-view-wrapper class="attachment-node">
<div class="attachment-main">
<a
v-if="node.attrs.url && node.attrs.uploaded"
:href="node.attrs.url"
class="attachment-link"
target="_blank"
rel="noopener noreferrer"
>
<span class="icon">📎</span>
<span class="meta">
<span class="name">{{ node.attrs.name || '未命名文件' }}</span>
<span class="size" v-if="node.attrs.size">({{ humanSize(node.attrs.size) }})</span>
</span>
</a>
<!-- 未上传完成 / 占位状态 -->
<div v-else class="attachment-placeholder">
<span class="icon">📎</span>
<span class="meta">
<span class="name">{{ node.attrs.name || '请选择待上传文件' }}</span>
<span class="size" v-if="node.attrs.size">({{ humanSize(node.attrs.size) }})</span>
</span>
</div>
<!-- 控制区 -->
<div class="controls">
<input ref="file" type="file" style="display:none" @change="onFileSelected" />
<button v-if="!node.attrs.uploaded" @click="triggerFileSelect" :disabled="uploading">
选择
</button>
<button v-if="node.attrs.uploaded" @click="downloadFile" :disabled="!node.attrs.url">
打开
</button>
<button @click="remove" class="del">删除</button>
</div>
</div>
<!-- 上传进度显示 -->
<div v-if="uploading" class="progress">
<div class="bar" :style="{ width: progress + '%' }"></div>
<div class="percent">{{ progress }}%</div>
</div>
</node-view-wrapper>
</template>
<script>
import { NodeViewWrapper } from '@tiptap/vue-2'
import uploadService from './upload.js'
export default {
name: 'AttachmentNode',
components: { NodeViewWrapper },
props: {
editor: { type: Object, required: true },
node: { type: Object, required: true },
updateAttributes: { type: Function, required: true },
deleteNode: { type: Function }, // 可能存在或不存在(不同版本)
getPos: { type: Function }, // 获取位置(可能用于更底层删除)
},
data() {
return { uploading: false, progress: 0 }
},
methods: {
humanSize(bytes) {
if (!bytes) return ''
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let n = bytes
while (n > 1024 && i < units.length - 1) { n /= 1024; i++ }
return `${n.toFixed(1)} ${units[i]}`
},
triggerFileSelect() {
this.$refs.file && this.$refs.file.click()
},
async onFileSelected(e) {
const file = e.target.files && e.target.files[0]
if (!file) return
this.updateAttributes({
name: file.name,
size: file.size,
mime: file.type
})
await this._uploadFile(file)
e.target.value = ''
},
async _uploadFile(file) {
try {
this.uploading = true
this.progress = 0
// 调用 uploadService 上传,传入 progress 回调
const res = await uploadService.uploadFile(file, (p) => {
this.progress = p
})
// res: { url, name, size, mime }
// 更新 node.attrs(uploaded=true)
this.updateAttributes({
url: res.url,
name: res.name || file.name,
size: res.size || file.size,
mime: res.mime || file.type,
uploaded: true,
})
} catch (err) {
console.error('上传失败', err)
alert('文件上传失败:' + (err.message || '请检查网络'))
} finally {
this.uploading = false
this.progress = 0
}
},
remove() {
// 尝试使用 deleteNode(由 VueNodeViewRenderer 传入)
if (this.deleteNode && typeof this.deleteNode === 'function') {
this.deleteNode()
return
}
// 回退:使用 editor API 在当前位置删除该节点
try {
const pos = (typeof this.getPos === 'function') ? this.getPos() : null
if (pos != null) {
const nodeSize = this.node.nodeSize
const tr = this.editor.state.tr.delete(pos, pos + nodeSize)
this.editor.view.dispatch(tr)
} else {
// fallback: 如果无法定位,尝试使用 select node + delete
this.editor.chain().focus().command(({ tr, state }) => {
// find first attachment node selection and delete it - best effort
let found = false
state.doc.descendants((n, p) => {
if (n.type.name === 'attachment' && !found) {
tr.delete(p, p + n.nodeSize)
found = true
}
})
if (found) this.editor.view.dispatch(tr)
return found
}).run()
}
} catch (e) {
console.error('删除附件失败', e)
}
},
downloadFile() {
if (!this.node.attrs.url) return
window.open(this.node.attrs.url, '_blank')
},
},
}
</script>
<style scoped>
.attachment-node { display:inline-block; padding:6px 8px; border-radius:6px;
background:#f4f5f7; border:1px solid #e6e6e6; margin:4px; }
.attachment-main { display:flex; align-items:center; gap:10px; }
.attachment-link { display:flex; align-items:center; text-decoration:none; color:#333; }
.icon { font-size:18px; margin-right:6px; }
.meta .name { font-weight:600; }
.controls button { margin-left:6px; padding:4px 8px; border-radius:4px;
border:1px solid #ccc; background:#fff; cursor:pointer; }
.controls .del { color:#b00; border-color:#f5c6c6 }
.progress { margin-top:8px; position:relative; height:8px; background:#eee;
border-radius:4px; overflow:hidden; }
.progress .bar { height:100%; background:#4caf50; transition: width .2s linear; }
.progress .percent { position:absolute; right:6px; top:-18px; font-size:12px; color:#666; }
</style>
4,扩展使用
(1)下面是具体使用样例,首先创建 Editor、注册扩展,点击工具栏“添加附件”按钮插入附件占位即可选择文件上传。
<!-- src/components/AttachmentEditor.vue -->
<template>
<div class="editor-container">
<!-- 工具栏 -->
<div class="menu-bar">
<button @click="addAttchment">添加附件</button>
</div>
<editor-content :editor="editor" class="editor-content" />
</div>
</template>
<script>
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { EditorContent } from '@tiptap/vue-2'
import Attachment from './attachment.js'
export default {
components: { EditorContent },
data() {
return { editor: null }
},
mounted() {
this.editor = new Editor({
extensions: [StarterKit, Attachment],
content: '<p>附件演示:点击上方按钮插入附件占位,然后上传。</p>',
})
},
methods: {
addAttchment() {
// 插入占位节点(uploaded: false),并保留 uploadId
const uploadId = `att_${Date.now()}_${Math.random().toString(36).slice(2,8)}`
this.editor.commands.insertAttachment({
url: null,
// name: file.name,
// size: file.size,
// mime: file.type,
uploaded: false,
uploadId,
})
}
},
beforeDestroy() {
if (this.editor) this.editor.destroy()
}
}
</script>
<style scoped>
.editor-container {
border: 1px solid #ccc;
border-radius: 4px;
}
.editor-content {
min-height: 200px;
padding: 12px;
}
.menu-bar {
padding: 8px;
border-bottom: 1px solid #eee;
background:#fafafa;
}
.menu-bar button {
padding: 6px 12px;
border: 1px solid #bbb;
background: #fafafa;
cursor: pointer;
}
.menu-bar button.active {
background: #ffe066;
}
</style>
(2)运行程序,我们点击工具栏的“添加附件”按钮即可在目标位置添加一个附件占位条目。

(3)点击附件占位条目中的“选择”按钮即可弹出文件选择框,选择文件后便会自动上传,同时下方会出现上传进度。

(4)上传成功后则会显示附件的名称和大小,并且可以打开该附件(后端返回的 url 地址),或者删除该附件。

全部评论(0)