返回 导航

Vue.js

hangge.com

Vue.js - 富文本编辑器Tiptap使用详解2(图片上传功能实现)

作者:hangge | 2026-02-11 08:38
    本文演示如何实现图片上传功能,包括如何在 Tiptap 编辑器中插入图片节点、实现本地文件选择上传、把后端或第三方存储返回的 URL 插入到编辑器。

二、图片上传功能实现

1,添加依赖

(1)首先我们项目总需要安装 Tiptap 基础依赖,具体安装方法见上一篇文章:

(2)此外,我们还需要安装 @tiptap/extension-image 这个扩展依赖:
npm install @tiptap/extension-image

2,功能实现

(1)下面是一个上传图片功能的样例代码,具体流程为:用户选择图片 → 前端将文件上传到后端 /api/upload → 后端返回 { url } → 前端插入图片节点。
这里通过如下方式实现将上传的图片插入到选择位置:
  • 上传图片前,会先在目标位置插入一张占位图片。该占位图片的 alt 属性中写入一个唯一 uploadId(例如 'upload_169...')。
  • 上传完成后通过遍历 editor.state.doc 找到具有该 uploadIdimage 节点并拿到它的文档位置 pos
  • 使用 ProseMirrortr.setNodeMarkup(pos, undefined, newAttrs)(通过 editor.view.dispatch)替换该节点的 attrs,只替换目标节点,不影响其他图片。
<template>
  <div>
    <div class="toolbar" role="toolbar" aria-label="Editor toolbar">
      <button
        @click="$refs.fileInput.click()"
      >上传图片</button>
      <span v-if="uploading">上传中:{{ uploadProgress }}%</span>
    </div>
    <input ref="fileInput" type="file" @change="onFileChange" accept="image/*" 
        style="display:none" />
    <editor-content :editor="editor" class="editor-content" />
  </div>
</template>

<script>
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import { EditorContent } from '@tiptap/vue-2'
import axios from 'axios'

export default {
  components: { EditorContent },
  data() {
    return {
      editor: null,
      uploading: false,
      uploadProgress: 0,
    }
  },
  mounted() {
    this.editor = new Editor({
      extensions: [StarterKit, Image],
      content: '<p>请上传图片测试(修正占位替换逻辑)。</p>',
    })
  },
  methods: {
    onFileChange(e) {
      const file = e.target.files && e.target.files[0]
      if (!file) return
      this.uploadAndInsert(file)
      e.target.value = ''
    },

    // 生成唯一 uploadId
    genUploadId(file) {
      return `upload_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
    },

    // 根据 uploadId 在 doc 中找到 image node 的 pos 和节点本身
    findImageNodeByUploadId(uploadId) {
      let found = null
      // doc.descendants((node, pos) => { ... }) 遍历节点
      this.editor.state.doc.descendants((node, pos) => {
        if (node.type.name === 'image' && node.attrs && node.attrs.alt === uploadId) {
          found = { node, pos }
          return false // 停止遍历
        }
        return true
      })
      return found // { node, pos } 或 null
    },

    // 替换特定 pos 的 image 节点 attrs(只替换该节点)
    replaceImageAttrsAtPos(pos, newAttrs) {
      const { state, view } = this.editor
      const node = state.doc.nodeAt(pos)
      if (!node || node.type.name !== 'image') return false
      // 合并旧 attrs 与新 attrs(保留 uploadId 可选)
      const attrs = Object.assign({}, node.attrs, newAttrs)
      const tr = state.tr.setNodeMarkup(pos, undefined, attrs)
      view.dispatch(tr)
      return true
    },

    async uploadAndInsert(file) {
      // 基本检查
      if (!file.type.startsWith('image/')) {
        alert('请上传图片文件')
        return
      }

      const uploadId = this.genUploadId(file)
      const placeholderSrc =
        'data:image/svg+xml;base64,' +
        btoa(
          `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 50 50">
            <g fill="none" stroke="#999" stroke-width="4" stroke-linecap="round">
                <circle cx="25" cy="25" r="20" stroke-opacity="0.2"/>
                <path d="M45 25a20 20 0 0 0-20-20" transform="translate(0 0)">
                    <animateTransform attributeName="transform" type="rotate" 
                    from="0 25 25" to="360 25 25" dur="1s" repeatCount="indefinite"/>
                </path>
            </g>
        </svg>`
        )

      // 插入占位:把 uploadId 放到 attrs 中
      this.editor.chain().focus().setImage({
        src: placeholderSrc,
        alt: uploadId // 用于后续定位替换
      }).run()

      this.uploading = true
      this.uploadProgress = 0

      try {
        console.log('开始上传', uploadId)
        // 构造 FormData 上传(参照你的后端接口)
        const form = new FormData()
        form.append('file', file)

        const res = await axios.post('/api/upload', form, {
          headers: { 'Content-Type': 'multipart/form-data' },
          // 若提示该方法不存在,需要关闭项目的mockjs功能
          onUploadProgress: (ev) => {
            if (ev.total) this.uploadProgress = Math.round((ev.loaded * 100) / ev.total)
          },
        })

        const url = res.data && (res.data.url || (res.data.data && res.data.data.url))
        if (!url) throw new Error('后端未返回图片 URL')

        // 找到占位 image 的位置
        const found = this.findImageNodeByUploadId(uploadId)
        if (found && typeof found.pos === 'number') {
          // 用真实 url 替换目标节点 attrs(保留或删除 uploadId)
          this.replaceImageAttrsAtPos(found.pos, { src: url, alt: file.name, uploadId: null })
        } else {
          // 若未找到占位(极少数情况),则退而插入真实图片(并可尝试移除最后的占位)
          console.warn('未找到占位节点,直接插入真实图片')
          this.editor.chain().focus().setImage({ src: url, alt: file.name }).run()
        }
      } catch (err) {
        console.error('上传失败', err)
        alert('上传失败:' + (err.message || '请检查网络或后端'))
        // 可选:把占位替换为错误图或删除占位
        // 例如:查找并删除占位节点
        const found = this.findImageNodeByUploadId(uploadId)
        if (found && typeof found.pos === 'number') {
          // 删除该节点:tr.delete(found.pos, found.pos + node.nodeSize)
          const { state, view } = this.editor
          const node = found.node
          const tr = state.tr.delete(found.pos, found.pos + node.nodeSize)
          view.dispatch(tr)
        }
      } finally {
        this.uploading = false
        this.uploadProgress = 0
      }
    },
  },

  beforeDestroy() {
    if (this.editor) this.editor.destroy()
  },
}
</script>

<style scoped>
.toolbar { padding:8px; background:#fafafa; display:flex; gap:6px; flex-wrap:wrap; }
.toolbar button { padding:6px 8px; border:1px solid #eaeaea; 
    background:white; border-radius:4px; cursor:pointer; }
.toolbar button.active { background:#e6f7ff; border-color:#91d5ff; font-weight:600; }
.editor-content { min-height: 200px; padding: 8px; border: 1px solid #eee; border-radius: 6px; }
</style>

(2)运行程序,点击“上传图片”按钮选择需要上传的图片。

(3)选择后会在目标位置显示一个 loading 图标,并且按钮旁边会显示实时进度。

(4)上传成功后,该位置会显示上传的图片。

附:实现直接粘贴图片、拖拽图片进行上传

1,样例代码

    下面对之前的代码做个功能改进,我们除了可用点击“上传图片”按钮选择图片进行上传外。还可以直接将剪切板中的图片粘贴到编辑区进行上传,或者直接拖拽图片文件到编辑区进行上传。
<template>
  <div>
    <div class="toolbar" role="toolbar" aria-label="Editor toolbar">
      <button
        @click="$refs.fileInput.click()"
      >上传图片</button>
      <span v-if="uploading">上传中:{{ uploadProgress }}%</span>
    </div>
    <input ref="fileInput" type="file" @change="onFileChange" accept="image/*" 
        style="display:none" />
    <editor-content :editor="editor" class="editor-content" />
  </div>
</template>

<script>
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import { EditorContent } from '@tiptap/vue-2'
import axios from 'axios'

export default {
  components: { EditorContent },
  data() {
    return {
      editor: null,
      uploading: false,
      uploadProgress: 0,
    }
  },
  mounted() {
    this.editor = new Editor({
      extensions: [StarterKit, Image],
      content: '<p>请上传图片测试(修正占位替换逻辑)。</p>',
    })

    // 可选:支持粘贴图片
    this.editor.on('paste', ({ event }) => {
      const clipboard = event.clipboardData
      if (!clipboard) return
      const items = clipboard.items || []
      for (let i = 0; i < items.length; i++) {
        const item = items[i]
        if (item.type.includes('image')) {
          const file = item.getAsFile()
          if (file) this.uploadAndInsert(file)
        }
      }
    })

    // 可选:支持拖拽图片文件到编辑区
    const dom = this.$el.querySelector('.editor-content')
    dom.addEventListener('drop', (e) => {
      e.preventDefault()
      if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
        const file = e.dataTransfer.files[0]
        if (file.type.startsWith('image/')) this.uploadAndInsert(file)
      }
    })
  },
  methods: {
    onFileChange(e) {
      const file = e.target.files && e.target.files[0]
      if (!file) return
      this.uploadAndInsert(file)
      e.target.value = ''
    },

    // 生成唯一 uploadId
    genUploadId(file) {
      return `upload_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
    },

    // 根据 uploadId 在 doc 中找到 image node 的 pos 和节点本身
    findImageNodeByUploadId(uploadId) {
      let found = null
      // doc.descendants((node, pos) => { ... }) 遍历节点
      this.editor.state.doc.descendants((node, pos) => {
        if (node.type.name === 'image' && node.attrs && node.attrs.alt === uploadId) {
          found = { node, pos }
          return false // 停止遍历
        }
        return true
      })
      return found // { node, pos } 或 null
    },

    // 替换特定 pos 的 image 节点 attrs(只替换该节点)
    replaceImageAttrsAtPos(pos, newAttrs) {
      const { state, view } = this.editor
      const node = state.doc.nodeAt(pos)
      if (!node || node.type.name !== 'image') return false
      // 合并旧 attrs 与新 attrs(保留 uploadId 可选)
      const attrs = Object.assign({}, node.attrs, newAttrs)
      const tr = state.tr.setNodeMarkup(pos, undefined, attrs)
      view.dispatch(tr)
      return true
    },

    async uploadAndInsert(file) {
      // 基本检查
      if (!file.type.startsWith('image/')) {
        alert('请上传图片文件')
        return
      }

      const uploadId = this.genUploadId(file)
      const placeholderSrc =
        'data:image/svg+xml;base64,' +
        btoa(
          `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 50 50">
            <g fill="none" stroke="#999" stroke-width="4" stroke-linecap="round">
                <circle cx="25" cy="25" r="20" stroke-opacity="0.2"/>
                <path d="M45 25a20 20 0 0 0-20-20" transform="translate(0 0)">
                    <animateTransform attributeName="transform" type="rotate" 
                    from="0 25 25" to="360 25 25" dur="1s" repeatCount="indefinite"/>
                </path>
            </g>
        </svg>`
        )

      // 插入占位:把 uploadId 放到 attrs 中
      this.editor.chain().focus().setImage({
        src: placeholderSrc,
        alt: uploadId // 用于后续定位替换
      }).run()

      this.uploading = true
      this.uploadProgress = 0

      try {
        console.log('开始上传', uploadId)
        // 构造 FormData 上传(参照你的后端接口)
        const form = new FormData()
        form.append('file', file)

        const res = await axios.post('/api/upload', form, {
          headers: { 'Content-Type': 'multipart/form-data' },
          // 若提示该方法不存在,需要关闭项目的mockjs功能
          onUploadProgress: (ev) => {
            if (ev.total) this.uploadProgress = Math.round((ev.loaded * 100) / ev.total)
          },
        })

        const url = res.data && (res.data.url || (res.data.data && res.data.data.url))
        if (!url) throw new Error('后端未返回图片 URL')

        // 找到占位 image 的位置
        const found = this.findImageNodeByUploadId(uploadId)
        if (found && typeof found.pos === 'number') {
          // 用真实 url 替换目标节点 attrs(保留或删除 uploadId)
          this.replaceImageAttrsAtPos(found.pos, { src: url, alt: file.name, uploadId: null })
        } else {
          // 若未找到占位(极少数情况),则退而插入真实图片(并可尝试移除最后的占位)
          console.warn('未找到占位节点,直接插入真实图片')
          this.editor.chain().focus().setImage({ src: url, alt: file.name }).run()
        }
      } catch (err) {
        console.error('上传失败', err)
        alert('上传失败:' + (err.message || '请检查网络或后端'))
        // 可选:把占位替换为错误图或删除占位
        // 例如:查找并删除占位节点
        const found = this.findImageNodeByUploadId(uploadId)
        if (found && typeof found.pos === 'number') {
          // 删除该节点:tr.delete(found.pos, found.pos + node.nodeSize)
          const { state, view } = this.editor
          const node = found.node
          const tr = state.tr.delete(found.pos, found.pos + node.nodeSize)
          view.dispatch(tr)
        }
      } finally {
        this.uploading = false
        this.uploadProgress = 0
      }
    },
  },

  beforeDestroy() {
    if (this.editor) this.editor.destroy()
  },
}
</script>

<style scoped>
.toolbar { padding:8px; background:#fafafa; display:flex; gap:6px; flex-wrap:wrap; }
.toolbar button { padding:6px 8px; border:1px solid #eaeaea; 
    background:white; border-radius:4px; cursor:pointer; }
.toolbar button.active { background:#e6f7ff; border-color:#91d5ff; font-weight:600; }
.editor-content { min-height: 200px; padding: 8px; border: 1px solid #eee; border-radius: 6px; }
</style>

2,运行测试

(1)我们可以直接将图片文件拖入到编辑框中进行上传。

(2)或者通过 Ctrl + V 快捷键将复制的图片粘贴到编辑框中上传。
评论

全部评论(0)

回到顶部