如何优雅地在TinyMCE编辑器中上传并解析pdf、word以及视频文件
前言
大家好,我是沐浴在曙光下的贰货道士。最近处理对外后台TinyMCE
编辑器优化, 需要上传并解析pdf
、word
和视频文件,并将解析后的结果放在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
回调函数中的逻辑
- 为了能在点击上传图标时选择文件,我们可以先创建一个
type
为file
的input
框,设置它的accept
属性为我们需要上传的文件类型 - 其次,触发它的点击事件,以供我们选择文件
- 然后,我们需要为
input
框添加change
事件。在change
事件中拿到我们需要上传的file
文件,并将它上传到阿里云服务器上,就能获取到该file
文件在阿里云服务器上的地址url
- 紧接着,我们可以根据
file.type
, 判断用户上传的文件类型,封装不同的方法。传入file
和url
作为不同函数方法的参数, 对不同情况进行分类讨论 - 最后,我们使用
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.PluginManager.add
使用TinyMCE
原生link
插件实现的效果差强人意。虽然继续走下去,通过查阅官方文档等方法,肯定能实现我们的业务需求。但通过打补丁写代码的方式,最后写出的代码也定然不会优雅。既然使用原生插件得不到称心如意的结果,那何不换种思路呢?我们可以通过TinyMCE
官方提供的api
,去创造一个TinyMCE
插件。
TinyMCE
官方提供了tinymce.PluginManager.add
这个api
去自定义插件,该方法需要提供两个参数。第一个参数是自定义插件的名称,第二个参数为接收两个参数的回调方法。通过这两个参数,插件可以与编辑器交互,并自定义编辑器的行为和样式。例如,插件可以添加自定义按钮、菜单、工具栏或其他UI
元素,也可以添加自定义命令或事件处理程序,并根据需要加载所需的资源。回调方法参数详解:
editor
是编辑器的实例,它提供了一组用于管理编辑器内容、样式和行为的API
, 插件可以访问编辑器的实例并与其交互。url
是一个包含插件根目录URL
的字符串,用于加载插件所需的资源,例如CSS
或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
的回调函数
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>`)
}
实现效果:
不足:
- 使用
iframe
标签最大的弊端在于:全屏按钮处于禁用状态。如果再去使用一些js
方法去处理,会很费劲。
最优解 — video标签
`utils.js`
function videoHandler(file, path) {
concatContent(`<video controls name="media"><source src="${path}" :type="${file.type}"></video>`)
}
实现效果(全屏按钮可以正常使用了
):
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
文件:
- 解析结果:
pdf
解析
和视频解析第一种方法一样,其实可以使用iframe
标签进行解析。但是,产品觉得这样的效果不尽人意,于是无奈舍弃。
pdfjs-dist
的引入
在安装pdfjs-dist
依赖并使用的过程中,出现了很多奇奇怪怪的问题。网上很多文章都是道听途说,解决不了实际问题。为此,只能用一种比较low
的方式去引入:
- 进入
pdf.js
库对应的github
地址。从历史releases
文件中,找到对应的dist
文件,下载 打包好的PDF.js库 ~ pdfjs-2.9.359-dist.zip - 将解压后的文件放在项目
public
文件夹下
- 在
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
进行解释,注明中的话不正确,可以忽略。
因此,问题的关键在于:canvas
中的dpi
默认是72
, 而每个pdf
的dpi
不尽相同,常见的pdf
页面dpi
包括72、150、300、600
等。像素(电脑显示) = dpi
* 英寸(物理尺寸), 而英寸和厘米之间有转换关系。所以,物理尺寸相同的图片,分辨率越大,像素越大。而不同pdf
的dpi
又普遍高于canvas
的dpi
,因此,相比于原始pdf
文件,使用canvas
导出的图片会较小从而产生模糊的问题。所以,我们需要基于pdf
和canvas
的dpi
比值,对canvas
导出的图片进行等比例放大。但是pdfjs-dist
库并没有提供获取每个pdf
文件dpi
的api
,为了解决这个问题,通过不断调参,给出了一个经验值96
。这个值与72
的比值,即为canvas
的缩放系数。
那我们为什么还要乘上一个缩放系数scale
,然后在编辑器中显示缩放一半的图片呢? 编辑器中默认显示的图片是导出图片的1 / scale
,是为了给业务提供一定的缩放空间,保证图片在scale
范围内不会模糊。由上图也可以发现,最终导出图片的实际dpi
仍是canvas
的dpi
, 即72
。
结果展示:
- 源
pdf
文件:
- 解析结果:
- 缩放一半大小的图片会糊一些,我们手动拖拽放大至小于或等于
scale
的倍数,图片会更加清晰 - 解析的
pdf
页数过多时,随着转换图片数目的增多,编辑器滚动条会一直向下滚动直至解析完成
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