Merge pull request #2724 from wangeditor-team/feature-uploadVideo

上传视频功能
This commit is contained in:
王福朋 2021-01-09 16:37:53 +08:00 committed by GitHub
commit f4525bc72f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1401 additions and 47 deletions

128
examples/upload-video.html Normal file
View File

@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>wangEditor example</title>
<script src="//cdn.jsdelivr.net/npm/xgplayer@2.9.6/browser/index.js" type="text/javascript"></script>
</head>
<body>
<p>
wangEditor demo
</p>
<div id="div1">
<p>欢迎使用 <b>wangEditor</b> 富文本编辑器</p>
<p>测试视频</p>
<p>
<video src="/server/upload-files/测试1-w9b.mp4" controls="controls" style="max-width:100%"></video>
</p>
</div>
<script src="../dist/wangEditor.js"></script>
<script>
// 改为使用var声明才能在window对象上获取到编辑器实例方便e2e测试
var E = window.wangEditor
var editor = new E('#div1')
// 上传地址
editor.config.uploadVideoServer = '/api/upload-video';
// 自定义插入视频
// editor.config.customInsertVideo = (url) => {
// console.log('自定义插入视频---->', url);
// // 演示西瓜视频
// editor.cmd.do('insertHTML', `<p>
// <p contenteditable = false id="mse" style="max-width:100%"></p>
// </p>`)
// let player = new Player({
// id: 'mse',
// url: url
// });
// }
// // 自定义上传
// editor.config.customUploadVideo = (files, insertVideoFn) => {
// console.log('自定义上传---->', files);
// insertVideoFn('/server/upload-files/测试1-w9b.mp4');
// }
// 显示“插入视频”
editor.config.showLinkVideo = true;
// accept
editor.config.uploadVideoAccept = ['mp4'];
// 上传视频的最大体积,默认 1024M
editor.config.uploadVideoMaxSize = 1 * 1024 * 1024 * 1024;
// 自定义上传视频的名称
editor.config.uploadVideoName = '';
// 上传视频自定义参数
editor.config.uploadVideoParams = {
name: 'v2',
};
// 自定义参数拼接到 url 中
editor.config.uploadVideoParamsWithUrl = true;
// 上传视频自定义 header
editor.config.uploadVideoHeaders = {};
// 钩子函数
editor.config.uploadVideoHooks = {
// 上传视频之前
before: function (xhr) {
console.log(xhr)
// 可阻止视频上传
// return {
// prevent: true,
// msg: '需要提示给用户的错误信息'
// }
},
// 视频上传并返回了结果,视频插入已成功
success: function (xhr) {
console.log('success', xhr)
},
// 视频上传并返回了结果,但视频插入时出错了
fail: function (xhr, editor, resData) {
console.log('fail', resData)
},
// 上传视频出错,一般为 http 请求的错误
error: function (xhr, editor, resData) {
console.log('error', xhr, resData)
},
// 上传视频超时
timeout: function (xhr) {
console.log('timeout')
},
// 视频上传并返回了结果,想要自己把视频插入到编辑器中
// 例如服务器端返回的不是 { errno: 0, data: {url: ....} } 这种格式,可使用 customInsert
customInsert: function (insertVideoFn, result) {
// result 即服务端返回的接口
console.log('customInsert', result)
// insertVideoFn 可把视频插入到编辑器,传入视频 src ,执行函数即可
insertVideoFn(result.data.url)
}
};
// 上传视频超时时间 ms 默认5分钟
editor.config.uploadVideoTimeout = 1000 * 60 * 5;
// 跨域带 cookie
editor.config.withVideoCredentials = false;
// 自定义alert
editor.config.customAlert = (s) => {
console.log('customAlert: ' + s)
}
editor.create()
</script>
</body>
</html>

View File

@ -0,0 +1,99 @@
/**
* @description 保存上传的文件
* @author wangfupeng
*/
const os = require('os')
const fs = require('fs')
const path = require('path')
const formidable = require('formidable')
const { objForEach } = require('../util')
const FILE_FOLDER = 'upload-files'
const isWindows = os.type().toLowerCase().indexOf('windows') >= 0
const TMP_FOLDER = 'upload-files-tmp'
/**
* 获取随机数
*/
function getRandom() {
return Math.random().toString(36).slice(-3)
}
/**
* 给文件名加后缀 a.png 转换为 a-123123.png
* @param {string} fileName 文件名
*/
function genRandomFileName(fileName = '') {
// 如 fileName === 'a.123.png'
const r = getRandom()
if (!fileName) return r
const length = fileName.length // 9
const pointLastIndexOf = fileName.lastIndexOf('.') // 5
if (pointLastIndexOf < 0) return `${fileName}-${r}`
const fileNameWithOutExt = fileName.slice(0, pointLastIndexOf) // "a.123"
const ext = fileName.slice(pointLastIndexOf + 1, length) // "png"
return `${fileNameWithOutExt}-${r}.${ext}`
}
/**
* 保存上传的文件
* @param {Object} req request
*/
function saveFiles(req) {
return new Promise((resolve, reject) => {
const videoLinks = []
const form = formidable({ multiples: true })
// windows 系统,处理 rename 报错
if (isWindows) {
const tmpPath = path.resolve(__dirname, '..', '..', TMP_FOLDER) // 在根目录下
if (!fs.existsSync(tmpPath)) {
fs.mkdirSync(tmpPath)
}
form.uploadDir = TMP_FOLDER
}
form.parse(req, function (err, fields, files) {
if (err) {
reject('formidable, form.parse err', err.stack)
}
// 存储视频的文件夹
const storePath = path.resolve(__dirname, '..', FILE_FOLDER)
if (!fs.existsSync(storePath)) {
fs.mkdirSync(storePath)
}
// 遍历所有上传来的视频
objForEach(files, (name, file) => {
console.log('name...', name)
// 视频临时位置
const tempFilePath = file.path
// 视频名称和路径
const fileName = genRandomFileName(name) // 为文件名增加一个随机数,防止同名文件覆盖
console.log('fileName...', fileName)
const fullFileName = path.join(storePath, fileName)
console.log('fullFileName...', fullFileName)
// 将临时文件保存为正式文件
fs.renameSync(tempFilePath, fullFileName)
// 存储链接
const url = `/server/${FILE_FOLDER}/` + fileName
videoLinks.push(url)
})
console.log('videoLinks...', videoLinks)
// 返回结果
resolve({
errno: 0,
data: {
url: videoLinks[0]
},
})
})
})
}
module.exports = saveFiles

View File

@ -5,6 +5,7 @@
const router = require('koa-router')()
const saveFiles = require('./controller/save-file')
const saveVideoFile = require('./controller/saveVideo-file')
router.prefix('/api')
@ -19,4 +20,10 @@ router.post('/upload-img', async function (ctx, next) {
ctx.body = data
})
// 上传视频
router.post('/upload-video', async function (ctx, next) {
const data = await saveVideoFile(ctx.req)
ctx.body = data
})
module.exports = router

View File

@ -141,8 +141,8 @@
}
}
/* 上传图片的 panel 定制样式 */
.w-e-up-img-container {
/* 上传图片、上传视频的 panel 定制样式 */
.w-e-up-img-container, .w-e-up-video-container {
text-align: center;
.w-e-up-btn {

View File

@ -12,7 +12,7 @@ import imageConfig, { UploadImageHooksType } from './image'
import textConfig from './text'
import langConfig from './lang'
import historyConfig from './history'
import videoConfig from './video'
import videoConfig, { UploadVideoHooksType } from './video'
// 字典类型
export type DicType = {
@ -76,6 +76,20 @@ export type ConfigType = {
onlineVideoCheck: Function
onlineVideoCallback: Function
showLinkVideo: Boolean
uploadVideoAccept: string[]
uploadVideoServer: string
uploadVideoMaxSize: number
uploadVideoName: string
uploadVideoParams: DicType
uploadVideoParamsWithUrl: boolean
uploadVideoHeaders: DicType
uploadVideoHooks: UploadVideoHooksType
uploadVideoTimeout: number
withVideoCredentials: boolean
customUploadVideo: Function | null
customInsertVideo: Function | null
}
export type Resource = {

View File

@ -86,6 +86,7 @@ export default {
},
video: {
: '插入视频',
: '上传视频',
},
table: {
: '行',
@ -126,6 +127,15 @@ export default {
: '请替换为支持的图片类型',
: '您插入的网络图片无法识别',
: '您刚才插入的图片链接未通过编辑器校验',
: '插入视频错误',
: '视频链接',
: '不是视频',
: '视频验证未通过',
: '个视频',
: '上传视频超时',
: '上传视频错误',
: '上传视频失败',
: '上传视频返回结果错误',
},
},
},
@ -214,6 +224,7 @@ export default {
},
video: {
: 'insert video',
: 'upload local video',
},
table: {
: 'rows',
@ -255,6 +266,15 @@ export default {
: 'the network picture you inserted is not recognized',
:
'the image link you just inserted did not pass the editor verification',
: 'insert video error',
: 'video link',
: 'is not video',
: 'video validate failed',
: 'videos',
: 'upload video timeout',
: 'upload video error',
: 'upload video failed',
: 'upload video return results error',
},
},
},

View File

@ -3,7 +3,26 @@
* @author hutianhao
*/
import Editor from '../editor/index'
import { EMPTY_FN } from '../utils/const'
import { ResType } from '../menus/video/upload-video'
export type UploadVideoHooksType = {
before?: (
xhr: XMLHttpRequest,
editor: Editor,
files: File[]
) => { prevent: boolean; msg: string } | void
success?: (xhr: XMLHttpRequest, editor: Editor, result: ResType) => void
fail?: (xhr: XMLHttpRequest, editor: Editor, err: ResType | string) => void
error?: (xhr: XMLHttpRequest, editor: Editor) => void
timeout?: (xhr: XMLHttpRequest, editor: Editor) => void
customInsert?: (
inserVideo: (this: Editor, src: string) => void,
result: ResType,
editor: Editor
) => void
}
export default {
// 插入网络视频前的回调函数
@ -13,4 +32,46 @@ export default {
// 插入网络视频成功之后的回调函数
onlineVideoCallback: EMPTY_FN,
// 显示“插入视频”
showLinkVideo: true,
// accept
uploadVideoAccept: ['mp4'],
// 服务端地址
uploadVideoServer: '',
// 上传视频的最大体积,默认 1024M
uploadVideoMaxSize: 1 * 1024 * 1024 * 1024,
// 一次最多上传多少个视频
// uploadVideoMaxLength: 2,
// 自定义上传视频的名称
uploadVideoName: '',
// 上传视频自定义参数
uploadVideoParams: {},
// 自定义参数拼接到 url 中
uploadVideoParamsWithUrl: false,
// 上传视频自定义 header
uploadVideoHeaders: {},
// 钩子函数
uploadVideoHooks: {},
// 上传视频超时时间 ms 默认2个小时
uploadVideoTimeout: 1000 * 60 * 60 * 2,
// 跨域带 cookie
withVideoCredentials: false,
// 自定义上传
customUploadVideo: null,
// 自定义插入视频
customInsertVideo: null,
}

View File

@ -0,0 +1,18 @@
/**
* @description
* @author lichunlin
*/
import Editor from '../../../editor/index'
import bindTooltipVideo from './tooltip-event'
/**
*
* @param editor
*/
function bindEvent(editor: Editor): void {
//Tooltip
bindTooltipVideo(editor)
}
export default bindEvent

View File

@ -0,0 +1,114 @@
/**
* @description tooltip
* @author lichunlin
*/
import $, { DomElement } from '../../../utils/dom-core'
import Tooltip, { TooltipConfType } from '../../menu-constructors/Tooltip'
import Editor from '../../../editor/index'
/**
* Tooltip
*/
export function createShowHideFn(editor: Editor) {
let tooltip: Tooltip | null
const t = (text: string, prefix: string = ''): string => {
return editor.i18next.t(prefix + text)
}
/**
* tooltip
* @param $node
*/
function showVideoTooltip($node: DomElement) {
const conf: TooltipConfType = [
{
$elem: $("<span class='w-e-icon-trash-o'></span>"),
onClick: (editor: Editor, $node: DomElement) => {
// 选中video元素
editor.selection.createRangeByElem($node)
editor.selection.restoreSelection()
editor.cmd.do('delete')
// 返回 true表示执行完之后隐藏 tooltip。否则不隐藏。
return true
},
},
{
$elem: $('<span>100%</span>'),
onClick: (editor: Editor, $node: DomElement) => {
$node.attr('width', '100%')
$node.removeAttr('height')
// 返回 true表示执行完之后隐藏 tooltip。否则不隐藏。
return true
},
},
{
$elem: $('<span>50%</span>'),
onClick: (editor: Editor, $node: DomElement) => {
$node.attr('width', '50%')
$node.removeAttr('height')
// 返回 true表示执行完之后隐藏 tooltip。否则不隐藏。
return true
},
},
{
$elem: $('<span>30%</span>'),
onClick: (editor: Editor, $node: DomElement) => {
$node.attr('width', '30%')
$node.removeAttr('height')
// 返回 true表示执行完之后隐藏 tooltip。否则不隐藏。
return true
},
},
{
$elem: $(`<span>${t('重置')}</span>`),
onClick: (editor: Editor, $node: DomElement) => {
$node.removeAttr('width')
$node.removeAttr('height')
// 返回 true表示执行完之后隐藏 tooltip。否则不隐藏。
return true
},
},
]
tooltip = new Tooltip(editor, $node, conf)
tooltip.create()
}
/**
* tooltip
*/
function hideVideoTooltip() {
// 移除 tooltip
if (tooltip) {
tooltip.remove()
tooltip = null
}
}
return {
showVideoTooltip,
hideVideoTooltip,
}
}
/**
* tooltip
* @param editor
*/
export default function bindTooltipEvent(editor: Editor) {
const { showVideoTooltip, hideVideoTooltip } = createShowHideFn(editor)
// 点击视频元素是,显示 tooltip
editor.txt.eventHooks.videoClickEvents.push(showVideoTooltip)
// 点击其他地方,或者滚动时,隐藏 tooltip
editor.txt.eventHooks.clickEvents.push(hideVideoTooltip)
editor.txt.eventHooks.keyupEvents.push(hideVideoTooltip)
editor.txt.eventHooks.toolbarClickEvents.push(hideVideoTooltip)
editor.txt.eventHooks.menuClickEvents.push(hideVideoTooltip)
editor.txt.eventHooks.textScrollEvents.push(hideVideoTooltip)
// change 时隐藏
editor.txt.eventHooks.changeEvents.push(hideVideoTooltip)
}

View File

@ -4,16 +4,22 @@
*/
import Editor from '../../editor/index'
import { PanelConf } from '../menu-constructors/Panel'
import { PanelConf, PanelTabConf } from '../menu-constructors/Panel'
import { getRandom } from '../../utils/util'
import $ from '../../utils/dom-core'
import UploadVideo from './upload-video'
import { videoRegex } from '../../utils/const'
export default function (editor: Editor, video: string): PanelConf {
const config = editor.config
const uploadVideo = new UploadVideo(editor)
// panel 中需要用到的id
const inputIFrameId = getRandom('input-iframe')
const btnOkId = getRandom('btn-ok')
const i18nPrefix = 'menus.panelMenus.video.'
const inputUploadId = getRandom('input-upload')
const btnStartId = getRandom('btn-local-ok')
const t = (text: string, prefix: string = i18nPrefix): string => {
return editor.i18next.t(prefix + text)
}
@ -64,54 +70,118 @@ export default function (editor: Editor, video: string): PanelConf {
return false
}
const conf = {
// tabs配置
// const fileMultipleAttr = config.uploadVideoMaxLength === 1 ? '' : 'multiple="multiple"'
const tabsConf: PanelTabConf[] = [
{
// tab 的标题
title: editor.i18next.t('menus.panelMenus.video.上传视频'),
tpl: `<div class="w-e-up-video-container">
<div id="${btnStartId}" class="w-e-up-btn">
<i class="w-e-icon-upload2"></i>
</div>
<div style="display:none;">
<input id="${inputUploadId}" type="file" accept="video/*"/>
</div>
</div>`,
events: [
// 触发选择视频
{
selector: '#' + btnStartId,
type: 'click',
fn: () => {
const $file = $('#' + inputUploadId)
const fileElem = $file.elems[0]
if (fileElem) {
fileElem.click()
} else {
// 返回 true 可关闭 panel
return true
}
},
},
// 选择视频完毕
{
selector: '#' + inputUploadId,
type: 'change',
fn: () => {
const $file = $('#' + inputUploadId)
const fileElem = $file.elems[0]
if (!fileElem) {
// 返回 true 可关闭 panel
return true
}
// 获取选中的 file 对象列表
const fileList = (fileElem as any).files
if (fileList.length) {
uploadVideo.uploadVideo(fileList)
}
// 返回 true 可关闭 panel
return true
},
},
],
},
{
// tab 的标题
title: editor.i18next.t('menus.panelMenus.video.插入视频'),
// 模板
tpl: `<div>
<input
id="${inputIFrameId}"
type="text"
class="block"
placeholder="${editor.i18next.t('如')}<iframe src=... ></iframe>"/>
</td>
<div class="w-e-button-container">
<button type="button" id="${btnOkId}" class="right">
${editor.i18next.t('插入')}
</button>
</div>
</div>`,
// 事件绑定
events: [
// 插入视频
{
selector: '#' + btnOkId,
type: 'click',
fn: () => {
// 执行插入视频
const $video = $('#' + inputIFrameId)
let video = $video.val().trim()
// 视频为空,则不插入
if (!video) return
// 对当前用户插入的内容进行判断插入为空或者返回false都停止插入
if (!checkOnlineVideo(video)) return
insertVideo(video)
// 返回 true表示该事件执行完之后panel 要关闭。否则 panel 不会关闭
return true
},
},
],
}, // tab end
]
const conf: PanelConf = {
width: 300,
height: 0,
// panel 中可包含多个 tab
tabs: [
{
// tab 的标题
title: editor.i18next.t('menus.panelMenus.video.插入视频'),
// 模板
tpl: `<div>
<input
id="${inputIFrameId}"
type="text"
class="block"
placeholder="${editor.i18next.t('如')}<iframe src=... ></iframe>"/>
</td>
<div class="w-e-button-container">
<button type="button" id="${btnOkId}" class="right">
${editor.i18next.t('插入')}
</button>
</div>
</div>`,
// 事件绑定
events: [
// 插入视频
{
selector: '#' + btnOkId,
type: 'click',
fn: () => {
// 执行插入视频
const $video = $('#' + inputIFrameId)
let video = $video.val().trim()
tabs: [], // tabs end
}
// 视频为空,则不插入
if (!video) return
// 对当前用户插入的内容进行判断插入为空或者返回false都停止插入
if (!checkOnlineVideo(video)) return
insertVideo(video)
// 返回 true表示该事件执行完之后panel 要关闭。否则 panel 不会关闭
return true
},
},
],
}, // tab end
], // tabs end
// 显示“上传视频”
if (window.FileReader && (config.uploadVideoServer || config.customUploadVideo)) {
conf.tabs.push(tabsConf[0])
}
// 显示“插入视频”
if (config.showLinkVideo) {
conf.tabs.push(tabsConf[1])
}
return conf

View File

@ -9,6 +9,7 @@ import Editor from '../../editor/index'
import PanelMenu from '../menu-constructors/PanelMenu'
import { MenuActive } from '../menu-constructors/Menu'
import createPanelConf from './create-panel-conf'
import bindEvent from './bind-event/index'
class Video extends PanelMenu implements MenuActive {
constructor(editor: Editor) {
@ -18,6 +19,9 @@ class Video extends PanelMenu implements MenuActive {
</div>`
)
super($elem, editor)
// 绑定事件 tootip
bindEvent(editor)
}
/**

View File

@ -0,0 +1,279 @@
/**
* @description
* @author lichunlin
*/
import Editor from '../../editor/index'
import { arrForEach, forEach } from '../../utils/util'
import post from '../../editor/upload/upload-core'
import Progress from '../../editor/upload/progress'
type ResData = {
url: string
}
// 后台返回的格式
export type ResType = {
errno: number | string
data: ResData
}
class UploadVideo {
private editor: Editor
constructor(editor: Editor) {
this.editor = editor
}
/**
*
* @param files
*/
public uploadVideo(files: FileList | File[]): void {
if (!files.length) {
return
}
const editor = this.editor
const config = editor.config
// ------------------------------ i18next ------------------------------
const i18nPrefix = 'validate.'
const t = (text: string): string => {
return editor.i18next.t(i18nPrefix + text)
}
// ------------------------------ 获取配置信息 ------------------------------
// 服务端地址
let uploadVideoServer = config.uploadVideoServer
// 上传视频的最大体积,默认 1024M
const maxSize = config.uploadVideoMaxSize
const uploadVideoMaxSize = maxSize / 1024 / 1024
// 一次最多上传多少个视频
// const uploadVideoMaxLength = config.uploadVideoMaxLength
// 自定义上传视频的名称
const uploadVideoName = config.uploadVideoName
// 上传视频自定义参数
const uploadVideoParams = config.uploadVideoParams
// 自定义参数拼接到 url 中
const uploadVideoParamsWithUrl = config.uploadVideoParamsWithUrl
// 上传视频自定义 header
const uploadVideoHeaders = config.uploadVideoHeaders
// 钩子函数
const uploadVideoHooks = config.uploadVideoHooks
// 上传视频超时时间 ms 默认2个小时
const uploadVideoTimeout = config.uploadVideoTimeout
// 跨域带 cookie
const withVideoCredentials = config.withVideoCredentials
// 自定义上传
const customUploadVideo = config.customUploadVideo
// 格式校验
const uploadVideoAccept = config.uploadVideoAccept
// ------------------------------ 验证文件信息 ------------------------------
const resultFiles: File[] = []
const errInfos: string[] = []
arrForEach(files, file => {
const name = file.name
const size = file.size / 1024 / 1024
// chrome 低版本 name === undefined
if (!name || !size) {
return
}
if (!(uploadVideoAccept instanceof Array)) {
// 格式不是数组
errInfos.push(`${uploadVideoAccept}${t('uploadVideoAccept 不是Array')}`)
return
}
if (
!uploadVideoAccept.some(
item => item === name.split('.')[name.split('.').length - 1]
)
) {
// 后缀名不合法,不是视频
errInfos.push(`${name}${t('不是视频')}`)
return
}
if (uploadVideoMaxSize < size) {
// 上传视频过大
errInfos.push(`${name}${t('大于')} ${uploadVideoMaxSize}M`)
return
}
//验证通过的加入结果列表
resultFiles.push(file)
})
// 抛出验证信息
if (errInfos.length) {
config.customAlert(`${t('视频验证未通过')}: \n` + errInfos.join('\n'), 'warning')
return
}
// 如果过滤后文件列表为空直接返回
if (resultFiles.length === 0) {
config.customAlert(t('传入的文件不合法'), 'warning')
return
}
// ------------------------------ 自定义上传 ------------------------------
if (customUploadVideo && typeof customUploadVideo === 'function') {
customUploadVideo(resultFiles, this.insertVideo.bind(this))
return
}
// 添加视频数据
const formData = new FormData()
resultFiles.forEach((file: File, index: number) => {
let name = uploadVideoName || file.name
if (resultFiles.length > 1) {
// 多个文件时filename 不能重复
name = name + (index + 1)
}
formData.append(name, file)
})
// ------------------------------ 上传视频 ------------------------------
//添加自定义参数 基于有服务端地址的情况下
if (uploadVideoServer) {
// 添加自定义参数
const uploadVideoServerArr = uploadVideoServer.split('#')
uploadVideoServer = uploadVideoServerArr[0]
const uploadVideoServerHash = uploadVideoServerArr[1] || ''
forEach(uploadVideoParams, (key: string, val: string) => {
// 因使用者反应,自定义参数不能默认 encode ,由 v3.1.1 版本开始注释掉
// val = encodeURIComponent(val)
// 第一,将参数拼接到 url 中
if (uploadVideoParamsWithUrl) {
if (uploadVideoServer.indexOf('?') > 0) {
uploadVideoServer += '&'
} else {
uploadVideoServer += '?'
}
uploadVideoServer = uploadVideoServer + key + '=' + val
}
// 第二,将参数添加到 formData 中
formData.append(key, val)
})
if (uploadVideoServerHash) {
uploadVideoServer += '#' + uploadVideoServerHash
}
// 开始上传
const xhr = post(uploadVideoServer, {
timeout: uploadVideoTimeout,
formData,
headers: uploadVideoHeaders,
withCredentials: !!withVideoCredentials,
beforeSend: xhr => {
if (uploadVideoHooks.before)
return uploadVideoHooks.before(xhr, editor, resultFiles)
},
onTimeout: xhr => {
config.customAlert(t('上传视频超时'), 'error')
if (uploadVideoHooks.timeout) uploadVideoHooks.timeout(xhr, editor)
},
onProgress: (percent, e) => {
const progressBar = new Progress(editor)
if (e.lengthComputable) {
percent = e.loaded / e.total
progressBar.show(percent)
}
},
onError: xhr => {
config.customAlert(
t('上传视频错误'),
'error',
`${t('上传视频错误')}${t('服务器返回状态')}: ${xhr.status}`
)
if (uploadVideoHooks.error) uploadVideoHooks.error(xhr, editor)
},
onFail: (xhr, resultStr) => {
config.customAlert(
t('上传视频失败'),
'error',
t('上传视频返回结果错误') + `${t('返回结果')}: ` + resultStr
)
if (uploadVideoHooks.fail) uploadVideoHooks.fail(xhr, editor, resultStr)
},
onSuccess: (xhr, result: ResType) => {
if (uploadVideoHooks.customInsert) {
// 自定义插入视频
uploadVideoHooks.customInsert(this.insertVideo.bind(this), result, editor)
return
}
if (result.errno != '0') {
// 返回格式不对,应该为 { errno: 0, data: [...] }
config.customAlert(
t('上传视频失败'),
'error',
`${t('上传视频返回结果错误')}${t('返回结果')} errno=${result.errno}`
)
if (uploadVideoHooks.fail) uploadVideoHooks.fail(xhr, editor, result)
return
}
// 成功,插入视频
const data = result.data
this.insertVideo(data.url)
// 钩子函数
if (uploadVideoHooks.success) uploadVideoHooks.success(xhr, editor, result)
},
})
if (typeof xhr === 'string') {
// 上传被阻止
config.customAlert(xhr, 'error')
}
}
}
/**
*
* @param url 访
*/
public insertVideo(url: string): void {
const editor = this.editor
const config = editor.config
const i18nPrefix = 'validate.'
const t = (text: string, prefix: string = i18nPrefix): string => {
return editor.i18next.t(prefix + text)
}
// 判断用户是否自定义插入视频
if (!config.customInsertVideo) {
editor.cmd.do(
'insertHTML',
`<p><video src="${url}" controls="controls" style="max-width:100%"></video></p><p><br></p>`
)
} else {
config.customInsertVideo(url)
return
}
// 加载视频
let video: any = document.createElement('video')
video.onload = () => {
video = null
}
video.onerror = () => {
config.customAlert(
t('插入视频错误'),
'error',
`wangEditor: ${t('插入视频错误')}${t('视频链接')} "${url}"${t('下载链接失败')}`
)
video = null
}
video.onabort = () => (video = null)
video.src = url
}
}
export default UploadVideo

View File

@ -54,6 +54,8 @@ type TextEventHooks = {
dropListMenuHoverEvents: (() => void)[]
/** 点击分割线时 */
splitLineEvents: ((e: DomElement) => void)[]
/** 视频点击事件 */
videoClickEvents: ((e: DomElement) => void)[]
}
class Text {
@ -85,6 +87,7 @@ class Text {
menuClickEvents: [],
dropListMenuHoverEvents: [],
splitLineEvents: [],
videoClickEvents: [],
}
}
@ -558,6 +561,27 @@ class Text {
const enterDownEvents = eventHooks.enterDownEvents
enterDownEvents.forEach(fn => fn(e))
})
// 视频 click
$textElem.on('click', (e: Event) => {
// 存储视频
let $video: DomElement | null = null
const target = e.target as HTMLElement
const $target = $(target)
//处理视频点击 简单的video 标签
if ($target.getNodeName() === 'VIDEO') {
// 当前点击的就是视频
e.stopPropagation()
$video = $target
}
if (!$video) return // 没有点击视频,则返回
const videoClickEvents = eventHooks.videoClickEvents
videoClickEvents.forEach(fn => fn($video as DomElement))
})
}
}

View File

@ -0,0 +1,61 @@
/**
* @description Video menu tooltip-event
* @author lichunlin
*/
import createEditor from '../../../helpers/create-editor'
import $ from '../../../../src/utils/dom-core'
import bindTooltipEvent, * as tooltipEvent from '../../../../src/menus/video/bind-event/tooltip-event'
describe('Video menu tooltip-event', () => {
test('绑定 tooltip-event 事件', () => {
const editor = createEditor(document, 'div1')
bindTooltipEvent(editor)
expect(editor.txt.eventHooks.videoClickEvents.length).toBeGreaterThanOrEqual(1)
})
test('调用 createShowHideFn 函数返回显示和隐藏tooltip方法', () => {
const editor = createEditor(document, 'div2')
const fns = tooltipEvent.createShowHideFn(editor)
expect(fns.showVideoTooltip instanceof Function).toBeTruthy()
expect(fns.hideVideoTooltip instanceof Function).toBeTruthy()
})
test('绑定 tooltip-event 事件执行视频点击事件会展示tooltip', () => {
const editor = createEditor(document, 'div4')
bindTooltipEvent(editor)
const videoClickEvents = editor.txt.eventHooks.videoClickEvents
videoClickEvents.forEach(fn => {
fn($(editor.$textElem))
})
expect($('.w-e-tooltip').elems[0]).not.toBeUndefined()
expect($('.w-e-tooltip').elems[0].childNodes.length).toBe(5)
})
test('绑定 tooltip-event 事件执行视频之外的其它点击事件会隐藏tooltip', () => {
const editor = createEditor(document, 'div5')
bindTooltipEvent(editor)
const videoClickEvents = editor.txt.eventHooks.videoClickEvents
const clickEvents = editor.txt.eventHooks.clickEvents
videoClickEvents.forEach(fn => {
fn($('<div></div>'))
})
clickEvents.forEach(fn => {
// @ts-ignore
fn()
})
expect($('#div5 .w-e-tooltip').elems[0]).toBeUndefined()
})
})

View File

@ -0,0 +1,455 @@
/**
* @description upload-video test
* @author lichunlin
*/
import createEditor from '../../../helpers/create-editor'
import mockCmdFn from '../../../helpers/command-mock'
import mockFile from '../../../helpers/mock-file'
import mockXHR from '../../../helpers/mock-xhr'
import Editor from '../../../../src/editor'
import UploadVideo from '../../../../src/menus/video/upload-video'
let editor: Editor
let id = 1
const videoUrl =
'https://stream7.iqilu.com/10339/upload_transcode/202002/18/20200218114723HDu3hhxqIT.mp4'
const uploadVideoServer = 'http://localhost:8881/api/upload-video'
const defaultRes = {
status: 200,
res: JSON.stringify({ data: { url: '' }, errno: 0 }),
}
const mockXHRHttpRequest = (res: any = defaultRes) => {
const mockXHRObject = mockXHR(res)
const mockObject = jest.fn().mockImplementation(() => mockXHRObject)
// @ts-ignore
window.XMLHttpRequest = mockObject
return mockXHRObject
}
const createUploadVideoInstance = (config: any) => {
const editor = createEditor(document, `div${id++}`, '', config)
const uploadVideo = new UploadVideo(editor)
return uploadVideo
}
const mockSupportCommand = () => {
mockCmdFn(document)
document.queryCommandSupported = jest.fn(() => true)
}
const deaultFiles = [{ name: '测试.mp4', size: 512, mimeType: 'video/mp4' }]
const createMockFilse = (fileList: any[] = deaultFiles) => {
const files = fileList.map(file => mockFile(file))
return files.filter(Boolean)
}
describe('upload video', () => {
beforeEach(() => {
editor = createEditor(document, `div${id++}`)
})
test('能够初始化基本的UploadVideo类', () => {
const uploadVideo = new UploadVideo(editor)
expect(uploadVideo.insertVideo instanceof Function).toBeTruthy()
expect(uploadVideo.insertVideo instanceof Function).toBeTruthy()
})
test('调用 insertVideo 可以网编辑器里插入视频', () => {
const uploadVideo = new UploadVideo(editor)
mockSupportCommand()
uploadVideo.insertVideo(videoUrl)
expect(document.execCommand).toBeCalledWith(
'insertHTML',
false,
`<p><video src="${videoUrl}" controls="controls" style="max-width:100%"></video></p><p><br></p>`
)
})
test('调用 insertVideo 可以网编辑器里插入视频,可以监听插入视频回调', () => {
const callback = jest.fn()
const uploadVideo = createUploadVideoInstance({
linkVideoCallback: callback,
})
mockSupportCommand()
uploadVideo.insertVideo(videoUrl)
expect(document.execCommand).toBeCalledWith(
'insertHTML',
false,
`<p><video src="${videoUrl}" controls="controls" style="max-width:100%"></video></p><p><br></p>`
)
})
test('调用 uploadVideo 上传视频', done => {
expect.assertions(1)
const jestFn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
uploadVideoHooks: {
success: jestFn,
},
})
const files = createMockFilse()
const mockXHRObject = mockXHRHttpRequest()
upload.uploadVideo(files)
mockXHRObject.onreadystatechange()
setTimeout(() => {
expect(jestFn).toBeCalled()
done()
}, 1000)
})
test('调用 uploadVideo 上传视频,如果传入的文件为空直接返回', () => {
const upload = new UploadVideo(editor)
const res = upload.uploadVideo([])
expect(res).toBeUndefined()
})
test('调用 uploadVideo 上传视频如果没有配置customUploadVideo, 则必须配置 uploadVideoServer ', () => {
const upload = new UploadVideo(editor)
const files = createMockFilse()
const res = upload.uploadVideo(files)
expect(res).toBeUndefined()
})
test('调用 uploadVideo 上传视频如果文件没有名字或者size为则会被过滤掉', () => {
const fn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
customAlert: fn,
})
const files = createMockFilse([{ name: '', size: 0, mimeType: 'video/mp4' }])
const res = upload.uploadVideo(files)
expect(res).toBeUndefined()
expect(fn).toBeCalledWith('传入的文件不合法', 'warning')
})
test('调用 uploadVideo 上传视频,如果文件非视频,则返回并提示错误信息', () => {
const fn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
customAlert: fn,
})
const files = createMockFilse([{ name: 'test.txt', size: 200, mimeType: 'text/plain' }])
const res = upload.uploadVideo(files)
expect(res).toBeUndefined()
expect(fn).toBeCalledWith('视频验证未通过: \n【test.txt】不是视频', 'warning')
})
test('调用 uploadVideo 上传视频,如果文件体积大小超过配置的大小,则返回并提示错误信息', () => {
const fn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
uploadVideoMaxSize: 5 * 1024 * 1024,
customAlert: fn,
})
const files = createMockFilse([
{ name: 'test.mp4', size: 6 * 1024 * 1024, mimeType: 'video/mp4' },
])
const res = upload.uploadVideo(files)
expect(res).toBeUndefined()
expect(fn).toBeCalledWith(`视频验证未通过: \n【test.mp4】大于 5M`, 'warning')
})
test('调用 uploadVideo 上传视频,如果配置了 customUploadVideo 选项则调用customUploadVideo上传', () => {
const fn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
customUploadVideo: fn,
})
const files = createMockFilse()
const res = upload.uploadVideo(files)
expect(res).toBeUndefined()
expect(fn).toBeCalled()
})
test('调用 uploadVideo 上传视频如果可以配置uploadVideoParamsWithUrl添加query参数', done => {
expect.assertions(1)
const fn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
uploadVideoParams: {
a: 'a',
b: 'b',
},
uploadVideoParamsWithUrl: true,
uploadVideoHooks: {
success: fn,
},
})
const files = createMockFilse()
const mockXHRObject = mockXHRHttpRequest()
upload.uploadVideo(files)
mockXHRObject.onreadystatechange()
setTimeout(() => {
expect(fn).toBeCalled()
done()
})
})
test('调用 uploadVideo 上传视频uploadVideoServer支持hash参数拼接', done => {
expect.assertions(1)
const fn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
uploadVideoParams: {
a: 'a',
b: 'b',
},
uploadVideoParamsWithUrl: true,
uploadVideoHooks: {
success: fn,
},
})
const files = createMockFilse([
{ name: 'test1.mp4', size: 2048, mimeType: 'video/mp4' },
{ name: 'test2.mp4', size: 2048, mimeType: 'video/mp4' },
])
const mockXHRObject = mockXHRHttpRequest()
upload.uploadVideo(files)
mockXHRObject.onreadystatechange()
setTimeout(() => {
expect(fn).toBeCalled()
done()
})
})
test('调用 uploadVideo 上传视频失败会有错误提示并支持配置onError hook', done => {
expect.assertions(2)
const fn = jest.fn()
const alertFn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
uploadVideoHooks: {
error: fn,
},
customAlert: alertFn,
})
const files = createMockFilse()
const mockXHRObject = mockXHRHttpRequest({ status: 500 })
upload.uploadVideo(files)
mockXHRObject.onreadystatechange()
setTimeout(() => {
expect(fn).toBeCalled()
expect(alertFn).toBeCalledWith(
'上传视频错误',
'error',
'上传视频错误,服务器返回状态: 500'
)
done()
})
})
test('调用 uploadVideo 上传视频后数据返回不正常会有错误提示并支持配置onFail hook', done => {
expect.assertions(2)
const fn = jest.fn()
const alertFn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
uploadVideoHooks: {
fail: fn,
},
customAlert: alertFn,
})
const files = createMockFilse()
const mockXHRObject = mockXHRHttpRequest({
status: 200,
res: '{test: 123}',
})
upload.uploadVideo(files)
mockXHRObject.onreadystatechange()
setTimeout(() => {
expect(fn).toBeCalled()
expect(alertFn).toBeCalledWith(
'上传视频失败',
'error',
'上传视频返回结果错误,返回结果: {test: 123}'
)
done()
})
})
test('调用 uploadVideo 上传视频成功后,支持自定义插入视频函数', done => {
expect.assertions(1)
const insertFn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
uploadVideoHooks: {
customInsert: insertFn,
},
})
const files = createMockFilse()
const mockXHRObject = mockXHRHttpRequest()
upload.uploadVideo(files)
mockXHRObject.onreadystatechange()
setTimeout(() => {
expect(insertFn).toBeCalled()
done()
})
})
test('调用 uploadVideo 上传被阻止,会有错误提示', done => {
expect.assertions(2)
const beforFn = jest.fn(() => ({ prevent: true, msg: '阻止发送请求' }))
const alertFn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
uploadVideoHooks: {
before: beforFn,
},
customAlert: alertFn,
})
const files = createMockFilse()
const mockXHRObject = mockXHRHttpRequest()
upload.uploadVideo(files)
mockXHRObject.onreadystatechange()
setTimeout(() => {
expect(beforFn).toBeCalled()
expect(alertFn).toBeCalledWith('阻止发送请求', 'error')
done()
})
})
test('调用 uploadVideo 上传返回的错误码不符合条件会有错误提示并触发fail回调', done => {
expect.assertions(2)
const failFn = jest.fn()
const alertFn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
uploadVideoHooks: {
fail: failFn,
},
customAlert: alertFn,
})
const files = createMockFilse()
const mockXHRObject = mockXHRHttpRequest({
status: 200,
res: { test: 123, errno: -1 },
})
upload.uploadVideo(files)
mockXHRObject.onreadystatechange()
setTimeout(() => {
expect(failFn).toBeCalled()
expect(alertFn).toBeCalledWith(
'上传视频失败',
'error',
'上传视频返回结果错误,返回结果 errno=-1'
)
done()
})
})
test('调用 uploadVideo 上传超时会触发超时回调', done => {
expect.assertions(2)
const timeoutFn = jest.fn()
const alertFn = jest.fn()
const upload = createUploadVideoInstance({
uploadVideoServer,
uploadVideoHooks: {
timeout: timeoutFn,
},
customAlert: alertFn,
})
const files = createMockFilse()
const mockXHRObject = mockXHRHttpRequest()
upload.uploadVideo(files)
mockXHRObject.ontimeout()
setTimeout(() => {
expect(timeoutFn).toBeCalled()
expect(alertFn).toBeCalledWith('上传视频超时', 'error')
done()
})
})
})