更新富文本编辑器

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

View File

@@ -1,29 +1,72 @@
<template>
<view class="container">
<view class="page-body">
<view class='wrapper'>
<view class='toolbar'>
<uni-icons type="bars" size="26" :color="formats.bold ? activeColor : ''" @tap.native="formatIcon('bold')" />
<uni-icons type="font" size="26" :color="formats.italic ? activeColor : ''" @tap.native="formatIcon('italic')" />
<uni-icons type="down" size="26" :color="formats.underline ? activeColor : ''" @tap.native="formatIcon('underline')" />
<uni-icons type="close" size="26" :color="formats.strike ? activeColor : ''" @tap.native="formatIcon('strike')" />
<uni-icons type="trash" size="26" @tap.native="clear" />
<uni-icons type="undo" size="26" @tap.native="undo" />
<uni-icons type="redo" size="26" @tap.native="redo" />
<uni-icons type="clear" size="26" @tap.native="removeFormat" />
</view>
<view class="editor-wrapper">
<editor id="editor" class="ql-container" placeholder="开始输入..." show-img-size show-img-toolbar show-img-resize
:read-only="readOnly" @statuschange="onStatusChange" @input="onInput" @ready="onEditorReady" />
<sv-editor
eid="editor"
class="ql-container"
placeholder="开始输入..."
show-img-size show-img-toolbar show-img-resize
:read-only="readOnly"
@statuschange="onStatusChange"
pasteMode="origin" @ready="onEditorReady" @input="onInput"
/>
</view>
<view class="page-editor-toolbar-container" v-if="toolbar">
<sv-editor-toolbar
ref="toolbarRef"
:style-tools="[
'header',
'divider',
'bold',
'italic',
'underline',
'strike',
'align',
'color',
'backgroundColor',
'removeformat'
]"
@changeTool="changeTool"
@toolMoreItem="onToolMoreItem"
>
<template #at>
<view class="panel-at">
<view v-for="item in atList" :key="item.id" class="panel-at-item" @click="onAt(item)">
{{ item.name }}
</view>
</view>
</template>
<template #topic>
<view class="panel-topic">
<view v-for="item in topicList" :key="item.id" class="panel-topic-item" @click="onTopic(item)">
{{ item.name }}
</view>
</view>
</template>
<template #setting>
<button size="mini" @click="onExport">导出</button>
</template>
</sv-editor-toolbar>
</view>
</view>
</view>
</view>
</template>
<script>
import SvEditorToolbar from '@/uni_modules/sv-editor/components/sv-editor/sv-editor-toolbar.vue'
import {
addAt,
addTopic,
addAttachment,
addImage,
addLink,
addVideo
} from '@/uni_modules/sv-editor/components/common/utils.js'
export default {
name: 'QuillEditor',
components: {
SvEditorToolbar
},
props: {
value: {
type: String,
@@ -32,7 +75,11 @@ export default {
readOnly: {
type: Boolean,
default: false
}
},
toolbar: {
type: Boolean,
default: true
}
},
data() {
return {
@@ -41,17 +88,6 @@ export default {
activeColor: '#2979ff'
}
},
components: {
'uni-icons': () => import('@/uni_modules/uni-icons/components/uni-icons/uni-icons.vue')
},
// watch: {
// value(val) {
// // 外部v-model变化时同步内容
// if (this.editorCtx) {
// this.editorCtx.setContents({ html: val });
// }
// }
// },
mounted() {
// #ifndef MP-BAIDU
uni.loadFontFace({
@@ -75,11 +111,43 @@ export default {
}).exec();
// #endif
},
changeTool(e) {
console.log('changeTool ==>', e)
},
onToolMoreItem(e) {
console.log('onToolMoreItem ==>', e)
if (e.name == 'clear') {
uni.showModal({
title: '提示',
content: '确定要清空内容吗?',
success: ({ confirm }) => {
if (confirm) {
this.editorCtx.clear()
}
}
})
}
},
onInput(e) {
// 触发v-model
this.$emit('input', e.detail.html);
this.$emit('update:value', e.detail.html);
},
onAt(e) {
addAt({ username: e.name, userid: e.id }, () => {
uni.showToast({ title: '艾特成功' })
})
// 关闭弹窗
this.$refs.toolbarRef.closeMorePop()
},
onTopic(e) {
addTopic({ topic: e.name, link: e.id }, () => {
uni.showToast({ title: '添加话题成功' })
})
// 关闭弹窗
this.$refs.toolbarRef.closeMorePop()
},
undo() {
this.editorCtx && this.editorCtx.undo();
},
@@ -152,10 +220,16 @@ export default {
color: #2979ff;
}
.editor-wrapper {
min-height: 300rpx;
background: #fff;
border-radius: 0 0 8rpx 8rpx;
border: 1rpx solid #eee;
padding: 12rpx;
}
.page-editor-container {
flex: 1;
overflow-y: auto;
border: 10px solid #66ccff;
box-sizing: border-box;
}
</style>

View File

@@ -86,11 +86,6 @@ export default {
{ value: 1, text: '外贸' }
// TODO: 替换为实际字典数据
],
projectGradeList: [
{ value: 'high', text: '高' },
{ value: 'middle', text: '中' },
{ value: 'low', text: '低' }
]
}
},
methods: {

View File

@@ -102,11 +102,11 @@
<view class="form-item">
<view class="form-label">报工内容</view>
<u-textarea
<Quill
v-model="form.content"
placeholder="请输入报工内容"
:height="200"
></u-textarea>
></Quill>
</view>
<view class="form-item">
@@ -569,6 +569,7 @@ export default {
max-height: 80vh;
display: flex;
flex-direction: column;
box-sizing: border-box;
.popup-header {
display: flex;
@@ -587,12 +588,14 @@ export default {
padding: 30rpx;
flex: 1;
overflow-y: auto;
box-sizing: border-box;
}
.form-container {
box-sizing: border-box;
.form-item {
margin-bottom: 40rpx;
box-sizing: border-box;
.form-label {
font-size: 28rpx;
color: #333;

View File

@@ -0,0 +1,35 @@
## 1.1.22025-04-14
1. 更新视频封面图片地址,之前的已失效
2. 更新文档
## 1.1.12025-03-19
1. 更新vue2环境下状态销毁异常的问题
## 1.1.02025-03-07
1. 更新状态销毁逻辑
## 1.0.92025-01-18
1. 修复调色板在微信小程序vue2环境下的问题
## 1.0.82025-01-18
1. 修复了微信小程序在vue2环境下的报错
## 1.0.72025-01-18
1. 修复了微信小程序在vue2环境下出现的报错
## 1.0.62024-12-17
1. 优化了ios端兼容性问题
2. 更新示例工程和文档
## 1.0.52024-12-17
1. 更新文档
## 1.0.42024-12-17
1. 新增扩展功能
2. 更新文档
3. 更新示例工程
## 1.0.32024-12-11
1. 优化了多编辑器实例模式,现在单页面可以存在多个编辑器了
2. 更新了文档与示例工程,多实例可以参考示例二
## 1.0.22024-12-10
1. 添加调色板功能
2. 预设更多样式格式
3. 更新示例工程和文档
## 1.0.12024-12-06
1. v1正式版发布
2. 更新文档
3. 上传示例工程
## 1.0.02024-11-29
1. 基于uni-editor的仿知乎富文本初稿

View File

@@ -0,0 +1,656 @@
<template>
<text
:eid="eid"
:change:eid="quillEditor.watchEID"
:sid="sid"
:change:sid="quillEditor.watchStartID"
:video="videoUrl"
:change:pastemode="quillEditor.watchPasteMode"
:pastemode="pastemode"
:change:video="quillEditor.watchVideoUrl"
:cover="coverUrl"
:change:cover="quillEditor.watchCoverUrl"
:coverios="coverUrlIOS"
:change:coverios="quillEditor.watchCoverUrlIOS"
></text>
</template>
<script>
/**
* 富文本plugin特殊扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-14
*/
export default {
props: {
sid: {
type: String,
default: ''
},
eid: {
type: String,
default: ''
}
},
data() {
return {
videoUrl: '', // 视频地址
coverUrl: '', // 封面地址
coverUrlIOS: '', // ios端封面地址
pastemode: 'text' // 粘贴模式 text | origin
}
},
mounted() {},
methods: {
changePasteMode(e) {
this.pastemode = e
},
editorPaste(e) {
this.$emit('epaste', e)
},
createVideoThumbnail(url) {
this.videoUrl = url
},
getVideoThumbnail(e) {
// e: { video, cover }
uni.$emit(`E_EDITOR_GET_VIDEO_THUMBNAIL_${e.video}`, e)
},
createCoverThumbnail(url) {
// #ifdef H5
this.coverUrl = url
// #endif
// #ifdef APP
const isIOS = uni.getSystemInfoSync().platform == 'ios'
if (isIOS) {
this.coverUrlIOS = url // iOS用不了OffscreenCanvas
} else {
this.coverUrl = url
}
// #endif
},
getCoverThumbnail(e) {
// e: { image, cover }
uni.$emit(`E_EDITOR_GET_COVER_THUMBNAIL_${e.image}`, e)
}
}
}
</script>
<script module="quillEditor" lang="renderjs">
import config from '../common/config.js'
export default {
data() {
return {
editorID: '',
idStack: [], // sid栈
matcherMode: '' // 粘贴模式 text | origin
}
},
methods: {
/**
* 注意watch频繁触发时需要异步修改否则可能会导致监听不到
*/
watchPasteMode(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.matcherMode = newValue
}
},
watchStartID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.addMatcher(newValue)
}
},
watchEID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.editorID = newValue
}
},
watchVideoUrl(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateVideoThumbnail(newValue).then((res) => {
this.$ownerInstance.callMethod('getVideoThumbnail', {
video: newValue,
cover: res
})
})
}
},
watchCoverUrl(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateCoverThumbnail(newValue).then((res) => {
this.$ownerInstance.callMethod('getCoverThumbnail', {
image: newValue,
cover: res
})
})
}
},
/**
* Only Apple Can Do !!!
*/
watchCoverUrlIOS(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateCoverThumbnailIOS(newValue).then((res) => {
this.$ownerInstance.callMethod('getCoverThumbnail', {
image: newValue,
cover: res
})
})
}
},
/**
* 保留格式粘贴内容
* @description 此方式尽可能保留原格式,易于再编辑,但是部分格式会丢失
* @param {String} sid 当前编辑器id
*/
addMatcher(sid) {
if(this.idStack.includes(sid)) return // 禁止重复添加Matcher
this.idStack.push(sid)
const el = document.querySelector(`#${sid}`);
const quill = Quill.find(el);
const getStyleAttributes = (node, style) => {
let attributes = {}
// node属性
const width = node.getAttribute('width');
const height = node.getAttribute('height');
if (width) attributes.width = width
if (height) attributes.height = height
const dataCustom = node.getAttribute('data-custom');
if (dataCustom) attributes['data-custom'] = dataCustom;
// style样式
if (style.textAlign) attributes.align = style.textAlign;
if (style.fontWeight === 'bold' || style.fontWeight === '700') attributes.bold = true;
if (style.fontStyle === 'italic') attributes.italic = true;
if (style.textDecoration.includes('underline')) attributes.underline = true;
if (style.textDecoration.includes('line-through')) attributes.strike = true;
if (style.verticalAlign === 'super') attributes.script = 'super'
if (style.verticalAlign === 'sub') attributes.script = 'sub'
if (style.fontFamily) attributes.font = style.fontFamily;
if (style.fontSize) attributes.size = parseFloat(style.fontSize);
if (style.color) attributes.color = style.color;
if (style.backgroundColor) attributes.background = style.backgroundColor;
return attributes
}
const processNode = (node) => {
let ops = [];
if (node.nodeType === Node.ELEMENT_NODE) {
const computedStyle = document.defaultView.getComputedStyle(node);
// 处理 <img> 标签
if (node.tagName === 'IMG') {
const imgSrc = node.getAttribute('src');
if (imgSrc) {
ops.push({ insert: '\n' }); // 插入换行符,确保图片前有一个空行
ops.push({
insert: { image: imgSrc },
attributes: getStyleAttributes(node, computedStyle)
});
ops.push({ insert: '\n' }); // 插入换行符,确保图片后有一个空行
return ops; // 不参与递归
}
}
// 处理 <p> 和 <div> 标签
else if (node.tagName === 'P' || node.tagName === 'DIV') {
ops.push({ insert: '\n' }); // 插入换行符
}
// 处理 <ol> 标签 有序
else if (node.tagName === 'OL') {
// ops.push({ insert: '\n', attributes: { list: 'ordered' } });
}
// 处理 <ul> 标签 无序
else if (node.tagName === 'UL') {
// ops.push({ insert: '\n', attributes: { list: 'bullet' } });
}
// 处理 <li> 标签
else if (node.tagName === 'LI') {
ops.push({ insert: '\n' });
}
// 处理 <hr> 标签
else if (node.tagName === 'HR') {
ops.push({ insert: '\n' }); // 插入换行符
ops.push({ insert: { divider: true } });
return ops; // 不参与递归
}
// 处理 <a> 标签
else if (node.tagName === 'A') {
const href = node.getAttribute('href');
const textContent = node.textContent.trim();
if (href && textContent) {
ops.push({
insert: ' ' + textContent + ' ',
attributes: {
link: href,
textDecoration: computedStyle.textDecoration,
...getStyleAttributes(node, computedStyle)
}
});
return ops; // 不参与递归
}
}
// 处理 <h1> 到 <h6> 标题
else if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(node.tagName)) {
// 处理 <h1> 到 <h6> 标题,生成 header 操作
const headerLevel = parseInt(node.tagName.charAt(1), 10);
const textContent = node.textContent.trim();
if (textContent) {
ops.push({
insert: textContent + '\n', // 必须要加上换行
attributes: {
header: headerLevel,
...getStyleAttributes(node, computedStyle)
}
});
return ops; // 不参与递归
}
}
// 递归处理子节点
for (let child of node.childNodes) {
ops = ops.concat(processNode(child));
}
} else if (node.nodeType === Node.TEXT_NODE) {
const textContent = node.nodeValue.trim();
if (textContent) {
// 从父元素获取样式
const parentNode = node.parentNode;
if (parentNode) {
const computedStyle = document.defaultView.getComputedStyle(parentNode);
ops.push({
insert: textContent,
attributes: getStyleAttributes(parentNode, computedStyle)
});
} else {
// 如果没有父元素,直接插入文本
ops.push({ insert: textContent });
}
}
}
return ops;
}
quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node, delta) => {
if (this.matcherMode == 'origin') {
let newOps = processNode(node);
if (newOps.length > 0) {
return { ops: newOps };
}
}
return delta;
})
const cleanClipboardHTML = (html, text) => {
if(!html) return text
// 使用正则表达式匹配 <!--StartFragment--> 和 <!--EndFragment--> 之间的内容
const fragmentRegex = /<!--StartFragment-->([\s\S]*?)<!--EndFragment-->/;
const match = html.match(fragmentRegex);
if (match && match[1]) {
// 返回匹配到的内容
return match[1].trim();
}
// 如果没有匹配到片段内容,返回原始 HTML
return html;
}
el.addEventListener('paste', (e) => {
let clipboardText = e.clipboardData.getData('text/plain'); // 获取剪切板中的纯文本内容
let clipboardHtml = e.clipboardData.getData('text/html'); // 获取剪切板中的 HTML 内容(如果存在)
clipboardHtml = cleanClipboardHTML(clipboardHtml, clipboardText)
setTimeout(() => {
this.$ownerInstance.callMethod('editorPaste', {
id: sid,
text: clipboardText,
html: clipboardHtml,
range: quill.getSelection() // 获取当前光标位置
})
}, 100);
});
},
/**
* 保留格式粘贴内容
* @description 此方式虽然可以极大程度保留格式,但是会导致粘贴下来的内容为一整个块,且不易再编辑
* @param {String} sid 当前编辑器id
*/
/*
addMatcher(sid) {
if(this.idStack.includes(sid)) return // 禁止重复添加Matcher
this.idStack.push(sid)
// 引入源码中的BlockEmbed
const BlockEmbed = Quill.import('blots/block/embed');
// 定义新的blot类型
class AppPanelEmbed extends BlockEmbed {
static create(value) {
const node = super.create(value);
node.setAttribute('width', '100%');
// 设置自定义html
node.innerHTML = this.transformValue(value)
return node;
}
static transformValue(value) {
let handleArr = value.split('\n')
handleArr = handleArr.map(e => e.replace(/^[\s]+/, '').replace(/[\s]+$/, ''))
return handleArr.join('')
}
// 返回节点自身的value值 用于撤销操作
static value(node) {
return node.innerHTML
}
}
// blotName
AppPanelEmbed.blotName = 'AppPanelEmbed';
// 标签类型自定义
AppPanelEmbed.tagName = 'p';
Quill.register(AppPanelEmbed, true);
const el = document.querySelector(`#${sid}`);
const quill = Quill.find(el);
const cleanClipboardHTML = (html, text) => {
if(!html) return text
// 使用正则表达式匹配 <!--StartFragment--> 和 <!--EndFragment--> 之间的内容
const fragmentRegex = /<!--StartFragment-->([\s\S]*?)<!--EndFragment-->/;
const match = html.match(fragmentRegex);
if (match && match[1]) {
// 返回匹配到的内容
return match[1].trim();
}
// 如果没有匹配到片段内容,返回原始 HTML
return html;
}
el.addEventListener('paste', (e) => {
e.preventDefault();
let clipboardText = e.clipboardData.getData('text/plain'); // 获取剪切板中的纯文本内容
let clipboardHtml = e.clipboardData.getData('text/html'); // 获取剪切板中的 HTML 内容(如果存在)
clipboardHtml = cleanClipboardHTML(clipboardHtml, clipboardText)
this.$ownerInstance.callMethod('editorPaste', {
id: sid,
text: clipboardText,
html: clipboardHtml
})
setTimeout(() => {
const range = quill.getSelection(); // 获取当前光标位置
quill.insertEmbed(range.index, 'AppPanelEmbed', clipboardHtml);
}, 100);
});
},
*/
/**
* 生成视频封面图片不兼容iOS
* @property {String} videoUrl 视频地址
* @returns {Promise} 视频封面图片 注意异步处理
*/
async generateVideoThumbnail(videoUrl) {
// 绘制播放按钮图标
// @param {CanvasContext} context canvas上下文
// @param {Canvas} canvas
const drawPlayButton = (context, canvas) => {
// 创建一个 <img> 元素来加载播放图标
const img = new Image();
img.src = config.video_playicon;
// 等待图像加载完成
return new Promise((resolve, reject) => {
img.onload = () => {
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15;
const playButtonSize = canvas.width * 0.15;
const playButtonX = (canvas.width - playButtonSize) / 2;
const playButtonY = (canvas.height - playButtonSize) / 2;
// 绘制播放按钮到 canvas
context.drawImage(img, playButtonX, playButtonY, playButtonSize, playButtonSize);
resolve();
};
img.onerror = (error) => {
reject(new Error('Failed to load SVG image.'));
};
});
}
return new Promise(async (resolve, reject) => {
try {
// 创建一个新的 video 元素,并设置 crossOrigin 属性
const video = document.createElement('video');
video.crossOrigin = 'anonymous'; // 添加 crossOrigin 属性
video.preload = 'metadata';
video.src = videoUrl;
// 创建一个新的 canvas 元素
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 监听视频加载元数据完成
video.onloadedmetadata = async () => {
// 设置 canvas 尺寸与视频相同
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// 尝试绘制视频的第一帧到 canvas
video.currentTime = 0; // 确保我们从视频的第一帧开始
video.onseeked = async () => {
try {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// 绘制播放按钮
await drawPlayButton(context, canvas);
// 将 canvas 内容转换为 Data URL
// resolve(canvas.toDataURL('image/png')); // base64太长了不建议使用
// 将 canvas 内容转换为 Blob 对象
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
reject(new Error('Failed to draw image to canvas.'));
}
};
// 如果 seek 操作没有成功,尝试直接绘制当前帧
setTimeout(async () => {
if (!video.seeking) {
try {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// 绘制播放按钮
await drawPlayButton(context, canvas);
// 将 canvas 内容转换为 Data URL
// resolve(canvas.toDataURL('image/png')); // base64太长了不建议使用
// 将 canvas 内容转换为 Blob 对象
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
reject(new Error('Failed to draw image to canvas.'));
}
}
}, 1000); // 等待1秒后尝试绘制防止 seek 操作未完成
};
// 监听视频加载错误
video.onerror = (error) => {
// reject(new Error('Failed to load video or get metadata. PS: Maybe the browser cannot play videos.'));
// 不直接抛出错误,而是抛出一个默认的封面图片,但是需要加以警告提示
console.warn('Failed to load video or get metadata. PS: Maybe the browser cannot play videos.');
resolve(config.video_thumbnail);
};
} catch (error) {
// reject(error);
// 不直接抛出错误,而是抛出一个默认的封面图片,但是需要加以警告提示
console.warn(error)
resolve(config.video_thumbnail);
}
});
},
/**
* 生成封面图片OffscreenCanvas方式不兼容iOS
* @param {Object} coverUrl 封面图片地址
* @returns {Promise}
*/
async generateCoverThumbnail(coverUrl) {
return new Promise((resolve, reject) => {
// 内联 Worker 代码
const workerCode = `
self.onmessage = async function(e) {
const { imageUrl, iconBase64 } = e.data;
try {
// 加载图片并创建 ImageBitmap
let imgResponse = await fetch(imageUrl);
if (!imgResponse.ok) {
throw new Error(\`Failed to load image from \${imageUrl}: \${imgResponse.statusText}\`);
}
let imgBlob = await imgResponse.blob();
let imgBitmap = await createImageBitmap(imgBlob);
// 创建 OffscreenCanvas 并绘制图片
const offscreen = new OffscreenCanvas(imgBitmap.width, imgBitmap.height);
const ctx = offscreen.getContext('2d');
ctx.drawImage(imgBitmap, 0, 0);
// 加载图标并创建 ImageBitmap
let iconResponse = await fetch(iconBase64);
if (!iconResponse.ok) {
throw new Error(\`Failed to load icon from \${iconBase64}: \${iconResponse.statusText}\`);
}
let iconBlob = await iconResponse.blob();
let iconBitmap = await createImageBitmap(iconBlob);
// 计算图标的中心位置并绘制
const x = (imgBitmap.width - iconBitmap.width) / 2;
const y = (imgBitmap.height - iconBitmap.height) / 2;
ctx.drawImage(iconBitmap, x, y);
// 获取处理后的图像数据
const result = await offscreen.convertToBlob();
// 发送结果回主线程
self.postMessage(result);
} catch (error) {
console.error('Error processing image:', error.message);
self.postMessage({ error: error.message });
}
};
`
// 创建 Blob
const blob = new Blob([workerCode], { type: 'application/javascript' });
// 创建一个指向 Blob 的 URL
const workerUrl = URL.createObjectURL(blob);
// 实例化 Worker
const worker = new Worker(workerUrl);
// 监听来自 Worker 的消息
worker.onmessage = (e) => {
if (e.data instanceof Blob) {
resolve(URL.createObjectURL(e.data));
} else {
console.warn(e.data.error);
// 不直接抛出错误,而是抛出一个默认的封面图片,但是需要加以警告提示
resolve(config.video_thumbnail);
}
worker.terminate(); // 处理完成后终止 worker
};
// 向 Worker 发送消息
worker.postMessage({ imageUrl: coverUrl, iconBase64: config.video_playicon });
});
},
/**
* 生成封面图片普通方式可能影响性能兼容iOS
* @param {Object} coverUrl 封面图片地址
* @returns {Promise}
*/
async generateCoverThumbnailIOS(coverUrl){
return new Promise(async (resolve, reject) => {
try {
// 创建 Image 对象并加载封面图片
const img = new Image();
img.src = coverUrl;
await new Promise(resolve => img.onload = resolve);
// 创建 Canvas 并绘制封面图片
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// 加载播放按钮图标并绘制
const icon = new Image();
icon.src = config.video_playicon; // 假设这是播放按钮图标的 URL
await new Promise(resolve => icon.onload = resolve);
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15;
const playButtonSize = canvas.width * 0.15;
const playButtonX = (canvas.width - playButtonSize) / 2;
const playButtonY = (canvas.height - playButtonSize) / 2;
// 确保播放按钮图标按比例缩放
const iconAspectRatio = icon.width / icon.height;
const iconWidth = playButtonSize;
const iconHeight = iconWidth / iconAspectRatio;
// 绘制播放按钮图标到 Canvas
ctx.drawImage(icon, playButtonX, playButtonY, iconWidth, iconHeight);
// 将 canvas 内容转换为 Blob 对象
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
// iOS Safari 的安全策略通常比其他浏览器更严格本地file://协议也会导致跨域
console.warn('iOS createCoverThumbnail error :', error);
// reject(error);
// 不直接抛出错误,而是抛出一个默认的封面图片,但是需要加以警告提示
resolve(config.video_thumbnail);
}
})
},
}
}
</script>

View File

@@ -0,0 +1,94 @@
/**
* 富文本plugin微信小程序特殊扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-17
*/
import config from '../common/config.js'
/**
* 微信小程序特有的OffscreenCanvas方法
* @param {String} coverImageUrl 封面资源地址
* @returns {Promise<String>} 处理后的封面图片的临时文件路径
*/
export function wxCreateCoverThumbnail(coverImageUrl) {
const loadImage = () => {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: coverImageUrl,
success: (info) => {
resolve(info)
},
fail: (err) => {
reject(err)
}
})
})
}
return new Promise(async (resolve, reject) => {
try {
const imageInfo = await loadImage()
// 创建离屏 Canvas
const canvas = uni.createOffscreenCanvas({
type: '2d',
width: imageInfo.width,
height: imageInfo.height
})
const ctx = canvas.getContext('2d')
// 创建一个图片
const coverImg = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
coverImg.onload = resolve
coverImg.src = coverImageUrl // 要加载的图片 url
})
// 绘制封面图片到离屏 Canvas
ctx.drawImage(coverImg, 0, 0, canvas.width, canvas.height)
// 加载播放按钮图标
const playIcon = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
playIcon.onload = resolve
playIcon.src = config.video_playicon // 要加载的图片 url
})
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15
const playButtonSize = canvas.width * 0.15
const playButtonX = (canvas.width - playButtonSize) / 2
const playButtonY = (canvas.height - playButtonSize) / 2
// 确保播放按钮图标按比例缩放
const iconAspectRatio = playIcon.width / playIcon.height
const iconWidth = playButtonSize
const iconHeight = iconWidth / iconAspectRatio
// 绘制播放按钮图标到离屏 Canvas
ctx.drawImage(playIcon, playButtonX, playButtonY, iconWidth, iconHeight)
// 获取画完后的数据
uni.canvasToTempFilePath({
canvas: canvas,
destWidth: canvas.width,
destHeight: canvas.height,
fileType: 'png',
success: (res) => {
resolve(res.tempFilePath)
},
fail: (err) => {
reject(new Error('Failed to convert canvas to image.'))
}
})
} catch (error) {
reject(error)
}
})
}
export default {
wxCreateCoverThumbnail
}

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

View File

@@ -0,0 +1,233 @@
@font-face {
font-family: "iconfont";
/* 在vue2中直接使用 ./iconfont.ttf 会找不到文件,很坑,需要返回上一级再点回来 */
src: url('../icons/iconfont.ttf') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-zitishangbiao:before {
content: "\e8e5";
}
.icon-ziti-xiabiao:before {
content: "\e8ea";
}
.icon-yousuojin:before {
content: "\e6f0";
}
.icon-zuosuojin:before {
content: "\e6f1";
}
.icon-wenziyanse:before {
content: "\e60b";
}
.icon-beijingyanse:before {
content: "\e634";
}
.icon-qingchugeshi:before {
content: "\e6f5";
}
.icon-tuige:before {
content: "\e61a";
}
.icon-xiajiantou:before {
content: "\eb6d";
}
.icon-shangjiantou:before {
content: "\eb6e";
}
.icon-huati:before {
content: "\e659";
}
.icon-video:before {
content: "\e60a";
}
.icon-jianpan:before {
content: "\e636";
}
.icon-at:before {
content: "\e81e";
}
.icon-bianji:before {
content: "\eb61";
}
.icon-icon_tianjia:before {
content: "\eb89";
}
.icon-xingzhuang-tupian:before {
content: "\eb98";
}
.icon-xingzhuang-wenzi:before {
content: "\eb99";
}
.icon-huixingzhen:before {
content: "\ebe6";
}
.icon-xiayibu:before {
content: "\ebef";
}
.icon-shangyibu:before {
content: "\ebf0";
}
.icon-baocun:before {
content: "\ec09";
}
.icon-xiayibu1:before {
content: "\ec0a";
}
.icon-shangyibu1:before {
content: "\ec0b";
}
.icon-weizhigeshi:before {
content: "\ec1a";
}
.icon-chehuisekuai:before {
content: "\ec45";
}
.icon-shexiangji:before {
content: "\ec59";
}
.icon-fuzhi:before {
content: "\ec7a";
}
.icon-shanchu:before {
content: "\ec7b";
}
.icon-bianjisekuai:before {
content: "\ec7c";
}
.icon-fengexian:before {
content: "\ec7f";
}
.icon-charulianjie:before {
content: "\ec80";
}
.icon-charutupian:before {
content: "\ec81";
}
.icon-quxiaolianjie:before {
content: "\ec82";
}
.icon-wuxupailie:before {
content: "\ec83";
}
.icon-juzhongduiqi:before {
content: "\ec84";
}
.icon-yinyong:before {
content: "\ec85";
}
.icon-youxupailie:before {
content: "\ec86";
}
.icon-youduiqi:before {
content: "\ec87";
}
.icon-zitidaima:before {
content: "\ec88";
}
.icon-xiaolian:before {
content: "\ec89";
}
.icon-zitijiacu:before {
content: "\ec8a";
}
.icon-zitishanchuxian:before {
content: "\ec8b";
}
.icon-zitibiaoti:before {
content: "\ec8c";
}
.icon-zitixiahuaxian:before {
content: "\ec8d";
}
.icon-zitixieti:before {
content: "\ec8e";
}
.icon-zitiyanse:before {
content: "\ec8f";
}
.icon-zuoduiqi:before {
content: "\ec90";
}
.icon-zuoyouduiqi:before {
content: "\ec91";
}
.icon-tianxie:before {
content: "\ec92";
}
.icon-kongxinwenhao:before {
content: "\ed19";
}
.icon-fangkuai:before {
content: "\ed1a";
}
.icon-jia_sekuai:before {
content: "\ed21";
}
.icon-jian_sekuai:before {
content: "\ed22";
}
.icon-fenxiangfangshi:before {
content: "\ed2e";
}

Binary file not shown.

View File

@@ -0,0 +1,52 @@
<template></template>
<script>
/**
* 富文本plugin特殊扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-14
*/
import config from '../common/config'
import { noAuthorization } from '../common/utils'
export default {
props: {
sid: {
type: String,
default: ''
},
eid: {
type: String,
default: ''
}
},
data() {
return {}
},
mounted() {},
methods: {
changePasteMode(e) {
return e
},
editorPaste(e) {
this.$emit('epaste', e)
},
createVideoThumbnail(url) {
noAuthorization('createVideoThumbnail')
this.getVideoThumbnail(url)
},
getVideoThumbnail(e) {
uni.$emit(`E_EDITOR_GET_VIDEO_THUMBNAIL_${e}`, config.video_thumbnail)
},
createCoverThumbnail(url) {
noAuthorization('createCoverThumbnail')
this.getCoverThumbnail(url)
},
getCoverThumbnail(e) {
uni.$emit(`E_EDITOR_GET_COVER_THUMBNAIL_${e}`, e)
}
}
}
</script>

View File

@@ -0,0 +1,10 @@
import { noAuthorization } from "../common/utils"
export function wxCreateCoverThumbnail(url) {
noAuthorization('wxCreateCoverThumbnail')
return url
}
export default {
wxCreateCoverThumbnail
}

View File

@@ -0,0 +1,122 @@
<template>
<text :data="flag" :props="config" :change:data="fileManager.watchData" :change:props="fileManager.watchProps"></text>
</template>
<script>
/**
* 文件选择 - APP端
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
export default {
props: {
/**
* 配置项
* @tutorial https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/input/file
*/
config: {
type: Object,
default: () => {
return {
accept: `.doc,.docx,.xls,.xlsx,.pdf,.zip,.rar,
application/msword,
application/vnd.openxmlformats-officedocument.wordprocessingml.document,
application/vnd.ms-excel,
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,
application/pdf,
application/zip,
application/x-rar-compressed`,
multiple: false
}
}
}
},
data() {
return {
flag: 0 // 监听标志
}
},
methods: {
chooseFile() {
this.flag++ // 修改监听标志
},
rawFile(file) {
this.$emit('confirm', file)
}
}
}
</script>
<script module="fileManager" lang="renderjs">
import { base64ToPath } from '../common/file-handler.js';
export default {
data() {
return {
configCopy: {}, // 跟随vue中props的配置
}
},
methods: {
watchData(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.openFileManager()
}
},
watchProps(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.configCopy = newValue
}
},
openFileManager() {
try {
const { accept, multiple } = this.configCopy
// 创建文件选择器input
let fileInput = document.createElement('input')
fileInput.setAttribute('type', 'file')
fileInput.setAttribute('accept', accept)
// 注是否多选不要直接赋值multiple应当是为false时不添加multiple属性
if(multiple) fileInput.setAttribute('multiple', multiple)
fileInput.click()
// 封装为Promise的FileReader读取文件
const readFileAsDataURL = (file) => {
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = async (event) => {
const base64 = event.target.result
const path = await base64ToPath(base64)
resolve({
name: file.name,
type: file.type,
size: file.size,
base64,
path
});
};
reader.onerror = (error) => {
reject(error);
};
});
}
fileInput.addEventListener('change', async (e) => {
let files = e.target.files // 注此处为FileList对象并非常规数组
let results = await Promise.all(
// Array.from 方法可以将类数组对象转换为真正的数组
Array.from(files).map(item => readFileAsDataURL(item))
);
// callMethod不支持流数据无法直接传递文件流对象
this.$ownerInstance.callMethod('rawFile', results)
})
} catch (err) {
console.warn('==== openFileManager catch error :', err);
}
}
}
}
</script>

View File

@@ -0,0 +1,178 @@
<template>
<view @touchmove.stop.prevent="moveStop">
<view class="sv-editor-colorpicker" v-if="showPicker">
<view class="editor-popup-header">
<!-- <view class="header-left" @click="cancel">取消</view> -->
<view class="header-left" @click="reset">重置</view>
<view class="header-title" :style="{ backgroundColor: selectColor }" v-if="selectColor">{{ selectColor }}</view>
<view class="header-right" @click="confirm">确认</view>
</view>
<view class="sv-editor-colorpicker-container">
<view
v-for="item in allColors"
:key="item"
class="color-item"
:style="{ backgroundColor: item }"
@click="onSelect(item)"
></view>
</view>
</view>
<view class="mask" v-if="showPicker" @click.stop="onMask"></view>
</view>
</template>
<script>
import { colorList } from '../common/tool-list'
export default {
name: 'sv-editor-colorpicker',
props: {
show: {
type: Boolean,
default: false
},
color: {
type: String,
default: ''
},
type: {
type: String,
default: 'color'
},
// 点击遮罩层自动关闭弹窗
maskClose: {
type: Boolean,
default: true
}
},
emits: ['update:show', 'open', 'close', 'onMask', 'cancel', 'confirm'],
// #ifdef VUE2
model: {
prop: 'show',
event: 'update:show'
},
// #endif
data() {
return {
selectColor: this.color
}
},
watch: {
color(newVal) {
this.selectColor = newVal
}
},
computed: {
showPicker: {
set(newVal) {
this.$emit('update:show', newVal)
},
get() {
return this.show
}
},
allColors() {
return colorList
}
},
methods: {
// 阻止滑动穿透
moveStop() {},
open() {
this.showPicker = true
this.$emit('open')
},
close() {
this.showPicker = false
this.$emit('close')
},
onMask() {
if (this.maskClose) this.close()
this.$emit('onMask')
},
cancel() {
this.$emit('cancel')
this.close()
},
confirm() {
this.$emit('confirm', this.selectColor, this.type)
},
reset() {
this.selectColor = ''
},
onSelect(e) {
this.selectColor = e
}
}
}
</script>
<style lang="scss">
.sv-editor-colorpicker {
--editor-colorpicker-bgcolor: #ffffff;
--editor-colorpicker-radius: 30rpx 30rpx 0 0;
--editor-colorpicker-confirm: #4d80f0;
--editor-colorpicker-cancel: #fa4350;
--editor-colorpicker-header-height: 50rpx;
width: 100%;
position: absolute;
bottom: 0;
z-index: 10000;
border-radius: var(--editor-colorpicker-radius);
padding: 30rpx;
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
background-color: var(--editor-colorpicker-bgcolor);
box-sizing: border-box;
.editor-popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
box-sizing: border-box;
height: var(--editor-colorpicker-header-height);
.header-left {
color: var(--editor-colorpicker-cancel);
}
.header-title {
color: #000000;
text-shadow: 1rpx 1rpx #ffffff, -1rpx 1rpx #ffffff, 1rpx -1rpx #ffffff, -1rpx -1rpx #ffffff;
padding: 4rpx 12rpx;
box-shadow: 0 0 8rpx #cccccc;
border-radius: 10rpx;
}
.header-right {
color: var(--editor-colorpicker-confirm);
}
}
.sv-editor-colorpicker-container {
// max-height: 40vh;
overflow: auto;
display: grid;
grid-template-columns: repeat(8, 1fr);
align-items: center; /* 垂直居中 */
justify-items: center; /* 水平居中 */
gap: 20rpx;
box-sizing: border-box;
.color-item {
width: 100%;
height: 60rpx;
box-shadow: 0 0 8rpx #ccc;
border-radius: 10rpx;
}
}
}
.mask {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 9999;
background-color: rgba(0, 0, 0, 0.5);
}
</style>

View File

@@ -0,0 +1,445 @@
<template>
<view @touchmove.stop.prevent="moveStop">
<view class="sv-editor-popup" v-if="showPop">
<view class="editor-popup-header">
<view class="header-left" @click="cancel">取消</view>
<view class="header-title">{{ title }}</view>
<view class="header-right" @click="confirm">确认</view>
</view>
<view class="sv-editor-popup-container">
<!-- 添加图片 -->
<view class="popup-image" v-if="toolName == 'image'">
<view class="popup-form-input">
<text class="form-label">网络图片</text>
<input v-model="imageForm.link" type="text" class="form-input" placeholder="请输入图片地址" />
</view>
<view class="popup-form-input">
<text class="form-label">本地图片</text>
<button size="mini" class="form-button" @click="selectImage">选择文件</button>
<view class="form-thumbnail">
<image
class="form-thumbnail-item form-thumbnail-image"
v-for="(item, index) in imageForm.file"
:key="item.path"
:src="item.path"
@click="deleteImage(index)"
></image>
</view>
</view>
</view>
<!-- 添加视频 -->
<view class="popup-video" v-if="toolName == 'video'">
<view class="popup-form-input">
<text class="form-label">网络视频</text>
<input v-model="videoForm.link" type="text" class="form-input" placeholder="请输入视频地址" />
</view>
<view class="popup-form-input">
<text class="form-label">本地视频</text>
<button size="mini" class="form-button" @click="selectVideo">选择文件</button>
<view class="form-thumbnail" v-if="videoForm.file.tempFilePath">
<view class="form-thumbnail-item form-thumbnail-icon" @click="deleteVideo">
<text class="iconfont icon-video"></text>
</view>
</view>
</view>
</view>
<!-- 添加链接 -->
<view class="popup-link" v-if="toolName == 'link'">
<view class="popup-form-input">
<text class="form-label">链接地址</text>
<input v-model="linkForm.link" type="text" class="form-input" placeholder="请输入链接地址 (必填)" />
</view>
<view class="popup-form-input">
<text class="form-label">链接文本</text>
<input v-model="linkForm.text" type="text" class="form-input" placeholder="请输入链接文本 (可选)" />
</view>
</view>
<!-- 添加附件 -->
<view class="popup-attachment" v-if="toolName == 'attachment'">
<view class="popup-form-input">
<text class="form-label">附件地址</text>
<input v-model="attachmentForm.link" type="text" class="form-input" placeholder="请输入附件地址" />
</view>
<view class="popup-form-input">
<text class="form-label">附件描述</text>
<input v-model="attachmentForm.text" type="text" class="form-input" placeholder="请输入附件描述" />
</view>
<view class="popup-form-input">
<text class="form-label">本地文件</text>
<button size="mini" class="form-button" @click="selectAttachment">选择文件</button>
<view class="form-thumbnail" v-if="attachmentForm.file.path">
<view class="form-thumbnail-item form-thumbnail-icon" @click="deleteAttachment">
<text class="iconfont icon-huixingzhen"></text>
</view>
</view>
</view>
</view>
<!-- 提及 -->
<view class="popup-at" v-if="toolName == 'at'">
<slot name="at"></slot>
</view>
<!-- 话题 -->
<view class="popup-topic" v-if="toolName == 'topic'">
<slot name="topic"></slot>
</view>
</view>
</view>
<view class="mask" v-if="showPop" @click.stop="onMask"></view>
<!-- #ifdef APP -->
<sv-choose-file ref="chooseFileRef" @confirm="selectAppFile"></sv-choose-file>
<!-- #endif -->
</view>
</template>
<script>
/**
* 扩展工具面板弹窗
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import { moreToolList } from '../common/tool-list.js'
import SvChooseFile from './sv-choose-file.vue'
export default {
name: 'sv-editor-popup-more',
// #ifdef MP-WEIXIN
// 微信小程序特殊配置
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared'
},
// #endif
components: {
SvChooseFile
},
props: {
show: {
type: Boolean,
default: false
},
toolName: {
type: [String, null],
default: 'image'
},
// 点击遮罩层自动关闭弹窗
maskClose: {
type: Boolean,
default: true
}
},
emits: ['update:show', 'open', 'close', 'onMask', 'cancel', 'confirm'],
// #ifdef VUE2
model: {
prop: 'show',
event: 'update:show'
},
// #endif
data() {
return {
imageForm: {
link: '',
file: []
},
videoForm: {
link: '',
file: {}
},
linkForm: {
link: '',
text: ''
},
attachmentForm: {
link: '',
text: '',
file: {}
}
}
},
computed: {
showPop: {
set(newVal) {
this.$emit('update:show', newVal)
},
get() {
return this.show
}
},
title() {
return moreToolList.find((item) => item.name == this.toolName)?.title
}
},
methods: {
// 阻止滑动穿透
moveStop() {},
open() {
this.showPop = true
this.$emit('open')
},
close() {
this.showPop = false
this.$emit('close')
},
onMask() {
if (this.maskClose) this.close()
this.$emit('onMask')
},
cancel() {
this.$emit('cancel')
this.close()
},
confirm() {
let params = {}
params.name = this.toolName
switch (this.toolName) {
case 'image':
Object.assign(params, this.imageForm)
break
case 'video':
Object.assign(params, this.videoForm)
break
case 'link':
Object.assign(params, this.linkForm)
break
case 'attachment':
Object.assign(params, this.attachmentForm)
break
}
this.$emit('confirm', params)
},
/**
* 业务方法
*/
// 选择图片
selectImage() {
// #ifdef APP || H5
uni.chooseImage({
count: 5, // 默认9此处限制为5
success: (res) => {
this.imageForm.file = res.tempFiles
},
fail: () => {
uni.showToast({
title: '未授权访问相册权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
// #ifdef MP-WEIXIN
uni.chooseMedia({
count: 5, // 默认9此处限制为5
mediaType: ['image'],
success: (res) => {
this.imageForm.file = res.tempFiles
},
fail: () => {
uni.showToast({
title: '未授权访问相册权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
},
// 删除指定图片
deleteImage(index) {
this.imageForm.file.splice(index, 1)
},
// 选择视频
selectVideo() {
uni.chooseVideo({
sourceType: ['camera', 'album'],
success: (res) => {
this.videoForm.file = res
},
fail: () => {
uni.showToast({
title: '未授权访问媒体权限,请授权后使用',
icon: 'none'
})
}
})
},
// 删除选择的本地视频
deleteVideo() {
this.videoForm.file = {}
},
// 选择附件
selectAttachment() {
// #ifdef H5
uni.chooseFile({
count: 1, // 默认100此处限制为1
extension: ['.doc', '.docx', '.xls', '.xlsx', '.pdf', '.zip', '.rar'],
success: (res) => {
this.attachmentForm.file = res.tempFiles[0]
},
fail: () => {
uni.showToast({
title: '未授权访问文件权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
// #ifdef APP
this.$refs.chooseFileRef.chooseFile()
// 选择文件完成后触发selectAppFile方法
// #endif
// #ifdef MP-WEIXIN
wx.chooseMessageFile({
count: 1, // 最多可以选择的文件个数,可以 0100此处限制为1
type: 'file', // 可以选择除了图片和视频之外的其它的文件
extension: ['.doc', '.docx', '.xls', '.xlsx', '.pdf', '.zip', '.rar'],
success: (res) => {
this.attachmentForm.file = res.tempFiles[0]
},
fail: () => {
uni.showToast({
title: '未授权访问文件权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
},
// 选择文件完成后触发
selectAppFile(files) {
this.attachmentForm.file = files[0]
},
// 删除指定附件
deleteAttachment() {
this.attachmentForm.file = {}
}
}
}
</script>
<style lang="scss">
@import '../icons/iconfont.css';
.sv-editor-popup {
--editor-popup-radius: 30rpx 30rpx 0 0;
--editor-popup-bgcolor: #ffffff;
--editor-popup-confirm: #4d80f0;
--editor-popup-cancel: #fa4350;
--thumbnail-icon-bgcolor: #cccccc;
--editor-popup-header-height: 50rpx;
width: 100%;
position: absolute;
bottom: 0;
z-index: 10000;
border-radius: var(--editor-popup-radius);
padding: 30rpx;
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
background-color: var(--editor-popup-bgcolor);
box-sizing: border-box;
.editor-popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
box-sizing: border-box;
height: var(--editor-popup-header-height);
.header-left {
color: var(--editor-popup-cancel);
}
.header-right {
color: var(--editor-popup-confirm);
}
}
.sv-editor-popup-container {
box-sizing: border-box;
.popup-form-input {
display: flex;
align-items: center;
margin-bottom: 30rpx;
box-sizing: border-box;
.form-label {
margin-right: 20rpx;
flex-shrink: 0;
}
.form-input {
flex: 1;
padding: 12rpx;
border: 1rpx solid #eeeeee;
border-radius: 8rpx;
line-height: unset;
height: unset;
min-height: unset;
box-sizing: border-box;
.uni-input-placeholder {
color: #dddddd;
}
}
.form-button {
margin-left: unset;
margin-right: unset;
}
.form-thumbnail {
.form-thumbnail-item {
width: 25px;
height: 25px;
margin-left: 20rpx;
position: relative;
border: 1rpx solid #eeeeee;
&:active {
border-color: #d83b01;
&::after {
content: 'X';
font-size: 25px;
font-weight: bold;
color: #d83b01;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.form-thumbnail-image {
vertical-align: bottom; // 取消image标签底部留白
}
.form-thumbnail-icon {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--form-thumbnail-icon-bgcolor);
}
}
}
}
}
.mask {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 9999;
background-color: rgba(0, 0, 0, 0.5);
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<text
:eid="eid"
:change:eid="quillEditor.watchEID"
:mode="inputmode"
:change:mode="quillEditor.watchInputMode"
:focus="focusFlag"
:change:focus="quillEditor.watchFocus"
:backspace="backspaceFlag"
:change:backspace="quillEditor.watchBackSpace"
></text>
</template>
<script>
/**
* 富文本renderjs扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
export default {
props: {
eid: {
type: String,
default: ''
}
},
data() {
return {
inputmode: '', // none | remove
focusFlag: 0, // 主动聚焦标志
backspaceFlag: 0 // 主动删除标志
}
},
methods: {
changeInputMode(mode) {
this.inputmode = mode
},
focus() {
this.focusFlag++
},
backspace() {
this.backspaceFlag++
}
}
}
</script>
<script module="quillEditor" lang="renderjs">
export default {
data() {
return {
editorID: ''
}
},
methods: {
watchEID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.editorID = newValue
}
},
watchInputMode(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.changeQuillInputMode(newValue)
}
},
watchFocus(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.changeFocus(newValue)
}
},
watchBackSpace(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.changeBackSpace(newValue)
}
},
/**
* 通过增加或移出inputmode属性来控制是否允许键盘弹出
* @param {String} type none | remove
* @tutorial https://ask.dcloud.net.cn/article/39915
*/
changeQuillInputMode(type) {
try {
// 要关闭软键盘的话需要给inputmode属性设置none
// 如果要打开软键盘的话需要移出inputmode属性
const el = document.querySelector(`#${this.editorID} .ql-editor`);
if(!el) return console.warn('==== quill dom error ====');
if(type == 'none') el.setAttribute('inputmode', 'none')
if(type == 'remove') el.removeAttribute('inputmode')
} catch (err) {
console.warn('==== changeQuillInputMode catch error :', err);
}
},
/**
* 通过quill节点实例的focus方法来主动触发编辑器聚焦
*/
changeFocus() {
try {
const el = document.querySelector(`#${this.editorID} .ql-editor`);
if(!el) return console.warn('==== quill dom error ====');
el.focus()
} catch (err) {
console.warn('==== changeFocus catch error :', err);
}
},
/**
* 通过quill节点实例的deleteText方法来主动触发编辑器删除
*/
changeBackSpace() {
try {
const el = document.querySelector(`#${this.editorID}`);
const quill = Quill.find(el);
if(!el || !quill) return console.warn('==== quill dom error ====');
const range = quill.getSelection(); // 获取当前光标位置
if (range && range.length === 0) {
// 如果没有选中文本且光标存在,则删除前一个字符或 emoji
if (range.index > 0) {
// 获取光标前的所有文本
const text = quill.getText(0, range.index);
// 规范化 Unicode 字符,确保正确处理组合字符和 emoji
const normalizedText = text.normalize('NFC');
// 将文本转换为字符数组,确保正确处理多字节字符
const chars = Array.from(normalizedText);
// 计算前一个字符的索引
const lastCharIndex = chars.length - 1;
if (lastCharIndex >= 0) {
// 删除前一个字符(包括多字节字符)
const lastChar = chars[lastCharIndex];
const lastCharLength = text.slice(-lastChar.length).length;
quill.deleteText(range.index - lastCharLength, lastCharLength);
quill.setSelection(range.index - lastCharLength); // 更新光标位置
}
}
} else if (range && range.length > 0) {
// 如果有选中文本,则删除选中的文本
quill.deleteText(range.index, range.length);
quill.setSelection(range.index); // 更新光标位置
}
} catch (err) {
console.warn('==== changeBackSpace catch error :', err);
}
},
}
}
</script>

View File

@@ -0,0 +1,647 @@
<template>
<view class="sv-editor-toolbar">
<view class="editor-tools" @tap="onTool">
<text
v-for="item in allTools"
:key="item.title"
class="iconfont"
:class="item.icon"
:data-name="item.name"
></text>
<!-- [展开/折叠] 为固定工具 -->
<text v-if="isShowPanel" class="iconfont icon-xiajiantou" data-name="fold" data-value="0"></text>
<text v-else class="iconfont icon-shangjiantou" data-name="fold" data-value="1"></text>
</view>
<!-- 样式面板 不建议使用 :key="item.name" 因为 name 可能重复 -->
<view class="tool-panel" v-if="curTool == 'style' && isShowPanel">
<view class="panel-grid panel-style">
<view
class="panel-style-item"
:class="[(item.value ? formats[item.name] === item.value : formats[item.name]) ? 'ql-active' : '']"
:style="{ color: item.name == 'color' ? curTextColor : item.name == 'backgroundColor' ? curBgColor : '' }"
v-for="item in allStyleTools"
:key="item.title"
:title="item.title"
:data-name="item.name"
:data-value="item.value"
@tap="onToolStyleItem"
>
<text class="iconfont pointer-events-none" :class="item.icon"></text>
<text class="tool-item-title pointer-events-none">{{ item.title }}</text>
</view>
</view>
</view>
<!-- 表情面板 -->
<view class="tool-panel" v-if="curTool == 'emoji' && isShowPanel">
<view class="panel-grid panel-emoji">
<view
class="panel-emoji-item"
v-for="item in allEmojiTools"
:key="item"
:data-name="item"
@tap="onToolEmojiItem"
>
{{ item }}
</view>
</view>
<!-- #ifdef H5 -->
<view class="editor-backspace iconfont icon-tuige" @click="onBackSpace"></view>
<!-- #endif -->
<!-- #ifdef APP -->
<view v-if="!isIOS" class="editor-backspace iconfont icon-tuige" @click="onBackSpace"></view>
<!-- #endif -->
</view>
<!-- 更多功能面板 -->
<view class="tool-panel" v-if="curTool == 'more' && isShowPanel">
<view class="panel-grid panel-more">
<view
class="panel-more-item"
v-for="item in allMoreTools"
:key="item.title"
:title="item.title"
:data-name="item.name"
:data-value="item.value"
@tap="onToolMoreItem"
>
<view class="iconfont pointer-events-none" :class="item.icon"></view>
<view class="panel-more-item-title pointer-events-none">{{ item.title }}</view>
</view>
</view>
</view>
<!-- 扩展面板 -->
<view class="tool-panel" v-if="curTool == 'setting' && isShowPanel">
<slot name="setting"></slot>
</view>
<!-- 弹窗 因vue2/3的v-model写法有区别故需要条件编译我也是醉了 -->
<!-- #ifdef VUE3 -->
<sv-editor-popup-more v-model:show="showMorePop" :tool-name="curMoreTool" @confirm="moreItemConfirm">
<!-- APP端不支持循环插槽此处建议挨个写 -->
<!-- <template v-for="(slot, name) in $slots" #[name]="scope">
<slot :name="name" v-bind="scope"></slot>
</template> -->
<template #at>
<slot name="at"></slot>
</template>
<template #topic>
<slot name="topic"></slot>
</template>
</sv-editor-popup-more>
<!-- #endif -->
<!-- 弹窗 特别是微信小程序端的vue2必须使用.sync -->
<!-- #ifdef VUE2 -->
<sv-editor-popup-more :show.sync="showMorePop" :tool-name="curMoreTool" @confirm="moreItemConfirm">
<template #at>
<slot name="at"></slot>
</template>
<template #topic>
<slot name="topic"></slot>
</template>
</sv-editor-popup-more>
<!-- #endif -->
<!-- 调色板 -->
<!-- #ifdef VUE3 -->
<sv-editor-colorpicker
v-model:show="showColorPicker"
:type="colorType"
:color="curColor"
@confirm="selectColor"
></sv-editor-colorpicker>
<!-- #endif -->
<!-- #ifdef VUE2 -->
<sv-editor-colorpicker
:show.sync="showColorPicker"
:type="colorType"
:color="curColor"
@confirm="selectColor"
></sv-editor-colorpicker>
<!-- #endif -->
</view>
</template>
<script>
/**
* sv-editor 默认工具栏
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import store from '../common/store.js'
import { toolList, emojiToolList, styleToolList, moreToolList } from '../common/tool-list.js'
import { noKeyboardEffect } from '../common/utils.js'
import SvEditorPopupMore from './sv-editor-popup-more.vue'
import SvEditorColorpicker from './sv-editor-colorpicker.vue'
export default {
// #ifdef MP-WEIXIN
// 微信小程序特殊配置
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared'
},
// #endif
components: {
SvEditorPopupMore,
SvEditorColorpicker
},
props: {
// 工具栏列表
tools: {
type: Array,
default: () => {
return [] // 空默认为全列表
}
},
// 样式工具列表
styleTools: {
type: Array,
default: () => {
return [] // 空默认为全列表
}
},
// 更多功能列表
moreTools: {
type: Array,
default: () => {
return [] // 空默认为全列表
}
}
},
emits: [
'toolMoreItem',
'moreItemConfirm',
'keyboardChange',
'changeMorePop',
'changeTool',
'tapTool',
'tapStyle',
'tapEmoji',
'backspace'
],
data() {
return {
curTool: '', // 当前工具(头部工具栏)默认第一个
showPanel: false, // 是否能显示工具面板区别于isShowPanel
showMorePop: false, // 是否弹出更多功能面板弹窗
showColorPicker: false, // 是否弹出调色板
curColor: '', // 当前颜色
curTextColor: '', // 当前文字颜色暂存
curBgColor: '', // 当前背景颜色暂存
colorType: '', // 当前颜色模式,可选 color | backgroundColor
curMoreTool: '', // 当前所选的更多功能项
keyboardHeight: 0 // 键盘高度
}
},
computed: {
isIOS() {
return uni.getSystemInfoSync().platform == 'ios'
},
allTools() {
if (this.tools.length == 0) return toolList
const indexMap = new Map(this.tools.map((item, index) => [item, index]))
const filtered = toolList
.filter((item) => indexMap.has(item.name)) // 过滤
.sort((a, b) => indexMap.get(a.name) - indexMap.get(b.name)) // 排序
return filtered
},
allStyleTools() {
if (this.styleTools.length == 0) return styleToolList
const indexMap = new Map(this.styleTools.map((item, index) => [item, index]))
const filtered = styleToolList
.filter((item) => indexMap.has(item.name)) // 过滤
.sort((a, b) => indexMap.get(a.name) - indexMap.get(b.name)) // 排序
return filtered
},
allEmojiTools() {
return emojiToolList
},
allMoreTools() {
if (this.moreTools.length == 0) return moreToolList
const indexMap = new Map(this.moreTools.map((item, index) => [item, index]))
const filtered = moreToolList
.filter((item) => indexMap.has(item.name)) // 过滤
.sort((a, b) => indexMap.get(a.name) - indexMap.get(b.name)) // 排序
return filtered
},
/**
* 在微信小程序端的vue2环境下无法直接使用计算属性读取editorCtx
* 为了统一化,只在各自需要使用编辑器实例的地方,按需重新获取
*/
// editorCtx() {
// const eid = store.actions.getEID()
// return store.actions.getEditor(eid)
// },
formats() {
return store.actions.getFormats()
},
isReadOnly: {
set(newVal) {
store.actions.setReadOnly(newVal)
},
get() {
return store.actions.getReadOnly()
}
},
isShowPanel() {
let show = this.showPanel
/**
* 规则:
* 1. 当键盘弹出时,必须折叠面板
* 2. 当点击有面板的工具栏时,必须展开面板
* 3. 展开工具栏时可以点击fold进行展开折叠切换
*/
if (this.keyboardHeight !== 0) {
show = this.showMorePop ? true : false
} else {
if (!this.curTool) {
show = false
}
}
return show
}
},
watch: {
curTool(newVal) {
this.$emit('changeTool', newVal)
}
},
mounted() {
this.curTool = this.allTools[0].name // 当前工具(头部工具栏)默认第一个
uni.$on('E_EDITOR_STATUSCHANGE', (e) => {
this.curTextColor = e.detail.color || ''
this.curBgColor = e.detail.backgroundColor || ''
})
// #ifndef H5
uni.onKeyboardHeightChange(this.keyboardChange)
// #endif
},
destroyed() {
// #ifndef H5
uni.offKeyboardHeightChange(this.keyboardChange)
// #endif
uni.$off('E_EDITOR_STATUSCHANGE')
},
unmounted() {
// #ifndef H5
uni.offKeyboardHeightChange(this.keyboardChange)
// #endif
uni.$off('E_EDITOR_STATUSCHANGE')
},
methods: {
getEditorCtx() {
const eid = store.actions.getEID()
return store.actions.getEditor(eid)
},
onTool(e) {
this.editorCtx = this.getEditorCtx() // 按需重新获取编辑器实例
if (!this.editorCtx) return console.warn('editor is null')
const { name, value } = e.target.dataset
this.$emit('tapTool', { name, value })
switch (name) {
case 'style':
case 'emoji':
case 'more':
case 'setting':
this.curTool = name
this.showPanel = true
break
case 'undo':
noKeyboardEffect(() => {
this.editorCtx.undo()
})
break
case 'redo':
noKeyboardEffect(() => {
this.editorCtx.redo()
})
break
case 'fold':
this.showPanel = value == '1' ? true : false
break
}
// 点击toolbar需要主动聚焦
// #ifdef H5
noKeyboardEffect(() => {
this.editorCtx.focus()
})
// #endif
// #ifdef APP
if (!this.isIOS) {
noKeyboardEffect(() => {
this.editorCtx.focus()
})
}
// #endif
},
onToolStyleItem(e) {
const { name, value } = e.target.dataset
this.$emit('tapStyle', { name, value })
this.editorCtx = this.getEditorCtx() // 按需重新获取编辑器实例
switch (name) {
case 'divider':
// 分割线单独使用insertDivider处理
noKeyboardEffect(() => {
this.editorCtx.insertDivider()
})
break
case 'color':
this.colorType = name
this.curColor = this.curTextColor
this.showColorPicker = true
break
case 'backgroundColor':
this.colorType = name
this.curColor = this.curBgColor
this.showColorPicker = true
break
case 'removeformat':
// 清除当前选区的样式
uni.showModal({
title: '系统提示',
content: '是否清除当前选区样式',
success: ({ confirm }) => {
if (confirm) {
noKeyboardEffect(() => {
this.editorCtx.removeFormat()
})
}
}
})
break
case 'bold':
case 'italic':
case 'underline':
case 'strike':
case 'script':
// 部分格式需要弹出键盘,若禁止弹出键盘,则会使格式丢失
this.editorCtx.format(name, value)
break
default:
noKeyboardEffect(() => {
this.editorCtx.format(name, value)
})
break
}
},
onToolEmojiItem(e) {
const { name, value } = e.target.dataset
this.$emit('tapEmoji', { name, value })
this.editorCtx = this.getEditorCtx() // 按需重新获取编辑器实例
noKeyboardEffect(() => {
this.editorCtx.insertText({
text: name
})
})
},
onToolMoreItem(e) {
const { name, value } = e.target.dataset
this.curMoreTool = name
if (value == 'popup') this.openMorePop()
this.$emit('toolMoreItem', { name, value })
},
moreItemConfirm(e) {
this.$emit('moreItemConfirm', e)
},
// 打开内置更多功能弹窗
openMorePop() {
this.showMorePop = true
this.$emit('changeMorePop', this.showMorePop)
},
// 关闭内置更多功能弹窗
closeMorePop() {
this.showMorePop = false
this.$emit('changeMorePop', this.showMorePop)
},
/**
* 键盘相关方法
*/
keyboardChange(e) {
this.keyboardHeight = e.height
this.$emit('keyboardChange', e)
if (this.showMorePop) return
// #ifdef H5
if (this.keyboardHeight > 0) {
this.showPanel = false
}
// #endif
// 可能存在秒闪的情况, 因此需要短暂延后判断
const timerHandler = () => {
if (this.timer) {
// 清除已有的计时器
clearTimeout(this.timer)
this.timer = null
}
this.timer = setTimeout(() => {
if (this.keyboardHeight > 0) {
this.showPanel = false
}
this.timer = null
}, 50)
}
// #ifdef APP
if (this.isIOS) {
timerHandler()
} else {
if (this.keyboardHeight > 0) {
this.showPanel = false
}
}
// #endif
// #ifdef MP-WEIXIN
timerHandler()
// #endif
},
// 退格
onBackSpace() {
this.$emit('backspace')
// #ifdef H5 || APP
this.editorCtx = this.getEditorCtx() // 按需重新获取编辑器实例
noKeyboardEffect(() => {
this.editorCtx.backspace()
})
// #endif
},
// 调色板确认
selectColor(color, type) {
this.curColor = color
this.showColorPicker = false
if (type == 'color') {
this.curTextColor = color
} else {
this.curBgColor = color
}
// 确认颜色选择后不要noKeyboardEffect取消键盘会造成颜色格式丢失
this.editorCtx = this.getEditorCtx() // 按需重新获取编辑器实例
this.editorCtx.format(type, color)
}
}
}
</script>
<style lang="scss">
@import '../icons/iconfont.css';
.sv-editor-toolbar {
--editor-toolbar-height: 88rpx;
--editor-toolbar-bgcolor: #ffffff;
--editor-toolbar-bordercolor: #eeeeee;
--editor-toolbar-iconsize: 32rpx;
--tool-panel-height: auto;
--tool-panel-bgcolor: #ffffff;
--tool-panel-max-height: 400rpx;
--tool-style-columns: 3;
--tool-style-iconsize: 32rpx;
--tool-style-titlesize: 28rpx;
--tool-emoji-columns: 8;
--tool-more-columns: 4;
--tool-more-iconsize: 60rpx;
--tool-more-titlesize: 24rpx;
--tool-item-bgcolor: #f1f1f1;
--editor-backspace-bgcolor: #ffffff;
--editor-backspace-shadow: 0 0 8px 6px rgba(0, 0, 0, 0.08);
.editor-tools {
width: 100%;
height: var(--editor-toolbar-height);
background-color: var(--editor-toolbar-bgcolor);
border-top: 1rpx solid var(--editor-toolbar-bordercolor);
border-bottom: 1rpx solid var(--editor-toolbar-bordercolor);
display: flex;
align-items: center;
justify-content: space-around;
box-sizing: border-box;
.iconfont {
width: 100%;
height: 100%;
font-size: var(--editor-toolbar-iconsize);
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
}
.tool-panel {
height: var(--tool-panel-height);
max-height: var(--tool-panel-max-height);
overflow: auto;
padding: 30rpx;
box-sizing: border-box;
// position: relative;
background-color: var(--tool-panel-bgcolor);
.editor-backspace {
width: 80rpx;
height: 60rpx;
position: absolute;
bottom: 30rpx;
right: 30rpx;
display: flex;
justify-content: center;
align-items: center;
font-size: 50rpx;
border-radius: 20rpx;
background-color: var(--editor-backspace-bgcolor);
box-shadow: var(--editor-backspace-shadow);
&:active {
opacity: 0.8;
bottom: 32rpx;
right: 32rpx;
}
}
.panel-grid {
width: 100%;
display: grid;
align-items: center; /* 垂直居中 */
justify-items: center; /* 水平居中 */
gap: 30rpx;
box-sizing: border-box;
&.panel-style {
grid-template-columns: repeat(var(--tool-style-columns), 1fr);
}
&.panel-emoji {
grid-template-columns: repeat(var(--tool-emoji-columns), 1fr);
}
&.panel-more {
grid-template-columns: repeat(var(--tool-more-columns), 1fr);
}
}
.panel-style-item {
width: 100%;
height: 80rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
background-color: var(--tool-item-bgcolor);
padding: 0 20rpx;
box-sizing: border-box;
.tool-item-title {
font-size: var(--tool-style-titlesize);
}
.iconfont {
font-size: var(--tool-style-iconsize);
margin-right: 10rpx;
}
}
.panel-emoji-item {
}
.panel-more-item {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: var(--tool-item-bgcolor);
padding: 20rpx;
border-radius: 20rpx;
box-sizing: border-box;
&:active {
opacity: 0.85;
}
.iconfont {
font-size: var(--tool-more-iconsize);
}
.panel-more-item-title {
font-size: var(--tool-more-titlesize);
margin-top: 12rpx;
}
}
}
}
.ql-active {
color: #66ccff;
}
.pointer-events-none {
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,445 @@
<template>
<view class="sv-editor-wrapper" @longpress="eLongpress">
<slot name="header"></slot>
<editor
:id="eid"
class="sv-editor-container"
show-img-size
show-img-toolbar
show-img-resize
:placeholder="placeholder"
:read-only="isReadOnly"
@statuschange="onStatusChange"
@ready="onEditorReady"
@input="onEditorInput"
@focus="onEditorFocus"
@blur="onEditorBlur"
></editor>
<view class="maxlength-tip" v-if="maxlength > 0 && !hideMax">{{ textlength }}/{{ maxlength }}</view>
<slot name="footer"></slot>
<!-- renderjs辅助插件 -->
<!-- #ifdef APP || H5 -->
<sv-editor-render ref="editorRenderRef" :eid="editorEID"></sv-editor-render>
<sv-editor-plugin ref="editorPluginRef" :sid="startID" :eid="editorEID" @epaste="ePaste"></sv-editor-plugin>
<!-- #endif -->
</view>
</template>
<script>
/**
* sv-editor
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import store from '../common/store.js'
import { linkFlag, copyrightPrint } from '../common/utils.js'
import { parseHtmlWithVideo, replaceVideoWithImageRender } from '../common/parse.js'
import SvEditorRender from './sv-editor-render.vue'
import SvEditorPlugin from '../plugins/sv-editor-plugin.vue'
import wxplugin from '../plugins/sv-editor-wxplugin.js'
export default {
// #ifdef MP-WEIXIN
// 微信小程序特殊配置
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared'
},
// #endif
components: {
SvEditorRender,
SvEditorPlugin
},
props: {
// 编辑器id可传入以便循环组件使用防止id重复
eid: {
type: String,
default: 'sv-editor' // 唯一,禁止重复
},
placeholder: {
type: String,
default: '写点什么吧 ~'
},
// 是否只读
readOnly: {
type: Boolean,
default: false
},
// 最大字数限制,<=0时表示不限
maxlength: {
type: Number,
default: -1
},
// 是否关闭最大字数显示
hideMax: {
type: Boolean,
default: false
},
// 粘贴模式,可选 text 纯文本(默认) | origin 尽可能保持原格式
pasteMode: {
type: String,
default: 'text'
}
},
emits: ['ready', 'input', 'statuschange', 'focus', 'blur', 'overmax', 'epaste'],
data() {
return {
textlength: 0, // 当前字数统计
startID: '',
// #ifdef VUE2
// #ifdef MP-WEIXIN
editorIns: null // 仅vue2环境下的微信小程序需要声明实例变量否则报错属实逆天
// #endif
// #endif
}
},
computed: {
editorEID: {
set(newVal) {
store.actions.setEID(newVal)
},
get() {
return store.actions.getEID()
}
},
editorCtx: {
set(newVal) {
store.actions.setEditor(newVal.eid, newVal.ctx)
// #ifdef VUE2
this.editorIns = newVal.ctx
this.editorIns.id = newVal.eid
// #endif
},
get() {
let instance = store.actions.getEditor(this.eid)
// #ifdef VUE2
instance = store.actions.getEditor(this.eid) || this.editorIns
// #endif
return instance
}
},
isReadOnly: {
set(newVal) {
store.actions.setReadOnly(newVal)
},
get() {
return store.actions.getReadOnly()
}
}
},
watch: {
readOnly(newVal) {
this.isReadOnly = newVal
}
},
mounted() {
// 首个实例初始化时执行
if (!store.state.firstInstanceFlag) {
this.editorEID = this.eid
store.state.firstInstanceFlag = this.eid
copyrightPrint()
}
},
destroyed() {
store.actions.destroy()
},
unmounted() {
store.actions.destroy()
},
methods: {
onEditorReady() {
this.$nextTick(() => {
uni
.createSelectorQuery()
.in(this)
.select('#' + this.eid)
.context((res) => {
// 存储上下文
this.editorCtx = { eid: this.eid, ctx: res.context }
// 挂载实例api
this.bindMethods()
// 初始化完成
this.$emit('ready', this.editorCtx)
// #ifdef APP || H5
if (this.pasteMode == 'origin') this.editorCtx.changePasteMode('origin')
// #endif
})
.exec()
})
},
/**
* 挂载实例api
*/
bindMethods() {
// ===== renderjs相关扩展api =====
// #ifdef APP || H5
/**
* 主动聚焦
* @returns {void}
*/
this.editorCtx.focus = this.$refs.editorRenderRef.focus
/**
* 退格
* @returns {void}
*/
this.editorCtx.backspace = this.$refs.editorRenderRef.backspace
/**
* 键盘输入模式
* @param {String} type 模式可选none | remove
* @returns {void}
*/
this.editorCtx.changeInputMode = this.$refs.editorRenderRef.changeInputMode
/**
* 粘贴模式
* @param {String} type 模式可选text纯文本(默认) | origin尽可能保持原格式
* @returns {void}
*/
this.editorCtx.changePasteMode = (type) => {
// 告知plugin启动
this.startID = this.eid
if (this.$refs.editorPluginRef?.changePasteMode) {
this.$refs.editorPluginRef.changePasteMode(type)
}
}
/**
* 生成视频封面图
* @param {String} url 封面图片地址
* @returns {Promise} 携带播放图标的封面图地址
*/
this.editorCtx.createCoverThumbnail = (url) => {
return new Promise((resolve) => {
if (this.$refs.editorPluginRef?.createCoverThumbnail) {
// 事件名必须唯一,否则会覆盖
uni.$once(`E_EDITOR_GET_COVER_THUMBNAIL_${url}`, (res) => {
resolve(res.cover)
})
setTimeout(() => {
this.$refs.editorPluginRef?.createCoverThumbnail(url)
})
}
})
}
/**
* 生成视频封面图
* @param {String} url 视频地址
* @returns {Promise} 封面图地址
*/
this.editorCtx.createVideoThumbnail = (url) => {
return new Promise((resolve) => {
if (this.$refs.editorPluginRef?.createVideoThumbnail) {
// 事件名必须唯一,否则会覆盖
uni.$once(`E_EDITOR_GET_VIDEO_THUMBNAIL_${url}`, (res) => {
resolve(res.cover)
})
setTimeout(() => {
this.$refs.editorPluginRef?.createVideoThumbnail(url)
})
}
})
}
// #endif
// ===== 微信小程序扩展api =====
// #ifdef MP-WEIXIN
/**
* 生成视频封面图
* @param {String} url 视频地址
* @returns {Promise} 封面图地址
*/
this.editorCtx.createCoverThumbnail = wxplugin?.wxCreateCoverThumbnail
// #endif
// ===== 通用扩展api =====
/**
* 主动触发input回调事件
* @returns {void}
*/
this.editorCtx.changeInput = () => {
this.editorCtx.getContents({
success: (res) => {
this.$emit('input', { ctx: this.editorCtx, html: res.html, text: res.text })
}
})
}
/**
* 获取最新内容
* @returns {Promise} 内容对象 { html, text... }
*/
this.editorCtx.getLastContent = async () => {
return new Promise((resolve) => {
this.editorCtx.getContents({
success: (res) => {
resolve(res)
}
})
})
}
/**
* 富文本内容初始化
* 注意微信小程序会导致聚焦滚动建议先将编辑器v-show=false待initHtml内容初始化完成后再true
* 也正是因为微信小程序端会聚焦滚动所以editorEID在初始阶段会默认保持最后一个实例eid需要手动重新聚焦
* @param {String} html 初始化的富文本
* @param {Function<Promise>} customCallback 自定义处理封面回调需要以Promise形式返回封面图片资源
* @returns {void}
*/
this.editorCtx.initHtml = async (html, customCallback) => {
let transHtml = await replaceVideoWithImageRender(html, customCallback)
// #ifdef APP || H5
this.editorCtx.changePasteMode('text') // text模式下可以防止初始化时对格式的影响
// #endif
setTimeout(() => {
this.editorCtx.setContents({
html: transHtml,
success: () => {
// 主动触发一次input回调事件
this.editorCtx.changeInput()
// #ifdef APP || H5
if (this.pasteMode == 'origin') this.editorCtx.changePasteMode('origin')
// #endif
}
})
})
}
/**
* 导出处理
* @param {String} html 要导出的富文本
* @returns {String} 处理后的富文本
*/
this.editorCtx.exportHtml = (html) => {
return parseHtmlWithVideo(html)
}
},
onEditorInput(e) {
// 注意不要使用getContents获取html和text会导致重复触发onStatusChange从而失去toolbar工具的高亮状态
// 复制粘贴的时候detail会为空此时应当直接return
if (Object.keys(e.detail).length <= 0) return
const { html, text } = e.detail
// 识别到链接特殊标识立即return
if (text.indexOf(linkFlag) !== -1) return
/**
* 因为uni-editor不提供最大字符限制故需要手动进行以下特殊处理
*/
const maxlength = parseInt(this.maxlength)
const textStr = text.replace(/[ \t\r\n]/g, '')
this.textlength = textStr.length // 当前字符数
if (this.textlength >= maxlength && maxlength > 0) {
this.textlength = maxlength // 因为editor特性需要手动赋阈值
if (!this.lockHtmlFlag) {
this.lockHtml = html // 锁定最后一次超出字数前的html
this.lockHtmlFlag = true // 锁定标志
// 首次到达最大限制时还需最后回调一次input事件
this.$emit('input', { ctx: this.editorCtx, html, text })
} else {
// 在超过字数时锁定,若再编辑则抛出超出事件
this.$emit('overmax', { ctx: this.editorCtx })
}
// 超过字数时锁定最后一次超出字数前的html
this.editorCtx.setContents({ html: this.lockHtml })
} else {
// 正常输入
this.$emit('input', { ctx: this.editorCtx, html, text })
this.lockHtmlFlag = false // 锁定标志
}
},
/**
* 样式格式改变时触发
* 注意微信小程序端在多编辑器实例下切换编辑器后可能不会及时触发onStatusChange
*/
onStatusChange(e) {
store.actions.setFormats(e.detail)
this.$emit('statuschange', { ...e, ctx: this.editorCtx })
uni.$emit('E_EDITOR_STATUSCHANGE', { ...e, ctx: this.editorCtx })
},
onEditorFocus(e) {
this.editorEID = this.eid
this.$emit('focus', { ...e, ctx: this.editorCtx })
},
onEditorBlur(e) {
this.$emit('blur', { ...e, ctx: this.editorCtx })
},
ePaste(e) {
this.$emit('epaste', { ...e, ctx: this.editorCtx })
uni.$emit('E_EDITOR_PASTE', { ...e, ctx: this.editorCtx })
},
/**
* 微信小程序官方editor的长按事件有bug需要重写覆盖不需做任何逻辑可见下面小程序社区问题链接
* @tutorial https://developers.weixin.qq.com/community/develop/doc/000c04b3e1c1006f660065e4f61000
*/
eLongpress() {}
}
}
</script>
<style lang="scss">
.sv-editor-wrapper {
--maxlength-text-color: #666666;
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.sv-editor-container {
flex: 1;
}
.maxlength-tip {
position: absolute;
bottom: 0;
right: 0;
font-size: 24rpx;
color: var(--maxlength-text-color);
opacity: 0.6;
}
}
// placeholder字样
.sv-editor-container ::v-deep .ql-blank::before {
font-style: normal;
color: #cccccc;
}
// 图片工具样式
::v-deep .ql-container {
min-height: unset;
.ql-image-overlay {
pointer-events: none;
.ql-image-size {
right: 28px !important;
}
.ql-image-toolbar {
// 删除按钮
pointer-events: auto;
}
.ql-image-handle {
// 四角缩放按钮
width: 30px;
height: 30px;
pointer-events: auto;
}
}
}
</style>

View File

@@ -0,0 +1,87 @@
{
"id": "sv-editor",
"displayName": "基于官方 uni-editor 的富文本编辑器",
"version": "1.1.2",
"description": "可插入图片、视频、链接、@提及、#话题、Emoji表情包且优化了聚焦键盘闪烁等问题",
"keywords": [
"富文本",
"编辑器",
"editor",
"html"
],
"repository": "https://gitee.com/Sonve/sv-editor",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "u",
"app-uvue": "u",
"app-harmony": "u"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,333 @@
## 基于官方 uni-editor 的富文本编辑器 [sv-editor]
### 一、前言
首先,你需要了解 uni-editor 相关注意事项以及api
传送门:
1. [editor 组件概况](https://uniapp.dcloud.net.cn/component/editor.html)
2. [editorContext api详情](https://uniapp.dcloud.net.cn/api/media/editor-context.html)
3. 仔细阅读 [HTML 标签和 style 内联样式支持情况](https://uniapp.dcloud.net.cn/component/editor.html#html-%E6%A0%87%E7%AD%BE%E5%92%8C-style-%E5%86%85%E8%81%94%E6%A0%B7%E5%BC%8F%E6%94%AF%E6%8C%81%E6%83%85%E5%86%B5)
4. 仔细了解 [注意事项](https://uniapp.dcloud.net.cn/component/editor.html#%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9)
### 二、本插件在官方 uni-editor 基础上做了什么
1. 提供插入视频的api
2. 提供插入链接的api
3. 在插入链接的基础上扩展了 @某人#话题#、以及 添加附件 的api
4. 支持插入emoji表情包可自定义表情包面板
5. 解决了在app端插入内容后编辑器聚焦后自动弹出键盘的问题提供api可在聚焦的同时取消键盘反复弹出带来的影响
6. 工具栏toolbar与编辑器editor分离式写法让你的代码更加自由
7. 插件内部大部分样式由css变量控制更方便你使用样式穿透去自定义对有暗黑主题的需求更加友好
8. 所有组件添加了 styleIsolation: 'shared' 配置项,再也不用怕小程序端的样式隔离穿透不了
9. 部分扩展基于renderjs因此小程序端无法使用可见下列关键功能概况详情
10. App与H5端关键扩展api如下
- noKeyboardEffect取消键盘影响不想让富文本聚焦后总是自动弹出键盘这个api可以完美解决你的问题
- focus主动聚焦你可以直接通过 editorCtx 实例调用此api以便直接主动使富文本聚焦
- backspace主动退格(删除)希望可以模拟键盘上的退格键这个api如同键盘的 backspace 键一样,删除光标前一个单位,或者删除所选区域
- 等等其他api详见下文
### 三、兼容性
✅已兼容,❌未兼容
| VUE2 | VUE3 | APP(Android) | APP(iOS)| H5 | 微信小程序 | 其他小程序 |
| :---:| :---:| :---: | :---: | :---: | :---: | :---: |
| ✅ | ✅️ | ✅ | ✅ | ✅ | ✅️️ | ❌(没测过) |
1. 实际请以真机效果为准,并不能保证所有机型都兼容,如遇到问题还请加群讨论
2. 注意因为部分api基于renderjs而小程序无法使用renderjs故部分api和功能并不适配小程序更多详情会在各api中说明
3. 特别注意:**在微信小程序中,生成的 a 标签的 href 属性会被自动抹去,因此在微信小程序中是无法点击超链接跳转的,这点目前微信小程序官方固件不支持,暂时也没啥好的办法**
### 四、关键功能概况
✅完美支持,☑可用但或有副作用,❌不支持
| 功能 | VUE2 | VUE3 | H5 | APP(Android) | APP(iOS) | 微信小程序 |
| :---: | :---:| :---:| :---:| :---: | :---: | :---: |
| 插入图片 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 插入视频 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 插入链接 | ✅ | ✅ | ✅ | ✅ | ✅ | ☑️ |
| 插入提及 | ✅ | ✅ | ✅ | ✅ | ✅ | ☑️ |
| 插入话题 | ✅ | ✅ | ✅ | ✅ | ✅ | ☑️ |
| 插入附件 | ✅ | ✅ | ✅ | ✅ | ✅ | ☑️ |
| 主动聚焦 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌️ |
| 主动退格 | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| 多编辑器实例 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 消除键盘影响 | ✅ | ✅ | ✅ | ✅ | ☑ | ☑️ |
| 粘贴保留格式 <br/> [(*特殊扩展)](#特殊扩展) | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| 粘贴事件监听 <br/> [(*特殊扩展)](#特殊扩展) | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| 视频截取封面 | ✅ | ✅ | ✅ | ✅ | ☑️ | ☑ |
| 视频回显解析 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 待补充 ... | | | | | | |
### 五、开始
1. 因为本插件不依赖其他第三方插件,因此直接点击右上角 `下载并导入HBuilderX` 导入至你的项目中即可
2. 强烈建议你先 `使用HBuilderX导入示例项目` ,跑一下示例看亿下先,部分写法可以直接抄示例
3. 因为本插件提供除 [editorContext](https://uniapp.dcloud.net.cn/api/media/editor-context.html) 官方api外额外扩展的api需要你对js有着基本的掌握特别是Promise和异步处理
4. 本插件仅为富文本编辑器如要解析回显还请自行寻找富文本解析插件不推荐rich-text
### 六、插件目录结构
```
uni_modules
└─ sv-editor
├─ components
│ ├─ common
│ │ ├─ config.js // 配置文件
│ │ ├─ file-handler.js // 文件处理方法
│ │ ├─ parse.js // 富文本解析工具
│ │ ├─ store.js // 插件内全局状态管理
│ │ ├─ tool-list.js // 工具栏工具列表
│ │ └─ utils.js // 通用工具api
│ ├─ icons
│ │ ├─ iconfont.css // 字体图标样式
│ │ └─ iconfont.ttf // 字体图标
│ └─ sv-editor
│ ├─ sv-choose-file.vue // 文件选择器
│ ├─ sv-editor-popup-more.vue // 更多工具弹窗面板
│ ├─ sv-editor-render.vue // renderjs组件
│ ├─ sv-editor-toolbar.vue // 内置工具栏
│ └─ sv-editor.vue // 编辑器主体
├─ changelog.md
├─ package.json
└─ readme.md
```
### 七、基本使用
#### sv-editor 编辑器主体
`符合uni_modules规范无需引入直接使用`
1. props
| 属性名 | 类型 | 默认值 | 说明 |
| :--- | :--- | :--- | :--- |
| eid | String | 'sv-editor' | 编辑器id唯一禁止重复多编辑器实例时必填 |
| placeholder | String | '写点什么吧 ~' | 占位字样 |
| readOnly | Boolean | false | 是否只读 |
| maxlength | Number | -1 | 最大字数限制,<=0时表示不限 |
| hideMax | Boolean | false | 是否关闭最大字数显示 |
2. emits
| 事件名 | 参数 | 说明 |兼容性 |
| :--- | :--- | :--- | :--- |
| ready | ctx 当前编辑器上下文实例 | 编辑器初始化完成时触发 | 通用 |
| input | { ctx, html, text } | 编辑器内容改变时触发 | 通用 |
| focus | { ctx, event } | 编辑器聚焦时触发 | 通用 |
| blur | { ctx, event } | 编辑器失去焦点时触发 | 通用 |
| statuschange | { ctx, event } | 通过 Context 方法改变编辑器内样式时触发,返回选区已设置的样式 | 通用 |
| overmax | { ctx } | 超过最大字数限制时回调 | 通用 |
| epaste <br/> [(*特殊扩展)](#特殊扩展) | { ctx, id, text, html, range } | 粘贴回调事件 | H5、APP |
- statuschange 事件还提供 uni.$emit('E_EDITOR_STATUSCHANGE', { ctx, event }) 抛出,你可以通过 uni.$on('E_EDITOR_STATUSCHANGE') 进行监听但是不要忘记在适当的地方off关掉
- epaste [(*特殊扩展)](#特殊扩展) 事件还提供 uni.$emit('E_EDITOR_PASTE', { ctx, id, text, html, range }) 抛出,你可以通过 uni.$on('E_EDITOR_PASTE') 进行监听但是不要忘记在适当的地方off关掉
#### sv-editor-toolbar 编辑器工具栏
`与编辑器本体分离,按需引入使用`
1. props
| 属性名 | 类型 | 默认值 | 说明 |
| :--- | :--- | :--- | :--- |
| tools | Array | [] 默认空数组即为全工具,可选 [详见 toolList](#toolList) | 工具栏列表,例如 ['style', ...] |
| styleTools| Array | [] 默认空数组即为全工具,可选 [详见 styleToolList](#styleToolList) | 样式工具列表,例如 ['header', ...] |
| moreTools | Array | [] 默认空数组即为全工具,可选 [详见 moreToolList](#moreToolList) | 更多功能列表,例如 ['image', ...] |
注意:
- 此处 toolList 等为全列表,详见 `uni_modules/sv-editor/components/common/tool-list.js` 文件。
- 若只想使用部分工具以及修改顺序则给组件对应的props属性例如 `:tools="['style', 'undo', 'redo']"` 即可只使用该三项工具且顺序以该数组顺序排序。
- 关于图标,本插件内置了 [阿里巴巴矢量图标库](https://www.iconfont.cn/) 的字体图标,如需使用其他图标,请自行替换。
2. emits
| 事件名 | 参数 | 说明 |
| :--- | :--- | :--- |
| toolMoreItem | { name, value } | 点击更多功能面板子项 |
| moreItemConfirm | { link, text, file } | 点击更多功能弹窗确认后回调 |
| keyboardChange | { height } | 键盘高度变化 |
| changeMorePop | true 打开 / false 关闭 | 更多功能弹窗打开/关闭 |
| tapTool | { name, value } | 点击工具栏 |
| changeTool | 工具name | 工具栏改变 |
| tapStyle | { name, value } | 点击样式工具 |
| tapEmoji | { name, value } | 点击Emoji表情 |
| backspace | | 触发编辑器实例主动使用backspace后回调 |
##### toolList
| title | name | value | icon |
| :--- | :--- | :--- | :--- |
| 样式 | style | | icon-zitiyanse |
| 表情 | emoji | | icon-xiaolian |
| 撤销 | undo | | icon-shangyibu1 |
| 重做 | redo | | icon-xiayibu1 |
| 更多 | more | | icon-icon_tianjia |
| 扩展 | setting | | icon-bianji |
##### styleToolList
| title | name | value | icon |
| :--- | :--- | :--- | :--- |
| 标题 | header | 2 | icon-zitibiaoti |
| 分割线 | divider | | icon-fengexian |
| 粗体 | bold | | icon-zitijiacu |
| 斜体 | italic | | icon-zitixieti |
| 下划线 | underline | | icon-zitixiahuaxian |
| 删除线 | strike | | icon-zitishanchuxian|
| 左对齐 | align | left | icon-zuoduiqi |
| 居中 | align | center | icon-juzhongduiqi |
| 右对齐 | align | right | icon-youduiqi |
| 有序列表 | list | ordered | icon-youxupailie |
| 无序列表 | list | bullet | icon-wuxupailie |
| 上标 | script | super | icon-zitishangbiao |
| 左缩进 | indent | +1 | icon-zuosuojin |
| 右缩进 | indent | -1 | icon-yousuojin |
| 下标 | script | sub | icon-ziti-xiabiao |
| 文字颜色 | color | | icon-wenziyanse |
| 背景颜色 | backgroundColor | | icon-beijingyanse' |
| 清除格式 | removeformat | | icon-qingchugeshi |
- 以上为插件内置样式工具,更多详见 [支持设置的样式列表](https://uniapp.dcloud.net.cn/api/media/editor-context.html#editorcontext-format)
- 缩进时需要在解析插件此处以mp-html为例中添加如下缩进样式以供识别
```
// uni_modules/mp-html/components/mp-html/node/node.vue
// 不要管插件内原始的样式代码
<style>...</style>
// 直接在该vue文件最底下添加如下scss样式
<style lang="scss">
@for $i from 1 through 10 {
.ql-indent-#{$i} {
// 默认一个缩进为2个em单位此处对应
text-indent: #{$i * 2}em;
}
}
</style>
```
##### moreToolList
| title | name | value | icon |
| :--- | :--- | :--- | :--- |
| 添加图片 | image | popup | icon-charutupian |
| 添加视频 | video | popup | icon-shexiangji |
| 添加链接 | link | popup | icon-charulianjie |
| 添加附件 | attachment| popup | icon-huixingzhen |
| 提及 | at | popup | icon-at |
| 话题 | topic | popup | icon-huati |
| 清空 | clear | button| icon-shanchu |
*在微信小程序中,生成的 a 标签的 href 属性会被自动抹去,因此在微信小程序中是无法点击超链接跳转的,这点目前微信小程序官方固件不支持,暂时也没啥好的办法*
##### emojiToolList
emoji默认列表
##### colorList
调色板默认颜色列表
#### api 合集
1. [editorContext 官方api](https://uniapp.dcloud.net.cn/api/media/editor-context.html)
2. 本插件编辑器实例 `editorCtx` 中,你可以直接通过富文本实例调用
| 方法名 | 参数 | 返回值 | 说明 | 兼容性 |
| :--- | :--- | :--- | :--- | :--- |
| focus | | | 主动聚焦 | H5、App |
| backspace | | | 退格会触发sv-editor-toolbar的backspace回调函数 | H5、App(Android) |
| createVideoThumbnail <br/> [(*特殊扩展)](#特殊扩展)| url:string 视频地址 | 封面图地址 Promise | 以视频资源地址,直接生成视频封面图(需要保证视频资源正常可以播放) | H5、App(Android) |
| createCoverThumbnail <br/> [(*特殊扩展)](#特殊扩展)| url:string 图片资源 | 封面图地址 Promise | 若后端返回视频封面但是没有播放图标,可以用此方法在图片中央叠加播放图标,用于作为视频封面 | 通用 |
| changeInputMode | type:string 模式可选none/remove | | 修改输入模式该api是取消键盘闪烁的关键none时将禁止键盘弹出remove时将移除该限制 | H5、App |
| changeInput | | | 主动触发input回调事件 | 通用 |
| getLastContent | | { html, text... } 内容对象 Promise | 获取富文本当前最新内容 | 通用 |
| exportHtml | html:string 要导出的富文本 | 处理后的富文本 String | 富文本导出若富文本携带视频则会自动解析为video标签 | 通用 |
| initHtml | html:string 初始化的富文本 <br/> customCallback 详见补充说明 | | 富文本内容初始化若富文本携带video标签将会自动进行解析转换 | 通用 |
- initHtml 在微信小程序端会导致聚焦滚动,建议先将编辑器 v-show=false待 initHtml 内容初始化完成后再 true。也正是因为微信小程序端会聚焦滚动所以 editorEID 在初始阶段会默认保持最后一个实例 eid需要手动重新聚焦
- initHtml 第二个参数 customCallback 和 api: replaceVideoWithImageRender 一致customCallback 为自定义处理封面回调自带参数为视频地址需要return封面图片资源若无有效返回则走默认封面处理建议配合后端生成视频封面以兼容各端。
3. `uni_modules/sv-editor/components/common/store.js` 文件中,插件内全局状态仓库,你可以按需引入后通过 store.state 与 store.actions 来访问变量
| 方法名 | 参数 | 返回值 | 说明 | 兼容性 |
| :--- | :--- | :--- | :--- | :--- |
| getEditor | eid | eid编辑器实例 | 获取指定eid的编辑器实例 | 通用 |
| setEditor | eid, ctx | | 设置eid编辑器实例 | 通用 |
| getEID | | 当前编辑器eid | 获取当前编辑器eid | 通用 |
| setEID | 当前编辑器eid | | 设置当前编辑器eid | 通用 |
| getFormats | | 编辑器样式格式 | 获取编辑器样式格式 | 通用 |
| setFormats | 编辑器样式格式 | | 设置编辑器样式格式 | 通用 |
| getReadOnly | | 是否只读 Boolean | 获取编辑器是否只读 | 通用 |
| setReadOnly | 是否只读 Boolean | | 设置编辑器是否只读 | 通用 |
4. `uni_modules/sv-editor/components/common/utils.js` 文件中,需要按需引入,实用工具
| 方法名 | 参数 | 返回值 | 说明 | 兼容性 |
| :--- | :--- | :--- | :--- | :--- |
| addImage | (uploadFunc必填, options) | Array/Promise 上传的文件 | 添加图片 | 通用 |
| addVideo | (uploadFunc必填, options) | Array/Promise 上传的文件 | 添加视频 | 通用 |
| addLink | (options, callback) | | 添加链接 | 通用 |
| addAttachment | (uploadFunc必填, options, callback) | Object/Promise 上传的文件 | 添加附件 | 通用 |
| addAt | (options, callback) | | 添加提及 | 通用 |
| addTopic | (options, callback) | | 添加话题 | 通用 |
| insertLink | (editorCtx必填, options, callback) | | 插入链接母本:添加链接、添加附件、添加提及、添加话题均基于此 | 通用 |
| noKeyboardEffect | (callback必填, options) | | 核心:消除键盘影响,但是微信小程序只能通过编辑器失焦的方式关闭键盘(依然会闪一下) | 通用 |
5. `uni_modules/sv-editor/components/common/parse.js` 文件中,需要按需引入,正则解析工具
| 方法名 | 参数 | 返回值 | 说明 | 兼容性 |
| :--- | :--- | :--- | :--- | :--- |
| replaceVideoWithImageRender| richText:string 要进行处理的富文本字符串 <br/> customCallback 自定义处理封面回调需要return处理后的封面图片自带参数为视频地址 | 处理结果 Promise | 带有视频的富文本逆向转换可通过customCallback回调函数自定义处理封面 | 通用 |
| parseHtmlWithVideo | richText:string 要进行处理的富文本字符串 | 处理结果 String | 将含有封面占位图形式的视频富文本转换成正常视频的富文本 | 通用 |
| parseImagesAndVideos | richText:string 要进行处理的富文本字符串 | 处理结果 Array < Object >| 解析当前富文本中所有图片和视频 | 通用 |
| parseImages | richText:string 要进行处理的富文本字符串 | 处理结果 Array < Object >| 解析当前富文本中所有图片 | 通用 |
| parseVideos | richText:string 要进行处理的富文本字符串 | 处理结果 Array < Object >| 解析当前富文本中所有视频 | 通用 |
6. `uni_modules/sv-editor/components/common/config.js` 配置文件
| 参数 | 说明 |
| :--- | :--- |
| video_thumbnail | 视频默认封面默认封面图可能会失效原图在示例工程根目录下static文件夹中建议自行替换 |
| video_playicon | 视频封面播放图标(默认三角播放图标) |
7. 具体使用代码案例请 `使用HBuilderX导入示例项目` 导入示例工程参考
### 八、特殊扩展
**本插件提供部分额外特殊扩展功能,具体如下:**
| 功能 | 说明 | 类型 | 兼容 |
| :--- | :--- | :--- | :--- |
| 粘贴保留格式 | 粘贴时尽可能的保留原有格式(并非完全复制) | 固有功能 | H5、APP |
| epaste | 粘贴回调事件 | 事件 | H5、APP |
| createVideoThumbnail| 以视频资源地址,直接生成视频封面图(需要保证视频资源正常可以播放) | api | H5、APP |
| createCoverThumbnail| 若后端返回视频封面但是没有播放图标,可以用此方法在图片中央叠加播放图标,用于作为视频封面 | api | 通用 |
| 待补充 ... | | | |
- createCoverThumbnail 在iOS端可能会报 `the operation is insecure` 的错这是iOS更加严格的安全策略导致的本地file://协议也会导致跨域,从而污染了画布
制作不易特殊扩展功能限时免费开放感谢支持Thanks♪(・ω・)ノ
使用方式:将插件内 backup 文件夹下的文件复制并粘贴进 plugins 文件夹下并覆盖原文件
### 九、结语
本插件免费开源(除特殊扩展外),如若借鉴源码还请注明出处,未经授权禁止转载售卖等侵犯版权行为,谢谢!
如若商用,望您可以联系作者本人,留下您的项目名,我希望能以方式此推广,谢谢!
感谢您使用本插件,如果在使用过程中遇到任何问题,欢迎在评论区留言,或在 [Gitee](https://gitee.com/Sonve/sv-editor) 上提交issue我会尽快回复您。
制作不易,还望五星好评,若能在 [Gitee](https://gitee.com/Sonve/sv-editor) 上点个 ⭐star不胜感激Thanks♪(・ω・)ノ
欢迎加群讨论Q群
① [852637893](https://qm.qq.com/cgi-bin/qm/qr?k=R7DHSqqDI4-xRCfwdUB2e3NrTytHpcVe&jump_from=webapi&authKey=2IpufavBOSPOLdncCt7EFnbmbWrUHg1c8iqNEdTzG8zCvnKb8/0aaLXF4HJzlp2R)
② [816646292](https://qm.qq.com/cgi-bin/qm/qr?k=ndZIUqx0xctbq8oDQVTiDir7AUO5jq9X&jump_from=webapi&authKey=fgk45wWObUUvig7FIuFUuM+0IFLvOJI7LMc1d4qNbWAIfehakai/ZfckYfAGLPne)