更新富文本编辑器

This commit is contained in:
砂糖
2025-07-31 16:27:16 +08:00
parent fce7f0985c
commit 384c4a7e38
24 changed files with 4772 additions and 37 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,261 @@
/**
* 以下方法出自 image-tools
* @see https://ext.dcloud.net.cn/plugin?id=123
*/
function getLocalFilePath(path) {
if (path.indexOf('_www') === 0 || path.indexOf('_doc') === 0 || path.indexOf('_documents') === 0 || path.indexOf(
'_downloads') === 0) {
return path
}
if (path.indexOf('file://') === 0) {
return path
}
if (path.indexOf('/storage/emulated/0/') === 0) {
return path
}
if (path.indexOf('/') === 0) {
let localFilePath = plus.io.convertAbsoluteFileSystem(path)
if (localFilePath !== path) {
return localFilePath
} else {
path = path.substr(1)
}
}
return '_www/' + path
}
function dataUrlToBase64(str) {
let array = str.split(',')
return array[array.length - 1]
}
let index = 0
function getNewFileId() {
return Date.now() + String(index++)
}
function biggerThan(v1, v2) {
let v1Array = v1.split('.')
let v2Array = v2.split('.')
let update = false
for (let index = 0; index < v2Array.length; index++) {
let diff = v1Array[index] - v2Array[index]
if (diff !== 0) {
update = diff > 0
break
}
}
return update
}
export function pathToBase64(path) {
return new Promise(function(resolve, reject) {
if (typeof window === 'object' && 'document' in window) {
if (typeof FileReader === 'function') {
let xhr = new XMLHttpRequest()
xhr.open('GET', path, true)
xhr.responseType = 'blob'
xhr.onload = function() {
if (this.status === 200) {
let fileReader = new FileReader()
fileReader.onload = function(e) {
resolve(e.target.result)
}
fileReader.onerror = reject
fileReader.readAsDataURL(this.response)
}
}
xhr.onerror = reject
xhr.send()
return
}
let canvas = document.createElement('canvas')
let c2x = canvas.getContext('2d')
let img = new Image
img.onload = function() {
canvas.width = img.width
canvas.height = img.height
c2x.drawImage(img, 0, 0)
resolve(canvas.toDataURL())
canvas.height = canvas.width = 0
}
img.onerror = reject
img.src = path
return
}
if (typeof plus === 'object') {
plus.io.resolveLocalFileSystemURL(getLocalFilePath(path), function(entry) {
entry.file(function(file) {
let fileReader = new plus.io.FileReader()
fileReader.onload = function(data) {
resolve(data.target.result)
}
fileReader.onerror = function(error) {
reject(error)
}
fileReader.readAsDataURL(file)
}, function(error) {
reject(error)
})
}, function(error) {
reject(error)
})
return
}
if (typeof wx === 'object' && wx.canIUse('getFileSystemManager')) {
wx.getFileSystemManager().readFile({
filePath: path,
encoding: 'base64',
success: function(res) {
resolve('data:image/png;base64,' + res.data)
},
fail: function(error) {
reject(error)
}
})
return
}
reject(new Error('not support'))
})
}
export function base64ToPath(base64) {
return new Promise(function(resolve, reject) {
if (typeof window === 'object' && 'document' in window) {
base64 = base64.split(',')
let type = base64[0].match(/:(.*?);/)[1]
let str = atob(base64[1])
let n = str.length
let array = new Uint8Array(n)
while (n--) {
array[n] = str.charCodeAt(n)
}
return resolve((window.URL || window.webkitURL).createObjectURL(new Blob([array], {
type: type
})))
}
let extName = base64.split(',')[0].match(/data\:\S+\/(\S+);/)
if (extName) {
extName = extName[1]
} else {
reject(new Error('base64 error'))
}
let fileName = getNewFileId() + '.' + extName
if (typeof plus === 'object') {
let basePath = '_doc'
let dirPath = 'uniapp_temp'
let filePath = basePath + '/' + dirPath + '/' + fileName
if (!biggerThan(plus.os.name === 'Android' ? '1.9.9.80627' : '1.9.9.80472', plus.runtime.innerVersion)) {
plus.io.resolveLocalFileSystemURL(basePath, function(entry) {
entry.getDirectory(dirPath, {
create: true,
exclusive: false,
}, function(entry) {
entry.getFile(fileName, {
create: true,
exclusive: false,
}, function(entry) {
entry.createWriter(function(writer) {
writer.onwrite = function() {
resolve(filePath)
}
writer.onerror = reject
writer.seek(0)
writer.writeAsBinary(dataUrlToBase64(base64))
}, reject)
}, reject)
}, reject)
}, reject)
return
}
let bitmap = new plus.nativeObj.Bitmap(fileName)
bitmap.loadBase64Data(base64, function() {
bitmap.save(filePath, {}, function() {
bitmap.clear()
resolve(filePath)
}, function(error) {
bitmap.clear()
reject(error)
})
}, function(error) {
bitmap.clear()
reject(error)
})
return
}
if (typeof wx === 'object' && wx.canIUse('getFileSystemManager')) {
let filePath = wx.env.USER_DATA_PATH + '/' + fileName
wx.getFileSystemManager().writeFile({
filePath: filePath,
data: dataUrlToBase64(base64),
encoding: 'base64',
success: function() {
resolve(filePath)
},
fail: function(error) {
reject(error)
}
})
return
}
reject(new Error('not support'))
})
}
/**
* 本方法为本人自己写的建议还是使用上述的pathToBase64方法
* @description 图片地址转换为base64格式图片
* @param {string} url 图片地址 网络地址 本地相对路径
* @param {string} type base64图片类型 默认png
*/
export function urlToBase64(url, type = 'png') {
let promises
// 网络地址 或者h5端本地相对路径 可使用request方式
promises = new Promise((resolve, reject) => {
uni.request({
url: url,
method: 'GET',
responseType: 'arraybuffer',
success: (res) => {
const base64 = `data:image/${type};base64,${uni.arrayBufferToBase64(res.data)}`
resolve(base64);
},
fail: (err) => {
reject(err);
},
})
})
// #ifdef APP
if (!url.startsWith('http')) {
// app真机本地相对路径
promises = new Promise((resolve, reject) => {
// 使用compressImage获取到安卓本地路径file:///...
uni.compressImage({
src: url,
quality: 100,
success: (res) => {
const tempUrl = res.tempFilePath
plus.io.resolveLocalFileSystemURL(tempUrl, (entry) => {
entry.file((e) => {
let fileReader = new plus.io.FileReader();
fileReader.onload = (r) => {
resolve(r.target.result)
}
fileReader.readAsDataURL(e)
})
})
},
fail: (err) => {
reject(err);
},
})
})
}
// #endif
return promises
}

View File

@@ -0,0 +1,179 @@
/**
* 富文本解析工具
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import config from './config.js'
/**
* 将含有封面占位图形式的视频富文本转换成正常视频的富文本
* @param {String} richText 要进行处理的富文本字符串
* @returns {String} 返回处理结果
*/
export function parseHtmlWithVideo(richText) {
// 正则表达式匹配<img>标签及其属性
const imgRegex = /<img\s+([^>]+)>/gi;
// 正则表达式匹配data-custom属性中的url值
const customUrlRegex = /\bdata-custom="[^"]*url=([^&"]+)/i;
return richText.replace(imgRegex, (match, attrs) => {
// 查找data-custom属性中的url值
const urlMatch = attrs.match(customUrlRegex);
if (urlMatch) {
// 获取data-custom中的url
const videoUrl = urlMatch[1];
// 解析出所有属性
const attrArray = attrs.split(/\s+/).filter(attr => attr.trim() !== '');
// 过滤掉src属性和data-custom属性
const newAttrs = attrArray.filter(attr => !attr.startsWith('src=') && !attr.startsWith('data-custom='))
.join(' ');
// 构建新的video标签保留原有的其他属性但去除src和data-custom
return `<video controls ${newAttrs}><source src="${videoUrl}" /></video>`;
}
// 如果没有匹配到data-custom中的url则保持原样
return match;
});
}
/**
* 带有视频的富文本逆向转换
* @description 可自定义处理封面
* @param {Promise} richText 要转换的富文本
* @param {Function<Promise>} customCallback 自定义处理封面回调需要return封面图片资源自带参数为视频地址
* @returns {Promise} 转换后的富文本 注意异步处理
*/
export async function replaceVideoWithImageRender(richText, customCallback) {
// 正则表达式用于匹配 <video> 标签以及其内部的 <source> 标签
const videoRegex = /<video\s+([^>]+)>(.*?)<\/video>/gi;
// 找到所有的 <video> 标签
const matches = [];
let match;
while ((match = videoRegex.exec(richText)) !== null) {
matches.push(match);
}
// 并行处理每个 <video> 标签,生成对应的缩略图
const replacements = await Promise.all(
matches.map(async (match) => {
const [fullMatch, attributes, content] = match;
// 匹配 <source> 标签中的 src 属性
const sourceRegex = /<source\s+[^>]*src="([^">]+)"/i;
const matchSource = content.match(sourceRegex);
let videoUrl = '';
if (matchSource && matchSource.length > 1) {
videoUrl = matchSource[1];
}
// 生成视频封面图
let thumbnailRes
if (customCallback) thumbnailRes = await customCallback(videoUrl) // 自定义封面处理
if (!thumbnailRes) thumbnailRes = config.video_thumbnail // 无效值则默认封面处理
// 过滤掉不需要的属性,例如 controls
const filteredAttributes = attributes
.split(/\s+/)
.filter(attr => !attr.startsWith('controls'))
.join(' ');
// 构建新的 img 标签,继承 video 的属性(除了 controls并添加 data-custom 属性
const imgTag = `<img ${filteredAttributes} src="${thumbnailRes}" data-custom="url=${videoUrl}" />`;
return { fullMatch, imgTag };
}));
// 使用 replacements 替换原始的 <video> 标签
let result = richText;
for (const { fullMatch, imgTag } of replacements) {
result = result.replace(fullMatch, imgTag);
}
return result;
}
/**
* 解析出富文本中的图片和视频
* @param {String} richText 要解析的富文本
* @returns {Array} 图片和视频数组
*/
export function parseImagesAndVideos(richText) {
// 创建一个空数组用于存储图片和视频信息
const result = [];
// 正则表达式匹配 <img> 标签及其属性
const imgRegex = /<img\s+[^>]*>/gi;
// 匹配属性名和值的正则表达式,改进后的版本可以处理属性名中包含连字符的情况
const attrRegex = /(\w+(-\w+)*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>]+))/gi;
// 找到所有的 <img> 标签
const matches = richText.match(imgRegex);
// 如果没有找到任何 <img> 标签,返回空数组
if (!matches) return [];
// 遍历所有的 <img> 标签
matches.forEach(match => {
// 创建一个对象用于存储单个图片或视频的信息
const ivInfo = {};
// 使用正则表达式匹配每个 <img> 标签的属性
let attrsMatch;
while ((attrsMatch = attrRegex.exec(match)) !== null) {
// 属性名
const name = attrsMatch[1].toLowerCase();
// 属性值可能存在于第三、第四或第五个捕获组中
let value = attrsMatch[3] || attrsMatch[4] || attrsMatch[5] || '';
// 去除属性值两端可能存在的引号
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
value = value.substring(1, value.length - 1);
}
// 将属性名和值添加到 ivInfo 对象中
ivInfo[name] = value;
}
// 将单个图片或视频信息添加到数组中
result.push(ivInfo);
});
// 返回包含所有图片和视频信息的数组
return result;
}
/**
* 解析出富文本中的图片
* @param {String} richText 要解析的富文本
* @returns {Array} 图片数组
*/
export function parseImages(richText) {
let result = []
const ivList = parseImagesAndVideos(richText)
ivList.forEach(item => {
if (!item['data-custom'] || !item['data-custom'].startsWith('url')) {
result.push(item)
}
})
return result
}
/**
* 解析出富文本中的视频
* @param {String} richText 要解析的富文本
* @returns {Array} 视频数组
*/
export function parseVideos(richText) {
let result = []
const ivList = parseImagesAndVideos(richText)
ivList.forEach(item => {
if (item['data-custom'] && item['data-custom'].startsWith('url')) {
result.push(item)
}
})
return result
}

View File

@@ -0,0 +1,101 @@
/**
* 插件内全局状态管理
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
// #ifdef VUE3
import { reactive } from 'vue';
// #endif
// #ifdef VUE2
import Vue from 'vue';
// #endif
// 定义state状态
let state = null
// #ifdef VUE3
// 定义响应式状态
state = reactive({
curEID: '',
formats: {},
isReadOnly: false,
firstInstanceFlag: '' // 首次实例化标志,禁止手动更改
})
// #endif
// #ifdef VUE2
// 定义响应式状态
state = Vue.observable({
curEID: '',
formats: {},
isReadOnly: false,
firstInstanceFlag: '' // 首次实例化标志,禁止手动更改
})
// #endif
// 定义方法
function getEditor(eid) {
return state[`${eid}-ctx`];
};
function setEditor(eid, ctx) {
state[`${eid}-ctx`] = ctx
// #ifdef MP-WEIXIN
state[`${eid}-ctx`].id = eid
// #endif
}
function getEID() {
return state.curEID
};
function setEID(eid) {
state.curEID = eid
}
function getFormats() {
return state.formats
}
function setFormats(formats) {
state.formats = formats
}
function getReadOnly() {
return state.isReadOnly
}
function setReadOnly(readOnly) {
state.isReadOnly = readOnly
}
function destroy() {
// 重置所有状态
state = {}
state.curEID = ''
state.formats = {}
state.isReadOnly = false
state.firstInstanceFlag = '' // 首次实例化标志,禁止手动更改
}
// 定义options对象
const options = {
state,
actions: {
getEditor,
setEditor,
getEID,
setEID,
getFormats,
setFormats,
getReadOnly,
setReadOnly,
destroy
}
}
// 导出
export default options

View File

@@ -0,0 +1,208 @@
/**
* 工具栏
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
export const toolList = [
{ title: '样式', name: 'style', icon: 'icon-zitiyanse' },
{ title: '表情', name: 'emoji', icon: 'icon-xiaolian' },
{ title: '撤销', name: 'undo', icon: 'icon-shangyibu1' },
{ title: '重做', name: 'redo', icon: 'icon-xiayibu1' },
{ title: '更多', name: 'more', icon: 'icon-icon_tianjia' },
{ title: '扩展', name: 'setting', icon: 'icon-bianji' },
]
export const styleToolList = [
{ title: '标题', name: 'header', value: 2, icon: 'icon-zitibiaoti' },
{ title: '分割线', name: 'divider', icon: 'icon-fengexian' },
{ title: '粗体', name: 'bold', icon: 'icon-zitijiacu' },
{ title: '斜体', name: 'italic', icon: 'icon-zitixieti' },
{ title: '下划线', name: 'underline', icon: 'icon-zitixiahuaxian' },
{ title: '删除线', name: 'strike', icon: 'icon-zitishanchuxian' },
{ title: '左对齐', name: 'align', value: 'left', icon: 'icon-zuoduiqi' },
{ title: '居中', name: 'align', value: 'center', icon: 'icon-juzhongduiqi' },
{ title: '右对齐', name: 'align', value: 'right', icon: 'icon-youduiqi' },
{ title: '有序列表', name: 'list', value: 'ordered', icon: 'icon-youxupailie' },
{ title: '无序列表', name: 'list', value: 'bullet', icon: 'icon-wuxupailie' },
{ title: '上标', name: 'script', value: 'super', icon: 'icon-zitishangbiao' },
{ title: '左缩进', name: 'indent', value: '+1', icon: 'icon-zuosuojin' },
{ title: '右缩进', name: 'indent', value: '-1', icon: 'icon-yousuojin' },
{ title: '下标', name: 'script', value: 'sub', icon: 'icon-ziti-xiabiao' },
{ title: '文字颜色', name: 'color', icon: 'icon-wenziyanse' },
{ title: '背景颜色', name: 'backgroundColor', icon: 'icon-beijingyanse' },
{ title: '清除格式', name: 'removeformat', icon: 'icon-qingchugeshi' },
]
export const moreToolList = [
{ title: '添加图片', name: 'image', value: 'popup', icon: 'icon-charutupian' },
{ title: '添加视频', name: 'video', value: 'popup', icon: 'icon-shexiangji' },
{ title: '添加链接', name: 'link', value: 'popup', icon: 'icon-charulianjie' },
{ title: '添加附件', name: 'attachment', value: 'popup', icon: 'icon-huixingzhen' },
{ title: '提及', name: 'at', value: 'popup', icon: 'icon-at' },
{ title: '话题', name: 'topic', value: 'popup', icon: 'icon-huati' },
{ title: '清空', name: 'clear', value: 'button', icon: 'icon-shanchu' },
]
export const emojiToolList = [
'😊', // 笑笑
'😃', // 大笑
'😄', // 开心果
'😁', // 嘲讽
'😆', // 爆笑
'😅', // 出汗笑
'🤣', // 滚地大笑
'😂', // 泪流满面
'🙂', // 轻松愉快
'🙃', // 上下翻白眼
'😉', // 鬼鬼祟祟
'😌', // 安慰
'😍', // 心动
'🥰', // 深情
'😘', // 吻
'😗', // 接吻
'😙', // 亲吻
'😚', // 亲吻
'😋', // 哇塞
'😛', // 舌头外伸
'😝', // 舌头吐出
'😜', // 顽皮
'🤪', // 疯狂
'😎', // 自豪
'🤓', // 学究
'🧐', // 思考
'😏', // 狡猾
'😒', // 不高兴
'😞', // 不开心
'😔', // 抒发情绪
'😟', // 担忧
'😕', // 困惑
'🙁', // 小失望
'☹️️', // 不好意思
'😣', // 苦恼
'😖', // 愤怒
'😫', // 累
'😩', // 悲伤
'😤', // 生气
'😠', // 生气
'😡', // 极端愤怒
'🤬', // 发飙
'🤯', // 爆炸头脑
'😳', // 吃惊
'😱', // 惊吓
'😨', // 恐惧
'😰', // 慌张
'😢', // 哭泣
'😭', // 大哭
'😓', // 受挫
'🤗', // 给力
'🤔', // 思考
'🤭', // 戴口罩捂嘴笑
'🤫', // 戴口罩做鬼脸
'🤥', // 说谎
'😬', // 格格不入
'😴', // 睡觉
'🤤', // 垂涎欲滴
'🥳', // 庆祝
'🥺', // 求求你
'😈', // 恶魔
'👿', // 恶灵
'🤡', // 小丑
'👻', // 鬼魂
'👽', // 外星人
'👾', // 游戏角色
'🤖', // 机器人
'😺', // 笑猫
'😸', // 大笑猫
'😹', // 开心猫
'😻', // 心动猫
'😼', // 傲娇猫
'😽', // 亲吻猫
'🙀', // 惊吓猫
'😿', // 哭猫
'😾' // 生气猫
]
export const colorList = [
'#000000',
'#222222',
'#444444',
'#666666',
'#999999',
'#cccccc',
'#eeeeee',
'#ffffff',
'#c92a2a',
'#e03131',
'#f03e3e',
'#fa5252',
'#ff6b6b',
'#ff8787',
'#ffa8a8',
'#ffc9c9',
'#a61e4d',
'#c2255c',
'#d6336c',
'#e64980',
'#f06595',
'#f783ac',
'#faa2c1',
'#fcc2d7',
'#862e9c',
'#9c36b5',
'#ae3ec9',
'#be4bdb',
'#cc5de8',
'#da77f2',
'#e599f7',
'#eebefa',
'#5f3dc4',
'#6741d9',
'#7048e8',
'#7950f2',
'#845ef7',
'#9775fa',
'#b197fc',
'#d0bfff',
'#0b7285',
'#0c8599',
'#1098ad',
'#15aabf',
'#22b8cf',
'#3bc9db',
'#66d9e8',
'#99e9f2',
'#087f5b',
'#099268',
'#0ca678',
'#12b886',
'#20c997',
'#38d9a9',
'#63e6be',
'#96f2d7',
'#5c940d',
'#66a80f',
'#74b816',
'#82c91e',
'#94d82d',
'#a9e34b',
'#c0eb75',
'#ffec99',
'#d9480f',
'#e8590c',
'#f76707',
'#fd7e14',
'#ff922b',
'#ffa94d',
'#ffc078',
'#ffd8a8'
]

View File

@@ -0,0 +1,412 @@
/**
* 通用工具api
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import store from './store.js'
/**
* 添加图片
* @param {Function} uploadFunc 文件上传函数开发者自行调用上传接口上传本地图片至服务器后获取服务器图片真实地址需要return包含地址的数组
* @param {Object} options 图片配置项
* @property {String} options.srcFiled 图片地址字段名,默认无时使用数组元素本身
* @property {String} options.alt 图像无法显示时的替代文本
* @property {String} options.width 图片宽度pixels/百分比为空时自适应图片本身宽度默认空不建议100%,预留一点空隙以便用户编辑)
* @property {String} options.height 图片高度 (pixels/百分比)为空时自适应图片本身高度,默认空
* @property {String} options.extClass 添加到图片 img 标签上的类名
* @property {String} options.data 被序列化为 v1=1;v2=2 的格式挂在属性 data-custom 上
* @returns {Array|Promise} 上传的文件数组
*/
export async function addImage(uploadFunc, options = {}) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 服务器上传图片
if (!uploadFunc) return
const upRes = await uploadFunc(editorCtx)
if (!upRes || !upRes?.length) return
// 取消键盘副作用
noKeyboardEffect(() => {
editorCtx.insertText({ text: '\n' })
upRes?.forEach((item) => {
editorCtx.insertImage({
...options,
src: options.srcFiled ? item[options.srcFiled] : item,
})
})
// 建议加个换行虽然会导致input回调再次触发不过问题不大
editorCtx.insertText({ text: '\n' })
})
return upRes
}
/**
* 添加视频
* @description uni-editor暂不支持插入视频此处使用视频封面占位将视频地址作为属性挂在至data-custom上携带视频的富文本需要使用专用的api进行解析导出。注建议后端配合返回视频封面图片地址或者使用固定的网络图片作为封面。
* @param {Function} uploadFunc 文件上传函数开发者自行调用上传接口上传本地视频至服务器后获取服务器视频真实地址需要return包含地址的数组
* @param {Object} options 视频封面图片配置项
* @property {String} options.imageFiled 视频封面图片地址字段名默认imagePath
* @property {String} options.videoFiled 视频真实地址字段名默认videoPath
* @property {String} options.alt 视频封面图片无法显示时的替代文本
* @property {String} options.width 视频封面图片宽度pixels/百分比默认空但是要注意不设置width的话video标签默认宽度为300px
* @property {String} options.height 视频封面图片高度 (pixels/百分比)默认空
* @property {String} options.extClass 添加到视频封面图片 img 标签上的类名
* @property {String} options.data 警告视频地址已存入data-custom中请勿使用此参数导致视频地址被覆盖
* @returns {Array|Promise} 上传的文件数组
*/
export async function addVideo(uploadFunc, options = {}) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 服务器上传视频
if (!uploadFunc) return
const upRes = await uploadFunc(editorCtx)
console.log(upRes);
if (!upRes || !upRes?.length) return
// 取消键盘副作用
noKeyboardEffect(() => {
editorCtx.insertText({ text: '\n' })
upRes?.forEach((item) => {
editorCtx.insertImage({
...options,
src: item[options.imageFiled || 'imagePath'],
data: { url: item[options.videoFiled || 'videoPath'] },
})
})
// 建议加个换行虽然会导致input回调再次触发不过问题不大
editorCtx.insertText({ text: '\n' })
})
return upRes
}
/**
* 添加链接
* @param {Object} options 链接配置项
* @property {String} options.link 链接地址
* @property {String} options.text 链接文本 空缺时使用link
* @property {String} options.textDecoration 下划线
* @property {String} options.color 颜色 默认#007aff
* @property {Object} options.style 其他样式,例如 { bold: true, italic: true } 等详见https://quilljs.com/docs/delta
* @param {Function} callback 添加链接成功后回调
* @returns {void}
*/
export async function addLink(options = {}, callback) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 取消键盘副作用
noKeyboardEffect(() => {
insertLink(editorCtx, {
...options,
link: options.link,
text: ` ${options.text || options.link} `, // 前后各加一个空格
}, () => {
editorCtx.changeInput() // 通知更新编辑器input事件
if (callback) callback()
})
})
}
/**
* 添加附件
* @param {Function} uploadFunc 文件上传函数开发者自行调用上传接口上传本地附件至服务器后获取服务器附件真实地址需要return包含地址的对象
* @param {Object} options 附件配置项
* @property {String} options.srcFiled 附件地址字段名默认path
* @property {String} options.link 附件地址 注临时地址会自动转成about:blank导致无效
* @property {String} options.text 附件文本 空缺时使用link
* @property {String} options.textDecoration 下划线
* @property {String} options.color 颜色 默认#34d19d
* @property {Object} options.style 其他样式,例如 { bold: true, italic: true } 等详见https://quilljs.com/docs/delta
* @param {Function} callback 添加附件成功后回调
* @returns {Object|Promise} 上传的文件对象
*/
export async function addAttachment(uploadFunc, options = {}, callback) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 服务器上传附件
if (!uploadFunc) return
const upRes = await uploadFunc(editorCtx)
if (!upRes) return
const link = upRes[options.srcFiled || 'path'] || options.link
if (!link) return
const text = ` 📄${upRes.text || options.text || upRes.file?.name || link } ` // 加上附件图标前置,并前后各加一个空格
// 取消键盘副作用
noKeyboardEffect(() => {
insertLink(editorCtx, {
color: '#34d19d',
...options,
text,
link,
}, () => {
editorCtx.changeInput() // 通知更新编辑器input事件
if (callback) callback()
})
})
return upRes
}
/**
* 添加提及
* @param {Object} options 提及配置项
* @property {String} options.username 用户名称
* @property {String} options.userid 用户id
* @property {String} options.textDecoration 下划线
* @property {String} options.color 颜色 默认#66ccff
* @property {Object} options.style 其他样式,例如 { bold: true, italic: true } 等详见https://quilljs.com/docs/delta
* @param {Function} callback 添加链接成功后回调
*/
export async function addAt(options = {}, callback) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 取消键盘副作用
noKeyboardEffect(() => {
insertLink(editorCtx, {
color: '#66ccff',
...options,
link: `@${options.userid}`, // 添加特殊前缀,后续便于解析标识
text: ` @${options.username} `, // 前后各加一个空格
}, () => {
editorCtx.changeInput() // 通知更新编辑器input事件
if (callback) callback()
})
})
}
/**
* 添加话题
* @param {Object} options 话题配置项
* @property {String} options.link 话题链接
* @property {String} options.topic 话题名称
* @property {String} options.textDecoration 下划线
* @property {String} options.color 颜色 默认#909399
* @property {Object} options.style 其他样式,例如 { bold: true, italic: true } 等详见https://quilljs.com/docs/delta
* @param {Function} callback 添加链接成功后回调
*/
export async function addTopic(options = {}, callback) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 取消键盘副作用
noKeyboardEffect(() => {
insertLink(editorCtx, {
color: '#909399',
...options,
link: `#${options.link}`, // 添加特殊前缀,后续便于解析标识
text: ` #${options.topic}# `, // 前后各加一个空格
}, () => {
editorCtx.changeInput() // 通知更新编辑器input事件
if (callback) callback()
})
})
}
/**
* 标识必须独一无二 - 标识是为了使用insertText插入标识文本后查找到标识所在delta位置的索引
* 注因为做了一次insertText操作所有可能会有linkFlag标识字样闪一下的副作用没有办法避免
*/
export const linkFlag = '🔗添加链接中, 请稍后...🔗' // 建议语义化该标识,否则闪烁的时候可能会有点尴尬
/**
* 插入链接
* @description uni-editor暂不支持插入链接此api使用delta替换链接本文标识方式实现因硬性原因会导致光标失焦
* @param {Object} editorCtx 编辑器上下文
* @param {Object} attr 链接属性
* @property {String} attr.link 链接地址 注临时地址会自动转成about:blank导致无效
* @property {String} attr.text 链接文本 空缺时使用link
* @property {String} attr.textDecoration 下划线
* @property {String} attr.color 颜色 默认#007aff
* @property {Object} attr.style 其他样式,例如 { bold: true, italic: true } 等详见https://quilljs.com/docs/delta
* @param {Object} callback 成功回调
*/
export function insertLink(editorCtx, attr, callback) {
// 先插入一段文本内容
editorCtx.insertText({ text: linkFlag })
// 必须先失焦,否则光标会移至开始位置
editorCtx.blur()
// 获取全文delta内容
editorCtx.getContents({
success: (res) => {
let options = res.delta.ops
const findex = options.findIndex(item => {
return item.insert && typeof item.insert !== 'object' && item.insert?.indexOf(linkFlag) !== -1
})
// 根据标识查找到插入的位置
if (findex > -1) {
const findOption = options[findex]
const findAttributes = findOption.attributes
// 将该findOption分成三部分前内容 要插入的link 后内容
const [prefix, suffix] = findOption.insert.split(linkFlag);
const handleOps = []
// 前内容
if (prefix) {
const prefixOps = findAttributes ? {
insert: prefix,
attributes: findAttributes
} : {
insert: prefix
}
handleOps.push(prefixOps)
}
// 插入的link
const linkOps = {
insert: attr.text || attr.link,
attributes: {
link: attr.link,
textDecoration: attr.textDecoration || 'none', // 下划线
color: attr.color || '#007aff',
...attr.style
}
}
handleOps.push(linkOps)
// 后内容
if (suffix) {
const suffixOps = findAttributes ? {
insert: suffix,
attributes: findAttributes
} : {
insert: suffix
}
handleOps.push(suffixOps)
}
// 删除原options[findex]并在findex位置插入上述三个ops
options.splice(findex, 1);
options.splice(findex, 0, ...handleOps);
// 最后重新初始化内容
editorCtx.setContents({
delta: {
ops: options
}
})
// 清除格式,以防残留超链接格式
editorCtx.removeFormat()
editorCtx.format('color', 'inherit')
// 后续回调操作
if (callback) callback()
}
}
})
}
/**
* 尽量消除键盘带来的影响;重要:核心功能!!!
* @param {Function} callback 回调
* @param {Object} options 配置项
* @property {String} options.mode 可选setInputMode通过控制ql-editor的inputmode属性控制键盘 [H5 APP] | loseFocus通过blur失焦隐藏键盘 [MP-WEIXIN] | hideKeyboard通过hideKeyboard隐藏键盘 | setReadOnly通过控制读写隐藏键盘
* @property {Number} options.delay 延时毫秒默认50
*/
export function noKeyboardEffect(callback, options) {
let defaultOpt = { delay: 50 }
// #ifdef APP
const isIOS = uni.getSystemInfoSync().platform == 'ios'
defaultOpt.mode = isIOS ? 'loseFocus' : 'setInputMode' // iOS使用setInputMode无效
// #endif
// #ifdef H5
defaultOpt.mode = 'setInputMode'
// #endif
// #ifdef MP-WEIXIN
defaultOpt.mode = 'loseFocus'
// #endif
const opt = Object.assign(defaultOpt, options)
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 通过 uni.hideKeyboard() 隐藏键盘,但是会导致键盘闪烁
// 微信小程序好像无法正常隐藏键盘
if (opt.mode == 'hideKeyboard') {
callback()
setTimeout(() => {
uni.hideKeyboard()
}, opt.delay)
}
// 通过控制编辑器失焦来隐藏键盘,但是会导致键盘闪烁
// 只推荐微信小程序使用(也是无可奈何)
if (opt.mode == 'loseFocus') {
callback()
editorCtx.blur()
}
// 通过控制编辑器读写模式进行屏蔽焦点,虽然隐藏了键盘,但是也失焦了
// 微信小程序中当只读时是无法使用api去修改内容的
if (opt.mode == 'setReadOnly') {
store.actions.setReadOnly(true)
callback()
setTimeout(() => {
store.actions.setReadOnly(false)
}, opt.delay)
}
// 使用renderjs给ql-editor节点设置inputmode属性来控制键盘是否弹出
// 设置none时将会阻止键盘弹出设置remove将会恢复完美适配H5、App(Android)但是不支持App(iOS)和微信小程序
if (opt.mode == 'setInputMode') {
// #ifdef APP || H5
// 以下严格处理异步与延时操作,缺一不可
editorCtx.changeInputMode('none')
setTimeout(() => {
callback()
setTimeout(() => {
editorCtx.changeInputMode('remove')
}, opt.delay)
}, opt.delay)
// #endif
}
}
/**
* 版权信息
*/
import packageConfig from '../../package.json'
export function copyrightPrint() {
/* 标题样式 */
const styleTitle1 = `font-size:16px;font-weight:700;color:#ff4500;`
const styleTitle2 = `font-style:oblique;font-size:14px;color:#fb7299;`
const styleContent = `color:#66ccff;`
/* 版权信息 */
const title1 = ` 📝 sv-editor v${packageConfig.version} `
const title2 = 'by Sonve'
const content = `
版权声明:
1. 本插件免费开源,还望保留此版权声明在控制台输出
2. 如需借鉴源码,还望注明出处
3. 未经授权您不得以任何形式转载、售卖本插件,或以其他形式侵犯版权及附属权利
4. 作者将保留对此插件版权信息的最终解释权
🏠 地址: https://ext.dcloud.net.cn/plugin?id=21184
😸 Gitee: https://gitee.com/Sonve/sv-editor
💬 微信: s1051399604
🐧 QQ群: ① 852637893 ② 816646292
`
console.log(`%c${title1}%c${title2}%c${content}`, styleTitle1, styleTitle2, styleContent)
}
export function noAuthorization(name) {
/* 标题样式 */
const styleTitle1 = `font-size:16px;font-weight:700;color:#e6a23c;`
const styleTitle2 = `font-style:oblique;font-size:14px;color:#fb7299;`
const styleContent = `color:#f56c6c;`
/* 授权信息 */
const title1 = ` ⛔ sv-editor ${name} `
const title2 = 'by Sonve'
const content = `
提示:您还未获取插件特殊扩展功能授权,可联系作者获取
💬 微信: s1051399604 | 🐧 QQ群: ① 852637893 ② 816646292
🏠 插件地址: https://ext.dcloud.net.cn/plugin?id=21184
`
console.log(`%c${title1}%c${title2}%c${content}`, styleTitle1, styleTitle2, styleContent)
}