Browse Source

增加富文本及样式调整

master
赵鹏 1 month ago
parent
commit
2b49f33457
  1. 2
      api/jobinfo/index.js
  2. 9
      pages.json
  3. 517
      sub/owner/job-detail.vue
  4. 143
      sub/owner/job-view.vue
  5. 249
      sub/owner/jobInfo.vue
  6. 131
      uni_modules/sp-editor/changelog.md
  7. 825
      uni_modules/sp-editor/components/sp-editor/color-picker.vue
  8. 140
      uni_modules/sp-editor/components/sp-editor/fab-tool.vue
  9. 152
      uni_modules/sp-editor/components/sp-editor/link-edit.vue
  10. 852
      uni_modules/sp-editor/components/sp-editor/sp-editor.vue
  11. 24
      uni_modules/sp-editor/icons/custom-icon.css
  12. 238
      uni_modules/sp-editor/icons/editor-icon.css
  13. 83
      uni_modules/sp-editor/package.json
  14. 19
      uni_modules/sp-editor/readme.md
  15. 1
      uni_modules/sp-editor/static/image-resize.min.js
  16. 8
      uni_modules/sp-editor/static/quill.min.js
  17. 132
      uni_modules/sp-editor/utils/index.js

2
api/jobinfo/index.js

@ -11,7 +11,7 @@ export const JobInfoApi = {
// 查询工作汇报详情
getJobInfo: (id) => {
return request({ url: `/system/job-info/get?id=` + id,method: 'get' })
return request({ url: `/system/job-info/get?id=` + id, method: 'get' })
},
// 模板信息

9
pages.json

@ -116,14 +116,7 @@
"componentPlaceholder": {}
}
},
{
"path": "owner/job-view",
"style": {
"navigationBarTitleText": "工作汇报",
"usingComponents": {},
"componentPlaceholder": {}
}
},
{
"path": "enterprise/edit",
"style": {

517
sub/owner/job-detail.vue

@ -1,143 +1,498 @@
<template>
<view class="jobInfo-detail">
<view class="view">
<scroll-view scroll-y class="content">
<!-- 详情内容区 -->
<view class="content-wrap">
<view class="jobInfo-title">{{ jobInfoDetail.title }}</view>
<view class="jobInfo-time">生效时间{{ jobInfoDetail.jobDate }}</view>
<!-- 富文本内容 -->
<view class="jobInfo-content">
<rich-text :nodes="jobInfoDetail.context"></rich-text>
<!-- 标题输入 -->
<view class="block">
<view>
<text style="color: #f8285a; margin-right: 4px">*</text>
标题
</view>
<view class="value">
<uni-easyinput
v-model="jobInfoDetail.title"
placeholder="请输入标题"
:inputBorder="false"
:clearable="false"
:autoHeight="true"
:adjust-position="true"
cursorSpacing="32"
/>
</view>
</view>
<!-- 日期选择 -->
<view class="block">
<view>
<text style="color: #f8285a; margin-right: 4px">*</text>
日期
</view>
<view class="value" @tap="showCalendar('jobDate')">
<text v-if="jobInfoDetail.jobDate" class="input-value">{{ jobInfoDetail.jobDate }}</text>
<text v-else class="input-value" style="color: #808080">请选择日期</text>
<u-icon name="arrow-right"></u-icon>
</view>
</view>
<uni-calendar
ref="calendarRef"
:insert="false"
@confirm="confirmCalendar"
/>
<!-- 汇报人 -->
<view class="block">
<view>
<text style="color: #f8285a; margin-right: 4px">*</text>
汇报人
</view>
<view class="value">
<uni-easyinput
v-model="jobInfoDetail.jobName"
placeholder="请输入汇报人"
:inputBorder="false"
:clearable="false"
:autoHeight="true"
:adjust-position="true"
cursorSpacing="32"
/>
</view>
</view>
<!-- 汇报内容 -->
<view class="block content-block">
<view>
<text style="color: #f8285a; margin-right: 4px">*</text>
汇报内容
</view>
<view class="content-wrap">
<sp-editor
:html="jobInfoDetail.content"
:toolbar-config="{
excludeKeys: ['direction', 'date', 'lineHeight', 'letterSpacing', 'listCheck'],
iconSize: '18px'
}"
:max-length="5000"
:min-height="300"
:placeholder="'请输入汇报内容'"
@init="initEditor"
@input="inputOver"
@upinImage="upinImage"
@overMax="overMax"
@addLink="addLink"
@exportHtml="exportHtml"
/>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="operation">
<button class="btn delete" @tap="handleDelete">删除</button>
<button class="btn save" @tap="handleSave">保存</button>
</view>
</view>
</template>
<script>
import { jobInfoApi } from '@/api/jobinfo/index.js'
import { JobInfoApi } from '@/api/jobinfo/index.js'
import { uploadFile } from '@/api/system/file.js'
export default {
// In data section
data() {
return {
id: '', // ID
id: '',
//
//
calendar: {
//
key: null
},
jobInfoDetail: {
name: '',
createTime: '',
context: ''
title: '',
jobDate: '',
jobName: '靖宇',
content: ''
}
}
},
},
// ... keep other existing methods ...
onLoad(options) {
if (options.id) {
this.id = options.id
this.getjobInfoDetail()
}
},
// onReady
onReady() {
},
methods: {
/**
* 显示日历
* @param {Object} key 表单中需要赋值的键
*/
showCalendar(key) {
this.$refs.calendarRef.open()
this.calendar = {
key
}
},
/**
* 日历选中之后的回调
* @param {Object} e
*/
confirmCalendar(e) {
this.jobInfoDetail[this.calendar.key] = e.fulldate
this.closeCalendar()
},
/**
* 关闭日历
*/
closeCalendar() {
this.calendar = {
key: null
}
},
//
async getjobInfoDetail() {
try {
const res = await jobInfoApi.getjobInfo(this.id)
console.log(JobInfoApi)
const res = await JobInfoApi.getJobInfo(this.id)
if (res.code === 0) {
this.jobInfoDetail = {
...res.data,
jobDate: this.formatTime(res.data.jobDate),
//
context: this.formatRichText(res.data.context)
jobDate: this.formatTime(res.data.jobDate)
}
// 使 nextTick
}
} catch (error) {
console.log(error)
}
},
//
openDatePicker() {
uni.showDatePicker({
current: this.jobInfoDetail.jobDate,
success: (res) => {
this.jobInfoDetail.jobDate = this.formatTime(res.date)
}
})
},
//
initEditor(ctx) {
this.ctx = ctx
//
if (this.jobInfoDetail.content) {
this.ctx.setContents({
html: this.jobInfoDetail.content
})
}
},
//
inputOver(e) {
this.jobInfoDetail.content = e.html
},
//
upinImage(tempFiles, editorCtx) {
uni.showLoading({ title: '上传中...' })
console.log('上传文件',tempFiles)
console.log('上传文件',editorCtx)
// API
//
tempFiles.forEach(async (item) => {
uni.showLoading({
title: '上传中请稍后',
mask: true
})
console.log('上传item',item)
const res = await uploadFile({ name: `file`, filePath: item.tempFilePath })
console.log('返回图片',res)
editorCtx.insertImage({
src: res.data.url,
width: '80%', // 100%便
success: function () {
uni.hideLoading()
}
})
})
},
//
overMax() {
uni.showToast({
title: '内容超出最大长度限制',
icon: 'none'
})
},
//
addLink(e) {
//
console.log('添加链接', e)
},
// HTML
exportHtml(e) {
this.jobInfoDetail.content = e.html
},
//
async handleSave() {
if (!this.validateForm()) return
try {
// HTML
if (this.ctx) {
this.ctx.getContents({
success: async (res) => {
this.jobInfoDetail.content = res.html
//
await this.saveContent()
}
})
} else {
//
await this.saveContent()
}
} catch (error) {
uni.hideLoading()
uni.showToast({
title: '获取详情失败',
title: '保存失败',
icon: 'none'
})
}
},
//
async saveContent() {
uni.showLoading({ title: '保存中...' })
// ID
const saveApi = this.id ?
JobInfoApi.updateJobInfo :
JobInfoApi.createJobInfo
const result = await saveApi(this.jobInfoDetail)
uni.hideLoading()
if (result.code === 0) {
uni.showToast({
title: '保存成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
},
//
handleDelete() {
if (!this.id) {
uni.navigateBack()
return
}
uni.showModal({
title: '提示',
content: '确定要删除此工作汇报吗?',
success: async (res) => {
if (res.confirm) {
try {
uni.showLoading({ title: '删除中...' })
const result = await JobInfoApi.deleteJobInfo(this.id)
uni.hideLoading()
if (result.code === 0) {
uni.showToast({
title: '删除成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
} catch (error) {
uni.hideLoading()
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
}
}
})
},
//
formatTime(time) {
if (!time) return ''
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
},
//
formatRichText(html) {
if (!html) return ''
//
return html.replace(/<img/gi, '<img style="max-width:100%;height:auto;display:block;"')
},
//
handleBack() {
uni.navigateBack()
//
validateForm() {
const { title, jobDate, content } = this.jobInfoDetail
if (!title) {
uni.showToast({ title: '请输入标题', icon: 'none' })
return false
}
if (!jobDate) {
uni.showToast({ title: '请选择日期', icon: 'none' })
return false
}
if (!content) {
uni.showToast({ title: '请输入汇报内容', icon: 'none' })
return false
}
return true
}
}
}
</script>
<style lang="scss">
.jobInfo-detail {
.view {
height: 100vh;
display: flex;
flex-direction: column;
background: #fff;
}
.content {
flex: 1;
padding: 12px;
padding-bottom: calc(156rpx + 12px); //
display: flex;
flex-direction: column;
gap: 12px;
height: calc(100vh - 156rpx - 12px);
overflow-y: auto;
background: #F9F9F9;
}
.block {
display: flex;
flex-flow: row nowrap;
background-color: #fff;
padding: 12px;
border-radius: 8px;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px; //
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 88rpx;
background: #fff;
&:last-child {
margin-bottom: 0; // block
}
.value {
flex: 1;
display: flex;
align-items: center;
padding: 0 30rpx;
border-bottom: 1rpx solid #eee;
z-index: 100;
flex-flow: row nowrap;
justify-content: space-between;
gap: 4px;
.back-btn {
font-size: 28rpx;
color: #333;
}
.title {
.input-value {
flex: 1;
text-align: center;
font-size: 32rpx;
font-weight: bold;
padding-right: 60rpx;
text-align: right;
white-space: nowrap;
text-overflow: ellipsis;
cursor: auto;
display: block;
font-family: UICTFontTextStyleBody;
height: 1.4rem;
min-height: 1.4rem;
overflow: hidden;
}
}
}
.content-block {
flex-direction: column;
align-items: flex-start;
.content-wrap {
padding: 30rpx 30rpx 30rpx;
.jobInfo-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.jobInfo-time {
font-size: 24rpx;
color: #999;
margin-bottom: 40rpx;
}
.jobInfo-content {
font-size: 28rpx;
line-height: 1.8;
color: #333;
:deep(img) {
max-width: 100%;
height: auto;
}
:deep(p) {
margin-bottom: 20rpx;
}
}
width: 100%;
margin-top: 12px;
}
}
::v-deep .uni-easyinput__content-input {
flex: 1;
text-align: right;
white-space: nowrap;
text-overflow: ellipsis;
cursor: auto;
display: block;
font-family: UICTFontTextStyleBody;
height: 1.4rem;
min-height: 1.4rem;
overflow: hidden;
padding-right: 0;
}
::v-deep .uni-easyinput__placeholder-class {
flex: 1;
text-align: right;
white-space: nowrap;
text-overflow: ellipsis;
cursor: auto;
display: block;
font-family: UICTFontTextStyleBody;
height: 1.4rem;
min-height: 1.4rem;
font-size: 14px;
overflow: hidden;
}
.operation {
height: 112rpx; //
position: fixed;
left: 0;
right: 0;
bottom: 34px;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
background: #FFFFFF;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
margin-top: 12px; //
.btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
font-size: 28rpx;
border-radius: 8px;
text-align: center;
}
.delete {
background: #F8285A;
color: #fff;
}
.save {
background: #17C653;
color: #fff;
}
}
::v-deep .editor-container {
border-radius: 8px;
background-color: var(--LightMode-Grey-Grey-100, #F9F9F9);
}
</style>

143
sub/owner/job-view.vue

@ -1,143 +0,0 @@
<template>
<view class="jobInfo-detail">
<!-- 详情内容区 -->
<view class="content-wrap">
<view class="jobInfo-title">{{ jobInfoDetail.title }}</view>
<view class="jobInfo-time">生效时间{{ jobInfoDetail.jobDate }}</view>
<!-- 富文本内容 -->
<view class="jobInfo-content">
<rich-text :nodes="jobInfoDetail.context"></rich-text>
</view>
</view>
</view>
</template>
<script>
import { jobInfoApi } from '@/api/jobinfo/index.js'
export default {
data() {
return {
id: '', // ID
jobInfoDetail: {
name: '',
createTime: '',
context: ''
}
}
},
onLoad(options) {
if (options.id) {
this.id = options.id
this.getjobInfoDetail()
}
},
methods: {
//
async getjobInfoDetail() {
try {
const res = await jobInfoApi.getjobInfo(this.id)
if (res.code === 0) {
this.jobInfoDetail = {
...res.data,
jobDate: this.formatTime(res.data.jobDate),
//
context: this.formatRichText(res.data.context)
}
}
} catch (error) {
uni.showToast({
title: '获取详情失败',
icon: 'none'
})
}
},
//
formatTime(time) {
if (!time) return ''
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
},
//
formatRichText(html) {
if (!html) return ''
//
return html.replace(/<img/gi, '<img style="max-width:100%;height:auto;display:block;"')
},
//
handleBack() {
uni.navigateBack()
}
}
}
</script>
<style lang="scss">
.jobInfo-detail {
height: 100vh;
background: #fff;
overflow-y: auto;
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 88rpx;
background: #fff;
display: flex;
align-items: center;
padding: 0 30rpx;
border-bottom: 1rpx solid #eee;
z-index: 100;
.back-btn {
font-size: 28rpx;
color: #333;
}
.title {
flex: 1;
text-align: center;
font-size: 32rpx;
font-weight: bold;
padding-right: 60rpx;
}
}
.content-wrap {
padding: 30rpx 30rpx 30rpx;
.jobInfo-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.jobInfo-time {
font-size: 24rpx;
color: #999;
margin-bottom: 40rpx;
}
.jobInfo-content {
font-size: 28rpx;
line-height: 1.8;
color: #333;
:deep(img) {
max-width: 100%;
height: auto;
}
:deep(p) {
margin-bottom: 20rpx;
}
}
}
}
</style>

249
sub/owner/jobInfo.vue

@ -1,49 +1,32 @@
<template>
<!-- 政策法规列表页面容器 -->
<view class="jobInfo-container">
<!-- 搜索框区域 -->
<view class="search-box">
<van-search
:value="abc"
placeholder="输入关键字查询"
id="sreach"
@clear="handleClear"
@change="handleSearch"
/>
<van-search :value="abc" placeholder="输入关键字查询" id="sreach" @clear="handleClear" @change="handleSearch" />
</view>
<!-- 政策法规列表区域 -->
<!-- 修改列表项部分 -->
<!-- 列表区域 -->
<view class="jobInfo-list">
<scroll-view
class="jobInfo-scroll"
scroll-y
@scrolltolower="loadNextPage"
@refresherrefresh="onRefresh"
refresher-enabled
:refresher-triggered="isRefreshing"
>
<view
class="jobInfo-item"
v-for="item in jobInfoList"
:key="item.id"
@click="btnView (item)"
>
<view class="item-left">
<image :src="itemImgUrl" class="jobInfo-icon"></image>
</view>
<view class="item-right">
<scroll-view class="jobInfo-scroll" scroll-y @scrolltolower="loadNextPage" @refresherrefresh="onRefresh"
refresher-enabled :refresher-triggered="isRefreshing">
<view class="jobInfo-item" v-for="item in jobInfoList" :key="item.id">
<view class="item-main" @click="handlejobInfoClick(item)">
<view class="jobInfo-title">{{ item.title }}</view>
<view class="jobInfo-time">汇报日期: {{ item.jobDate }}</view>
<view class="jobInfo-time">汇报日期{{ item.jobDate }}</view>
</view>
<view class="item-btns">
<button class="btn-preview" @click.stop="btnView(item)">生成预览</button>
<button class="btn-share" @click.stop="btnShare(item)">分享PDF</button>
</view>
</view>
</scroll-view>
</view>
<!-- 加载更多状态组件 -->
<uni-load-more :status="loadMoreStatus" />
<!-- 新增按钮 -->
<button class="add-btn" @click="handleAdd">新增</button>
</view>
</template>
@ -58,27 +41,32 @@ export default {
return {
isRefreshing: false, //
searchValue: '', //
abc: '',
abc: '',
jobInfoList: [], //
pageNum: 1, //
pageSize: 10, //
total: 0, //
loadMoreStatus: 'more' ,// more- loading- noMore-
itemImgUrl: config.imgUrl + '/miniohb/c103c92335bdd30d33ff8a062c219ead0626bf0a1898536bd7590285ca11c9db.png'
loadMoreStatus: 'more',// more- loading- noMore-
itemImgUrl: config.imgUrl + '/miniohb/c103c92335bdd30d33ff8a062c219ead0626bf0a1898536bd7590285ca11c9db.png'
}
},
//
onShow() {
console.log(this.itemImgUrl)
console.log(this.itemImgUrl)
this.getjobInfoList()
},
methods: {
btnView(itml){
console.log('查看',itml)
handleAdd() {
uni.navigateTo({
url: '/sub/owner/job-detail'
})
},
btnView(itml) {
console.log('查看', itml)
let url = itml.pdfUrl
//
//
wx.downloadFile({
// PDFURL
url: url,
@ -101,6 +89,33 @@ export default {
});
},
btnShare(item) {
let url = item.pdfUrl
let fileName = item.title
//
wx.downloadFile({
url: url, //
success(res) {
if (res.statusCode === 200) {
wx.shareFileMessage({
filePath: res.tempFilePath, //
fileName: fileName, //
success() {
console.log('文件分享成功');
},
fail(err) {
console.error('文件分享失败', err);
}
});
}
},
fail(err) {
console.error('文件下载失败', err);
}
});
},
//
onRefresh() {
this.isRefreshing = true
@ -108,39 +123,39 @@ export default {
this.getjobInfoList()
},
loadNextPage() {
console.log('列表值',this.jobInfoList.length)
if (this.jobInfoList.length < this.total) {
this.pageNum++
this.getjobInfoList()
console.log('列表值', this.jobInfoList.length)
if (this.jobInfoList.length < this.total) {
this.pageNum++
this.getjobInfoList()
}
},
}
},
//
async getjobInfoList() {
try {
console.log('jobInfoApi',JobInfoApi)
console.log('jobInfoApi', JobInfoApi)
this.loadMoreStatus = 'loading'
const res = await JobInfoApi.getJobInfoPage({
pageNum: this.pageNum,
pageSize: this.pageSize,
title: this.searchValue //
})
if (res.code === 0 && res.data.total > 0) {
//
const formattedData = res.data.list.map(item => ({
...item,
title: item.title.length > 15 ? item.title.slice(0, 15) + '...' : item.title,
jobDate: formatDate(item.jobDate,'YYYY年MM月DD日')
}))
jobDate: formatDate(item.jobDate, 'YYYY年MM月DD日')
}))
//
if (this.pageNum === 1) {
this.jobInfoList = formattedData
} else {
this.jobInfoList = [...this.jobInfoList, ...formattedData]
}
console.log('列表信息',res)
console.log('列表信息', res)
this.total = res.data.total
//
this.loadMoreStatus = this.jobInfoList.length >= this.total ? 'noMore' : 'more'
@ -154,17 +169,17 @@ export default {
})
}
finally {
this.loadMoreStatus = this.jobInfoList.length >= this.total? 'noMore' :'more'
//
//
setTimeout(() => {
this.loadMoreStatus = this.jobInfoList.length >= this.total ? 'noMore' : 'more'
//
//
setTimeout(() => {
this.isRefreshing = false
}, 300) //
}
},
//
handleSearch(e) {
if(e.detail && e.detail.length > 1){
if (e.detail && e.detail.length > 1) {
this.pageNum = 1 //
this.searchValue = e.detail //
this.getjobInfoList()
@ -186,81 +201,113 @@ export default {
}
}
</script>
<style lang="scss">
.jobInfo-container {
background: var(--LightMode-Secondary-Secondary, #F9F9F9);
background: #F9F9F9;
min-height: 100vh;
display: flex;
flex-direction: column;
padding-bottom: calc(88rpx + 68rpx); // +
.search-box {
padding: 20rpx;
background: #fff;
padding: 24rpx;
background: #FFFFFF;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1;
}
.jobInfo-list {
margin-top: 120rpx; //
flex: 1;
margin-top: 120rpx; // + padding
.jobInfo-scroll {
height: calc(100vh - 120rpx);
height: calc(100vh - 120rpx - 156rpx); // - -
box-sizing: border-box;
}
.jobInfo-item {
display: flex;
background: #FFFFFF;
padding: 24rpx;
align-items: center;
gap: 24rpx;
background: #fff;
margin: 24rpx;
margin-bottom: 0;
border-radius: 16rpx;
.item-left {
border-radius: 100rpx;
background: var(--LightMode-Grey-Grey-100, #F9F9F9);
padding: 16rpx;
display: flex;
justify-content: center;
align-items: center;
.jobInfo-icon {
width: 40rpx;
height: 40rpx;
flex-shrink: 0;
}
.item-main {
margin-bottom: 24rpx;
}
.item-right {
flex: 1;
overflow: hidden;
.jobInfo-title {
.jobInfo-title {
font-size: 28rpx;
color: #333333;
line-height: 40rpx;
margin-bottom: 8rpx;
}
.jobInfo-time {
font-size: 24rpx;
color: #999999;
line-height: 34rpx;
}
.item-btns {
display: flex;
gap: 16rpx;
button {
flex: 1;
height: 64rpx;
line-height: 64rpx;
text-align: center;
font-size: 28rpx;
color: #333;
margin-bottom: 12rpx;
line-height: 1.4;
border-radius: 8rpx;
border: none;
}
.btn-preview {
background: rgba(255, 111, 30, 0.1);
color: #FF6F1E;
}
.jobInfo-time {
font-size: 24rpx;
color: #999;
line-height: 1.2;
.btn-share {
background: rgba(23, 198, 83, 0.1);
color: #17C653;
}
}
}
}
}
//
::v-deep .uni-load-more {
padding: 24rpx 0;
.add-btn {
position: fixed;
bottom: 68rpx; // 34px = 68rpx
left: 24rpx;
right: 24rpx;
height: 88rpx;
line-height: 88rpx;
background: #17C653;
color: #FFFFFF;
font-size: 32rpx;
border-radius: 8rpx;
text-align: center;
border: none;
}
.search-box {
padding: 24rpx;
background: #FFFFFF;
::v-deep .van-search {
padding: 0;
background: transparent;
.van-search__content {
background: #F5F5F5;
border-radius: 8rpx;
}
}
}
</style>

131
uni_modules/sp-editor/changelog.md

@ -0,0 +1,131 @@
## 1.5.0(2024-11-01)
1. 更新弹窗中使用的示例
## 1.4.9(2024-08-30)
1. 修复在app端和小程序端插入超链接可能会回显出部分特殊标识的bug
## 1.4.8(2024-08-16)
1. 有群友反馈,通过wangEditor添加的含有图片的富文本的宽高是在style内联样式中的,但是uni-editor不能解析img标签的内联宽高,因此我封装一个工具方法convertImgStylesToAttributes,处理一下富文本字符串即可,详见示例一
## 1.4.7(2024-08-12)
1. 新增标题、字体、字体大小、字间距段前后距等工具子级悬浮工具栏
2. 对于字体样式:可在组件的data中的fabTools.fontFamily中自行引入添加你本地的自定义字体(app中对于部分css自带的字体例如微软雅黑等不支持,因为app中不自带这些字体,你可能需要自行引入字体文件)
## 1.4.6(2024-08-08)
1. 修复字体工具栏无法隐藏的bug
## 1.4.5(2024-07-21)
1. 解决video图标冲突问题
## 1.4.4(2024-07-20)
1. 新增视频插入功能
2. 更新示例一(关于视频插入参考请见示例一)
## 1.4.3(2024-07-16)
1. 更新示例工程
## 1.4.2(2024-07-16)
1. 更新示例工程
## 1.4.1(2024-06-14)
1. 更新示例工程
## 1.4.0(2024-05-31)
1. 更新示例工程
## 1.3.9(2024-05-31)
1. 修复调色板无法正常选色的问题
## 1.3.8(2024-05-13)
1. 更新示例三(微信小程序上使用setContents造成聚焦滚动的处理)
## 1.3.7(2024-05-10)
1. 修复添加超链接后,不触发input更新当前最新内容的bug
## 1.3.6(2024-05-09)
1. 文档迁移
## 1.3.5(2024-05-09)
1. 所有事件携带编辑器id参数,以便循环时能区分处理
2. 更新示例工程
## 1.3.4(2024-05-08)
1. 更新示例工程
2. 新增editorId参数
## 1.3.3(2024-03-22)
1. 修复微信小程序长按无法粘贴的问题
## 1.3.2(2024-03-14)
1. 更新了toolbar样式与配置,见文档
2. 更新示例工程,媒体查询响应式写法
3. 优化了只读模式效果,开启只读模式后,文章内容的超链接可正常点击并跳转
## 1.3.1(2024-03-14)
1. 优化了只读功能,开启只读后自动隐藏工具栏
2. 更新示例工程
## 1.3.0(2024-03-07)
1. 新增addLink的emit事件
## 1.2.9(2024-02-23)
1. 更新文档
## 1.2.8(2024-02-23)
1. 新增了添加超链接的工具,toolbar中link字段,默认开启
2. 优化了部分逻辑
3. 更新文档、更新示例工程
## 1.2.7(2024-02-23)
1. 更新文档,更新示例工程
2. 添加toolbar中图标字体大小可配置项
## 1.2.6(2024-02-22)
1. 添加导出工具按钮,可将当前已编辑的html导出至页面解析
2. 超链接工具按钮正在尝试开发中(貌似目前官方不支持)
## 1.2.5(2024-02-19)
1. 更新示例工程(吸顶写法)
2. 完善调色板功能
## 1.2.4(2024-02-18)
1. 修复工具栏颜色按钮底色动态切换问题
## 1.2.3(2024-02-18)
1. 更新示例工程
## 1.2.2(2024-02-18)
1. 删除log调试打印
## 1.2.1(2024-02-18)
1. 修复了颜色图标不会动态切换的问题
## 1.2.0(2024-02-18)
1. 修复选择颜色时会将所选文字删除的bug
## 1.1.9(2024-02-04)
1. 更新示例工程
## 1.1.8(2024-02-04)
1. 文档修改
## 1.1.7(2024-02-04)
1. 新增toolbar配置项,可自由配置工具栏工具列表
2. 移除组件内原templates属性,默认初始化编辑器内容请看文档使用方式示例
3. 更新文档
## 1.1.6(2024-01-31)
1. 更好的兼容vue2了,修复在vue2下高度可能超出的问题
2. 示例工程兼容vue2
## 1.1.5(2024-01-30)
1. 修复工具栏字体按钮无效的问题
## 1.1.4(2024-01-30)
1. 解决默认初始化内容时前缀空格或缩进无效的问题
2. 解决点击工具栏高亮状态后输入内容时便失去高亮的bug
3. 更新示例工程
## 1.1.3(2024-01-23)
1. 重写高度动态计算逻辑,现在对不同屏幕尺寸的适应性更强了
## 1.1.2(2024-01-17)
1. 修复分割线会生成多条的问题
## 1.1.1(2024-01-15)
1. 更新文档
## 1.1.0(2024-01-15)
1. insertText方法在插入内容的时候会移动光标聚焦,导致焦点回滚到视口处
2. 更新示例工程
## 1.0.9(2024-01-04)
1. 更新文档
## 1.0.8(2024-01-04)
1. 修复h5端官方cdn请求失败的问题,详见问答贴:https://ask.dcloud.net.cn/article/40900
## 1.0.7(2024-01-03)
1. 移除v-bind="$attrs",该写法在微信小程序不支持
## 1.0.6(2023-12-29)
1. 更新文档
## 1.0.5(2023-12-29)
1. 更新了init方法,可以使用返回的editor实例尽情自定义
2. 组件在<editor>上添加v-bind="$attrs"属性穿透,可以使用原editor参数,官方文档:https://uniapp.dcloud.net.cn/component/editor.html
## 1.0.4(2023-12-29)
1. 优化了切换文字和背景颜色是,可能会导致切换后不生效的问题
2. 修复在部分设备上的微信小程序中可能会存在颜色版无法正常滑动的问题
3. 更友好的交互体验:添加图标悬停字样描述、添加格式化文本弹窗确认
4. 有插入视频的需求,暂时可能无法实现,官方给予的回复是:目前各端的eidtor组件都不能直接插入视频,编辑时可以采用视频封面占位,并在图片中保存视频信息,在预览时再还原为视频。
## 1.0.3(2023-10-13)
1. 更新readme文档
2. 更新调整组件示例项目,添加插件代码中部分注释
## 1.0.2(2023-10-13)
1. 更新uni_modules规范,可一键导入组件
2. 更新组件示例项目(包括使用uniCloud.uploadFile多选上传图片示例方法)
## 1.0.1(2023-10-12)
1. 修复小程序中自动聚焦滚动到富文本组件区域的bug
2. 略微调整了富文本上方toolbar工具栏中按钮的大小尺寸
## 1.0.0(2023-9-19)
1. 新增字体与背景颜色板
2. 可自定义预设内容模板
3. 解决官方样例在小程序和app部分报错不兼容的问题
4. 可配合云存储上传富文本中插入的图片 本质上是基于官方内置富文本editor组件改版封装,所以官方有的功能都有,官方能兼容的也都兼容

825
uni_modules/sp-editor/components/sp-editor/color-picker.vue

@ -0,0 +1,825 @@
<template>
<view v-if="show" class="t-wrapper" @touchmove.stop.prevent="moveHandle">
<view class="t-mask" :class="{ active: active }" @click.stop="close"></view>
<view class="t-box" :class="{ active: active }">
<view class="t-header">
<view class="t-header-button" @click="close">取消</view>
<view class="t-header-button" @click="confirm">确认</view>
</view>
<view class="t-color__box" :style="{ background: 'rgb(' + bgcolor.r + ',' + bgcolor.g + ',' + bgcolor.b + ')' }">
<view
class="t-background boxs"
@touchstart="touchstart($event, 0)"
@touchmove="touchmove($event, 0)"
@touchend="touchend($event, 0)"
>
<view class="t-color-mask"></view>
<view class="t-pointer" :style="{ top: site[0].top - 8 + 'px', left: site[0].left - 8 + 'px' }"></view>
</view>
</view>
<view class="t-control__box">
<view class="t-control__color">
<view
class="t-control__color-content"
:style="{ background: 'rgba(' + rgba.r + ',' + rgba.g + ',' + rgba.b + ',' + rgba.a + ')' }"
></view>
</view>
<view class="t-control-box__item">
<view
class="t-controller boxs"
@touchstart="touchstart($event, 1)"
@touchmove="touchmove($event, 1)"
@touchend="touchend($event, 1)"
>
<view class="t-hue">
<view class="t-circle" :style="{ left: site[1].left - 12 + 'px' }"></view>
</view>
</view>
<view
class="t-controller boxs"
@touchstart="touchstart($event, 2)"
@touchmove="touchmove($event, 2)"
@touchend="touchend($event, 2)"
>
<view class="t-transparency">
<view class="t-circle" :style="{ left: site[2].left - 12 + 'px' }"></view>
</view>
</view>
</view>
</view>
<view class="t-result__box">
<view v-if="mode" class="t-result__item">
<view class="t-result__box-input">{{ hex }}</view>
<view class="t-result__box-text">HEX</view>
</view>
<template v-else>
<view class="t-result__item">
<view class="t-result__box-input">{{ rgba.r }}</view>
<view class="t-result__box-text">R</view>
</view>
<view class="t-result__item">
<view class="t-result__box-input">{{ rgba.g }}</view>
<view class="t-result__box-text">G</view>
</view>
<view class="t-result__item">
<view class="t-result__box-input">{{ rgba.b }}</view>
<view class="t-result__box-text">B</view>
</view>
<view class="t-result__item">
<view class="t-result__box-input">{{ rgba.a }}</view>
<view class="t-result__box-text">A</view>
</view>
</template>
<view class="t-result__item t-select" @click="select">
<view class="t-result__box-input">
<view>切换</view>
<view>模式</view>
</view>
</view>
</view>
<view class="t-alternative">
<view class="t-alternative__item" v-for="(item, index) in colorList" :key="index">
<view
class="t-alternative__item-content"
:style="{ background: 'rgba(' + item.r + ',' + item.g + ',' + item.b + ',' + item.a + ')' }"
@click="selectColor(item)"
></view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
props: {
color: {
type: Object,
default: () => {
return {
r: 0,
g: 0,
b: 0,
a: 0
}
}
},
spareColor: {
type: Array,
default() {
return []
}
}
},
data() {
return {
show: false,
active: false,
// rgba
rgba: {
r: 0,
g: 0,
b: 0,
a: 1
},
// hsb
hsb: {
h: 0,
s: 0,
b: 0
},
site: [
{
top: 0,
left: 0
},
{
left: 0
},
{
left: 0
}
],
index: 0,
bgcolor: {
r: 255,
g: 0,
b: 0,
a: 1
},
hex: '#000000',
mode: true,
colorList: [
{
r: 244,
g: 67,
b: 54,
a: 1
},
{
r: 233,
g: 30,
b: 99,
a: 1
},
{
r: 156,
g: 39,
b: 176,
a: 1
},
{
r: 103,
g: 58,
b: 183,
a: 1
},
{
r: 63,
g: 81,
b: 181,
a: 1
},
{
r: 33,
g: 150,
b: 243,
a: 1
},
{
r: 3,
g: 169,
b: 244,
a: 1
},
{
r: 0,
g: 188,
b: 212,
a: 1
},
{
r: 0,
g: 150,
b: 136,
a: 1
},
{
r: 76,
g: 175,
b: 80,
a: 1
},
{
r: 139,
g: 195,
b: 74,
a: 1
},
{
r: 205,
g: 220,
b: 57,
a: 1
},
{
r: 255,
g: 235,
b: 59,
a: 1
},
{
r: 255,
g: 193,
b: 7,
a: 1
},
{
r: 255,
g: 152,
b: 0,
a: 1
},
{
r: 255,
g: 87,
b: 34,
a: 1
},
{
r: 121,
g: 85,
b: 72,
a: 1
},
{
r: 158,
g: 158,
b: 158,
a: 1
},
{
r: 0,
g: 0,
b: 0,
a: 0.5
},
{
r: 0,
g: 0,
b: 0,
a: 0
}
]
}
},
created() {
this.ready()
},
methods: {
ready() {
this.rgba = this.color
if (this.spareColor.length !== 0) {
this.colorList = this.spareColor
}
},
/**
* 初始化
*/
init() {
// hsb
this.hsb = this.rgbToHex(this.rgba)
// this.setColor();
this.setValue(this.rgba)
},
moveHandle() {},
open() {
this.show = true
this.$nextTick(() => {
this.init()
setTimeout(() => {
this.active = true
setTimeout(() => {
this.getSelectorQuery()
}, 350)
}, 50)
})
},
close() {
this.active = false
this.$nextTick(() => {
setTimeout(() => {
this.show = false
}, 500)
})
},
confirm() {
this.close()
this.$emit('confirm', {
rgba: this.rgba,
hex: this.hex
})
},
//
select() {
this.mode = !this.mode
},
//
selectColor(item) {
this.setColorBySelect(item)
},
touchstart(e, index) {
const { pageX, pageY, clientX, clientY } = e.touches[0]
// pageXclientX
this.moveX = clientX || pageX
this.moveY = clientY || pageY
this.setPosition(this.moveX, this.moveY, index)
},
touchmove(e, index) {
const { pageX, pageY, clientX, clientY } = e.touches[0]
this.moveX = clientX || pageX
this.moveY = clientY || pageY
this.setPosition(this.moveX, this.moveY, index)
},
touchend(e, index) {},
/**
* 设置位置
*/
setPosition(x, y, index) {
this.index = index
const { top, left, width, height } = this.position[index]
//
this.site[index].left = Math.max(0, Math.min(parseInt(x - left), width))
if (index === 0) {
this.site[index].top = Math.max(0, Math.min(parseInt(y - top), height))
//
this.hsb.s = parseInt((100 * this.site[index].left) / width)
this.hsb.b = parseInt(100 - (100 * this.site[index].top) / height)
this.setColor()
this.setValue(this.rgba)
} else {
this.setControl(index, this.site[index].left)
}
},
/**
* 设置 rgb 颜色
*/
setColor() {
const rgb = this.HSBToRGB(this.hsb)
this.rgba.r = rgb.r
this.rgba.g = rgb.g
this.rgba.b = rgb.b
},
/**
* 设置二进制颜色
* @param {Object} rgb
*/
setValue(rgb) {
this.hex = '#' + this.rgbToHex(rgb)
},
setControl(index, x) {
const { top, left, width, height } = this.position[index]
if (index === 1) {
this.hsb.h = parseInt((360 * x) / width)
this.bgcolor = this.HSBToRGB({
h: this.hsb.h,
s: 100,
b: 100
})
this.setColor()
} else {
this.rgba.a = (x / width).toFixed(1)
}
this.setValue(this.rgba)
},
/**
* rgb 二进制 hex
* @param {Object} rgb
*/
rgbToHex(rgb) {
let hex = [rgb.r.toString(16), rgb.g.toString(16), rgb.b.toString(16)]
hex.map(function (str, i) {
if (str.length == 1) {
hex[i] = '0' + str
}
})
return hex.join('')
},
setColorBySelect(getrgb) {
const { r, g, b, a } = getrgb
let rgb = {}
rgb = {
r: r ? parseInt(r) : 0,
g: g ? parseInt(g) : 0,
b: b ? parseInt(b) : 0,
a: a ? a : 0
}
this.rgba = rgb
this.hsb = this.rgbToHsb(rgb)
this.changeViewByHsb()
},
changeViewByHsb() {
const [a, b, c] = this.position
this.site[0].left = parseInt((this.hsb.s * a.width) / 100)
this.site[0].top = parseInt(((100 - this.hsb.b) * a.height) / 100)
this.setColor(this.hsb.h)
this.setValue(this.rgba)
this.bgcolor = this.HSBToRGB({
h: this.hsb.h,
s: 100,
b: 100
})
this.site[1].left = (this.hsb.h / 360) * b.width
this.site[2].left = this.rgba.a * c.width
},
/**
* hsb rgb
* @param {Object} 颜色模式 H(hues)表示色相S(saturation)表示饱和度Bbrightness表示亮度
*/
HSBToRGB(hsb) {
let rgb = {}
let h = Math.round(hsb.h)
let s = Math.round((hsb.s * 255) / 100)
let v = Math.round((hsb.b * 255) / 100)
if (s == 0) {
rgb.r = rgb.g = rgb.b = v
} else {
let t1 = v
let t2 = ((255 - s) * v) / 255
let t3 = ((t1 - t2) * (h % 60)) / 60
if (h == 360) h = 0
if (h < 60) {
rgb.r = t1
rgb.b = t2
rgb.g = t2 + t3
} else if (h < 120) {
rgb.g = t1
rgb.b = t2
rgb.r = t1 - t3
} else if (h < 180) {
rgb.g = t1
rgb.r = t2
rgb.b = t2 + t3
} else if (h < 240) {
rgb.b = t1
rgb.r = t2
rgb.g = t1 - t3
} else if (h < 300) {
rgb.b = t1
rgb.g = t2
rgb.r = t2 + t3
} else if (h < 360) {
rgb.r = t1
rgb.g = t2
rgb.b = t1 - t3
} else {
rgb.r = 0
rgb.g = 0
rgb.b = 0
}
}
return {
r: Math.round(rgb.r),
g: Math.round(rgb.g),
b: Math.round(rgb.b)
}
},
rgbToHsb(rgb) {
let hsb = {
h: 0,
s: 0,
b: 0
}
let min = Math.min(rgb.r, rgb.g, rgb.b)
let max = Math.max(rgb.r, rgb.g, rgb.b)
let delta = max - min
hsb.b = max
hsb.s = max != 0 ? (255 * delta) / max : 0
if (hsb.s != 0) {
if (rgb.r == max) hsb.h = (rgb.g - rgb.b) / delta
else if (rgb.g == max) hsb.h = 2 + (rgb.b - rgb.r) / delta
else hsb.h = 4 + (rgb.r - rgb.g) / delta
} else hsb.h = -1
hsb.h *= 60
if (hsb.h < 0) hsb.h = 0
hsb.s *= 100 / 255
hsb.b *= 100 / 255
return hsb
},
getSelectorQuery() {
const views = uni.createSelectorQuery().in(this)
views
.selectAll('.boxs')
.boundingClientRect((data) => {
if (!data || data.length === 0) {
setTimeout(() => this.getSelectorQuery(), 20)
return
}
this.position = data
// this.site[0].top = data[0].height;
// this.site[0].left = 0;
// this.site[1].left = data[1].width;
// this.site[2].left = data[2].width;
this.setColorBySelect(this.rgba)
})
.exec()
},
hex2Rgb(hexColor, alpha = 1) {
const color = hexColor.slice(1)
const r = parseInt(color.slice(0, 2), 16)
const g = parseInt(color.slice(2, 4), 16)
const b = parseInt(color.slice(4, 6), 16)
return {
r: r,
g: g,
b: b,
a: alpha
}
}
},
watch: {
spareColor(newVal) {
this.colorList = newVal
},
color(newVal) {
this.ready()
}
}
}
</script>
<style>
.t-wrapper {
position: fixed;
top: 0;
bottom: 0;
left: 0;
width: 100%;
box-sizing: border-box;
z-index: 9999;
}
.t-box {
width: 100%;
position: absolute;
bottom: 0;
padding: 30upx 0;
padding-top: 0;
background: #fff;
transition: all 0.3s;
transform: translateY(100%);
}
.t-box.active {
transform: translateY(0%);
}
.t-header {
display: flex;
justify-content: space-between;
width: 100%;
height: 100upx;
border-bottom: 1px #eee solid;
box-shadow: 1px 0 2px rgba(0, 0, 0, 0.1);
background: #fff;
}
.t-header-button {
display: flex;
align-items: center;
width: 150upx;
height: 100upx;
font-size: 30upx;
color: #666;
padding-left: 20upx;
}
.t-header-button:last-child {
justify-content: flex-end;
padding-right: 20upx;
}
.t-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: -1;
transition: all 0.3s;
opacity: 0;
}
.t-mask.active {
opacity: 1;
}
.t-color__box {
position: relative;
height: 400upx;
background: rgb(255, 0, 0);
overflow: hidden;
box-sizing: border-box;
margin: 0 20upx;
margin-top: 20upx;
box-sizing: border-box;
}
.t-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
}
.t-color-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 400upx;
background: linear-gradient(to top, #000, rgba(0, 0, 0, 0));
}
.t-pointer {
position: absolute;
bottom: -8px;
left: -8px;
z-index: 2;
width: 15px;
height: 15px;
border: 1px #fff solid;
border-radius: 50%;
}
.t-show-color {
width: 100upx;
height: 50upx;
}
.t-control__box {
margin-top: 50upx;
width: 100%;
display: flex;
padding-left: 20upx;
box-sizing: border-box;
}
.t-control__color {
flex-shrink: 0;
width: 100upx;
height: 100upx;
border-radius: 50%;
background-color: #fff;
background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee),
linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee);
background-size: 36upx 36upx;
background-position: 0 0, 18upx 18upx;
border: 1px #eee solid;
overflow: hidden;
}
.t-control__color-content {
width: 100%;
height: 100%;
}
.t-control-box__item {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
padding: 0 30upx;
}
.t-controller {
position: relative;
width: 100%;
height: 16px;
background-color: #fff;
background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee),
linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee);
background-size: 32upx 32upx;
background-position: 0 0, 16upx 16upx;
}
.t-hue {
width: 100%;
height: 100%;
background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
}
.t-transparency {
width: 100%;
height: 100%;
background: linear-gradient(to right, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0));
}
.t-circle {
position: absolute;
/* right: -10px; */
top: -2px;
width: 20px;
height: 20px;
box-sizing: border-box;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 2px 1px rgba(0, 0, 0, 0.1);
}
.t-result__box {
margin-top: 20upx;
padding: 10upx;
width: 100%;
display: flex;
box-sizing: border-box;
}
.t-result__item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10upx;
width: 100%;
box-sizing: border-box;
}
.t-result__box-input {
padding: 10upx 0;
width: 100%;
font-size: 28upx;
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.1);
color: #999;
text-align: center;
background: #fff;
}
.t-result__box-text {
margin-top: 10upx;
font-size: 28upx;
line-height: 2;
}
.t-select {
flex-shrink: 0;
width: 150upx;
padding: 0 30upx;
}
.t-select .t-result__box-input {
border-radius: 10upx;
border: none;
color: #999;
box-shadow: 1px 1px 2px 1px rgba(0, 0, 0, 0.1);
background: #fff;
}
.t-select .t-result__box-input:active {
box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.1);
}
.t-alternative {
display: flex;
flex-wrap: wrap;
/* justify-content: space-between; */
width: 100%;
padding-right: 10upx;
box-sizing: border-box;
}
.t-alternative__item {
margin-left: 12upx;
margin-top: 10upx;
width: 50upx;
height: 50upx;
border-radius: 10upx;
background-color: #fff;
background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee),
linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee);
background-size: 36upx 36upx;
background-position: 0 0, 18upx 18upx;
border: 1px #eee solid;
overflow: hidden;
}
.t-alternative__item-content {
width: 50upx;
height: 50upx;
background: rgba(255, 0, 0, 0.5);
}
.t-alternative__item:active {
transition: all 0.3s;
transform: scale(1.1);
}
</style>

140
uni_modules/sp-editor/components/sp-editor/fab-tool.vue

@ -0,0 +1,140 @@
<template>
<view class="fab-tool">
<view id="toolfab">
<slot></slot>
</view>
<view class="fab-tool-content" :style="placementStyle" id="placementfab">
<slot name="content" v-if="visible"></slot>
</view>
</view>
</template>
<script>
export default {
props: {
visible: {
type: Boolean,
default: false
},
placement: {
type: String,
default: 'auto' // 'auto' | 'top-start' | 'top-center' | 'top-end' | 'bottom-start' | 'bottom-center' | 'bottom-end'
}
},
data() {
return {
placementHeight: '0',
placementType: ''
}
},
watch: {
visible(newVal) {
if (newVal) {
const { screenWidth } = uni.getSystemInfoSync()
this.$nextTick(() => {
let placementWidth = 0
uni
.createSelectorQuery()
.in(this)
.select('#placementfab')
.boundingClientRect((res) => {
this.placementHeight = -res.height + 'px'
placementWidth = res.width
})
.exec()
//
if (this.placement == 'auto') {
uni
.createSelectorQuery()
.in(this)
.select('#toolfab')
.boundingClientRect((res) => {
let leftRemain = res.left
let rightRemain = screenWidth - leftRemain
if (rightRemain > placementWidth) {
this.placementType = 'bottom-start'
} else if (leftRemain > placementWidth) {
this.placementType = 'bottom-end'
} else {
this.placementType = 'bottom-center'
}
})
.exec()
}
})
}
}
},
mounted() {
this.placementType = this.placement
},
computed: {
placementStyle() {
let position = {}
switch (this.placementType) {
case 'top-start':
position = {
top: this.placementHeight,
left: 0
}
break
case 'top-center':
position = {
top: this.placementHeight,
left: '50%',
transform: 'translateX(-50%)'
}
break
case 'top-end':
position = {
top: this.placementHeight,
right: 0
}
break
case 'bottom-start':
position = {
bottom: this.placementHeight,
left: 0
}
break
case 'bottom-center':
position = {
bottom: this.placementHeight,
left: '50%',
transform: 'translateX(-50%)'
}
break
case 'bottom-end':
position = {
bottom: this.placementHeight,
right: 0
}
break
default:
break
}
return position
}
},
methods: {
//
}
}
</script>
<style lang="scss">
.fab-tool {
position: relative;
.fab-tool-content {
position: absolute;
z-index: 999;
background-color: #ffffff;
box-shadow: -2px -2px 4px rgba(0, 0, 0, 0.05), 2px 2px 4px rgba(0, 0, 0, 0.05);
border-radius: 12rpx;
box-sizing: border-box;
}
}
</style>

152
uni_modules/sp-editor/components/sp-editor/link-edit.vue

@ -0,0 +1,152 @@
<template>
<view class="link-edit-container" v-if="showPopup">
<view class="link-edit">
<view class="title">添加链接</view>
<view class="edit">
<view class="description">
链接描述
<input v-model="descVal" type="text" class="input" placeholder="请输入链接描述" />
</view>
<view class="address">
链接地址
<input v-model="addrVal" type="text" class="input" placeholder="请输入链接地址" />
</view>
</view>
<view class="control">
<view class="cancel" @click="close">取消</view>
<view class="confirm" @click="onConfirm">确认</view>
</view>
</view>
<view class="mask"></view>
</view>
</template>
<script>
export default {
data() {
return {
showPopup: false,
descVal: '',
addrVal: ''
}
},
methods: {
open() {
this.showPopup = true
this.$emit('open')
},
close() {
this.showPopup = false
this.descVal = ''
this.addrVal = ''
this.$emit('close')
},
onConfirm() {
if (!this.descVal) {
uni.showToast({
title: '请输入链接描述',
icon: 'none'
})
return
}
if (!this.addrVal) {
uni.showToast({
title: '请输入链接地址',
icon: 'none'
})
return
}
this.$emit('confirm', {
text: this.descVal,
href: this.addrVal
})
this.close()
}
}
}
</script>
<style lang="scss">
.link-edit-container {
.link-edit {
width: 80%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #ffffff;
box-shadow: -2px -2px 4px rgba(0, 0, 0, 0.05), 2px 2px 4px rgba(0, 0, 0, 0.05);
border-radius: 12rpx;
box-sizing: border-box;
z-index: 999;
font-size: 14px;
.title {
height: 80rpx;
display: flex;
justify-content: center;
align-items: center;
}
.edit {
padding: 24rpx;
border-top: 1px solid #eeeeee;
border-bottom: 1px solid #eeeeee;
box-sizing: border-box;
.input {
flex: 1;
padding: 4px;
font-size: 14px;
border: 1px solid #eeeeee;
border-radius: 8rpx;
.uni-input-placeholder {
color: #dddddd;
}
}
.description {
display: flex;
align-items: center;
}
.address {
display: flex;
align-items: center;
margin-top: 24rpx;
}
}
.control {
height: 80rpx;
display: flex;
cursor: pointer;
.cancel {
flex: 1;
color: #dd524d;
display: flex;
justify-content: center;
align-items: center;
}
.confirm {
border-left: 1px solid #eeeeee;
flex: 1;
color: #007aff;
display: flex;
justify-content: center;
align-items: center;
}
}
}
.mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.05);
z-index: 998;
}
}
</style>

852
uni_modules/sp-editor/components/sp-editor/sp-editor.vue

@ -0,0 +1,852 @@
<template>
<view class="sp-editor" :style="{ '--icon-size': iconSize, '--icon-columns': iconColumns }">
<view class="sp-editor-toolbar" v-if="!readOnly" @tap="format">
<!-- 标题栏 -->
<fab-tool v-if="toolbarList.includes('header')" :visible="curFab == 'header'">
<view
:class="formats.header ? 'ql-active' : ''"
class="iconfont icon-header"
title="标题"
data-name="header"
@click.stop="fabTap('header')"
></view>
<template #content>
<view class="fab-tools" @click.stop="fabTapSub($event, 'header')">
<view v-for="item in fabTools.header" :key="item.value">
<view
v-if="toolbarList.includes(item.name)"
class="fab-sub"
:class="[formats.header === item.value ? 'ql-active' : '', item.icon ? 'iconfont' : '', item.icon]"
:title="item.title"
data-name="header"
:data-value="item.value"
></view>
</view>
</view>
</template>
</fab-tool>
<view
v-if="toolbarList.includes('bold')"
:class="formats.bold ? 'ql-active' : ''"
class="iconfont icon-zitijiacu"
title="加粗"
data-name="bold"
></view>
<view
v-if="toolbarList.includes('italic')"
:class="formats.italic ? 'ql-active' : ''"
class="iconfont icon-zitixieti"
title="斜体"
data-name="italic"
></view>
<view
v-if="toolbarList.includes('underline')"
:class="formats.underline ? 'ql-active' : ''"
class="iconfont icon-zitixiahuaxian"
title="下划线"
data-name="underline"
></view>
<view
v-if="toolbarList.includes('strike')"
:class="formats.strike ? 'ql-active' : ''"
class="iconfont icon-zitishanchuxian"
title="删除线"
data-name="strike"
></view>
<!-- 对齐方式 -->
<fab-tool v-if="toolbarList.includes('align')" :visible="curFab == 'align'">
<view
:class="formats.align ? 'ql-active' : ''"
class="iconfont icon-zuoyouduiqi"
title="对齐方式"
data-name="align"
@click.stop="fabTap('align')"
></view>
<template #content>
<view class="fab-tools" @click.stop="fabTapSub($event, 'align')">
<view v-for="item in fabTools.align" :key="item.value">
<view
v-if="toolbarList.includes(item.name)"
class="fab-sub"
:class="[formats.align === item.value ? 'ql-active' : '', item.icon ? 'iconfont' : '', item.icon]"
:title="item.title"
data-name="align"
:data-value="item.value"
></view>
</view>
</view>
</template>
</fab-tool>
<!-- 行间距 -->
<fab-tool v-if="toolbarList.includes('lineHeight')" :visible="curFab == 'lineHeight'">
<view
:class="formats.lineHeight ? 'ql-active' : ''"
class="iconfont icon-line-height"
title="行间距"
data-name="lineHeight"
@click.stop="fabTap('lineHeight')"
></view>
<template #content>
<view class="fab-tools" @click.stop="fabTapSub($event, 'lineHeight')">
<view v-for="item in fabTools.lineHeight" :key="item.value">
<view
class="fab-sub"
:class="[formats.lineHeight === item.value ? 'ql-active' : '', item.icon ? 'iconfont' : '', item.icon]"
:title="item.title"
data-name="lineHeight"
:data-value="item.value"
>
{{ item.name }}
</view>
</view>
</view>
</template>
</fab-tool>
<!-- 字间距 -->
<fab-tool v-if="toolbarList.includes('letterSpacing')" :visible="curFab == 'letterSpacing'">
<view
:class="formats.letterSpacing ? 'ql-active' : ''"
class="iconfont icon-Character-Spacing"
title="字间距"
data-name="letterSpacing"
@click.stop="fabTap('letterSpacing')"
></view>
<template #content>
<view class="fab-tools" @click.stop="fabTapSub($event, 'letterSpacing')">
<view v-for="item in fabTools.space" :key="item.value">
<view
class="fab-sub"
:class="[
formats.letterSpacing === item.value ? 'ql-active' : '',
item.icon ? 'iconfont' : '',
item.icon
]"
:title="item.title"
data-name="letterSpacing"
:data-value="item.value"
>
{{ item.name }}
</view>
</view>
</view>
</template>
</fab-tool>
<!-- 段前距 -->
<fab-tool v-if="toolbarList.includes('marginTop')" :visible="curFab == 'marginTop'">
<view
:class="formats.marginTop ? 'ql-active' : ''"
class="iconfont icon-722bianjiqi_duanqianju"
title="段前距"
data-name="marginTop"
@click.stop="fabTap('marginTop')"
></view>
<template #content>
<view class="fab-tools" @click.stop="fabTapSub($event, 'marginTop')">
<view v-for="item in fabTools.space" :key="item.value">
<view
class="fab-sub"
:class="[formats.marginTop === item.value ? 'ql-active' : '', item.icon ? 'iconfont' : '', item.icon]"
:title="item.title"
data-name="marginTop"
:data-value="item.value"
>
{{ item.name }}
</view>
</view>
</view>
</template>
</fab-tool>
<!-- 段后距 -->
<fab-tool v-if="toolbarList.includes('marginBottom')" :visible="curFab == 'marginBottom'">
<view
:class="formats.marginBottom ? 'ql-active' : ''"
class="iconfont icon-723bianjiqi_duanhouju"
title="段后距"
data-name="marginBottom"
@click.stop="fabTap('marginBottom')"
></view>
<template #content>
<view class="fab-tools" @click.stop="fabTapSub($event, 'marginBottom')">
<view v-for="item in fabTools.space" :key="item.value">
<view
class="fab-sub"
:class="[
formats.marginBottom === item.value ? 'ql-active' : '',
item.icon ? 'iconfont' : '',
item.icon
]"
:title="item.title"
data-name="marginBottom"
:data-value="item.value"
>
{{ item.name }}
</view>
</view>
</view>
</template>
</fab-tool>
<!-- 字体栏 -->
<fab-tool v-if="toolbarList.includes('fontFamily')" :visible="curFab == 'fontFamily'">
<view
:class="formats.fontFamily ? 'ql-active' : ''"
class="iconfont icon-font"
title="字体"
data-name="fontFamily"
@click.stop="fabTap('fontFamily')"
></view>
<template #content>
<view class="fab-tools" @click.stop="fabTapSub($event, 'fontFamily')">
<view v-for="item in fabTools.fontFamily" :key="item.value">
<view
class="fab-sub"
:class="[formats.fontFamily === item.value ? 'ql-active' : '', item.icon ? 'iconfont' : '', item.icon]"
:title="item.title"
data-name="fontFamily"
:data-value="item.value"
>
{{ item.name }}
</view>
</view>
</view>
</template>
</fab-tool>
<!-- 字体大小栏 -->
<fab-tool v-if="toolbarList.includes('fontSize')" :visible="curFab == 'fontSize'">
<view
:class="formats.fontSize ? 'ql-active' : ''"
class="iconfont icon-fontsize"
title="字号"
data-name="fontSize"
@click.stop="fabTap('fontSize')"
></view>
<template #content>
<view class="fab-tools" @click.stop="fabTapSub($event, 'fontSize')">
<view v-for="item in fabTools.fontSize" :key="item.value">
<view
class="fab-sub"
:class="[formats.fontSize === item.value ? 'ql-active' : '', item.icon ? 'iconfont' : '', item.icon]"
:title="item.title"
data-name="fontSize"
:data-value="item.value"
>
{{ item.name }}
</view>
</view>
</view>
</template>
</fab-tool>
<view
v-if="toolbarList.includes('color')"
:style="{ color: formats.color ? textColor : 'initial' }"
class="iconfont icon-text_color"
title="文字颜色"
data-name="color"
:data-value="textColor"
></view>
<view
v-if="toolbarList.includes('backgroundColor')"
:style="{ color: formats.backgroundColor ? backgroundColor : 'initial' }"
class="iconfont icon-fontbgcolor"
title="背景颜色"
data-name="backgroundColor"
:data-value="backgroundColor"
></view>
<view v-if="toolbarList.includes('date')" class="iconfont icon-date" title="日期" @tap="insertDate"></view>
<view
v-if="toolbarList.includes('listCheck')"
class="iconfont icon--checklist"
title="待办"
data-name="list"
data-value="check"
></view>
<view
v-if="toolbarList.includes('listOrdered')"
:class="formats.list === 'ordered' ? 'ql-active' : ''"
class="iconfont icon-youxupailie"
title="有序列表"
data-name="list"
data-value="ordered"
></view>
<view
v-if="toolbarList.includes('listBullet')"
:class="formats.list === 'bullet' ? 'ql-active' : ''"
class="iconfont icon-wuxupailie"
title="无序列表"
data-name="list"
data-value="bullet"
></view>
<view
v-if="toolbarList.includes('divider')"
class="iconfont icon-fengexian"
title="分割线"
@click="insertDivider"
></view>
<view
v-if="toolbarList.includes('indentDec')"
class="iconfont icon-outdent"
title="减少缩进"
data-name="indent"
data-value="-1"
></view>
<view
v-if="toolbarList.includes('indentInc')"
class="iconfont icon-indent"
title="增加缩进"
data-name="indent"
data-value="+1"
></view>
<view
v-if="toolbarList.includes('scriptSub')"
:class="formats.script === 'sub' ? 'ql-active' : ''"
class="iconfont icon-zitixiabiao"
title="下标"
data-name="script"
data-value="sub"
></view>
<view
v-if="toolbarList.includes('scriptSuper')"
:class="formats.script === 'super' ? 'ql-active' : ''"
class="iconfont icon-zitishangbiao"
title="上标"
data-name="script"
data-value="super"
></view>
<view
v-if="toolbarList.includes('direction')"
:class="formats.direction === 'rtl' ? 'ql-active' : ''"
class="iconfont icon-direction-rtl"
title="文本方向"
data-name="direction"
data-value="rtl"
></view>
<view
v-if="toolbarList.includes('image')"
class="iconfont icon-charutupian"
title="图片"
@tap="insertImage"
></view>
<view v-if="toolbarList.includes('video')" class="iconfont icon-video" title="视频" @tap="insertVideo"></view>
<view
v-if="toolbarList.includes('link')"
class="iconfont icon-charulianjie"
title="超链接"
@tap="insertLink"
></view>
<view v-if="toolbarList.includes('undo')" class="iconfont icon-undo" title="撤销" @tap="undo"></view>
<view v-if="toolbarList.includes('redo')" class="iconfont icon-redo" title="重做" @tap="redo"></view>
<view
v-if="toolbarList.includes('removeFormat')"
class="iconfont icon-clearedformat"
title="清除格式"
@tap="removeFormat"
></view>
<view v-if="toolbarList.includes('clear')" class="iconfont icon-shanchu" title="清空" @tap="clear"></view>
<view v-if="toolbarList.includes('export')" class="iconfont icon-baocun" title="导出" @tap="exportHtml"></view>
</view>
<!-- 自定义功能组件 -->
<!-- 调色板 -->
<color-picker
v-if="toolbarList.includes('color') || toolbarList.includes('backgroundColor')"
ref="colorPickerRef"
:color="defaultColor"
@confirm="confirmColor"
></color-picker>
<!-- 添加链接的操作弹窗 -->
<link-edit v-if="toolbarList.includes('link') && !readOnly" ref="linkEditRef" @confirm="confirmLink"></link-edit>
<view class="sp-editor-wrapper" @longpress="eLongpress">
<editor
:id="editorId"
class="ql-editor editor-container"
:class="{ 'ql-image-overlay-none': readOnly }"
show-img-size
show-img-toolbar
show-img-resize
:placeholder="placeholder"
:read-only="readOnly"
@statuschange="onStatusChange"
@ready="onEditorReady"
@input="onEditorInput"
></editor>
</view>
</view>
</template>
<script>
import ColorPicker from './color-picker.vue'
import LinkEdit from './link-edit.vue'
import FabTool from './fab-tool.vue'
import { addLink, linkFlag } from '../../utils'
export default {
components: {
ColorPicker,
LinkEdit,
FabTool
},
props: {
// id便使id
editorId: {
type: String,
default: 'editor'
},
placeholder: {
type: String,
default: '写点什么吧 ~'
},
//
readOnly: {
type: Boolean,
default: false
},
// -1
maxlength: {
type: Number,
default: -1
},
//
toolbarConfig: {
type: Object,
default: () => {
return {
keys: [], //
excludeKeys: [], //
iconSize: '18px', //
iconColumns: 10 //
}
}
}
},
watch: {
toolbarConfig: {
deep: true,
immediate: true,
handler(newToolbar) {
/**
* 若工具栏配置中keys存在则以keys为准
* 否则以excludeKeys向toolbarAllList中排查
* 若keys与excludeKeys皆为空则以toolbarAllList为准
*/
if (newToolbar.keys?.length > 0) {
this.toolbarList = newToolbar.keys
} else {
this.toolbarList =
newToolbar.excludeKeys?.length > 0
? this.toolbarAllList.filter((item) => !newToolbar.excludeKeys.includes(item))
: this.toolbarAllList
}
this.iconSize = newToolbar.iconSize || '18px'
this.iconColumns = newToolbar.iconColumns || 10
}
}
},
data() {
return {
formats: {},
curFab: '', //
fabXY: {},
textColor: '',
backgroundColor: '',
curColor: '',
defaultColor: { r: 0, g: 0, b: 0, a: 1 }, //
iconSize: '20px', //
iconColumns: 10, //
toolbarList: [],
toolbarAllList: [
'header', //
'H1', //
'H2', //
'H3', //
'H4', //
'H5', //
'H6', //
'bold', //
'italic', //
'underline', // 线
'strike', // 线
'align', //
'alignLeft', //
'alignCenter', //
'alignRight', //
'alignJustify', //
'lineHeight', //
'letterSpacing', //
'marginTop', //
'marginBottom', //
'fontFamily', //
'fontSize', //
'color', //
'backgroundColor', //
'date', //
'listCheck', //
'listOrdered', //
'listBullet', //
'indentInc', //
'indentDec', //
'divider', // 线
'scriptSub', //
'scriptSuper', //
'direction', //
'image', //
'video', //
'link', //
'undo', //
'redo', //
'removeFormat', //
'clear', //
'export' //
],
fabTools: {
header: [
{ title: '一级标题', name: 'H1', value: 1, icon: 'icon-format-header-1' },
{ title: '二级标题', name: 'H2', value: 2, icon: 'icon-format-header-2' },
{ title: '三级标题', name: 'H3', value: 3, icon: 'icon-format-header-3' },
{ title: '四级标题', name: 'H4', value: 4, icon: 'icon-format-header-4' },
{ title: '五级标题', name: 'H5', value: 5, icon: 'icon-format-header-5' },
{ title: '六级标题', name: 'H6', value: 6, icon: 'icon-format-header-6' }
],
fontFamily: [
{ title: '宋体', name: '宋', value: '宋体', icon: '' },
{ title: '黑体', name: '黑', value: '黑体', icon: '' },
{ title: '楷体', name: '楷', value: '楷体', icon: '' },
{ title: '仿宋', name: '仿', value: '仿宋', icon: '' },
{ title: '华文隶书', name: '隶', value: 'STLiti', icon: '' },
{ title: '华文行楷', name: '行', value: 'STXingkai', icon: '' },
{ title: '幼圆', name: '圆', value: 'YouYuan', icon: '' }
],
fontSize: [
{ title: '12', name: '12', value: '12px', icon: '' },
{ title: '14', name: '14', value: '14px', icon: '' },
{ title: '16', name: '16', value: '16px', icon: '' },
{ title: '18', name: '18', value: '18px', icon: '' },
{ title: '20', name: '20', value: '20px', icon: '' },
{ title: '22', name: '22', value: '22px', icon: '' },
{ title: '24', name: '24', value: '24px', icon: '' }
],
align: [
{ title: '左对齐', name: 'alignLeft', value: 'left', icon: 'icon-zuoduiqi' },
{ title: '居中对齐', name: 'alignCenter', value: 'center', icon: 'icon-juzhongduiqi' },
{ title: '右对齐', name: 'alignRight', value: 'right', icon: 'icon-youduiqi' },
{ title: '两端对齐', name: 'alignJustify', value: 'justify', icon: 'icon-zuoyouduiqi' }
],
lineHeight: [
{ title: '1倍', name: '1', value: '1', icon: '' },
{ title: '1.5倍', name: '1.5', value: '1.5', icon: '' },
{ title: '2倍', name: '2', value: '2', icon: '' },
{ title: '2.5倍', name: '2.5', value: '2.5', icon: '' },
{ title: '3倍', name: '3', value: '3', icon: '' }
],
// //
space: [
{ title: '0.5倍', name: '0.5', value: '0.5em', icon: '' },
{ title: '1倍', name: '1', value: '1em', icon: '' },
{ title: '1.5倍', name: '1.5', value: '1.5em', icon: '' },
{ title: '2倍', name: '2', value: '2em', icon: '' },
{ title: '2.5倍', name: '2.5', value: '2.5em', icon: '' },
{ title: '3倍', name: '3', value: '3em', icon: '' }
]
}
}
},
methods: {
onEditorReady() {
uni
.createSelectorQuery()
.in(this)
.select('#' + this.editorId)
.context((res) => {
this.editorCtx = res.context
this.$emit('init', this.editorCtx, this.editorId)
})
.exec()
},
undo() {
this.editorCtx.undo()
},
redo() {
this.editorCtx.redo()
},
format(e) {
let { name, value } = e.target.dataset
if (!name) return
switch (name) {
case 'color':
case 'backgroundColor':
this.curColor = name
this.showPicker()
break
default:
this.editorCtx.format(name, value)
break
}
},
//
fabTap(fabType) {
if (this.curFab != fabType) {
this.curFab = fabType
} else {
this.curFab = ''
}
},
//
fabTapSub(e, fabType) {
this.format(e)
this.fabTap(fabType)
},
showPicker() {
switch (this.curColor) {
case 'color':
this.defaultColor = this.textColor
? this.$refs.colorPickerRef.hex2Rgb(this.textColor)
: { r: 0, g: 0, b: 0, a: 1 }
break
case 'backgroundColor':
this.defaultColor = this.backgroundColor
? this.$refs.colorPickerRef.hex2Rgb(this.backgroundColor)
: { r: 0, g: 0, b: 0, a: 0 }
break
}
this.$refs.colorPickerRef.open()
},
confirmColor(e) {
switch (this.curColor) {
case 'color':
this.textColor = e.hex
this.editorCtx.format('color', this.textColor)
break
case 'backgroundColor':
this.backgroundColor = e.hex
this.editorCtx.format('backgroundColor', this.backgroundColor)
break
}
},
onStatusChange(e) {
if (e.detail.color) {
this.textColor = e.detail.color
}
if (e.detail.backgroundColor) {
this.backgroundColor = e.detail.backgroundColor
}
this.formats = e.detail
},
insertDivider() {
this.editorCtx.insertDivider()
},
clear() {
uni.showModal({
title: '清空编辑器',
content: '确定清空编辑器吗?',
success: ({ confirm }) => {
if (confirm) {
this.editorCtx.clear()
}
}
})
},
removeFormat() {
uni.showModal({
title: '文本格式化',
content: '确定要清除所选择部分文本块格式吗?',
showCancel: true,
success: ({ confirm }) => {
if (confirm) {
this.editorCtx.removeFormat()
}
}
})
},
insertDate() {
const date = new Date()
const formatDate = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
this.editorCtx.insertText({ text: formatDate })
},
insertLink() {
this.$refs.linkEditRef.open()
},
/**
* 确认添加链接
* @param {Object} e { text: '链接描述', href: '链接地址' }
*/
confirmLink(e) {
this.$refs.linkEditRef.close()
addLink(this.editorCtx, e, () => {
// inputbug
this.editorCtx.getContents({
success: (res) => {
this.$emit('input', { html: res.html, text: res.text }, this.editorId)
}
})
})
this.$emit('addLink', e, this.editorId)
},
insertImage() {
// #ifdef APP-PLUS || H5
uni.chooseImage({
// count: 1, // 9
success: (res) => {
const { tempFiles } = res
//
this.$emit('upinImage', tempFiles, this.editorCtx, this.editorId)
},
fail() {
uni.showToast({
title: '未授权访问相册权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
// #ifdef MP-WEIXIN
// 2.21.0 wx.chooseImage 使 uni.chooseMedia
uni.chooseMedia({
// count: 1, // 9
mediaType: ['image'],
success: (res) => {
// chooseImage
const { tempFiles } = res
this.$emit('upinImage', tempFiles, this.editorCtx, this.editorId)
},
fail() {
uni.showToast({
title: '未授权访问相册权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
},
insertVideo() {
uni.chooseVideo({
sourceType: ['camera', 'album'],
success: (res) => {
const { tempFilePath } = res
//
this.$emit('upinVideo', tempFilePath, this.editorCtx, this.editorId)
},
fail() {
uni.showToast({
title: '未授权访问媒体权限,请授权后使用',
icon: 'none'
})
}
})
},
onEditorInput(e) {
// 使getContentshtmltextonStatusChangetoolbar
// detailreturn
if (Object.keys(e.detail).length <= 0) return
const { html, text } = e.detail
// return
if (text.indexOf(linkFlag) !== -1) return
const maxlength = parseInt(this.maxlength)
const textStr = text.replace(/[ \t\r\n]/g, '')
if (textStr.length > maxlength && maxlength != -1) {
uni.showModal({
content: `超过${maxlength}字数啦~`,
confirmText: '确定',
showCancel: false,
success: () => {
this.$emit('overMax', { html, text }, this.editorId)
}
})
} else {
this.$emit('input', { html, text }, this.editorId)
}
},
//
exportHtml() {
this.editorCtx.getContents({
success: (res) => {
this.$emit('exportHtml', res.html, this.editorId)
}
})
},
eLongpress() {
/**
* 微信小程序官方editor的长按事件有bug需要重写覆盖不需做任何逻辑可见下面小程序社区问题链接
* @tutorial https://developers.weixin.qq.com/community/develop/doc/000c04b3e1c1006f660065e4f61000
*/
}
}
}
</script>
<style lang="scss">
@import '@/uni_modules/sp-editor/icons/editor-icon.css';
@import '@/uni_modules/sp-editor/icons/custom-icon.css';
.sp-editor {
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.sp-editor-toolbar {
box-sizing: border-box;
padding: calc(var(--icon-size) / 4) 0;
border-bottom: 1px solid #e4e4e4;
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
display: grid;
grid-template-columns: repeat(var(--icon-columns), 1fr);
}
.iconfont {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: calc(var(--icon-size) * 1.8);
cursor: pointer;
font-size: var(--icon-size);
}
.sp-editor-wrapper {
flex: 1;
overflow: hidden;
position: relative;
}
.editor-container {
padding: 8rpx 16rpx;
box-sizing: border-box;
width: 100%;
height: 100%;
font-size: 16px;
line-height: 1.5;
}
.ql-image-overlay-none {
::v-deep .ql-image-overlay {
pointer-events: none;
opacity: 0;
}
}
::v-deep .ql-editor.ql-blank::before {
font-style: normal;
color: #cccccc;
}
::v-deep .ql-container {
min-height: unset;
}
.ql-active {
color: #66ccff;
}
.fab-tools {
display: flex;
padding: 0 10rpx;
box-sizing: border-box;
.fab-sub {
width: auto;
height: auto;
margin: 10rpx;
}
}
</style>

24
uni_modules/sp-editor/icons/custom-icon.css

@ -0,0 +1,24 @@
@font-face {
font-family: 'iconfont';
src: url('data:font/ttf;charset=utf-8;base64,AAEAAAANAIAAAwBQRkZUTahWRN8AAAeEAAAAHEdERUYAKQALAAAHZAAAAB5PUy8yPElI7QAAAVgAAABgY21hcOY96ckAAAHMAAABSmdhc3D//wADAAAHXAAAAAhnbHlmPxK+mgAAAyQAAAF4aGVhZCjWXYcAAADcAAAANmhoZWEHngOFAAABFAAAACRobXR4DIgAQAAAAbgAAAASbG9jYQCkALwAAAMYAAAADG1heHABGQB9AAABOAAAACBuYW1lXoIBAgAABJwAAAKCcG9zdENa1TcAAAcgAAAAOQABAAAAAQAA20yLC18PPPUACwQAAAAAAOLbjMwAAAAA4tuMzABA/78DwAMzAAAACAACAAAAAAAAAAEAAAOA/4AAXAQAAAAAAAPAAAEAAAAAAAAAAAAAAAAAAAAEAAEAAAAFAHEACgAAAAAAAgAAAAoACgAAAP8AAAAAAAAABAQAAZAABQAAAokCzAAAAI8CiQLMAAAB6wAyAQgAAAIABQMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUGZFZADA5hTmHgOA/4AAAAPcAIAAAAABAAAAAAAAAAAAAAAgAAEEAAAAAAAAAAQAAAAEAABAAIgAAAAAAAMAAAADAAAAHAABAAAAAABEAAMAAQAAABwABAAoAAAABgAEAAEAAuYU5h7//wAA5hTmHv//Ge8Z5gABAAAAAAAAAAABBgAAAQAAAAAAAAABAgAAAAIAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAApAC8AAoAQABAA8ACwAARABQAGAAoADQAQABMAFgAZABwAAABHgEUBg8BBiIuAT0BND4BMh8BFTcBESERBSEiJjURNDYzITIWFREUBgE0NjIWFREUBiImNRMyFhQGKwEiJjQ2MxcyFhQGKwEiJjQ2MwE0NjIWFREUBiImNRMiJjQ2OwEyFhQGIwciJjQ2OwEyFhQGIwJwCAgICKAHEQ8JCQ8RBxBCAV79AAMA/QAbJSUbAwAbJSX9JRMaExMaEyANExMNgA0TEw2ADRMTDYANExMNAqATGhMTGhMgDRMTDYANExMNgA0TEw2ADRMTDQGcBQ8QDwVgBAgPCcAJDwgEVU4n/wACAP4AQCUbAgAbJSUb/gAbJQJgDRMTDf3gDRMTDQGAExoTExoTwBMaExMaEwFgDRMTDf3gDRMTDQFAExoTExoTwBMaExMaEwABAIj/vwNpAzMACwAAEzMRIREzESMRIREjiJABwZCQ/j+QAzP+jwFx/IwBiP54AAAAABIA3gABAAAAAAAAABMAKAABAAAAAAABAAgATgABAAAAAAACAAcAZwABAAAAAAADAAgAgQABAAAAAAAEAAgAnAABAAAAAAAFAAsAvQABAAAAAAAGAAgA2wABAAAAAAAKACsBPAABAAAAAAALABMBkAADAAEECQAAACYAAAADAAEECQABABAAPAADAAEECQACAA4AVwADAAEECQADABAAbwADAAEECQAEABAAigADAAEECQAFABYApQADAAEECQAGABAAyQADAAEECQAKAFYA5AADAAEECQALACYBaABDAHIAZQBhAHQAZQBkACAAYgB5ACAAaQBjAG8AbgBmAG8AbgB0AABDcmVhdGVkIGJ5IGljb25mb250AABpAGMAbwBuAGYAbwBuAHQAAGljb25mb250AABSAGUAZwB1AGwAYQByAABSZWd1bGFyAABpAGMAbwBuAGYAbwBuAHQAAGljb25mb250AABpAGMAbwBuAGYAbwBuAHQAAGljb25mb250AABWAGUAcgBzAGkAbwBuACAAMQAuADAAAFZlcnNpb24gMS4wAABpAGMAbwBuAGYAbwBuAHQAAGljb25mb250AABHAGUAbgBlAHIAYQB0AGUAZAAgAGIAeQAgAHMAdgBnADIAdAB0AGYAIABmAHIAbwBtACAARgBvAG4AdABlAGwAbABvACAAcAByAG8AagBlAGMAdAAuAABHZW5lcmF0ZWQgYnkgc3ZnMnR0ZiBmcm9tIEZvbnRlbGxvIHByb2plY3QuAABoAHQAdABwADoALwAvAGYAbwBuAHQAZQBsAGwAbwAuAGMAbwBtAABodHRwOi8vZm9udGVsbG8uY29tAAAAAAIAAAAAAAAACgAAAAAAAQAAAAAAAAAAAAAAAAAAAAAABQAAAAEAAgECAQMFdmlkZW8GaGVhZGVyAAAAAAAAAf//AAIAAQAAAAwAAAAWAAAAAgABAAMABAABAAQAAAACAAAAAAAAAAEAAAAA4p8rRgAAAADi24zMAAAAAOLbjMw=') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-header:before {
content: "\e61e";
}
.icon-video:before {
content: "\e614";
}

238
uni_modules/sp-editor/icons/editor-icon.css

File diff suppressed because one or more lines are too long

83
uni_modules/sp-editor/package.json

@ -0,0 +1,83 @@
{
"id": "sp-editor",
"displayName": "官方富文本编辑器editor组件改良扩展优化版",
"version": "1.5.0",
"description": "基于官方的富文本编辑器editor组件,进行改良扩展优化版,添加了调色板,添加超链接等功能,可自定义扩展工具,快来试试吧~",
"keywords": [
"富文本",
"editor",
"编辑器"
],
"repository": "",
"engines": {
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "n"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

19
uni_modules/sp-editor/readme.md

@ -0,0 +1,19 @@
# sp-editor
### 视频插入功能
`于2024-7-20日v1.4.4版本更新视频插入功能(属实是鸽🕊了太久了)`
- 实现方案:先以图片占位,在导出时,将携带视频链接的图片转换成视频标签。
- 更多请参考示例一
- 如果该插件有帮助到您,还望能点赞好评一下,谢谢!🌟
### 文档迁移
> 防止文档失效,提供下列五个地址,内容一致
- [地址一](https://sonvee.github.io/sv-app-docs/docs-github/src/plugins/sp-editor/sp-editor.html)
- [地址二](https://sv-app-docs.pages.dev/src/plugins/sp-editor/sp-editor.html)
- [地址三](https://sv-app-docs.4everland.app/src/plugins/sp-editor/sp-editor.html)
- [地址四](https://sv-app-docs.vercel.app/src/plugins/sp-editor/sp-editor.html) (需要梯子)
- [地址五](https://static-mp-74bfcbac-6ba6-4f39-8513-8831390ff75a.next.bspapp.com/docs-uni/src/plugins/sp-editor/sp-editor.html) (有IP限制)

1
uni_modules/sp-editor/static/image-resize.min.js vendored

File diff suppressed because one or more lines are too long

8
uni_modules/sp-editor/static/quill.min.js vendored

File diff suppressed because one or more lines are too long

132
uni_modules/sp-editor/utils/index.js

@ -0,0 +1,132 @@
// 标识必须独一无二 - 标识是为了使用insertText插入标识文本后,查找到标识所在delta位置的索引
export const linkFlag = '#-*=*-*=*-*=*@-link超链接标识link-@*=*-*=*-*=*-#'
export function addLink(editorCtx, attr, callback) {
// 先插入一段文本内容
editorCtx.insertText({
text: linkFlag
})
// 获取全文delta内容
editorCtx.getContents({
success(res) {
let options = res.delta.ops
const findex = options.findIndex(item => {
return item.insert && typeof item.insert !== 'object' && item.insert?.indexOf(linkFlag) !== -1
})
// 根据标识查找到插入的位置
if (findex > -1) {
const findOption = options[findex]
const findAttributes = findOption.attributes
// 将该findOption分成三部分:前内容 要插入的link 后内容
const [prefix, suffix] = findOption.insert.split(linkFlag);
const handleOps = []
// 前内容
if (prefix) {
const prefixOps = findAttributes ? {
insert: prefix,
attributes: findAttributes
} : {
insert: prefix
}
handleOps.push(prefixOps)
}
// 插入的link
const linkOps = {
insert: attr.text,
attributes: {
link: attr.href,
textDecoration: attr.textDecoration || 'none', // 下划线
color: attr.color || '#007aff'
}
}
handleOps.push(linkOps)
// 后内容
if (suffix) {
const suffixOps = findAttributes ? {
insert: suffix,
attributes: findAttributes
} : {
insert: suffix
}
handleOps.push(suffixOps)
}
// 删除原options[findex]并在findex位置插入上述三个ops
options.splice(findex, 1);
options.splice(findex, 0, ...handleOps);
// 最后重新初始化内容,注意该方法会导致光标重置到最开始位置
editorCtx.setContents({
delta: {
ops: options
}
})
// 所以最后建议使富文本光标失焦,让用户手动聚焦光标
editorCtx.blur()
// 后续回调操作
if (callback) callback()
}
}
})
}
/**
* 将含有特殊图片形式视频的富文本转换成正常视频的富文本
* @param {String} html 要进行处理的富文本字符串
* @returns {String} 返回处理结果
*/
export function handleHtmlWithVideo(html) {
// 正则表达式用于匹配img标签中带有alt属性且alt属性值为视频链接的模式
const regex = /<img\s+src="[^"]*"\s+alt="([^"]*)"[^>]*>/g
// 使用replace方法和一个函数回调来替换匹配到的内容
return html.replace(regex, (match, videoUrl) => {
// 替换为video标签,并添加controls属性以便用户可以控制播放
return `<video width="80%" controls><source src="${videoUrl}" type="video/mp4"></video>`
})
}
/**
* 将img标签中内联style属性中的宽高样式提取出标签width与height属性
* @param {Object} html 要处理的富文本字符串
* @returns {Object} 返回处理结果
*/
export function convertImgStylesToAttributes(html) {
return html.replace(/<img\s+([^>]+)\s*>/g, function(match, attributes) {
// 分割属性
const attrs = attributes.split(/\s+/);
// 找到style属性的位置
const styleIndex = attrs.findIndex(attr => attr.startsWith('style='));
if (styleIndex === -1) return match; // 如果没有找到style属性,则返回原样
// 提取style属性值
const styleAttr = attrs.splice(styleIndex, 1)[0];
const style = styleAttr.match(/"([^"]*)"/)[1];
// 解析 style 属性
const styleObj = {};
style.split(';').forEach(function(part) {
if (part) {
const [name, value] = part.split(':');
styleObj[name.trim()] = value.trim();
}
});
// 创建新的 img 标签
let newTag = '<img';
if (styleObj.width) {
newTag += ` width="${styleObj.width}"`;
}
if (styleObj.height) {
newTag += ` height="${styleObj.height}"`;
}
// 添加原有的属性,包括修改过的style属性
newTag += ` ${styleAttr} ${attrs.join(' ')}`;
// 关闭 img 标签
newTag += '>';
return newTag;
});
}
Loading…
Cancel
Save