tiny-vue/packages/fluent-editor/index.html

754 lines
26 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FluentEditor Demo</title>
</head>
<body>
<link href="./dist/style.css" rel="stylesheet" />
<button id="toggle-editor">销毁/生成组件</button>
<!-- Create the editor container -->
<script type="module">
import FluentEditor from '/dist/index.es.js'
const SizeStyle = FluentEditor.imports['formats/size'] // 文字大小
const FontClass = FluentEditor.imports['formats/font'] // 文字字体
const lineheight = FluentEditor.imports['formats/lineheight'] // 行高
const CustomImageSpec = FluentEditor.imports['modules/image-spec']
const Range = FluentEditor.imports['core/selection/range'];
const BetterTable = FluentEditor.imports['modules/better-table']
const Delta = FluentEditor.imports['delta']
// 上传:图片、视频和文件
const { DEFAULTS: UploaderDfls } = FluentEditor.imports['modules/uploader']
UploaderDfls.enableMultiUpload = { file: true, image: false }
UploaderDfls.handler = (range, files, fileFlags, rejectFlags) => {
const fileArr = []
const imgArr = []
files.forEach((file, index) =>
fileFlags[index] ? fileArr.push(file) : imgArr.push(file),
)
if (modules.file && (fileArr.length || rejectFlags.file)) {
handleUploadFile(range, fileArr, rejectFlags.file)
}
if (imgArr.length || rejectFlags.image) {
handleUploadImage(
range,
{ file: imgArr[0], files: imgArr },
rejectFlags.image,
)
}
}
const popoverComponentRef = []
const mentionObj = {
data: [
{
name: 'Jack',
age: 26,
cn: 'Jack杰克',
},
{
name: 'Lucy',
age: 22,
cn: 'Lucy露西',
},
],
searchKey: 'name',
}
const container = [
['undo', 'redo', 'clean'],
[
{ font: FontClass.whitelist },
{ size: SizeStyle.whitelist },
{ lineheight: lineheight.whitelist },
{ header: [1, 2, 3, 4, 5, 6, false] },
],
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ header: 1 }, { header: 2 }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ script: 'sub' }, { script: 'super' }],
[{ indent: '-1' }, { indent: '+1' }],
[{ direction: 'rtl' }],
[{ color: [] }, { background: [] }],
[{ align: [] }],
['link', 'image', 'video', 'file'],
['better-table'],
['fullscreen']
]
const modules = {
file: true, // 上传文件需开启
image: {
specs: [CustomImageSpec],
},
// counter: true, // 字符计数器,两种配置方式都可以
counter: {
format: 'html',
count: 102400, // 默认500
errorTemplate:
'<span style="color:red">字数不能超过102400个字符</span>',
},
toolbar: {
container,
handlers: {
undo: function () {
// 如果当前有表格被激活则清除表格工具条
const betterTableModule = EDITOR.getModule('better-table')
if (betterTableModule.table) {
betterTableModule.hideTableTools()
}
EDITOR.history.undo()
},
redo: function () {
// 如果当前有表格被激活则清除表格工具条
const betterTableModule = EDITOR.getModule('better-table')
if (betterTableModule.table) {
betterTableModule.hideTableTools()
}
EDITOR.history.redo()
},
lineheight: function (value) {
EDITOR.format('lineheight', value, FluentEditor.sources.USER)
},
file: function () {
const option = EDITOR.options.uploadOption
const accept = option && option.fileAccept
inputFile.call(this, 'file', accept)
},
image: function () {
const option = EDITOR.options.uploadOption
const accept = option && option.imageAccept
inputFile.call(this, 'image', accept)
},
link: function (value) {
const range = this.quill.getSelection();
const { tooltip } = this.quill.theme;
const insertDefaultLink = () => {
const linkPlaceholder = '链接';
this.quill.insertText(range.index, linkPlaceholder, FluentEditor.sources.USER);
this.quill.setSelection(range.index, linkPlaceholder.length, FluentEditor.sources.USER);
const newRange = this.quill.getSelection();
if (newRange) {
// 初次生成blot时如果值为空不会走blot的create()方法
this.quill.formatText(newRange, 'link', ' ', FluentEditor.sources.USER);
this.quill.formatText(newRange, 'link', '', FluentEditor.sources.USER);
tooltip.edit('link', '', newRange);
}
};
if (value) {
if (isNullOrUndefined(range) || range.length === 0) {
// 直接插入超链接
insertDefaultLink();
} else {
// 选中文本插入超链接
this.quill.formatText(range, 'link', ' ', Emitter.sources.USER);
this.quill.formatText(range, 'link', '', Emitter.sources.USER);
tooltip.edit('link', '', range);
}
} else {
const [link, offset] = this.quill.scroll.descendant(LinkBlot, range.index);
if (isNullOrUndefined(link)) {
// 在超链接后面插入超链接
insertDefaultLink();
} else {
// 取消超链接
const fullRange = new Range(range.index - offset, link.length());
this.quill.formatText(fullRange, 'link', false, FluentEditor.sources.USER);
}
}
},
'better-table': function() {
insertTableHandler()
},
fullscreen: function() {
fullscreenHandler()
}
},
},
'better-table': {
operationMenu: {
items: {
copyCells: {
text: '复制',
},
copyTable: {
text: '复制表格',
},
emptyCells: {
text: '清空内容',
},
insertRowUp: {
text: '上插入行',
},
insertRowDown: {
text: '下插入行',
},
insertColumnLeft: {
text: '左插入列',
},
insertColumnRight: {
text: '右插入列',
},
mergeCells: {
text: '合并单元格',
},
unmergeCells: {
text: '拆分单元格',
},
deleteRow: {
text: '删除当前行',
},
deleteColumn: {
text: '删除当前列',
},
deleteTable: {
text: '删除表格',
},
},
color: true,
},
},
mention: {
search: async term => {
const { data, searchKey } = mentionObj
try {
return data.filter(d => {
return d[searchKey] && String(d[searchKey]).includes(term)
})
} catch (e) {
console.warn(e)
return []
}
},
},
keyboard: {
bindings: {
...BetterTable.keyboardBindings,
'list autofill': {
key: ' ',
shiftKey: null,
collapsed: true,
format: {
list: false,
'code-block': false,
blockquote: false,
header: false,
table: true,
},
prefix: /^\s*?(\d+\.|-|\*|\[ ?\]|\[x\])$/,
handler(range, context) {
const formats = EDITOR.getFormat(range)
if (formats['table-cell-line']) {
return true
}
if (isNullOrUndefined(EDITOR.scroll.query('list'))) {
return true
}
const { length } = context.prefix
const [line, offset] = EDITOR.getLine(range.index)
if (offset > length) {
return true
}
let value
switch (context.prefix.trim()) {
case '[]':
case '[ ]':
value = 'unchecked'
break
case '[x]':
value = 'checked'
break
case '-':
case '*':
value = 'bullet'
break
default:
value = 'ordered'
}
EDITOR.insertText(range.index, ' ', FluentEditor.sources.USER)
EDITOR.history.cutoff()
const delta = new Delta()
.retain(range.index - offset)
.delete(length + 1)
.retain(line.length() - 2 - offset)
.retain(1, { list: { value: value } })
EDITOR.updateContents(delta, FluentEditor.sources.USER)
EDITOR.history.cutoff()
EDITOR.setSelection(
range.index - length,
FluentEditor.sources.SILENT,
)
return false
},
},
// fix:在list的offset为0时即只在最左侧触发加键盘事件使其跳过contenteditable=false的span进行移动
'list ignoreRight': {
key: 'ArrowRight',
shiftKey: null,
format: ['list'],
collapsed: true,
offset: 0,
handler(range) {
EDITOR.setSelection(
range.index + 1,
FluentEditor.sources.SILENT,
)
},
},
'list ignoreLeft': {
key: 'ArrowLeft',
shiftKey: null,
format: ['list'],
collapsed: true,
offset: 0,
handler(range) {
EDITOR.setSelection(
range.index - 1,
FluentEditor.sources.SILENT,
)
},
},
'list softBreak': {
key: 'Enter',
format: ['list'],
shiftKey: true,
collapsed: true,
handler(range) {
const [line, offset] = EDITOR.getLine(range.index)
const length = line.length()
// li的末尾不能添加软回车
if (length > offset + 1) {
EDITOR.insertEmbed(
range.index,
'soft-break',
true,
FluentEditor.sources.USER,
)
}
},
},
},
},
}
var tableModule = null
var insertButton
var insertTableHandler
var EDITOR = null;
var div = null;
var fullscreenButton
var fullscreenHandler
var isFullscreen = false
document.addEventListener('fullscreenchange', (event) => {
isFullscreen = !isFullscreen
});
document.querySelector('#toggle-editor').addEventListener('click', function() {
div = document.querySelector('.editor');
if (div) {
EDITOR.root.removeEventListener('compositionstart', handleCompositionstart, false)
EDITOR.root.removeEventListener('compositionend', handleCompositionend, false);
div.remove();
if(insertButton){
insertButton.removeEventListener('click', insertTableHandler)
insertButton.remove()
insertTableHandler = null
}
if(fullscreenButton){
fullscreenButton.removeEventListener('click', fullscreenHandler)
fullscreenButton.remove()
fullscreenButton = null
}
div = null;
EDITOR = null;
}
else {
div = document.createElement('div');
div.classList.add('editor');
var childDiv = document.createElement('div');
div.appendChild(childDiv);
document.body.appendChild(div);
EDITOR = new FluentEditor('.editor div', {
modules,
placeholder: 'Compose an epic...',
theme: 'snow', // or 'bubble'
// 上传:图片、视频和文件,相关配置
uploadOption: {
fileAccept: '.mp4,.xls,.doc,.docx,.ppt,.pptx',
imageAccept: '.gif,.png,.tiff,image/jpeg',
isVideoPlay: true,
imageUploadToServer: false, // 是否需要上传到服务器
},
});
EDITOR.root.addEventListener('compositionstart', handleCompositionstart, false)
EDITOR.root.addEventListener('compositionend', handleCompositionend, false);
insertButton = document.querySelector('.ql-better-table')
fullscreenButton = document.querySelector('.ql-fullscreen');
insertTableHandler = function (row = 3, col = 3) {
tableModule.insertTable(row, col)
}
fullscreenHandler = function() {
// 此方案有问题:点击上传文件或图片,浏览器自动退出全屏
// 改用样式实现全屏由UI层做适配
isFullscreen
? document.exitFullscreen()
: document.getElementsByClassName('editor')[0].requestFullscreen();
}
tableModule = EDITOR.getModule('better-table')
}
});
// 开始输入中文
function handleCompositionstart() {
EDITOR.root.classList.remove('ql-blank');
}
//
function handleCompositionend(event) {
// fix: 修复中文输入时无法判断编辑器内容是否为空
if (EDITOR.editor.isBlank()) {
if(EDITOR.getLength() > 0 && event.data.length > 0 ) {
let data = event.data
EDITOR.setContents([
{ insert: data }
]);
EDITOR.setSelection(data.length);
EDITOR.root.classList.remove('ql-blank');
} else {
EDITOR.root.classList.add('ql-blank');
}
} else {
EDITOR.root.classList.remove('ql-blank');
}
EDITOR.insertText
// fix: 修复 mention 后输入中文,光标无法定位至输入的文字最后
const range = EDITOR.getSelection();
const [mentionItem, offset] = EDITOR.getLeaf(range.index);
if (mentionItem.statics.blotName === 'mention' && offset === 1) {
setTimeout(() => {
const [textItem] = EDITOR.getLeaf(range.index + 1);
if (textItem && textItem.text) {
EDITOR.setSelection(range.index + textItem.length());
}
});
}
}
// 触发上传
function inputFile(type, accept) {
const defaultMIMETypes = EDITOR.uploader.options[type].join(', ')
const mimeTypes = accept || defaultMIMETypes
let fileInput = this.container.querySelector(
`input.ql-${type}[type=file]`,
)
if (isNullOrUndefined(fileInput)) {
fileInput = document.createElement('input')
fileInput.classList.add(`ql-${type}`)
fileInput.setAttribute('type', 'file')
fileInput.setAttribute('style','display:none');
fileInput.setAttribute('accept', mimeTypes)
if (
UploaderDfls.enableMultiUpload === true ||
(UploaderDfls.enableMultiUpload['file'] && type === 'file') ||
(UploaderDfls.enableMultiUpload['image'] && type === 'image')
) {
fileInput.setAttribute('multiple', '')
}
fileInput.addEventListener('change', () => {
const range = EDITOR.getSelection(true)
EDITOR.uploader.upload(range, fileInput.files, type === 'file')
fileInput.value = ''
})
this.container.appendChild(fileInput)
}
fileInput.click()
}
function isNullOrUndefined(param) {
return param === null || param === undefined
}
// 处理上传图片
function handleUploadImage(range, { file, files }, hasRejectedImage) {
if (EDITOR.options.uploadOption.imageUploadToServer) {
const imageEnableMultiUpload = UploaderDfls.enableMultiUpload === true
|| UploaderDfls.enableMultiUpload['image']
const target = document.querySelector('.ql-image')
if (files.length) {
// showUploadingTips('image', target)
console.log('showUploadingTips')
}
const result = {
file,
data: { files: [file] },
hasRejectedImage: hasRejectedImage,
callback: res => {
// closeUploadingTips('image')
console.log('closeUploadingTips')
if (!res) {
return
}
if (imageEnableMultiUpload && Array.isArray(res)) {
res.forEach(value => insertImageToEditor(range, value))
} else {
insertImageToEditor(range, res)
}
},
}
if (imageEnableMultiUpload) {
result['data'] = { files }
}
uploadImageToSev(result)
} else {
const promises = files.map(fileItem => {
return new Promise(resolve => {
const reader = new FileReader()
reader.onload = e => {
resolve(e.target.result)
}
reader.readAsDataURL(fileItem)
})
})
Promise.all(promises).then(images => {
const update = images.reduce((delta, image) => {
return delta.insert({ image })
}, new Delta().retain(range.index).delete(range.length))
EDITOR.updateContents(update, FluentEditor.sources.USER)
EDITOR.setSelection(
range.index + images.length,
FluentEditor.sources.SILENT,
)
})
}
}
// 处理上传文件
function handleUploadFile(range, files, hasRejectedFile) {
const fileEnableMultiUpload =
UploaderDfls.enableMultiUpload === true
|| UploaderDfls.enableMultiUpload['file']
const target = document.querySelector('.ql-file')
if (files.length) {
// showUploadingTips('files', target)
console.log('showUploadingTips')
}
fileOperationToSev({
operation: 'upload',
data: fileEnableMultiUpload ? { files } : { file: files[0] },
hasRejectedFile: hasRejectedFile,
callback: res => {
// this.closeUploadingTips('file')
console.log('closeUploadingTips')
if (!res) {
return
}
console.log('fileEnableMultiUpload')
console.log(fileEnableMultiUpload)
if (fileEnableMultiUpload && Array.isArray(res)) {
res.forEach((value, index) =>
insertFileToEditor(range, files[index], value),
)
} else {
insertFileToEditor(range, files[0], res)
}
},
})
}
// 图片上传到服务器,需要将示例中的上传接口替换成真实的后台接口才能正确运行。
function uploadImageToSev(event) {
const {
file,
hasRejectedImage,
data: { files},
callback,
} = event
if (hasRejectedImage) {
this.msgs = [
{
severity: 'error',
summary: '上传图片失败',
detail: `只支持上传以下类型:\n${EDITOR.options.uploadOption.imageAccept}`,
},
]
}
if (file || files) {
/* 使用时取消注释,填写您的上传接口 */
/* setTimeout用于模拟接口延迟演示上传提示使用时请移除 */
// const formData = new FormData()
// formData.append('file', file)
// this.http.post('url/to/your/api', formData).subscribe(res => {
let res
const imageUrl = './src/assets/image1.png' /* 后台返回的图片URL */
if (UploaderDfls.enableMultiUpload.image) {
res = files.map(fileItem => {
return {
code: 0,
message: 'Upload image successfully!',
data: { imageId: 100, imageUrl },
}
})
} else {
res = {
code: 0,
message: 'Upload image successfully!',
data: { imageId: 100, imageUrl },
}
}
setTimeout(() => callback(res), 500)
// })
}
}
// 文件上传到服务器,需要将示例中的上传接口替换成真实的后台接口才能正确运行。
function fileOperationToSev(event) {
const {
operation,
hasRejectedFile,
data: { fileId, file, files, fileDownloadUrl },
callback,
} = event
switch (operation) {
case 'upload':
if (hasRejectedFile) {
this.msgs = [
{
severity: 'error',
summary: '上传文件失败',
detail: `只支持上传以下类型:\n${EDITOR.options.uploadOption.fileAccept}`,
},
]
}
if (file || files) {
/* 使用时取消注释,填写您的上传接口 */
/* setTimeout用于模拟接口延迟演示上传提示使用时请移除 */
/* const formData = new FormData();
formData.append('file', file);
this.http.post('url/to/your/api', formData)
.subscribe(res => { */
let res
if (UploaderDfls.enableMultiUpload.file) {
res = files.map(fileItem => {
return {
code: 0,
message: 'Upload file successfully!',
data: {
id: 'faf40644468b497d90cbaf4304c39e1a',
title: fileItem.name,
size: fileItem.size,
lastModified: fileItem.lastModified,
src: 'http://www.baidu.com',
},
}
})
} else {
res = {
code: 0,
message: 'Upload file successfully!',
data: {
id: 'faf40644468b497d90cbaf4304c39e1a',
title: file.name,
size: file.size,
lastModified: file.lastModified,
src: '已上传视频地址',
},
}
}
setTimeout(() => callback(res), 500)
/* }); */
}
break
case 'download':
console.log('download file' + fileId)
break
case 'delete':
this.http.post(fileDownloadUrl, { id: fileId })
break
}
}
// 将图片插入编辑器
function insertImageToEditor(range, { code, message, data }) {
if (code === 0) {
const { imageId, imageUrl } = data
// 粘贴截图或者从外源直接拷贝的单图时,需要将编辑器中已选中的内容删除
const oldContent = new Delta()
.retain(range.index)
.delete(range.length)
const currentContent = new Delta([
{
insert: { image: imageUrl },
attributes: { 'image-id': imageId },
},
])
const newContent = oldContent.concat(currentContent)
EDITOR.updateContents(newContent, FluentEditor.sources.USER)
} else {
console.error('error message:', message)
}
}
// 将文件插入编辑器
function insertFileToEditor(range, file, { code, message, data }) {
if (code === 0) {
const oldContent = new Delta()
.retain(range.index)
.delete(range.length)
const videoFlag =
EDITOR.options.uploadOption &&
EDITOR.options.uploadOption.isVideoPlay &&
/^video\/[-\w.]+$/.test(file.type)
const insertObj = videoFlag ? { video: data } : { file: data }
const currentContent = new Delta([{ insert: insertObj }])
const newContent = oldContent.concat(currentContent)
EDITOR.updateContents(newContent, FluentEditor.sources.USER)
} else {
console.error('error message:', message)
}
}
function onPaste({ files, callback }) {
const _this = this
function uploadImage(file) {
return new Promise((resolve, reject) => {
const formData = new FormData()
formData.append('file', file)
_this.http.post('url/to/your/api', formData).subscribe(res => {
const imageUrl = 'url/image.png' /* 后台返回的图片URL */
resolve(imageUrl)
})
})
}
;(async () => {
try {
const imageUrls = []
for (let i = 0; i < files.length; i++) {
imageUrls.push(await uploadImage(files[i]))
}
callback({
code: 0,
message: 'Upload all images successfully!',
data: {
imageUrls: imageUrls,
},
})
} catch (e) {
throw new Error('Upload failed.')
}
})()
}
</script>
</body>
</html>