返回 导航

Vue.js

hangge.com

Vue.js - 富文本编辑器Tiptap使用详解5(自定义扩展2:上传附件)

作者:hangge | 2026-02-16 10:03
    前文我演示了如何实现一个 Mark 类型的 Highlight 扩展,本文接着以实现一个上传附件扩展为例,演示如何实现 Node + Vue NodeView 类型的扩展。具体来说,创建一个 attachment Node,用来在编辑器中插入文件(非图片)条目。这个 Node 通过 VueNodeViewRenderer 渲染一个 Vue 组件作为 NodeView,从而做到“富交互”,不仅可以显示文件名、大小信息,还可以进行选择文件、显示上传状态、打开文件、删除文件等操作。

五、自定义实现附件上传扩展

1,基本介绍

(1)首先我们需要熟悉如下一些概念:
  • Extension:是 Tiptap 的扩展单元,底层基于 ProseMirrorExtension 可是 Node(块或内联内容)、Mark(文本内的格式/注释)或通用扩展(插件/命令/键盘等)。创建扩展时会实现 parseHTMLrenderHTMLaddCommands()addAttributes()addNodeView() 等钩子。
  • Mark:用于内联文本样式(加粗、斜体、链接、下划线、highlight 等)。适合“标记”或注释文本。
  • Node:文档树的节点(段落、图片、附件、表格、引用、嵌入组件等)。当需要可交互或包含子内容(content)时用 Node

(2)开发扩展惯用步骤如下:
  • 决定类型:Markinline)或 Nodeblock/inline、可含子内容)。
  • 设计 schema(属性/attrscontentgroupinline/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)

回到顶部