如何优雅地在TinyMCE编辑器中上传并解析pdf、word以及视频文件

前言

  大家好,我是沐浴在曙光下的贰货道士 。最近处理对外后台TinyMCE编辑器优化, 需要上传并解析pdfword和视频文件,并将解析后的结果放在TinyMCE编辑器中。对外后台文章发布后,需要在对外业务端帮助中心回显并展示。为此,我苦心钻研TinyMCE 5的官方文档,最终找到一种较为优雅的方式去实现这个需求,并手把手解释这次编辑器优化的过程。如果掘友们有更好的处理方式,欢迎在评论区指点江山。同时,有喜欢本文的朋友,也欢迎一键三连哦~

黎明前的错误探索 --- file_picker_callback

TinyMCE组件部分初始化代码

js 复制代码
<template>
  <Editor
    class="tinymce" 
    v-model="content"
    :init="init" 
  >
  </Editor>
</template>

<script>
import tinymce from 'tinymce/tinymce'
import 'tinymce/plugins/image'
import 'tinymce/plugins/link'
import 'tinymce/plugins/code'
import 'tinymce/plugins/table'
import 'tinymce/plugins/lists'
import 'tinymce/plugins/fullscreen'
import 'tinymce/plugins/preview'
`引入TinyMCE编辑器图标`
import 'tinymce/icons/default/icons'

export default {
  components: { Editor },
  data() {
    return {
      content: '',
      init: {
        `挂载我们按需引入的插件`
        plugins: 'image link code table lists fullscreen  preview',
        file_picker_callback: function (callback, value, meta) {
          
        }
      }
    }
  }
}
</script>

核心思想

掘友们比较关心的,关于各文件类型的解析思路和源码,会在本文第二小节中明确给出

  • tinymce编辑器初始化时,添加选择文件的回调函数file_picker_callback
  • 添加回调函数,link插件下的模态框就会多出一个上传svg图标。在不写函数逻辑的情况下,此时点击上传图标是没有任何反应的,因为未触发file_picker_callback回调函数中的逻辑
  • 为了能在点击上传图标时选择文件,我们可以先创建一个typefileinput框,设置它的accept属性为我们需要上传的文件类型
  • 其次,触发它的点击事件,以供我们选择文件
  • 然后,我们需要为input框添加change事件。在change事件中拿到我们需要上传的file文件,并将它上传到阿里云服务器上,就能获取到该file文件在阿里云服务器上的地址url
  • 紧接着,我们可以根据file.type, 判断用户上传的文件类型,封装不同的方法。传入fileurl作为不同函数方法的参数, 对不同情况进行分类讨论
  • 最后,我们使用file_picker_callback提供的callback方法,调用回调函数即可。callback方法有两个作用:a. 文件上传完成后,在插入/编辑链接模态框回显上传的文件信息 。其中,url插入/编辑链接模态框回显的地址信息,text插入/编辑链接模态框回显的显示文字信息。b. 点击保存按钮后,会在TinyMCE编辑器中展示一个指向上传文件的链接。
js 复制代码
callback(url, {
   text: file.name
})

不足

  • 在未刷新页面的情况下,每次上传文件后,再次上传文件,插入/编辑链接模态框都会保留上一次上传的文件信息
  • 由于未找到插入/编辑链接模态框保存按钮的回调方法,对于pdf、word以及视频文件的解析,都是在file_picker_callback回调中对content进行插入处理的。这些文件上传完毕后,在未点击保存按钮的情况下,就会将解析的结果,插入到TinyMCE编辑器中光标指定的位置中,这一点是不符合需求的。
  • 在点击保存按钮后,会在TinyMCE编辑器中展示上传文件的链接(也可以在preview预览插件中查看效果 )。这些链接是我们不需要的,因此需要业务人员手动清除(除非手动点击取消按钮,就不会展示蓝色链接)。

最终的抉择 --- 自定义插件tinymce.PluginManager.add

  使用TinyMCE原生link插件实现的效果差强人意。虽然继续走下去,通过查阅官方文档等方法,肯定能实现我们的业务需求。但通过打补丁写代码的方式,最后写出的代码也定然不会优雅。既然使用原生插件得不到称心如意的结果,那何不换种思路呢?我们可以通过TinyMCE官方提供的api,去创造一个TinyMCE插件。   TinyMCE官方提供了tinymce.PluginManager.add这个api去自定义插件,该方法需要提供两个参数。第一个参数是自定义插件的名称,第二个参数为接收两个参数的回调方法。通过这两个参数,插件可以与编辑器交互,并自定义编辑器的行为和样式。例如,插件可以添加自定义按钮、菜单、工具栏或其他UI元素,也可以添加自定义命令或事件处理程序,并根据需要加载所需的资源。 回调方法参数详解:

  • editor是编辑器的实例,它提供了一组用于管理编辑器内容、样式和行为的API, 插件可以访问编辑器的实例并与其交互。
  • url是一个包含插件根目录URL的字符串,用于加载插件所需的资源,例如CSSjs文件。`
js 复制代码
`tinymce官方示例:`

tinymce.PluginManager.add('MyPlugin', (editor, url) => {
  
  `实测发现,text也可以为svg图标`
  editor.ui.registry.addButton('myCustomToolbarButton', {
    text: 'My custom button',
    onAction: () => {
      alert('Button clicked!')
    }
  })
  
  `在TinyMCE5中实测,发现不用return,也可以达到目的`
  return { name: 'MyPlugin', url: 'https://mydocs.com/myplugin' }
})

核心思想

  • editor.vue同级,创建utils.js文件,用于存放自定义插件的一些方法
  • utils.js中,先封装一个方法,用于返回PluginManager.add的回调函数
js 复制代码
import { Loading } from 'element-ui'

`以服务的方式调用,引入loading服务`
`传送门: https://element.eleme.cn/2.13/#/zh-CN/component/loading`
function loadingHandler(option = { fullscreen: true }) {
  const loadingInstance = Loading.service(option)
  return function () {
    loadingInstance.close()
  }
}

`阿里云服务器无法识别名称带有+号的图片,因此需要对+号进行特殊处理,将其转换为%2B`
`该方法接受一个字符串参数src,并将其编码为URI格式,并替换其中的+号字符为%2B`
function parseImgSrc(src) {
  return encodeURI(src).replace(/\+/g, '%2B')
}

`$uploadOSSPics是挂载到全局的变量,用于将文件上传到阿里云上,在此不予以赘述`
`该方法接收一个文件对象参数file,上传成功返回[false, url], 上传失败则返回[true, null]`
async function uploadToOss(file) {
  try {
    const uploadedObj = await $uploadOSSPics([
      {
        files: [file],
        prop: 'imagePath',
        dirPrefix: $ossDirMapWithType['0'],
        uuidPrefix: 'test'
      }
    ])
    if (!uploadedObj) return [true, null]
    const url = `${process.env.VUE_APP_OSS_BASE_URL}${parseImgSrc(uploadedObj.imagePath)}`
    return [false, url]
  } catch {
    return [true, null]
  }
}

`创建自定义accept属性的input框`
function createInput(accept) {
  const input = document.createElement('input')
  input.setAttribute('type', 'file')
  input.setAttribute('accept', accept)
  return input
}

`为上传pdf、word以及视频文件,创建自定义插件的公共方法,并以svg进行展示`
`name: 按钮名称`
`svg: 分别对应pdf、word以及视频文件的图标`
`accept:用于定义input框接收的accept属性`
`callback: 回调方法,用于分别对pdf、word以及视频文件进行解析`
export function createPlugin(name, svg, accept, callback) {
  return function (editor, url) {
    editor.ui.registry.addButton(name, {
      text: svg,
      onAction: function () {
        const input = createInput(accept)
        input.click()
        input.onchange = async function () {
          const file = this.files[0]
          const close = loadingHandler()
          const [err, url] = await uploadToOss(file)
          close()
          if (err) return
          callback(file, url)
        }
      }
    })
  }
}

`调用createPlugin方法,返回PluginManager.add需要的回调函数`
`pdfSvg、videoSvg、wordSvg是从网上找的svg文件:https://www.svgrepo.com/collection/files-types/2`
`pdfHandler, videoHandler, wordHandler会在下文单独讲解`
export const uploadPdfPlugin = createPlugin('uploadPdf', pdfSvg, '.pdf', pdfHandler)
export const uploadVideoPlugin = createPlugin(
  'uploadVideo', 
   videoSvg, 
  '.mp3, .mp4, .avi, .mkv, .wmv、.mov、.flv', 
   videoHandler
)
export const uploadWordPlugin = createPlugin('uploadWord', wordSvg, '.doc, .docx', wordHandler)
  • editor.vue中注册并挂载我们自定义的插件
js 复制代码
<script>
`引入PluginManager.add需要的回调函数`
import { uploadPdfPlugin, uploadVideoPlugin, uploadWordPlugin } from './utils'

`注册插件`
tinymce.PluginManager.add('uploadPdf', uploadPdfPlugin)
tinymce.PluginManager.add('uploadVideo', uploadVideoPlugin)
tinymce.PluginManager.add('uploadWord', uploadWordPlugin)

export default {
  components: { Editor },
  data() {
    return {
      content: '',
      init: {
        `挂载我们自定义和按需引入的插件`
        plugins: 'uploadPdf uploadVideo uploadWord image link code table lists fullscreen preview'
      }
    }
  }
}
</script>

解析到富文本编辑器光标指定位置的公共方法

js 复制代码
function concatContent(addContent = '', wrap = true) {
  const activeEditor = tinymce.activeEditor
  const char = wrap ? '\n' : ''
  activeEditor.insertContent([addContent].join(char))
}

视频解析

差评 --- iframe

js 复制代码
`utils.js`

function videoHandler(file, path) {
  concatContent(`<iframe src="${path}" height="600px"></iframe>`)
}

实现效果:

不足:

  • 使用iframe标签最大的弊端在于:全屏按钮处于禁用状态。如果再去使用一些js方法去处理,会很费劲。

最优解 --- video标签

js 复制代码
`utils.js`

function videoHandler(file, path) {
  concatContent(`<video controls name="media"><source src="${path}" :type="${file.type}"></video>`)
}

实现效果(全屏按钮可以正常使用了):

word解析

  word解析是一个比较复杂的问题,在网上没找到特别好的第三方库。目前使用的插件是mammoth, 只能针对docx文件进行解析。这好像是word解析的通病,有些细节也无法百分百还原word文档的格式。没办法,剩下的就只能交给业务,在富文本编辑器中微调了。

js 复制代码
import mammoth from 'mammoth'

async function wordHandler(file) {
  `创建FileReader`
  const reader = new FileReader()
  `添加文件的加载事件,调用mammoth库,将得到的结果插入到富文本编辑器中`
  reader.onload = async (event) => {
    const arrayBuffer = event.target.result
    const res = await mammoth.convertToHtml({ arrayBuffer })
    if (!res) return
    concatContent(res.value)
  }
  
  `以ArrayBuffer的形式读取文件`
  reader.readAsArrayBuffer(file)
}

结果展示:

  • word文件:
  • 解析结果:

pdf解析

  和视频解析第一种方法一样,其实可以

pdfjs-dist的引入

  在安装pdfjs-dist依赖并使用的过程中,出现了很多奇奇怪怪的问题。网上很多文章都是道听途说,解决不了实际问题。为此,只能用一种比较low的方式去引入:

  • index.html中引入pdf.js文件(代价是会增加网站首屏渲染时间)
html 复制代码
`<%= BASE_URL %>为模板字符串,BASE_URL是Web服务器的根目录`
<script type="text/javascript" src="<%= BASE_URL %>js/pdf/pdf.js"></script>

封装pdf转图片的类

js 复制代码
`pdf2image.js`

`指定PDF.js库中的pdf.worker.js文件的路径,用于在后台解析和渲染PDF文档, 即public下的对应文件`
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf/pdf.worker.js'

export class Pdf2Image {
  constructor(pdfDoc) {
    this.pdfDoc = pdfDoc
  }

  static async open(url) {
    const pdfDoc = await pdfjsLib.getDocument({ url }).promise
    return new Pdf2Image(pdfDoc)
  }
  
  `计算缩放比例`
  static calcScale(page, option) {
    if (option.scale !== undefined) {
      return option.scale
    }
    if (option.width === undefined || option.height === undefined) {
      return 1.0
    }
    const viewport = page.getViewport({ scale: 1.0 })
    return Math.min(option.width / viewport.width, option.height / viewport.height)
  }

  numPages() {
    return this.pdfDoc.numPages
  }
  
  `将第pageNo页的pdf转换为指定格式的图片,option用于添加一些配置`
  `比如设置图片导出格式,设置缩放比例、执行回调函数等`
  async getImageDataUrl(pageNo, option) {
    const page = await this.pdfDoc.getPage(pageNo)
    let scale = 1

    if (option) {
      scale = Pdf2Image.calcScale(page, option)
    }
    option = option || {}
    if (!option.image) {
      `默认导出图片格式为jpeg`
      option.image = 'jpeg'
    }
    `指定pdf页面的视口大小`
    const viewport = page.getViewport({ scale })
    const canvas = document.createElement('canvas')
    const canvasContext = canvas.getContext('2d')
    `设置画布的大小`
    canvas.height = viewport.height
    canvas.width = viewport.width
    `设置画布上下文的大小, 决定了在画布上下文中绘制的图形大小`
    canvasContext.height = viewport.height
    canvasContext.width = viewport.width

    const renderContext = {
      canvasContext,
      viewport
    }
    `将pdf页面渲染为图片并返回promise`
    await page.render(renderContext).promise
    `相比原作者封装的方法,增加了一个回调函数,获取canvas的大小,对后续的图片缩放有帮助
    if (option.callback) option.callback({ canvas })
    `将canvas导出为指定格式的图片`
    switch (option.image) {
      case 'jpeg':
        return canvas.toDataURL('image/jpeg')
      case 'webp':
        return canvas.toDataURL('image/webp')
      default:
        return canvas.toDataURL()
    }
  }
  
  `将整个pdf转换为指定格式的图片`
  async getAllImageDataUrl(option) {
    const pages = []
    const numPages = this.numPages()
    for (let i = 1; i <= numPages; i += 1) {
      const img = await this.getImageDataUrl(i, option)
      pages.push(img)
    }
    return pages
  }
}

export default Pdf2Image

pdf解析方法

js 复制代码
import { Pdf2Image } from '@/utils/utils/pdf2image.js'

`设置常量,分别定义canvas的dpi, pdf的dpi以及图片缩放比例`
const CANVASDPI = 72
const PDFDPI = 96
const SCALE = 2

async function pdfHandler(file, path) {
  const pdf2img = await Pdf2Image.open(path)
  const page = pdf2img.numPages()
  for (let i = 1; i <= page; i++) {
    let c
    const url = await pdf2img.getImageDataUrl(i, {
      scale: PDFDPI / CANVASDPI * SCALE,
      callback: ({ canvas }) => {
        c = canvas
      }
    })
    concatContent(`<img width="${c.width / 2}" height="${c.height / 2}"  data-pdf="pdf" src="${url}" ></img>`)
  }
}

灵魂拷问:pdfHandler中的scale为什么需要这么设置?

  首先解释什么是DPI: DPI是打印机、扫描仪、显示器等设备的分辨率单位,它表示每英寸的点数。在打印机中,DPI表示打印机每英寸可以打印的点数,DPI越高,打印出来的图像就越清晰。借用网上找的图对dpi进行解释,注明中的话不正确,可以忽略。

  因此,问题的关键在于:canvas中的dpi默认是72, 而每个pdfdpi不尽相同,常见的pdf页面dpi包括72、150、300、600等。像素(电脑显示) = dpi * 英寸(物理尺寸), 而英寸和厘米之间有转换关系。所以,物理尺寸相同的图片,分辨率越大,像素越大。而不同pdfdpi又普遍高于canvasdpi,因此,相比于原始pdf文件,使用canvas导出的图片会较小从而产生模糊的问题。所以,我们需要基于pdfcanvasdpi比值,对canvas导出的图片进行等比例放大。但是pdfjs-dist库并没有提供获取每个pdf文件dpiapi,为了解决这个问题,通过不断调参,给出了一个经验值96。这个值与72的比值,即为canvas的缩放系数。

  那我们为什么还要乘上一个缩放系数scale,然后在编辑器中显示缩放一半的图片呢? 编辑器中默认显示的图片是导出图片的1 / scale,是为了给业务提供一定的缩放空间,保证图片在scale范围内不会模糊。由上图也可以发现,最终导出图片的实际dpi仍是canvasdpi, 即72

结果展示:

  • pdf文件:
  • 解析结果:
  1. 缩放一半大小的图片会糊一些,我们手动拖拽放大至小于或等于scale的倍数,图片会更加清晰
  2. 解析的pdf页数过多时,随着转换图片数目的增多,编辑器滚动条会一直向下滚动直至解析完成

content_style用于调整编辑器中的标签样式

js 复制代码
<script>
export default {
  data() {
    return {
      content: '',
      init: {
        content_style: `
          *                         { margin:0;font-size:revert;font-family:"微软雅黑";}
          img                       { max-width:100%; object-fit:contain;}
          video                     { max-width: 100%; }
          iframe                    { width: 100%; }
          p                         { line-height:1.5; margin: 0px; }
          table                     { border:none; border-color:#999; }
          ul                        { list-style: disc; }
          ol                        { list-style: decimal; }
          .mce-object-iframe        { max-width:100%;  margin:0; padding:0; }
        `
      }
    }
  }
}
</script>

结语

往期精彩推荐(强势引流):

  面试不面试,你都必须得掌握的vue知识

  无论如何,你都必须得掌握的JS知识

  无论如何,你都必须得掌握的JS知识(续)

  我的css世界

  什么?都2022年了,你还在一遍又一遍重复写form表单?

  大概就这样吧, 有兴趣的掘友们可以去试试~

相关推荐
Cao_Ron3 分钟前
用 SVG image symbol 画 ECharts graph 圆形节点
前端
hxy06017 分钟前
React-Call让你的React组件像异步函数一样调用
前端·react.js
Hilaku8 分钟前
为什么 AI 写前端时,总是优先选择 React,而不是 Vue?
前端·javascript·程序员
京东云开发者10 分钟前
GPT-5.6、ChatGPT Work 与 Codex 更新食用指南
前端
Cao_Ron21 分钟前
用 ECharts graph 画出卡片式矩形节点
前端
skiyee30 分钟前
换个底座的 Wot Starter 居然让 AI 更懂项目!
前端·ai编程
anyup36 分钟前
这一套绝了!AI 大模型写故事,星云 SDK 驱动 3D 数字人实时演绎
前端·人工智能·aigc
耀耀切克闹灬1 小时前
短链跳转
前端
孟陬1 小时前
高质量全方位的 AI 工具 sse-stuntman — Mock SSE 接口
前端·node.js·openai
Cao_Ron1 小时前
给 ECharts 6 graph 节点叠加角标:从原理到性能优化
前端