更新富文本编辑器

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

@@ -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>