Merge pull request #2798 from wangeditor-team/dev

release: v4.6.3
This commit is contained in:
echoLC 2021-01-14 21:02:28 +08:00 committed by GitHub
commit db9b3ad7f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 3038 additions and 409 deletions

View File

@ -0,0 +1,30 @@
import Editor from '../../../../src/editor/index'
describe('test editor.txt.append()', () => {
beforeEach(() => {
cy.visit('/examples/txt-append-function.html')
})
const text = 'test123'
it('editor.txt.append()功能光标位置是否正常', () => {
cy.get('#div1').contains('欢迎使用 wangEditor 富文本编辑器')
cy.getEditor().then((editor: Editor) => {
const menusLen = editor.menus.menuList.length + 1
cy.getByClass('toolbar')
.children()
.should('have.length', menusLen + 1)
})
cy.get('#div1')
.find('.w-e-text-container')
.children()
.first()
.as('Editable')
.should('have.attr', 'contenteditable', 'true')
// 上面部分先确认编辑器是否生成
cy.get('@Editable').type(text)
cy.get('@Editable').contains('123' + text)
})
})

View File

@ -0,0 +1,30 @@
import Editor from '../../../../src/editor/index'
describe('test editor.txt.html()功能', () => {
beforeEach(() => {
cy.visit('/examples/txt-html-function.html')
})
const text = 'test123'
it('editor.txt.html()功能光标位置是否正常', () => {
cy.get('#div1').contains('123456')
cy.getEditor().then((editor: Editor) => {
const menusLen = editor.menus.menuList.length + 1
cy.getByClass('toolbar')
.children()
.should('have.length', menusLen + 1)
})
cy.get('#div1')
.find('.w-e-text-container')
.children()
.first()
.as('Editable')
.should('have.attr', 'contenteditable', 'true')
// 上面部分先确认编辑器是否生成
cy.get('@Editable').type(text)
cy.get('@Editable').contains('456' + text)
})
})

View File

@ -0,0 +1,39 @@
<!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>
<style>
</style>
</head>
<body>
<p>
wangEditor demo
</p>
<div id="div1">
<p>欢迎使用 <b>wangEditor</b> 富文本编辑器</p>
<p>
<img src="http://www.wangeditor.com/imgs/logo.jpeg" />
</p>
</div>
<script src="../dist/wangEditor.js"></script>
<script>
// 改为使用var声明才能在window对象上获取到编辑器实例方便e2e测试
var E = window.wangEditor
var editor = new E('#div1')
editor.config.onchange = function (newHtml) {
console.log('onchange', newHtml)
}
editor.config.showFullScreen = true
editor.create()
editor.txt.append("<p>123</p>")
</script>
</body>
</html>

View File

@ -0,0 +1,39 @@
<!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>
<style>
</style>
</head>
<body>
<p>
wangEditor demo
</p>
<div id="div1">
<p>欢迎使用 <b>wangEditor</b> 富文本编辑器</p>
<p>
<img src="http://www.wangeditor.com/imgs/logo.jpeg" />
</p>
</div>
<script src="../dist/wangEditor.js"></script>
<script>
// 改为使用var声明才能在window对象上获取到编辑器实例方便e2e测试
var E = window.wangEditor
var editor = new E('#div1')
editor.config.onchange = function (newHtml) {
console.log('onchange', newHtml)
}
editor.config.showFullScreen = true
editor.create()
editor.txt.html("<p>123</p><p>456</p>")
</script>
</body>
</html>

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

@ -29,10 +29,10 @@
// 测试如果输入'测试'返回false停止插入
editor.config.onlineVideoCheck = function (video) {
if (video === '测试') {
return '测试禁止插入';
return '测试禁止插入'
}
return true;
return true
}
editor.config.onlineVideoCallback = function (video) {

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

@ -25,6 +25,10 @@ export type UploadImageHooksType = {
}
export default {
// 网络图片校验的配置函数
linkImgCheck: function (src: string): string | boolean {
return true
},
// 显示“插入网络图片”
showLinkImg: true,

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 = {
@ -110,12 +124,6 @@ const defaultConfig = Object.assign(
linkCheck: function (text: string, link: string): string | boolean {
return true
},
},
//网络图片校验的配置函数
{
linkImgCheck: function (src: string): string | boolean {
return true
},
}
)

View File

@ -36,7 +36,7 @@ export default {
: '恢复',
: '撤销',
: '全屏',
: '代办事项',
: '待办事项',
},
dropListMenu: {
: '设置标题',
@ -86,6 +86,7 @@ export default {
},
video: {
: '插入视频',
: '上传视频',
},
table: {
: '行',
@ -126,6 +127,15 @@ export default {
: '请替换为支持的图片类型',
: '您插入的网络图片无法识别',
: '您刚才插入的图片链接未通过编辑器校验',
: '插入视频错误',
: '视频链接',
: '不是视频',
: '视频验证未通过',
: '个视频',
: '上传视频超时',
: '上传视频错误',
: '上传视频失败',
: '上传视频返回结果错误',
},
},
},
@ -164,7 +174,7 @@ export default {
: 'undo',
: 'redo',
: 'fullscreen',
: 'todo',
: 'todo',
},
dropListMenu: {
: 'title',
@ -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

@ -109,12 +109,16 @@ export default class RangeCache extends Cache<[RangeItem, RangeItem]> {
* @param range Range
*/
public set(range: RangeItem | undefined) {
if (range) {
const handle = this.rangeHandle
handle.setStart(...range.start)
handle.setEnd(...range.end)
this.editor.menus.changeActive()
return true
try {
if (range) {
const handle = this.rangeHandle
handle.setStart(...range.start)
handle.setEnd(...range.end)
this.editor.menus.changeActive()
return true
}
} catch (err) {
return false
}
return false
}

View File

@ -179,7 +179,6 @@ class Editor {
public destroy(): void {
// 调用钩子函数
this.beforeDestroyHooks.forEach(fn => fn.call(this))
// 销毁 DOM 节点
this.$toolbarElem.remove()
this.$textContainerElem.remove()

View File

@ -56,10 +56,7 @@ function _bindFocusAndBlur(editor: Editor): void {
if (isToolbar && !isMenu) {
return
}
if (editor.isFocus) {
_blurHandler(editor)
}
_blurHandler(editor)
editor.isFocus = false
} else {
if (!editor.isFocus) {
@ -103,6 +100,7 @@ function _blurHandler(editor: Editor) {
const config = editor.config
const onblur = config.onblur
const currentHtml = editor.txt.html() || ''
editor.txt.eventHooks.onBlurEvents.forEach(fn => fn())
onblur(currentHtml)
}

View File

@ -35,9 +35,12 @@ function initSelection(editor: Editor, newLine?: boolean) {
}
}
editor.selection.createRangeByElem($last, true, true)
editor.selection.createRangeByElem($last, false, true)
if (editor.config.focus) {
editor.selection.restoreSelection()
} else {
// 防止focus=false受其他因素影响
editor.selection.clearWindowSelectionRange()
}
}

View File

@ -54,7 +54,7 @@ class SelectionRangeTopNodes {
* @param $node
*/
private getNextSibling($node: DomElement): DomElement {
return $($node.elems[0].nextSibling)
return $($node.elems[0].nextElementSibling)
}
/**

View File

@ -42,7 +42,7 @@ class SelectionAndRange {
// 获取选区范围的 DOM 元素
const $containerElem = this.getSelectionContainerElem(range)
if (!$containerElem) {
if (!$containerElem?.length) {
// 当 选区范围内没有 DOM元素 则抛出
return
}
@ -223,6 +223,11 @@ class SelectionAndRange {
if (toStart != null) {
// 传入了 toStart 参数,折叠选区。如果没传入 toStart 参数,则忽略这一步
range.collapse(toStart)
if (!toStart) {
this.saveRange(range)
this.editor.selection.moveCursor(elem)
}
}
// 存储 range
@ -233,8 +238,8 @@ class SelectionAndRange {
* ()
* @param $editor
*/
public getSelectionRangeTopNodes(editor: Editor): DomElement[] {
const item = new SelectionRangeTopNodes(editor)
public getSelectionRangeTopNodes(): DomElement[] {
const item = new SelectionRangeTopNodes(this.editor)
item.init()
return item.getSelectionNodes()
}
@ -276,6 +281,16 @@ class SelectionAndRange {
return selection?.anchorOffset
}
/**
* Range,notice:不影响已保存的Range
*/
public clearWindowSelectionRange(): void {
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
}
}
}
export default SelectionAndRange

View File

@ -43,13 +43,16 @@ class BackColor extends DropListMenu implements MenuActive {
const editor = this.editor
const isEmptySelection = editor.selection.isSelectionEmpty()
const $selectionElem = editor.selection.getSelectionContainerElem()?.elems[0]
if ($selectionElem == null) return
const isSpan = $selectionElem?.nodeName.toLowerCase() !== 'p'
const bgColor = $selectionElem?.style.backgroundColor
const isSameColor = hexToRgb(value) === bgColor
if (isEmptySelection) {
if (isSpan && !isSameColor) {
const $elems = editor.selection.getSelectionRangeTopNodes(editor)
const $elems = editor.selection.getSelectionRangeTopNodes()
editor.selection.createRangeByElem($elems[0])
editor.selection.moveCursor($elems[0].elems[0])
}

View File

@ -7,7 +7,7 @@ import Editor from '../../editor/index'
function isActive(editor: Editor): boolean {
const $selectionELem = editor.selection.getSelectionContainerElem()
if (!$selectionELem) {
if (!$selectionELem?.length) {
return false
}
if (

View File

@ -43,12 +43,15 @@ class FontColor extends DropListMenu implements MenuActive {
const editor = this.editor
const isEmptySelection = editor.selection.isSelectionEmpty()
const $selectionElem = editor.selection.getSelectionContainerElem()?.elems[0]
if ($selectionElem == null) return
const isFont = $selectionElem?.nodeName.toLowerCase() !== 'p'
const isSameColor = $selectionElem?.getAttribute('color') === value
if (isEmptySelection) {
if (isFont && !isSameColor) {
const $elems = editor.selection.getSelectionRangeTopNodes(editor)
const $elems = editor.selection.getSelectionRangeTopNodes()
editor.selection.createRangeByElem($elems[0])
editor.selection.moveCursor($elems[0].elems[0])
}

View File

@ -40,12 +40,15 @@ class FontSize extends DropListMenu implements MenuActive {
const isEmptySelection = editor.selection.isSelectionEmpty()
const $selectionElem = editor.selection.getSelectionContainerElem()?.elems[0]
if ($selectionElem == null) return
const isFont = $selectionElem?.nodeName.toLowerCase() !== 'p'
const isSameSize = $selectionElem?.getAttribute('size') === value
if (isEmptySelection) {
if (isFont && !isSameSize) {
const $elems = editor.selection.getSelectionRangeTopNodes(editor)
const $elems = editor.selection.getSelectionRangeTopNodes()
editor.selection.createRangeByElem($elems[0])
editor.selection.moveCursor($elems[0].elems[0])
}

View File

@ -40,12 +40,15 @@ class FontStyle extends DropListMenu implements MenuActive {
const isEmptySelection = editor.selection.isSelectionEmpty()
const $selectionElem = editor.selection.getSelectionContainerElem()?.elems[0]
if ($selectionElem == null) return
const isFont = $selectionElem?.nodeName.toLowerCase() !== 'p'
const isSameValue = $selectionElem?.getAttribute('face') === value
if (isEmptySelection) {
if (isFont && !isSameValue) {
const $elems = editor.selection.getSelectionRangeTopNodes(editor)
const $elems = editor.selection.getSelectionRangeTopNodes()
editor.selection.createRangeByElem($elems[0])
editor.selection.moveCursor($elems[0].elems[0])
}

View File

@ -60,6 +60,15 @@ class Head extends DropListMenu implements MenuActive {
return
}
// 选中内容包含序列code表格分割线时不处理
if (
['OL', 'UL', 'LI', 'TABLE', 'TH', 'TR', 'CODE', 'HR'].indexOf(
$($selectionElem).getNodeName()
) > -1
) {
return
}
editor.cmd.do('formatBlock', value)
// 标题设置成功且不是<p>正文标签就配置大纲id

View File

@ -8,7 +8,6 @@ import { PanelConf, PanelTabConf } from '../menu-constructors/Panel'
import { getRandom } from '../../utils/util'
import $ from '../../utils/dom-core'
import UploadImg from './upload-img'
import { imgRegex } from '../../utils/const'
export default function (editor: Editor): PanelConf {
const config = editor.config
@ -30,29 +29,11 @@ export default function (editor: Editor): PanelConf {
* @param linkImg
*/
function checkLinkImg(src: string): boolean {
//编辑器进行正常校验图片合规则使指针为true不合规为false
let flag = true
if (!imgRegex.test(src)) {
flag = false
}
//查看开发者自定义配置的返回值
const check = config.linkImgCheck(src)
if (check === undefined) {
//用户未能通过开发者的校验,且开发者不希望编辑器提示用户
if (flag === false) console.log(t('您刚才插入的图片链接未通过编辑器校验', 'validate.'))
} else if (check === true) {
//用户通过了开发者的校验
if (flag === false) {
config.customAlert(
`${t('您插入的网络图片无法识别', 'validate.')}${t(
'请替换为支持的图片类型',
'validate.'
)}jpg | png | gif ...`,
'warning'
)
} else return true
} else {
if (check === true) {
return true
} else if (typeof check === 'string') {
//用户未能通过开发者的校验,开发者希望我们提示这一字符串
config.customAlert(check, 'error')
}

View File

@ -62,7 +62,7 @@ class Indent extends DropListMenu implements MenuActive {
if ($selectionElem && editor.$textElem.equal($selectionElem)) {
// 当 当前选区 等于 textElem 时
// 代表 当前选区 可能是一个选择了一个完整的段落或者多个段落
const $elems = editor.selection.getSelectionRangeTopNodes(editor)
const $elems = editor.selection.getSelectionRangeTopNodes()
if ($elems.length > 0) {
$elems.forEach((item: any) => {
operateElement($(item), value, editor)

View File

@ -8,6 +8,9 @@ import $, { DomElement } from '../../utils/dom-core'
import Editor from '../../editor/index'
import { MenuActive } from '../menu-constructors/Menu'
const SPECIAL_NODE_LIST = ['LI']
const SPECIAL_TOP_NODE_LIST = ['UL', 'BLOCKQUOTE']
class Justify extends DropListMenu implements MenuActive {
constructor(editor: Editor) {
const $elem = $(
@ -26,7 +29,7 @@ class Justify extends DropListMenu implements MenuActive {
${editor.i18next.t('menus.dropListMenu.justify.靠左')}
</p>`
),
value: 'justifyLeft',
value: 'left',
},
{
$elem: $(
@ -35,7 +38,7 @@ class Justify extends DropListMenu implements MenuActive {
${editor.i18next.t('menus.dropListMenu.justify.居中')}
</p>`
),
value: 'justifyCenter',
value: 'center',
},
{
$elem: $(
@ -44,7 +47,7 @@ class Justify extends DropListMenu implements MenuActive {
${editor.i18next.t('menus.dropListMenu.justify.靠右')}
</p>`
),
value: 'justifyRight',
value: 'right',
},
{
$elem: $(
@ -53,7 +56,7 @@ class Justify extends DropListMenu implements MenuActive {
${editor.i18next.t('menus.dropListMenu.justify.两端')}
</p>`
),
value: 'justifyFull',
value: 'justify',
},
],
clickHandler: (value: string) => {
@ -71,46 +74,81 @@ class Justify extends DropListMenu implements MenuActive {
const editor = this.editor
const selection = editor.selection
const $selectionElem = selection.getSelectionContainerElem()
// 保存选区
selection.saveRange()
// 定义对齐方式的type
type justifyType = {
[key: string]: string
}
// 数据项
const justifyClass: justifyType = {
justifyLeft: 'left',
justifyCenter: 'center',
justifyRight: 'right',
justifyFull: 'justify',
}
// 获取顶级元素
const $elems = editor.selection.getSelectionRangeTopNodes(editor)
if ($selectionElem) {
// 获取在css中对应style的值
const justifyValue = justifyClass[value]
$elems.forEach((el: DomElement) => {
el.css('text-align', justifyValue)
})
const $elems = editor.selection.getSelectionRangeTopNodes()
if ($selectionElem?.length) {
// list 在chrome下默认多包裹一个 p导致不能通过顶层元素判断所以单独加个判断
if (this.isSpecialNode($selectionElem, $elems[0]) || this.isSpecialTopNode($elems[0])) {
const el = this.getSpecialNodeUntilTop($selectionElem, $elems[0])
if (el == null) return
$(el).css('text-align', value)
} else {
$elems.forEach((el: DomElement) => {
el.css('text-align', value)
})
}
}
//恢复选区
selection.restoreSelection()
}
/**
*
* @param el DomElement
* @param topEl DomElement
*/
private getSpecialNodeUntilTop(el: DomElement, topEl: DomElement) {
let parentNode: Node | null = el.elems[0]
const topNode = topEl.elems[0]
// 可能出现嵌套的情况,所以一级一级向上找,是否是特殊元素
while (parentNode != null) {
if (SPECIAL_NODE_LIST.indexOf(parentNode?.nodeName) !== -1) {
return parentNode
}
// 如果再到 top 元素之前还没找到特殊元素,直接返回元素
if (parentNode.parentNode === topNode) {
return parentNode
}
parentNode = parentNode.parentNode
}
return parentNode
}
/**
*
* @param el DomElement
* @param topEl DomElement
*/
private isSpecialNode(el: DomElement, topEl: DomElement) {
// 如果以后有类似的元素要这样处理,直接修改这个数组即可
const parentNode = this.getSpecialNodeUntilTop(el, topEl)
if (parentNode == null) return false
return SPECIAL_NODE_LIST.indexOf(parentNode.nodeName) !== -1
}
/**
* top
* @param el DomElement
*/
private isSpecialTopNode(topEl: DomElement) {
if (topEl == null) return false
return SPECIAL_TOP_NODE_LIST.indexOf(topEl.elems[0]?.nodeName) !== -1
}
/**
*
* ,active进行高亮否则unActive
* ?
*/
public tryChangeActive(): void {
// const editor = this.editor
// let isjustify = ['justifyCenter', 'justifyRight'].some(e => editor.cmd.queryCommandState(e))
// if (isjustify) {
// this.active()
// } else {
// this.unActive()
// }
}
public tryChangeActive(): void { }
}
export default Justify

View File

@ -47,6 +47,9 @@ class LineHeight extends DropListMenu implements MenuActive {
//恢复焦点
editor.selection.restoreSelection()
const $selectionElem = $(editor.selection.getSelectionContainerElem())
if (!$selectionElem?.length) return
const $selectionAll = $(editor.selection.getSelectionContainerElem())
// let dom:HTMLElement= $selectionElem.elems[0]
let dom: HTMLElement = $(editor.selection.getSelectionStartElem()).elems[0]
@ -275,6 +278,9 @@ class LineHeight extends DropListMenu implements MenuActive {
return
}
let dom: DomElement | HTMLElement = $(editor.selection.getSelectionStartElem())
// 有些情况下 dom 可能为空,比如编辑器初始化
if (dom.length === 0) return
dom = this.getDom(dom.elems[0])
let style: string | null = dom.getAttribute('style') ? dom.getAttribute('style') : ''

View File

@ -7,7 +7,7 @@ import Editor from '../../editor/index'
function isActive(editor: Editor): boolean {
const $selectionELem = editor.selection.getSelectionContainerElem()
if (!$selectionELem) {
if (!$selectionELem?.length) {
return false
}
if ($selectionELem.getNodeName() === 'A') {

View File

@ -0,0 +1,90 @@
import { ContainerFragment } from '.'
import $, { DomElement } from '../../../utils/dom-core'
import { Exec, HandlerListOptions, ListHandle } from './ListHandle'
import {
filterSelectionNodes,
getEndPoint,
insertBefore,
createElement,
createDocumentFragment,
createElementFragment,
} from '../utils'
export default class EndJoinListHandle extends ListHandle implements Exec {
constructor(options: HandlerListOptions) {
super(options)
}
exec(): void {
const { editor, listType, listTarget, $endElem } = this.options
// 容器 - HTML 文档片段
let $containerFragment: ContainerFragment
// 获取选中的段落
const $nodes: DomElement[] = editor.selection.getSelectionRangeTopNodes()
// 获取结束段落标签名
const endNodeName = $endElem?.getNodeName()
// 弹出 结束序列
$nodes.pop()
// 下序列元素数组
const lowerListElems: DomElement[] = []
// 获取结束元素
let $endDom: DomElement = getEndPoint($endElem)
// 获取下半序列中选中的内容
while ($endDom.length) {
lowerListElems.unshift($endDom)
$endDom = $endDom.prev()
}
// =====================================
// 当前序列类型和结束序列的类型 一致
// 代表当前是一个 融合(把其他段落加入到结束序列中) 的操作
// =====================================
if (endNodeName === listType) {
// 生成 li 元属,并删除原来的 dom 元素
$containerFragment = createElementFragment(
filterSelectionNodes($nodes), // 过滤元素节点数据
createDocumentFragment() // 创建 文档片段
)
lowerListElems.forEach($list => $containerFragment.append($list.elems[0]))
// 插入到结束序列之前
this.selectionRangeElem.set($containerFragment)
if ($endElem.children()?.length) {
const $endElemChild = $endElem.children() as DomElement
insertBefore($endElemChild, $containerFragment, $endElemChild.elems[0])
} else {
$endElem.elems[0].append($containerFragment)
}
}
// =====================================
// 当前序列类型和结束序列的类型 不一致
// 代表当前是一个 设置序列 的操作
// =====================================
else {
// 过滤元素节点数据
const $selectionNodes = filterSelectionNodes($nodes)
// 把下序列的内容添加到过滤元素中
$selectionNodes.push(...lowerListElems)
// 生成 li 元素并且添加到序列节点后删除原节点
$containerFragment = createElementFragment(
$selectionNodes,
createElement(listTarget) // 创建 序列节点
)
// 插入到结束序列之前
this.selectionRangeElem.set($containerFragment)
$($containerFragment).insertBefore($endElem)
// 序列全选被掏空了后,就卸磨杀驴吧
!$endElem.children()?.length && $endElem.remove()
}
}
}

View File

@ -0,0 +1,259 @@
import { ContainerFragment } from '.'
import $, { DomElement } from '../../../utils/dom-core'
import { Exec, HandlerListOptions, ListHandle } from './ListHandle'
import {
filterSelectionNodes,
getStartPoint,
getEndPoint,
insertBefore,
createElement,
createDocumentFragment,
createElementFragment,
} from '../utils'
export default class JoinListHandle extends ListHandle implements Exec {
constructor(options: HandlerListOptions) {
super(options)
}
exec(): void {
const { editor, listType, listTarget, $startElem, $endElem } = this.options
// 容器 - HTML 文档片段
let $containerFragment: ContainerFragment
// 获取选中的段落
const $nodes: DomElement[] = editor.selection.getSelectionRangeTopNodes()
// 获取开始段落和结束段落 标签名
const startNodeName = $startElem?.getNodeName()
const endNodeName = $endElem?.getNodeName()
// =====================================
// 开头结尾都是序列的情况下
// 开头序列 和 结尾序列的标签名一致的时候
// =====================================
if (startNodeName === endNodeName) {
// =====================================
// 开头序列 和 结尾序列 中间还有其他的段落的时候
// =====================================
if ($nodes.length > 2) {
// 弹出 开头 和 结尾
$nodes.shift()
$nodes.pop()
// 把中间部分的节点元素转换成 li 元素并添加到文档片段后删除
$containerFragment = createElementFragment(
filterSelectionNodes($nodes), // 过滤 $nodes 获取到符合要求的选中元素节点
createDocumentFragment() // 创建 文档片段
)
// =====================================
// 由于开头序列 和 结尾序列的标签名一样,所以只判断了开头序列的
// 当开头序列的标签名和按钮类型 一致 的时候
// 代表着当前是一个 设置序列 的操作
// =====================================
if (startNodeName === listType) {
// 把结束序列的 li 元素添加到 文档片段中
$endElem.children()?.forEach(($list: HTMLElement) => {
$containerFragment.append($list)
})
// 下序列全选被掏空了,就卸磨杀驴吧
$endElem.remove()
// 在开始序列中添加 文档片段
this.selectionRangeElem.set($containerFragment)
$startElem.elems[0].append($containerFragment)
}
// =====================================
// 由于开头序列 和 结尾序列的标签名一样,所以只判断了开头序列的
// 当开头序列的标签名和按钮类型 不一致 的时候
// 代表着当前是一个 转换序列 的操作
// =====================================
else {
// 创建 开始序列和结束序列的文档片段
const $startFragment = document.createDocumentFragment()
const $endFragment = document.createDocumentFragment()
// 获取起点元素
let $startDom: DomElement = getStartPoint($startElem)
// 获取上半序列中的选中内容,并添加到文档片段中
while ($startDom.length) {
const _element = $startDom.elems[0]
$startDom = $startDom.next()
$startFragment.append(_element)
}
// 获取结束元素
let $endDom: DomElement = getEndPoint($endElem)
// 获取下半序列中选中的内容
const domArr: Element[] = []
while ($endDom.length) {
domArr.unshift($endDom.elems[0])
$endDom = $endDom.prev()
}
// 添加到文档片段中
domArr.forEach(($node: Element) => {
$endFragment.append($node)
})
// 合并文档片段
const $orderFragment = createElement(listTarget)
$orderFragment.append($startFragment)
$orderFragment.append($containerFragment)
$orderFragment.append($endFragment)
$containerFragment = $orderFragment
// 插入
this.selectionRangeElem.set($containerFragment)
$($orderFragment).insertAfter($startElem)
// 序列全选被掏空了后,就卸磨杀驴吧
!$startElem.children()?.length && $startElem.remove()
!$endElem.children()?.length && $endElem.remove()
}
}
// =====================================
// 开头序列 和 结尾序列 中间没有其他的段落
// =====================================
else {
$nodes.length = 0
// 获取起点元素
let $startDom: DomElement = getStartPoint($startElem)
// 获取上半序列中的选中内容
while ($startDom.length) {
$nodes.push($startDom)
$startDom = $startDom.next()
}
// 获取结束元素
let $endDom: DomElement = getEndPoint($endElem)
// 获取下半序列中选中的内容
const domArr: DomElement[] = []
// 获取下半序列中的选中内容
while ($endDom.length) {
domArr.unshift($endDom)
$endDom = $endDom.prev()
}
// 融合内容
$nodes.push(...domArr)
// =====================================
// 由于开头序列 和 结尾序列的标签名一样,所以只判断了开头序列的
// 当开头序列的标签名和按钮类型 一致 的时候
// 代表着当前是一个 取消序列 的操作
// =====================================
if (startNodeName === listType) {
// 创建 文档片段
// 把 li 转换为 p 标签
$containerFragment = createElementFragment(
$nodes,
createDocumentFragment(),
'p'
)
// 插入到 endElem 前
this.selectionRangeElem.set($containerFragment)
insertBefore($startElem, $containerFragment, $endElem.elems[0])
}
// =====================================
// 由于开头序列 和 结尾序列的标签名一样,所以只判断了开头序列的
// 当开头序列的标签名和按钮类型 不一致 的时候
// 代表着当前是一个 设置序列 的操作
// =====================================
else {
// 创建 序列元素
$containerFragment = createElement(listTarget)
// li 元素添加到 序列元素 中
$nodes.forEach(($list: DomElement) => {
$containerFragment.append($list.elems[0])
})
// 插入到 startElem 之后
this.selectionRangeElem.set($containerFragment)
$($containerFragment).insertAfter($startElem)
}
// 序列全选被掏空了后,就卸磨杀驴吧
!$startElem.children()?.length && $endElem.remove()
!$endElem.children()?.length && $endElem.remove()
}
}
// =====================================
// 由于开头序列 和 结尾序列的标签名不一样
// =====================================
else {
// 下序列元素数组
const lowerListElems: DomElement[] = []
// 获取结束元素
let $endDom: DomElement = getEndPoint($endElem)
// 获取下半序列中选中的内容
while ($endDom.length) {
lowerListElems.unshift($endDom)
$endDom = $endDom.prev()
}
// 上序列元素数组
const upperListElems: DomElement[] = []
// 获取起点元素
let $startDom: DomElement = getStartPoint($startElem)
// 获取上半序列中的选中内容,并添加到文档片段中
while ($startDom.length) {
upperListElems.push($startDom)
$startDom = $startDom.next()
}
// 创建 文档片段
$containerFragment = createDocumentFragment()
// 弹出开头和结尾的序列
$nodes.shift()
$nodes.pop()
// 把头部序列的内容添加到文档片段当中
upperListElems.forEach($list => $containerFragment.append($list.elems[0]))
// 生成 li 标签,并且添加到 文档片段中,删除无用节点
$containerFragment = createElementFragment(
filterSelectionNodes($nodes), // 序列中间的数据 - 进行数据过滤
$containerFragment
)
// 把尾部序列的内容添加到文档片段当中
lowerListElems.forEach($list => $containerFragment.append($list.elems[0]))
// 记录
this.selectionRangeElem.set($containerFragment)
// =====================================
// 开头序列 和 设置序列类型相同
// =====================================
if (startNodeName === listType) {
// 插入到 开始序列的尾部(作为子元素)
$startElem.elems[0].append($containerFragment)
// 序列全选被掏空了后,就卸磨杀驴吧
!$endElem.children()?.length && $endElem.remove()
}
// =====================================
// 结尾序列 和 设置序列类型相同
// =====================================
else {
// 插入到结束序列的顶部(作为子元素)
if ($endElem.children()?.length) {
const $endElemChild = $endElem.children() as DomElement
insertBefore($endElemChild, $containerFragment, $endElemChild.elems[0])
} else {
$endElem.elems[0].append($containerFragment)
}
}
}
}
}

View File

@ -0,0 +1,26 @@
import Editor from '../../../editor'
import { DomElement } from '../../../utils/dom-core'
import SelectionRangeElem from '../SelectionRangeElem'
export type HandlerListOptions = {
editor: Editor
listType: string
listTarget: string
$selectionElem: DomElement
$startElem: DomElement
$endElem: DomElement
}
export interface Exec {
exec: Function
}
export class ListHandle {
public options: HandlerListOptions
public selectionRangeElem: SelectionRangeElem
constructor(options: HandlerListOptions) {
this.options = options
this.selectionRangeElem = new SelectionRangeElem()
}
}

View File

@ -0,0 +1,30 @@
import { ContainerFragment } from '.'
import { DomElement } from '../../../utils/dom-core'
import { Exec, HandlerListOptions, ListHandle } from './ListHandle'
import { filterSelectionNodes, createElement, createElementFragment } from '../utils'
export default class OtherListHandle extends ListHandle implements Exec {
public range: Range
constructor(options: HandlerListOptions, range: Range) {
super(options)
this.range = range
}
exec(): void {
const { editor, listTarget } = this.options
// 获取选中的段落
const $nodes: DomElement[] = editor.selection.getSelectionRangeTopNodes()
// 生成 li 元素并且添加到序列节点后删除原节点
const $containerFragment: ContainerFragment = createElementFragment(
filterSelectionNodes($nodes), // 过滤选取的元素
createElement(listTarget) // 创建 序列节点
)
// 插入节点到选区
this.selectionRangeElem.set($containerFragment)
this.range.insertNode($containerFragment)
}
}

View File

@ -0,0 +1,87 @@
import { ContainerFragment } from '.'
import $, { DomElement } from '../../../utils/dom-core'
import { Exec, HandlerListOptions, ListHandle } from './ListHandle'
import {
filterSelectionNodes,
getStartPoint,
createElement,
createDocumentFragment,
createElementFragment,
} from '../utils'
export default class StartJoinListHandle extends ListHandle implements Exec {
constructor(options: HandlerListOptions) {
super(options)
}
exec(): void {
const { editor, listType, listTarget, $startElem } = this.options
// 容器 - HTML 文档片段
let $containerFragment: ContainerFragment
// 获取选中的段落
const $nodes: DomElement[] = editor.selection.getSelectionRangeTopNodes()
// 获取开始段落标签名
const startNodeName = $startElem?.getNodeName()
// 弹出 开头序列
$nodes.shift()
// 上序列元素数组
const upperListElems: DomElement[] = []
// 获取起点元素
let $startDom: DomElement = getStartPoint($startElem)
// 获取上半序列中的选中内容,并添加到文档片段中
while ($startDom.length) {
upperListElems.push($startDom)
$startDom = $startDom.next()
}
// =====================================
// 当前序列类型和开头序列的类型 一致
// 代表当前是一个 融合(把其他段落加入到开头序列中) 的操作
// =====================================
if (startNodeName === listType) {
$containerFragment = createDocumentFragment()
upperListElems.forEach($list => $containerFragment.append($list.elems[0]))
// 生成 li 元属,并删除
$containerFragment = createElementFragment(
filterSelectionNodes($nodes), // 过滤元素节点数据
$containerFragment
)
// 插入到开始序列末尾
this.selectionRangeElem.set($containerFragment)
// this.selectionRangeElem.set($startElem.elems[0])
$startElem.elems[0].append($containerFragment)
}
// =====================================
// 当前序列类型和开头序列的类型 不一致
// 代表当前是一个 设置序列 的操作
// =====================================
else {
// 创建 序列节点
$containerFragment = createElement(listTarget)
upperListElems.forEach($list => $containerFragment.append($list.elems[0]))
// 生成 li 元素,并添加到 序列节点 当中,删除无用节点
$containerFragment = createElementFragment(
filterSelectionNodes($nodes), // 过滤普通节点
$containerFragment
)
// 插入到开始元素
this.selectionRangeElem.set($containerFragment)
$($containerFragment).insertAfter($startElem)
// 序列全选被掏空了后,就卸磨杀驴吧
!$startElem.children()?.length && $startElem.remove()
}
}
}

View File

@ -0,0 +1,195 @@
import { ContainerFragment } from '.'
import $, { DomElement } from '../../../utils/dom-core'
import { Exec, HandlerListOptions, ListHandle } from './ListHandle'
import {
insertBefore,
createElement,
createDocumentFragment,
createElementFragment,
} from '../utils'
/**
*
*/
export default class WrapListHandle extends ListHandle implements Exec {
constructor(options: HandlerListOptions) {
super(options)
}
exec(): void {
const { listType, listTarget, $selectionElem, $startElem, $endElem } = this.options
let $containerFragment: ContainerFragment // 容器 - HTML 文档片段
const $nodes: DomElement[] = [] // 获取选中的段落
// 获取 selectionElem 的标签名
const containerNodeName = $selectionElem?.getNodeName()
// 获取开始以及结束的 li 元素
const $start = $startElem.prior
const $end = $endElem.prior
// =====================================
// 当 开始节点 和 结束节点 没有 prior
// 并且 开始节点 没有前一个兄弟节点
// 并且 结束节点 没有后一个兄弟节点
// 即代表 全选序列
// =====================================
if (
(!$startElem.prior && !$endElem.prior) ||
(!$start?.prev().length && !$end?.next().length)
) {
// 获取当前序列下的所有 li 标签
; ($selectionElem?.children() as DomElement).forEach(($node: HTMLElement) => {
$nodes.push($($node))
})
// =====================================
// 当 selectionElem 的标签名和按钮类型 一致 的时候
// 代表着当前的操作是 取消 序列
// =====================================
if (containerNodeName === listType) {
// 生成对应的段落(p)并添加到文档片段中,然后删除掉无用的 li
$containerFragment = createElementFragment(
$nodes,
createDocumentFragment(), // 创建 文档片段
'p'
)
}
// =====================================
// 当 selectionElem 的标签名和按钮类型 不一致 的时候
// 代表着当前的操作是 转换 序列
// =====================================
else {
// 创建 序列节点
$containerFragment = createElement(listTarget)
// 因为是转换,所以 li 元素可以直接使用
$nodes.forEach($node => {
$containerFragment.append($node.elems[0])
})
}
// 把 文档片段 或 序列节点 插入到 selectionElem 的前面
this.selectionRangeElem.set($containerFragment)
// 插入到 $selectionElem 之前
insertBefore($selectionElem, $containerFragment, $selectionElem.elems[0])
// 删除无用的 selectionElem 因为它被掏空了
$selectionElem.remove()
}
// =====================================
// 当不是全选序列的时候就代表是非全选序列(废话)
// 非全选序列的情况
// =====================================
else {
// 获取选中的内容
let $startDom: DomElement = $start as DomElement
while ($startDom.length) {
$nodes.push($startDom)
$end?.equal($startDom)
? ($startDom = $(undefined)) // 结束
: ($startDom = $startDom.next()) // 继续
}
// 获取开始节点的上一个兄弟节点
const $prveDom: DomElement = ($start as DomElement).prev()
// 获取结束节点的下一个兄弟节点
let $nextDom: DomElement = ($end as DomElement).next()
// =====================================
// 当 selectionElem 的标签名和按钮类型一致的时候
// 代表着当前的操作是 取消 序列
// =====================================
if (containerNodeName === listType) {
// 生成对应的段落(p)并添加到文档片段中,然后删除掉无用的 li
$containerFragment = createElementFragment(
$nodes,
createDocumentFragment(), // 创建 文档片段
'p'
)
}
// =====================================
// 当 selectionElem 的标签名和按钮类型不一致的时候
// 代表着当前的操作是 转换 序列
// =====================================
else {
// 创建 文档片段
$containerFragment = createElement(listTarget)
// 因为是转换,所以 li 元素可以直接使用
$nodes.forEach(($node: DomElement) => {
$containerFragment.append($node.elems[0])
})
}
// =====================================
// 当 prveDom 和 nextDom 都存在的时候
// 代表着当前选区是在序列的中间
// 所以要先把 下半部分 未选择的 li 元素独立出来生成一个 序列
// =====================================
if ($prveDom.length && $nextDom.length) {
// 获取尾部的元素
const $tailDomArr: DomElement[] = []
while ($nextDom.length) {
$tailDomArr.push($nextDom)
$nextDom = $nextDom.next()
}
// 创建 尾部序列节点
const $tailDocFragment = createElement(containerNodeName)
// 把尾部元素节点添加到尾部序列节点中
$tailDomArr.forEach(($node: DomElement) => {
$tailDocFragment.append($node.elems[0])
})
// 把尾部序列节点插入到 selectionElem 的后面
$($tailDocFragment).insertAfter($selectionElem)
// =====================================
// 获取选区容器元素的父元素,一般就是编辑区域
// 然后判断 selectionElem 是否还有下一个兄弟节点
// 如果有,就把文档片段添加到 selectionElem 下一个兄弟节点前
// 如果没有,就把文档片段添加到 编辑区域 末尾
// =====================================
this.selectionRangeElem.set($containerFragment)
const $selectionNextDom: DomElement = $selectionElem.next()
$selectionNextDom.length
? insertBefore($selectionElem, $containerFragment, $selectionNextDom.elems[0])
: $selectionElem.parent().elems[0].append($containerFragment)
}
// =====================================
// 不管是 取消 还是 转换 都需要重新插入节点
//
// prveDom.length 等于 0 即代表选区是 selectionElem 序列的上半部分
// 上半部分的 li 元素
// =====================================
else if (!$prveDom.length) {
// 文档片段插入到 selectionElem 之前
this.selectionRangeElem.set($containerFragment)
insertBefore($selectionElem, $containerFragment, $selectionElem.elems[0])
}
// =====================================
// 不管是 取消 还是 转换 都需要重新插入节点
//
// nextDom.length 等于 0 即代表选区是 selectionElem 序列的下半部分
// 下半部分的 li 元素 if (!$nextDom.length)
// =====================================
else {
// 文档片段插入到 selectionElem 之后
this.selectionRangeElem.set($containerFragment)
const $selectionNextDom: DomElement = $selectionElem.next()
$selectionNextDom.length
? insertBefore($selectionElem, $containerFragment, $selectionNextDom.elems[0])
: $selectionElem.parent().elems[0].append($containerFragment)
}
}
}
}

View File

@ -0,0 +1,65 @@
import $, { DomElement } from '../../../utils/dom-core'
import WrapListHandle from './WrapListHandle'
import JoinListHandle from './JoinListHandle'
import StartJoinListHandle from './StartJoinListHandle'
import EndJoinListHandle from './EndJoinListHandle'
import OtherListHandle from './OtherListHandle'
import { HandlerListOptions } from './ListHandle'
// 片段类型
export type ContainerFragment = HTMLElement | DocumentFragment
// 处理类
export type ListHandleClass =
| WrapListHandle
| JoinListHandle
| StartJoinListHandle
| EndJoinListHandle
| OtherListHandle
export enum ClassType {
Wrap = 'WrapListHandle',
Join = 'JoinListHandle',
StartJoin = 'StartJoinListHandle',
EndJoin = 'EndJoinListHandle',
Other = 'OtherListHandle',
}
const handle = {
WrapListHandle,
JoinListHandle,
StartJoinListHandle,
EndJoinListHandle,
OtherListHandle,
}
export function createListHandle(
classType: ClassType,
options: HandlerListOptions,
range?: Range
): ListHandleClass {
if (classType === ClassType.Other && range === undefined) {
throw new Error('other 类需要传入 range')
}
return classType !== ClassType.Other
? new handle[classType](options)
: new handle[classType](options, range as Range)
}
/**
*
*/
export default class ListHandleCommand {
private handle: ListHandleClass
constructor(handle: ListHandleClass) {
this.handle = handle
this.handle.exec()
}
getSelectionRangeElem(): DomElement {
return $(this.handle.selectionRangeElem.get())
}
}

View File

@ -0,0 +1,48 @@
type SelectionRangeType = HTMLElement | ChildNode[]
type SetSelectionRangeType = SelectionRangeType | DocumentFragment
export type SelectionRangeElemType = SelectionRangeType | null
/**
* @description Element
* @author tonghan
*/
class SelectionRangeElem {
private _element: SelectionRangeElemType
constructor() {
this._element = null
}
/**
* SelectionRangeElem
* @param { SetSelectionRangeType } data
*/
public set(data: SetSelectionRangeType) {
//
if (data instanceof DocumentFragment) {
const childNode: ChildNode[] = []
data.childNodes.forEach(($node: ChildNode) => {
childNode.push($node)
})
data = childNode
}
this._element = data
}
/**
* SelectionRangeElem
* @returns { SelectionRangeType } Elem
*/
public get(): SelectionRangeElemType {
return this._element
}
/**
* SelectionRangeElem
*/
public clear() {
this._element = null
}
}
export default SelectionRangeElem

View File

@ -3,11 +3,27 @@
* @author tonghan
*/
import $ from '../../utils/dom-core'
import $, { DomElement } from '../../utils/dom-core'
import Editor from '../../editor/index'
import DropListMenu from '../menu-constructors/DropListMenu'
import { MenuActive } from '../menu-constructors/Menu'
import { updateRange } from './utils'
import { HandlerListOptions } from './ListHandle/ListHandle'
import ListHandleCommand, { createListHandle, ClassType } from './ListHandle'
/**
*
*/
export enum ListType {
OrderedList = 'OL',
UnorderedList = 'UL',
}
// 序列类型
type ListTypeValue = ListType
class List extends DropListMenu implements MenuActive {
constructor(editor: Editor) {
const $elem = $(
@ -27,7 +43,7 @@ class List extends DropListMenu implements MenuActive {
<i class="w-e-icon-list2 w-e-drop-list-item"></i>
${editor.i18next.t('menus.dropListMenu.list.无序列表')}
<p>`),
value: 'insertUnorderedList',
value: ListType.UnorderedList,
},
{
@ -37,66 +53,145 @@ class List extends DropListMenu implements MenuActive {
${editor.i18next.t('menus.dropListMenu.list.有序列表')}
<p>`
),
value: 'insertOrderedList',
value: ListType.OrderedList,
},
],
clickHandler: (value: string) => {
// 注意 this 是指向当前的 List 对象
this.command(value)
this.command(value as ListTypeValue)
},
}
super($elem, editor, dropListConf)
}
public command(value: string): void {
public command(type: ListTypeValue): void {
const editor = this.editor
const $textElem = editor.$textElem
editor.selection.restoreSelection()
const $selectionElem = editor.selection.getSelectionContainerElem()
// 判断是否已经执行了命令
if (editor.cmd.queryCommandState(value)) {
return
}
// 选区范围的 DOM 元素不存在,不执行命令
if ($selectionElem === undefined) return
//禁止在table中添加列表
let $selectionElem = $(editor.selection.getSelectionContainerElem())
let $dom = $($selectionElem.elems[0]).parentUntil('TABLE', $selectionElem.elems[0])
if ($dom && $($dom.elems[0]).getNodeName() === 'TABLE') {
return
}
// 获取选区范围内的顶级 DOM 元素
this.handleSelectionRangeNodes(type)
editor.cmd.do(value)
// 验证列表是否被包裹在 <p> 之内
if ($selectionElem.getNodeName() === 'LI') {
$selectionElem = $selectionElem.parent()
}
if (/^ol|ul$/i.test($selectionElem.getNodeName()) === false) {
return
}
if ($selectionElem.equal($textElem)) {
// 证明是顶级标签,没有被 <p> 包裹
return
}
const $parent = $selectionElem.parent()
if ($parent.equal($textElem)) {
// $parent 是顶级标签,不能删除
return
}
$selectionElem.insertAfter($parent)
$parent.remove()
// 恢复选区
editor.selection.restoreSelection()
// 是否激活
this.tryChangeActive()
}
public tryChangeActive(): void {}
public validator($startElem: DomElement, $endElem: DomElement, $textElem: DomElement): boolean {
if (
!$startElem.length ||
!$endElem.length ||
$textElem.equal($startElem) ||
$textElem.equal($endElem)
) {
return false
}
return true
}
private handleSelectionRangeNodes(listType: ListTypeValue): void {
const editor = this.editor
const selection = editor.selection
// 获取 序列标签
const listTarget = listType.toLowerCase()
// 获取相对应的 元属节点
let $selectionElem = selection.getSelectionContainerElem() as DomElement
const $startElem = (selection.getSelectionStartElem() as DomElement).getNodeTop(editor)
const $endElem = (selection.getSelectionEndElem() as DomElement).getNodeTop(editor)
// 验证是否执行 处理逻辑
if (!this.validator($startElem, $endElem, editor.$textElem)) {
return
}
// 获取选区
const _range = selection.getRange()
const _collapsed = _range?.collapsed
// 防止光标的时候判断异常
if (!editor.$textElem.equal($selectionElem)) {
$selectionElem = $selectionElem.getNodeTop(editor)
}
const options: HandlerListOptions = {
editor,
listType,
listTarget,
$selectionElem,
$startElem,
$endElem,
}
let classType: ClassType
// =====================================
// 当 selectionElem 属于序列元素的时候
// 代表着当前选区一定是在一个序列元素内的
// =====================================
if (this.isOrderElem($selectionElem as DomElement)) {
classType = ClassType.Wrap
}
// =====================================
// 当 startElem 和 endElem 属于序列元素的时候
// 代表着当前选区一定是在再两个序列的中间(包括两个序列)
// =====================================
else if (
this.isOrderElem($startElem as DomElement) &&
this.isOrderElem($endElem as DomElement)
) {
classType = ClassType.Join
}
// =====================================
// 选区开始元素为 序列 的时候
// =====================================
else if (this.isOrderElem($startElem as DomElement)) {
classType = ClassType.StartJoin
}
// =====================================
// 选区结束元素为 序列 的时候
// =====================================
else if (this.isOrderElem($endElem as DomElement)) {
classType = ClassType.EndJoin
}
// =====================================
// 当选区不是序列内且开头和结尾不是序列的时候
// 直接获取所有顶级段落然后过滤
// 代表着 设置序列 的操作
// =====================================
else {
classType = ClassType.Other
}
const listHandleCmd = new ListHandleCommand(
createListHandle(classType, options, _range as Range)
)
// 更新选区
updateRange(editor, listHandleCmd.getSelectionRangeElem(), !!_collapsed)
}
/**
* UL and OL
* @param $node
*/
private isOrderElem($node: DomElement) {
const nodeName = $node.getNodeName()
if (nodeName === ListType.OrderedList || nodeName === ListType.UnorderedList) {
return true
}
return false
}
public tryChangeActive(): void { }
}
export default List

131
src/menus/list/utils.ts Normal file
View File

@ -0,0 +1,131 @@
import { ListType } from '.'
import Editor from '../../editor/index'
import $, { DomElement } from '../../utils/dom-core'
import { ContainerFragment } from './ListHandle'
/**
* node
* @returns { DomElement[] } DomElement[]
*/
export function filterSelectionNodes($nodes: DomElement[]): DomElement[] {
const $listHtml: DomElement[] = []
$nodes.forEach(($node: DomElement) => {
const targerName = $node.getNodeName()
if (targerName !== ListType.OrderedList && targerName !== ListType.UnorderedList) {
// 非序列
$listHtml.push($node)
} else {
// 序列
if ($node.prior) {
$listHtml.push($node.prior)
} else {
const $children = $node.children()
$children?.forEach(($li: HTMLElement) => {
$listHtml.push($($li))
})
}
}
})
return $listHtml
}
/**
*
* @param $node
*/
export function updateRange(editor: Editor, $node: DomElement, collapsed: boolean) {
const selection = editor.selection
const range = document.createRange()
// ===============================
// length 大于 1
// 代表着转换是一个文档节点多段落
// ===============================
if ($node.length > 1) {
range.setStart($node.elems[0], 0)
range.setEnd($node.elems[$node.length - 1], $node.elems[$node.length - 1].childNodes.length)
}
// ===============================
// 序列节点 或 单段落
// ===============================
else {
range.selectNodeContents($node.elems[0])
}
// ===============================
// collapsed 为 true 代表是光标
// ===============================
collapsed && range.collapse(false)
selection.saveRange(range)
selection.restoreSelection()
}
/**
*
* @param $startElem
*/
export function getStartPoint($startElem: DomElement): DomElement {
return $startElem.prior
? $startElem.prior // 有 prior 代表不是全选序列
: $($startElem.children()?.elems[0]) // 没有则代表全选序列
}
/**
*
* @param $endElem
*/
export function getEndPoint($endElem: DomElement): DomElement {
return $endElem.prior
? $endElem.prior // 有 prior 代表不是全选序列
: $($endElem.children()?.last().elems[0]) // 没有则代表全选序列
}
/**
*
* @param { DomElement } $node
* @param { ContainerFragment } newNode
* @param { Node | null } existingNode
*/
export function insertBefore(
$node: DomElement,
newNode: ContainerFragment,
existingNode: Node | null = null
) {
$node.parent().elems[0].insertBefore(newNode, existingNode)
}
/**
* element
*/
export function createElement(target: string): HTMLElement {
return document.createElement(target)
}
/**
*
*/
export function createDocumentFragment(): DocumentFragment {
return document.createDocumentFragment()
}
/**
* li $fragment
* @param { DomElement[] } $nodes li dom
* @param { ContainerFragment } $fragment li
*/
export function createElementFragment(
$nodes: DomElement[],
$fragment: ContainerFragment,
tag: string = 'li'
): ContainerFragment {
$nodes.forEach(($node: DomElement) => {
const $list = createElement(tag)
$list.innerHTML = $node.html()
$fragment.append($list)
$node.remove()
})
return $fragment
}

View File

@ -159,9 +159,9 @@ class Panel {
const type = event.type
const fn = event.fn || EMPTY_FN
const $content = tabContentArr[index]
$content.find(selector).on(type, (e: Event) => {
$content.find(selector).on(type, async (e: Event) => {
e.stopPropagation()
const needToHide = fn(e)
const needToHide = await fn(e)
// 执行完事件之后,是否要关闭 panel
if (needToHide) {
this.remove()

View File

@ -31,7 +31,6 @@ class Tooltip {
this.conf = conf
this._show = false
this._isInsertTextContainer = false
// 定义 container
const $container = $('<div></div>')
$container.addClass('w-e-tooltip')
@ -167,6 +166,9 @@ class Tooltip {
}
this._show = true
editor.beforeDestroy(this.remove.bind(this))
editor.txt.eventHooks.onBlurEvents.push(this.remove.bind(this))
}
/**

View File

@ -3,7 +3,7 @@ import $, { DomElement } from '../../../utils/dom-core'
function bindEvent(editor: Editor) {
function quoteEnter(e: Event) {
const $selectElem = editor.selection.getSelectionContainerElem() as DomElement
const $topSelectElem = editor.selection.getSelectionRangeTopNodes(editor)[0]
const $topSelectElem = editor.selection.getSelectionRangeTopNodes()[0]
// 对quote的enter进行特殊处理
//最后一行为<p><br></p>时再按会出跳出blockquote
if ($topSelectElem?.getNodeName() === 'BLOCKQUOTE') {

View File

@ -27,7 +27,7 @@ class Quote extends BtnMenu implements MenuActive {
public clickHandler(): void {
const editor = this.editor
const isSelectEmpty = editor.selection.isSelectionEmpty()
const topNodeElem: DomElement[] = editor.selection.getSelectionRangeTopNodes(editor)
const topNodeElem: DomElement[] = editor.selection.getSelectionRangeTopNodes()
const $topNodeElem: DomElement = topNodeElem[topNodeElem.length - 1]
const nodeName = this.getTopNodeName()
// IE 中不支持 formatBlock <BLOCKQUOTE> ,要用其他方式兼容
@ -52,6 +52,9 @@ class Quote extends BtnMenu implements MenuActive {
$quote.insertAfter($topNodeElem)
this.delSelectNode(topNodeElem)
const moveNode = $quote.childNodes()?.last().getNode() as Node
if (moveNode == null) return
// 兼容firefoxfirefox下空行情况下选区会在br后造成自动换行的问题
moveNode.textContent
? editor.selection.moveCursor(moveNode)
@ -75,7 +78,7 @@ class Quote extends BtnMenu implements MenuActive {
*/
public tryChangeActive(): void {
const editor = this.editor
const cmdValue = editor.selection.getSelectionRangeTopNodes(editor)[0]?.getNodeName()
const cmdValue = editor.selection.getSelectionRangeTopNodes()[0]?.getNodeName()
if (cmdValue === 'BLOCKQUOTE') {
this.active()
} else {
@ -90,7 +93,7 @@ class Quote extends BtnMenu implements MenuActive {
*/
private getTopNodeName(): string {
const editor = this.editor
const $topNodeElem = editor.selection.getSelectionRangeTopNodes(editor)[0]
const $topNodeElem = editor.selection.getSelectionRangeTopNodes()[0]
const nodeName = $topNodeElem?.getNodeName()
return nodeName

View File

@ -11,7 +11,7 @@ import { MenuActive } from '../menu-constructors/Menu'
class Redo extends BtnMenu implements MenuActive {
constructor(editor: Editor) {
const $elem = $(
`<div class="w-e-menu" data-title="撤销">
`<div class="w-e-menu" data-title="恢复">
<i class="w-e-icon-redo"></i>
</div>`
)
@ -22,7 +22,17 @@ class Redo extends BtnMenu implements MenuActive {
*
*/
public clickHandler(): void {
this.editor.history.restore()
const editor = this.editor
editor.history.restore()
// 重新创建 range是处理当初始化编辑器API插入内容后撤销range 不在编辑器内部的问题
const children = editor.$textElem.children()
if (!children?.length) return
const $last = children.last()
editor.selection.createRangeByElem($last, false, true)
editor.selection.restoreSelection()
}
/**

View File

@ -24,7 +24,7 @@ class splitLine extends BtnMenu implements MenuActive {
const range = editor.selection.getRange()
const $selectionElem = editor.selection.getSelectionContainerElem()
if (!$selectionElem) return
if (!$selectionElem?.length) return
const $DomElement = $($selectionElem.elems[0])
const $tableDOM = $DomElement.parentUntil('TABLE', $selectionElem.elems[0])

View File

@ -18,7 +18,7 @@ function bindEvent(editor: Editor) {
if (isAllTodo(editor)) {
e.preventDefault()
const selection = editor.selection
const $topSelectElem = selection.getSelectionRangeTopNodes(editor)[0]
const $topSelectElem = selection.getSelectionRangeTopNodes()[0]
const $li = $topSelectElem.childNodes()?.get(0)
const selectionNode = window.getSelection()?.anchorNode as Node
const range = selection.getRange()
@ -95,7 +95,7 @@ function bindEvent(editor: Editor) {
function delDown(e: Event) {
if (isAllTodo(editor)) {
const selection = editor.selection
const $topSelectElem = selection.getSelectionRangeTopNodes(editor)[0]
const $topSelectElem = selection.getSelectionRangeTopNodes()[0]
const $li = $topSelectElem.childNodes()?.getNode()
const $p = $(`<p></p>`)
const p = $p.getNode()
@ -136,7 +136,7 @@ function bindEvent(editor: Editor) {
*/
function deleteUp() {
const selection = editor.selection
const $topSelectElem = selection.getSelectionRangeTopNodes(editor)[0]
const $topSelectElem = selection.getSelectionRangeTopNodes()[0]
if (isTodo($topSelectElem)) {
if ($topSelectElem.text() === '') {
$(`<p><br></p>`).insertAfter($topSelectElem)

View File

@ -9,7 +9,7 @@ import createTodo from './todo'
class Todo extends BtnMenu implements MenuActive {
constructor(editor: Editor) {
const $elem = $(
`<div class="w-e-menu" data-title="办事项">
`<div class="w-e-menu" data-title="办事项">
<i class="w-e-icon-checkbox-checked"></i>
</div>`
)
@ -44,7 +44,7 @@ class Todo extends BtnMenu implements MenuActive {
*/
private setTodo() {
const editor = this.editor
const topNodeElem: DomElement[] = editor.selection.getSelectionRangeTopNodes(editor)
const topNodeElem: DomElement[] = editor.selection.getSelectionRangeTopNodes()
topNodeElem.forEach($node => {
const nodeName = $node?.getNodeName()
if (nodeName === 'P') {
@ -64,7 +64,7 @@ class Todo extends BtnMenu implements MenuActive {
*/
private cancelTodo() {
const editor = this.editor
const $topNodeElems: DomElement[] = editor.selection.getSelectionRangeTopNodes(editor)
const $topNodeElems: DomElement[] = editor.selection.getSelectionRangeTopNodes()
$topNodeElems.forEach($topNodeElem => {
let content = $topNodeElem.childNodes()?.childNodes()?.clone(true) as DomElement

View File

@ -13,7 +13,7 @@ function isTodo($topSelectElem: DomElement) {
* @param editor
*/
function isAllTodo(editor: Editor): boolean | undefined {
const $topSelectElems = editor.selection.getSelectionRangeTopNodes(editor)
const $topSelectElems = editor.selection.getSelectionRangeTopNodes()
// 排除为[]的情况
if ($topSelectElems.length === 0) return

View File

@ -11,7 +11,7 @@ import { MenuActive } from '../menu-constructors/Menu'
class Undo extends BtnMenu implements MenuActive {
constructor(editor: Editor) {
const $elem = $(
`<div class="w-e-menu" data-title="恢复">
`<div class="w-e-menu" data-title="撤销">
<i class="w-e-icon-undo"></i>
</div>`
)
@ -22,7 +22,17 @@ class Undo extends BtnMenu implements MenuActive {
*
*/
public clickHandler(): void {
this.editor.history.revoke()
const editor = this.editor
editor.history.revoke()
// 重新创建 range是处理当初始化编辑器API插入内容后撤销range 不在编辑器内部的问题
const children = editor.$textElem.children()
if (!children?.length) return
const $last = children.last()
editor.selection.createRangeByElem($last, false, true)
editor.selection.restoreSelection()
}
/**

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,19 +4,20 @@
*/
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 { videoRegex } from '../../utils/const'
import UploadVideo from './upload-video'
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 t = (text: string, prefix: string = i18nPrefix): string => {
return editor.i18next.t(prefix + text)
}
const inputUploadId = getRandom('input-upload')
const btnStartId = getRandom('btn-local-ok')
/**
*
@ -34,84 +35,130 @@ export default function (editor: Editor, video: string): PanelConf {
* @param video 线
*/
function checkOnlineVideo(video: string): boolean {
// 编辑器进行正常校验video 合规则使指针为true不合规为false
let flag = true
if (!videoRegex.test(video)) {
flag = false
}
// 查看开发者自定义配置的返回值
const check = editor.config.onlineVideoCheck(video)
if (check === undefined) {
if (flag === false) console.log(t('您刚才插入的视频链接未通过编辑器校验', 'validate.'))
} else if (check === true) {
// 用户通过了开发者的校验
if (flag === false) {
editor.config.customAlert(
`${t('您插入的网络视频无法识别', 'validate.')}${t(
'请替换为正确的网络视频格式',
'validate.'
)}<iframe src=...></iframe>`,
'warning'
)
} else {
return true
}
} else {
if (check === true) {
return true
}
if (typeof check === 'string') {
//用户未能通过开发者的校验,开发者希望我们提示这一字符串
editor.config.customAlert(check, 'error')
}
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

@ -13,9 +13,10 @@ import getHtmlByNodeList from './getHtmlByNodeList'
/** 按键函数 */
type KeyBoardHandler = (event: KeyboardEvent) => unknown
/** 普通事件回调 */
type EventHandler = (event: Event) => unknown
type EventHandler = (event?: Event) => unknown
// 各个事件钩子函数
type TextEventHooks = {
onBlurEvents: EventHandler[]
changeEvents: (() => void)[] // 内容修改时
dropEvents: ((event: DragEvent) => unknown)[]
clickEvents: EventHandler[]
@ -54,6 +55,8 @@ type TextEventHooks = {
dropListMenuHoverEvents: (() => void)[]
/** 点击分割线时 */
splitLineEvents: ((e: DomElement) => void)[]
/** 视频点击事件 */
videoClickEvents: ((e: DomElement) => void)[]
}
class Text {
@ -64,6 +67,7 @@ class Text {
this.editor = editor
this.eventHooks = {
onBlurEvents: [],
changeEvents: [],
dropEvents: [],
clickEvents: [],
@ -85,6 +89,7 @@ class Text {
menuClickEvents: [],
dropListMenuHoverEvents: [],
splitLineEvents: [],
videoClickEvents: [],
}
}
@ -229,11 +234,18 @@ class Text {
public append(html: string): void {
const editor = this.editor
const $textElem = editor.$textElem
const blankLineReg = /(<p><br><\/p>)+$/g
if (html.indexOf('<') !== 0) {
// 普通字符串,用 <p> 包裹
html = `<p>${html}</p>`
}
$textElem.append($(html))
if (blankLineReg.test($textElem.html().trim())) {
// 如果有多个空行替换最后一个 <p><br></p>
const insertHtml = $textElem.html().replace(/(.*)<p><br><\/p>/, '$1' + html)
this.html(insertHtml)
} else {
$textElem.append($(html))
}
// 初始化选区,将光标定位到内容尾部
editor.initSelection()
@ -551,6 +563,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

@ -4,11 +4,6 @@
*/
export function EMPTY_FN() {}
//用于校验图片链接是否符合规范
export const imgRegex = /\.(gif|jpg|jpeg|png)$/i
//用于校验是否为url格式字符串
export const urlRegex = /^(http|ftp|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-.,@?^=%&amp;:/~+#]*[\w\-@?^=%&amp;/~+#])?/
//用于校验在线视频是否符合规范
export const videoRegex = /((<iframe|video|embed|object)\s+[\s\S]*<\/(iframe|video|embed|object))>|<(iframe|video|embed|object)\s+[\s\S]*\/?>/

View File

@ -89,13 +89,15 @@ function _styleArrTrim(style: string | string[]): string[] {
export type DomElementSelector =
| string
| DomElement
| HTMLElement
| Element
| Document
| HTMLCollection
| Node
| NodeList
| ChildNode
| ChildNode[]
| Element
| HTMLElement
| HTMLElement[]
| HTMLCollection
| EventTarget
| null
| undefined
@ -107,6 +109,7 @@ export class DomElement<T extends DomElementSelector = DomElementSelector> {
length: number
elems: HTMLElement[]
dataSource: Map<string, any>
prior?: DomElement // 通过 getNodeTop 获取顶级段落的时候,可以通过 prior 去回溯来源的子节点
/**
*
@ -805,15 +808,25 @@ export class DomElement<T extends DomElementSelector = DomElementSelector> {
* @param editor
*/
getNodeTop(editor: Editor): DomElement {
// 异常抛出,空的 DomElement 直接返回
if (this.length < 1) {
return this
}
// 获取父级元素,并判断是否是 编辑区域
// 如果是则返回当前节点
const $parent = this.parent()
if (editor.$textElem.equal($parent)) {
return this
}
// 到了此处,即代表当前节点不是顶级段落
// 将当前节点存放于父节点的 prior 字段下
// 主要用于 回溯 子节点
// 例如ul ol 等标签
// 实际操作的节点是 li 但是一个 ul ol 的子节点可能有多个
// 所以需要对其进行 回溯 找到对应的子节点
$parent.prior = this
return $parent.getNodeTop(editor)
}

View File

@ -2,19 +2,19 @@
* @description upload-img test
* @author luochao
*/
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 UploadImg from '../../../../src/menus/img/upload-img'
let editor: Editor
let id = 1
const imgUrl = 'http://www.wangeditor.com/imgs/logo.jpeg'
const errorUrl = 'logo123.jpeg'
const errorUrl = 'error.jpeg'
const uploadImgServer = 'http://localhost:8881/api/upload-img'
const uploadImgServerWithHash = 'http://localhost:8881/api/upload-img#/123'
const defaultRes = {
status: 200,
@ -32,7 +32,7 @@ const mockXHRHttpRequest = (res: any = defaultRes) => {
return mockXHRObject
}
const createUploadImgInstance = (config: any) => {
const createUploadImgInstance = (config: any = {}) => {
const editor = createEditor(document, `div${id++}`, '', config)
const uploadImg = new UploadImg(editor)
return uploadImg
@ -43,44 +43,38 @@ const mockSupportCommand = () => {
document.queryCommandSupported = jest.fn(() => true)
}
const deaultFiles = [{ name: 'test.png', size: 512, mimeType: 'image/png' }]
const createMockFilse = (fileList: any[] = deaultFiles) => {
const defaultFiles = [{ name: 'test.png', size: 512, mimeType: 'image/png' }]
const createMockFiles = (fileList: any[] = defaultFiles) => {
const files = fileList.map(file => mockFile(file))
return files.filter(Boolean)
}
// mock img onload and onerror event
const mockImgOnloadAndOnError = () => {
// Mocking Image.prototype.src to call the onload or onerror
// callbacks depending on the src passed to it
// @ts-ignore
Object.defineProperty(global.Image.prototype, 'src', {
// Define the property setter
set(src) {
if (src === errorUrl) {
// Call with setTimeout to simulate async loading
setTimeout(() => this.onerror(new Error('mocked error')))
} else if (src === imgUrl) {
setTimeout(() => this.onload())
}
},
})
}
describe('upload img', () => {
// mock img onload and onerror event
beforeAll(() => {
// Mocking Image.prototype.src to call the onload or onerror
// callbacks depending on the src passed to it
// @ts-ignore
Object.defineProperty(global.Image.prototype, 'src', {
// Define the property setter
set(src) {
if (src === errorUrl) {
// Call with setTimeout to simulate async loading
setTimeout(() => this.onerror(new Error('mocked error')))
} else if (src === imgUrl) {
setTimeout(() => this.onload())
}
},
})
})
beforeEach(() => {
editor = createEditor(document, `div${id++}`)
})
test('能够初始化基本的UploadImg类', () => {
const uploadImg = new UploadImg(editor)
expect(uploadImg.insertImg instanceof Function).toBeTruthy()
expect(uploadImg.uploadImg instanceof Function).toBeTruthy()
// mock img onload and onerror event
mockImgOnloadAndOnError()
})
test('调用 insertImg 可以网编辑器里插入图片', () => {
const uploadImg = new UploadImg(editor)
const uploadImg = createUploadImgInstance()
mockSupportCommand()
@ -104,11 +98,6 @@ describe('upload img', () => {
uploadImg.insertImg(imgUrl)
expect(document.execCommand).toBeCalledWith(
'insertHTML',
false,
`<img src="${imgUrl}" style="max-width:100%;"/>`
)
expect(callback).toBeCalledWith(imgUrl)
})
@ -130,10 +119,10 @@ describe('upload img', () => {
`wangEditor: 插入图片错误,图片链接 "${errorUrl}",下载链接失败`
)
done()
}, 1000)
}, 200)
})
test('调用 uploadImg 上传图片', done => {
test('可以调用 uploadImg 上传图片', done => {
expect.assertions(1)
const jestFn = jest.fn()
@ -145,7 +134,7 @@ describe('upload img', () => {
},
})
const files = createMockFilse()
const files = createMockFiles()
const mockXHRObject = mockXHRHttpRequest()
upload.uploadImg(files)
@ -158,24 +147,24 @@ describe('upload img', () => {
}, 1000)
})
test('调用 uploadImg 上传图片,如果传入的文件为空直接返回', () => {
const upload = new UploadImg(editor)
test('调用 uploadImg 上传图片,如果传入的文件为空直接返回 undefined不执行上传操作', () => {
const upload = createUploadImgInstance()
const res = upload.uploadImg([])
expect(res).toBeUndefined()
})
test('调用 uploadImg 上传图片如果没有配置customUploadImg, 则必须配置 uploadImgServer 或者 uploadImgShowBase64', () => {
const upload = new UploadImg(editor)
const files = createMockFilse()
test('调用 uploadImg 上传图片如果没有配置customUploadImg, 则必须配置 uploadImgServer 或者 uploadImgShowBase64,否则直接返回', () => {
const upload = createUploadImgInstance()
const files = createMockFiles()
const res = upload.uploadImg(files)
expect(res).toBeUndefined()
})
test('调用 uploadImg 上传图片如果文件没有名字或者size为,则会被过滤掉', () => {
test('调用 uploadImg 上传图片如果文件没有名字或者size为0则会弹窗警告', () => {
const fn = jest.fn()
const upload = createUploadImgInstance({
@ -183,15 +172,14 @@ describe('upload img', () => {
customAlert: fn,
})
const files = createMockFilse([{ name: '', size: 0, mimeType: 'image/png' }])
const files = createMockFiles([{ name: '', size: 0, mimeType: 'image/png' }])
const res = upload.uploadImg(files)
upload.uploadImg(files)
expect(res).toBeUndefined()
expect(fn).toBeCalledWith('传入的文件不合法', 'warning')
})
test('调用 uploadImg 上传图片,如果文件非图片,则返回并提示错误信息', () => {
test('调用 uploadImg 上传图片,如果文件非图片,则提示错误信息', () => {
const fn = jest.fn()
const upload = createUploadImgInstance({
@ -199,15 +187,14 @@ describe('upload img', () => {
customAlert: fn,
})
const files = createMockFilse([{ name: 'test.txt', size: 200, mimeType: 'text/plain' }])
const files = createMockFiles([{ name: 'test.txt', size: 200, mimeType: 'text/plain' }])
const res = upload.uploadImg(files)
upload.uploadImg(files)
expect(res).toBeUndefined()
expect(fn).toBeCalledWith('图片验证未通过: \n【test.txt】不是图片', 'warning')
})
test('调用 uploadImg 上传图片,如果文件体积大小超过配置的大小,则返回并提示错误信息', () => {
test('调用 uploadImg 上传图片,如果文件体积大小超过配置的大小,则提示错误信息', () => {
const fn = jest.fn()
const upload = createUploadImgInstance({
@ -216,13 +203,12 @@ describe('upload img', () => {
customAlert: fn,
})
const files = createMockFilse([
const files = createMockFiles([
{ name: 'test.png', size: 6 * 1024 * 1024, mimeType: 'image/png' },
])
const res = upload.uploadImg(files)
upload.uploadImg(files)
expect(res).toBeUndefined()
expect(fn).toBeCalledWith(`图片验证未通过: \n【test.png】大于 5M`, 'warning')
})
@ -235,19 +221,18 @@ describe('upload img', () => {
customAlert: fn,
})
const files = createMockFilse([
const files = createMockFiles([
{ name: 'test1.png', size: 2048, mimeType: 'image/png' },
{ name: 'test2.png', size: 2048, mimeType: 'image/png' },
{ name: 'test3.png', size: 2048, mimeType: 'image/png' },
])
const res = upload.uploadImg(files)
upload.uploadImg(files)
expect(res).toBeUndefined()
expect(fn).toBeCalledWith('一次最多上传2张图片', 'warning')
})
test('调用 uploadImg 上传图片,如果配置了 customUploadImg 选项,则调用customUploadImg上传', () => {
test('调用 uploadImg 上传图片,如果配置了 customUploadImg 选项,则调用 customUploadImg 上传', () => {
const fn = jest.fn()
const upload = createUploadImgInstance({
@ -255,15 +240,14 @@ describe('upload img', () => {
customUploadImg: fn,
})
const files = createMockFilse()
const files = createMockFiles()
const res = upload.uploadImg(files)
upload.uploadImg(files)
expect(res).toBeUndefined()
expect(fn).toBeCalled()
})
test('调用 uploadImg 上传图片,如果可以配置uploadImgParamsWithUrl添加query参数', done => {
test('调用 uploadImg 上传图片,如果配置uploadImgParamsWithUrl 为true 则允许添加query参数', done => {
expect.assertions(1)
const fn = jest.fn()
@ -280,7 +264,7 @@ describe('upload img', () => {
},
})
const files = createMockFilse()
const files = createMockFiles()
const mockXHRObject = mockXHRHttpRequest()
@ -289,18 +273,18 @@ describe('upload img', () => {
mockXHRObject.onreadystatechange()
setTimeout(() => {
expect(fn).toBeCalled()
expect(mockXHRObject.open).toHaveBeenCalledWith('POST', `${uploadImgServer}?a=a&b=b`)
done()
})
})
test('调用 uploadImg 上传图片uploadImgServer支持hash参数拼接', done => {
test('调用 uploadImg 上传图片uploadImgServer支持 hash query 参数拼接', done => {
expect.assertions(1)
const fn = jest.fn()
const upload = createUploadImgInstance({
uploadImgServer,
uploadImgServer: uploadImgServerWithHash,
uploadImgParams: {
a: 'a',
b: 'b',
@ -311,10 +295,7 @@ describe('upload img', () => {
},
})
const files = createMockFilse([
{ name: 'test1.png', size: 2048, mimeType: 'image/png' },
{ name: 'test2.png', size: 2048, mimeType: 'image/png' },
])
const files = createMockFiles([{ name: 'test1.png', size: 2048, mimeType: 'image/png' }])
const mockXHRObject = mockXHRHttpRequest()
@ -323,25 +304,28 @@ describe('upload img', () => {
mockXHRObject.onreadystatechange()
setTimeout(() => {
expect(fn).toBeCalled()
expect(mockXHRObject.open).toHaveBeenCalledWith(
'POST',
`${uploadImgServer}?a=a&b=b#/123`
)
done()
})
})
test('调用 uploadImg 上传图片失败,会有错误提示并支持配置onError hook', done => {
expect.assertions(2)
test('调用 uploadImg 上传图片失败,支持配置 onError 钩子监听', done => {
expect.assertions(1)
const fn = jest.fn()
const errorFn = jest.fn()
const alertFn = jest.fn()
const upload = createUploadImgInstance({
uploadImgServer,
uploadImgHooks: {
error: fn,
error: errorFn,
},
customAlert: alertFn,
})
const files = createMockFilse()
const files = createMockFiles()
const mockXHRObject = mockXHRHttpRequest({ status: 500 })
@ -350,18 +334,13 @@ describe('upload img', () => {
mockXHRObject.onreadystatechange()
setTimeout(() => {
expect(fn).toBeCalled()
expect(alertFn).toBeCalledWith(
'上传图片错误',
'error',
'上传图片错误,服务器返回状态: 500'
)
expect(errorFn).toBeCalled()
done()
})
})
test('调用 uploadImg 上传图片成功后数据返回不正常会有错误提示并支持配置onFail hook', done => {
expect.assertions(2)
test('调用 uploadImg 上传图片成功后数据返回格式不正常,支持配置 onFail 钩子监听', done => {
expect.assertions(1)
const fn = jest.fn()
const alertFn = jest.fn()
@ -373,7 +352,7 @@ describe('upload img', () => {
},
customAlert: alertFn,
})
const files = createMockFilse()
const files = createMockFiles()
const mockXHRObject = mockXHRHttpRequest({
status: 200,
@ -386,11 +365,6 @@ describe('upload img', () => {
setTimeout(() => {
expect(fn).toBeCalled()
expect(alertFn).toBeCalledWith(
'上传图片失败',
'error',
'上传图片返回结果错误,返回结果: {test: 123}'
)
done()
})
})
@ -407,7 +381,7 @@ describe('upload img', () => {
},
})
const files = createMockFilse()
const files = createMockFiles()
const mockXHRObject = mockXHRHttpRequest()
@ -421,21 +395,21 @@ describe('upload img', () => {
})
})
test('调用 uploadImg 上传被阻止,会有错误提示', done => {
test('调用 uploadImg 如果配置 before 钩子阻止上传,会有错误提示', done => {
expect.assertions(2)
const beforFn = jest.fn(() => ({ prevent: true, msg: '阻止发送请求' }))
const beforeFn = jest.fn(() => ({ prevent: true, msg: '阻止发送请求' }))
const alertFn = jest.fn()
const upload = createUploadImgInstance({
uploadImgServer,
uploadImgHooks: {
before: beforFn,
before: beforeFn,
},
customAlert: alertFn,
})
const files = createMockFilse()
const files = createMockFiles()
const mockXHRObject = mockXHRHttpRequest()
@ -444,14 +418,14 @@ describe('upload img', () => {
mockXHRObject.onreadystatechange()
setTimeout(() => {
expect(beforFn).toBeCalled()
expect(beforeFn).toBeCalled()
expect(alertFn).toBeCalledWith('阻止发送请求', 'error')
done()
})
})
test('调用 uploadImg 上传返回的错误码不符合条件有错误提示,并触发fail回调', done => {
expect.assertions(2)
test('调用 uploadImg 上传返回的错误码不符合条件, 会触发fail回调', done => {
expect.assertions(1)
const failFn = jest.fn()
const alertFn = jest.fn()
@ -464,7 +438,7 @@ describe('upload img', () => {
customAlert: alertFn,
})
const files = createMockFilse()
const files = createMockFiles()
const mockXHRObject = mockXHRHttpRequest({
status: 200,
@ -477,39 +451,33 @@ describe('upload img', () => {
setTimeout(() => {
expect(failFn).toBeCalled()
expect(alertFn).toBeCalledWith(
'上传图片失败',
'error',
'上传图片返回结果错误,返回结果 errno=-1'
)
done()
})
})
test('调用 uploadImg 上传,如果配置 uploadImgShowBase64 参数则直接插入base64到编辑器', () => {
const callback = jest.fn()
test('调用 uploadImg 上传,如果配置 uploadImgShowBase64 参数,则直接插入图片 base64 字符串到编辑器', () => {
const upload = createUploadImgInstance({
uploadImgShowBase64: true,
linkImgCallback: callback,
})
const files = createMockFilse()
const mockFn = jest.fn()
const files = createMockFiles()
const mockReadAsDataURL = jest.fn()
// @ts-ignore
jest.spyOn(global, 'FileReader').mockImplementation(() => {
return {
readAsDataURL: mockFn,
readAsDataURL: mockReadAsDataURL,
}
})
upload.uploadImg(files)
expect(mockFn).toBeCalled()
expect(mockReadAsDataURL).toBeCalled()
})
test('调用 uploadImg 上传超时会触发超时回调', done => {
expect.assertions(2)
expect.assertions(1)
const timeoutFn = jest.fn()
const alertFn = jest.fn()
@ -522,7 +490,7 @@ describe('upload img', () => {
customAlert: alertFn,
})
const files = createMockFilse()
const files = createMockFiles()
const mockXHRObject = mockXHRHttpRequest()
upload.uploadImg(files)
@ -531,7 +499,6 @@ describe('upload img', () => {
setTimeout(() => {
expect(timeoutFn).toBeCalled()
expect(alertFn).toBeCalledWith('上传图片超时', 'error')
done()
})
})

View File

@ -13,36 +13,82 @@ import { getMenuInstance } from '../../helpers/menus'
let editor: Editor
let justifyMenu: justify
test('justify 菜单dropList', () => {
editor = createEditor(document, 'div1') // 赋值给全局变量
justifyMenu = getMenuInstance(editor, justify) as justify // 赋值给全局变量
expect(justifyMenu.dropList).not.toBeNull()
justifyMenu.dropList.show()
expect(justifyMenu.dropList.isShow).toBe(true)
justifyMenu.dropList.hide()
expect(justifyMenu.dropList.isShow).toBe(false)
})
describe('Justify Menu', () => {
test('justify 菜单dropList', () => {
editor = createEditor(document, 'div1') // 赋值给全局变量
justifyMenu = getMenuInstance(editor, justify) as justify // 赋值给全局变量
expect(justifyMenu.dropList).not.toBeNull()
justifyMenu.dropList.show()
expect(justifyMenu.dropList.isShow).toBe(true)
justifyMenu.dropList.hide()
expect(justifyMenu.dropList.isShow).toBe(false)
})
test('justify 菜单:设置对齐方式', () => {
type justifyType = {
[key: string]: string
}
const justifyClass: justifyType = {
justifyLeft: 'left',
justifyCenter: 'center',
justifyRight: 'right',
justifyFull: 'justify',
}
const mockGetSelectionRangeTopNodes = (tagString: string) => {
const domArr = [$(tagString)]
jest.spyOn(editor.selection, 'getSelectionRangeTopNodes').mockImplementation(() => domArr)
return domArr
}
const $elems = mockGetSelectionRangeTopNodes('<p>123</p>')
for (let key in justifyClass) {
justifyMenu.command(key)
$elems.forEach((el: DomElement) => {
expect(el.elems[0].getAttribute('style')).toContain(`text-align:${justifyClass[key]}`)
})
}
test('justify 菜单:设置对齐方式', () => {
const justifyClasses = ['left', 'right', 'center', 'justify']
const mockGetSelectionRangeTopNodes = (tagString: string) => {
const domArr = [$(tagString)]
jest.spyOn(editor.selection, 'getSelectionRangeTopNodes').mockImplementation(
() => domArr
)
return domArr
}
const $elems = mockGetSelectionRangeTopNodes('<p>123</p>')
for (let value of justifyClasses) {
justifyMenu.command(value)
$elems.forEach((el: DomElement) => {
expect(el.elems[0].getAttribute('style')).toContain(`text-align:${value}`)
})
}
})
test('justify 菜单:设置对齐方式,如果当前选区是 list则只修改当前选区的元素对其样式', () => {
const justifyClasses = ['left', 'right', 'center', 'justify']
const mockGetSelectionRangeTopNodes = (tagString: string) => {
const domArr = [$(tagString)]
jest.spyOn(editor.selection, 'getSelectionRangeTopNodes').mockImplementation(
() => domArr
)
return domArr
}
const mockGetSelectionRangeContainer = (tagString: string) => {
const dom = $(tagString)
jest.spyOn(editor.selection, 'getSelectionContainerElem').mockImplementation(() => dom)
return dom
}
const topElems = mockGetSelectionRangeTopNodes('<ul><li>123</li></ul>')
const containerElems = mockGetSelectionRangeContainer('<li>123</li>')
for (let value of justifyClasses) {
justifyMenu.command(value)
topElems.forEach((el: DomElement) => {
expect(el.elems[0]).not.toHaveStyle(`text-align:${value}`)
})
expect(containerElems.elems[0]).toHaveStyle(`text-align:${value}`)
}
})
test('justify 菜单:设置对齐方式,如果当前选区是 blockquote则只修改当前选区的元素对其样式', () => {
const justifyClasses = ['left', 'right', 'center', 'justify']
const $p = $('<p>123</p>')
const blockquote = $('<blockquote></blockquote>')
blockquote.append($p)
jest.spyOn(editor.selection, 'getSelectionRangeTopNodes').mockImplementation(() => [
blockquote,
])
jest.spyOn(editor.selection, 'getSelectionContainerElem').mockImplementation(() => $p)
for (let value of justifyClasses) {
justifyMenu.command(value)
expect(blockquote.elems[0]).not.toHaveStyle(`text-align:${value}`)
expect($p.elems[0]).toHaveStyle(`text-align:${value}`)
}
})
})

View File

@ -66,7 +66,9 @@ describe('LineHeight menu', () => {
const cmdVal = '2'
lineHeightMenu.command(cmdVal)
// 此处触发 editor.cmd.do('insertHTML', xx),可以被 jest 成功执行,具体参考 mockCmdFn 的描述
expect(editor.$textElem.elems[0].innerHTML.indexOf('<p>123</p>')).toBeGreaterThanOrEqual(0)
expect(
editor.$textElem.elems[0].innerHTML.indexOf('<p style="line-height:2;">123</p>')
).toBeGreaterThanOrEqual(0)
})
test('lineHeight 菜单:选择多行增加行高, 设置非段落的P标签开头全选', () => {

View File

@ -3,7 +3,7 @@
* @author tonghan
*/
import List from '../../../src/menus/list/index'
import List, { ListType } from '../../../src/menus/list/index'
import Editor from '../../../src/editor'
import mockCmdFn from '../../helpers/command-mock'
import createEditor from '../../helpers/create-editor'
@ -24,11 +24,11 @@ test('list 菜单dropList', () => {
test('list 菜单:有序', () => {
mockCmdFn(document)
listMenu.command('insertOrderedList')
expect(document.execCommand).toBeCalledWith('insertOrderedList', false, undefined)
listMenu.command(ListType.OrderedList)
expect(editor.$textElem.html().includes('<ol>')).toBeTruthy()
})
test('list 菜单:无序', () => {
listMenu.command('insertUnorderedList')
expect(document.execCommand).toBeCalledWith('insertUnorderedList', false, undefined)
listMenu.command(ListType.UnorderedList)
expect(editor.$textElem.html().includes('<ul>')).toBeTruthy()
})

View File

@ -8,7 +8,7 @@ import Undo from '../../../src/menus/undo/index'
import mockCmdFn from '../../helpers/command-mock'
import { getMenuInstance } from '../../helpers/menus'
test('重做', done => {
test('重做 兼容模式', done => {
let count = 0
const editor = createEditor(document, 'div1', '', {
onchange: function () {
@ -22,21 +22,37 @@ test('重做', done => {
if (count == 2) {
const redo = getMenuInstance(editor, Redo) as Redo
redo.clickHandler()
if (editor.isCompatibleMode) {
// 兼容模式
expect(editor.$textElem.html()).toEqual('<p><span>123</span><br></p>')
} else {
// 标准模式
expect(editor.history.size).toEqual([1, 0])
}
// 兼容模式
expect(editor.$textElem.html()).toEqual('<p><br><span>123</span></p>')
}
done()
},
compatibleMode: function () {
return Math.round(Math.random()) == 1
},
})
mockCmdFn(document)
editor.cmd.do('insertHTML', '<span>123</span>')
})
test('重做 标准模式', done => {
let count1 = 0
const editor = createEditor(document, 'div2', '', {
onchange: function () {
count1++
// 由 editor.cmd.do 触发的 onchange
if (count1 == 1) {
const undo = getMenuInstance(editor, Undo) as Undo
undo.clickHandler()
}
// 由 undo.clickHandler 触发的 onchange
if (count1 == 2) {
const redo = getMenuInstance(editor, Redo) as Redo
redo.clickHandler()
// 标准模式
expect(editor.history.size).toEqual([1, 0])
}
done()
},
})
mockCmdFn(document)

View File

@ -9,13 +9,13 @@ editor.txt.append('<p>abc</p><p>test</p>')
test('设置todo功能', () => {
boldMenu.clickHandler()
expect(editor.txt.html()).toEqual(
`<p><br></p><p>abc</p><ul class="w-e-todo"><li><span contenteditable="false"><input type="checkbox"></span>test</li></ul>`
`<p>abc</p><ul class="w-e-todo"><li><span contenteditable="false"><input type="checkbox"></span>test</li></ul>`
)
})
test('取消todo功能', () => {
boldMenu.clickHandler()
expect(editor.txt.html()).toEqual(`<p><br></p><p>abc</p><p>test</p>`)
expect(editor.txt.html()).toEqual(`<p>abc</p><p>test</p>`)
})
test('在第一行设置todo', () => {

View File

@ -43,6 +43,7 @@ test('点击 tooltip', () => {
})
test('tooltip 显示和隐藏', () => {
tooltip.create()
expect(tooltip.isShow).toBe(true)
tooltip.remove()

View File

@ -70,12 +70,10 @@ test('video onlineVideoCheck 自定义检查', () => {
videoMenu.clickHandler()
editor.config.onlineVideoCheck = function (video: string) {
if (video === '测试') {
return '测试禁止插入'
return false
}
return true
}
const fn3 = jest.fn()
editor.config.customAlert = fn3
const panel = videoMenu.panel as Panel
const panelElem = panel.$container.elems[0]
@ -91,7 +89,6 @@ test('video onlineVideoCheck 自定义检查', () => {
$videoIFrame.val(video)
$btnInsert.click()
expect(fn3).toBeCalled()
expect(editor.$textElem.html().indexOf(video)).toBe(-1)
})

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()
})
})
})

View File

@ -111,7 +111,7 @@ describe('Editor Text test', () => {
test('编辑器初始化后,调用 txt append 方法 能追加 html', () => {
editor.txt.append('12345<span>1234</span>')
expect(editor.txt.html()).toEqual('<p><br></p><p>12345<span>1234</span></p>')
expect(editor.txt.html()).toEqual('<p>12345<span>1234</span></p>')
})
test('编辑器初始化后,编辑器区域会绑定 keyup 事件触发保存range和激活菜单函数', () => {