更新富文本编辑器
This commit is contained in:
122
uni_modules/sv-editor/components/sv-editor/sv-choose-file.vue
Normal file
122
uni_modules/sv-editor/components/sv-editor/sv-choose-file.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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, // 最多可以选择的文件个数,可以 0~100,此处限制为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>
|
||||
150
uni_modules/sv-editor/components/sv-editor/sv-editor-render.vue
Normal file
150
uni_modules/sv-editor/components/sv-editor/sv-editor-render.vue
Normal 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>
|
||||
647
uni_modules/sv-editor/components/sv-editor/sv-editor-toolbar.vue
Normal file
647
uni_modules/sv-editor/components/sv-editor/sv-editor-toolbar.vue
Normal 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>
|
||||
445
uni_modules/sv-editor/components/sv-editor/sv-editor.vue
Normal file
445
uni_modules/sv-editor/components/sv-editor/sv-editor.vue
Normal 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>
|
||||
Reference in New Issue
Block a user