likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 40

前言

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

黎明前的错误探索 — file_picker_callback

TinyMCE组件部分初始化代码

<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回调函数中的逻辑

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

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

不足

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

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

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

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

  • editor是编辑器的实例,它提供了一组用于管理编辑器内容、样式和行为的API, 插件可以访问编辑器的实例并与其交互。
  • url是一个包含插件根目录URL的字符串,用于加载插件所需的资源,例如CSSjs文件。`
`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的回调函数
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中注册并挂载我们自定义的插件
<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>

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

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

视频解析

差评 — iframe

`utils.js`

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

实现效果:

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

不足:

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

最优解 — video标签

`utils.js`

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

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

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

word解析

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

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文件:

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

  • 解析结果:

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

pdf解析

  和视频解析第一种方法一样,其实可以使用iframe标签进行解析。但是,产品觉得这样的效果不尽人意,于是无奈舍弃。

pdfjs-dist的引入

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

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

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

封装pdf转图片的类

`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解析方法

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}" src="${url}" ></img>`)
  }
}

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

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

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

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

  因此,问题的关键在于: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文件:

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

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

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

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

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

<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>

valid_elements用于配置富文本编辑器允许使用的html标签

`使用extended_valid_elements配置选项来指定允许的img元素及其属性`

extended_valid_elements: 'img[src|alt|width|height]'

结语

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

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

转载自:https://juejin.cn/post/7262588535340498999
评论
请登录