1
0
Fork 0

add editor

master
panliang 2023-09-22 09:46:23 +08:00
parent ea162d0ee9
commit eaad940502
14 changed files with 1756 additions and 225 deletions

View File

@ -1,4 +1,3 @@
location ^~ /h5/ {
root h5
try_files $uri $uri/ /index.html
location /h5 {
try_files $uri $uri/h5 /h5/index.html?$query_string;
}

View File

@ -0,0 +1,85 @@
# 富文本编辑器插件
uniapp 富文本编辑器插件
## 兼容性
|微信小程序|H5|APP|
|:--:|:--:|:--:|
|√|√ |x|
## 使用方式
`script` 中引用组件
```js
import myeditor from "@/components/robin-editor/editor.vue"
export default {
components: {myeditor}
}
```
`template` 中使用组件
```html
<myeditor class="editor"
@cancel="hideEditor"
@save="saveEditor"
v-model="html"
:imageUploader="uploadImg"
:muiltImage="true">
</myeditor>
```
## Demo
https://github.com/health901/uniapp-editor-demo
## 属性说明
|属性|类型|默认值|说明|
|--|--|--|--|
|v-model|String| |富文本,双向绑定|
|imageUploader|function(img,callback)| |上传图片处理函数 接受参数 img:本地图片地址,callback:上传成功回调传入图片链接|
|muiltImage|Boolean|false|是否支持多图上传|
|compressImage|Boolean|true|图片上传是否压缩|
|previewMode|Boolean|false|预览模式,不可编辑|
|autoHideToolbar|Boolean|false|失去焦点时自动隐藏工具栏|
|tools|Array|['bold', 'italic', 'underline', 'strike', 'align-left', 'align-center', 'align-right', 'remove', 'font', 'color', 'backgroundColor','image', 'clear', 'preview']|工具栏|
### 工具栏
|名称|值|
|--|--|
|加粗|`bold`|
|斜体|`italic`|
|下划线|`underline`|
|删除线|`strike`|
|右对齐|`align-left`|
|居中|`align-center`|
|左对齐|`align-right`|
|清除格式|`remove`|
|字体大小|`font`|
|字体颜色|`color`|
|背景色|`backgroundColor`|
|插入图片|`image`|
|清空|`clear`|
|预览|`preview`|
|插入日期|`date`|
|列表|`list-check`,`list-ordered`,`list-bullet`|
|上下标|`sub`,`super`|
|撤销,恢复撤销|`undo`,`redo`|
|缩进|`indent`,`outdent`|
|分割线|`divider`|
|标题|`h1`,`h2`,`h3`,`h4`,`h5`,`h6`|
|书写方向|`rtl`|
## 事件说明
|事件|说明|参数|
|--|--|--|
|cancel|点击取消按钮|
|save|点击保存按钮|e={html,text,delta}|
## 依赖
|组件|链接|备注|
|---|--|--|
|Popup 弹出层<sup>[[1]](#注)</sup>|https://ext.dcloud.net.cn/plugin?id=329|uni-ui库|
|Transition动画|https://ext.dcloud.net.cn/plugin?id=1231|uni-ui库,Popup依赖|
|颜色选择器ColorPicker<sup>[[2]](#注)</sup>|https://ext.dcloud.net.cn/plugin?id=1237|字体颜色,背景色|
## 注
1. 修改:新增动画结束事件
2. 修改:添加按钮,支持预设颜色值

View File

@ -0,0 +1,289 @@
<template>
<view class="content">
<topbar class="head" @cancel="cancel" @save="confirm"></topbar>
<view class="color-picker">
<view class="color-name">{{ colorName }}</view>
<view class="show-view" :style="{ background: colorName }"></view>
<view class="hue-view" @touchstart="pickHue" @touchmove="pickHue">
<text class="anchor" :style="{ left: hueView.anchorLeft + 'px' }"></text>
</view>
<view class="color-view" @touchstart="pickColor" @touchmove="pickColor" :style="{ backgroundColor: 'hsl(' + hueView.H + ', 100%, 50%)' }">
<text class="anchor" :style="{ top: colorView.anchorTop + 'px', left: colorView.anchorLeft + 'px' }"></text>
</view>
</view>
</view>
</template>
<script>
import topbar from '@/components/robin-editor/header.vue'
export default {
components: {
topbar
},
props: {
color: {
type: String,
default: ""
},
show: {
type: Boolean,
default: false
}
},
data() {
return {
hueView: {},
colorView: {},
colorName: '',
hueLeft: 0.5, // [0, 1]
anchorTop: 0.5, // top [0, 1]
anchorLeft: 0.5, // left [0, 1]
};
},
watch: {
show: function(newval, oldvar) {
if (!oldvar && newval) {
this.init()
}
}
},
mounted() {
if (this.show) {
this.init()
}
},
methods: {
init() {
const reg = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})*$/
if (this.color !== '' && reg.test(this.color)) {
this.getColorOffset()
}
Promise.all([this.getHueViewOffset(), this.getColorViewOffset()]).then(() => {
this.colorName = this.getColorString() // HLS RGB
})
},
getHueViewOffset() { //
return new Promise(resolve => uni.createSelectorQuery().in(this).select('.hue-view').boundingClientRect(
data => {
this.hueView = {
...data,
anchorLeft: data.width * this.hueLeft,
H: this.hueLeft * 360,
}
resolve()
}).exec())
},
getColorViewOffset() { //
return new Promise(resolve => uni.createSelectorQuery().in(this).select('.color-view').boundingClientRect(
data => {
this.colorView = {
...data,
anchorTop: data.height * this.anchorTop,
anchorLeft: data.width * this.anchorLeft,
S: this.anchorLeft,
L: (1 - this.anchorLeft * 0.5) - this.anchorTop / (this.anchorLeft + 1)
}
resolve()
}).exec())
},
getColorString() { // RGB
const arr = hslToRgb(this.hueView.anchorLeft / this.hueView.width, this.colorView.S, this.colorView.L)
const r = arr[0].toString(16).length === 1 ? `0${arr[0].toString(16)}` : arr[0].toString(16)
const g = arr[1].toString(16).length === 1 ? `0${arr[1].toString(16)}` : arr[1].toString(16)
const b = arr[2].toString(16).length === 1 ? `0${arr[2].toString(16)}` : arr[2].toString(16)
return `#${r.toUpperCase()}${g.toUpperCase()}${b.toUpperCase()}`
},
getColorOffset() {
var color = this.color.substr(1)
color = color.length == 6 ? color : color.charAt(0) + color.charAt(0) + color.charAt(1) + color.charAt(
1) + color.charAt(2) + color.charAt(2)
const r = parseInt("0x" + color.substr(0, 2))
const g = parseInt("0x" + color.substr(2, 2))
const b = parseInt("0x" + color.substr(4, 2))
const arr = rgbToHsl(r, g, b)
this.hueLeft = arr[0]
this.anchorLeft = arr[1]
this.anchorTop = ((1 - arr[1] * 0.5) - arr[2]) * (arr[1] + 1)
},
pickColor(e) { //
const top = e.touches[0].clientY - this.colorView.top
const left = e.touches[0].clientX - this.colorView.left
if (top < 0) {
this.colorView.anchorTop = 0
} else if (top > this.colorView.height) {
this.colorView.anchorTop = this.colorView.height
} else {
this.colorView.anchorTop = top
}
if (left < 0) {
this.colorView.anchorLeft = 0
} else if (left > this.colorView.width) {
this.colorView.anchorLeft = this.colorView.width
} else {
this.colorView.anchorLeft = e.touches[0].clientX - this.colorView.left
}
this.colorView.S = this.colorView.anchorLeft / this.colorView.width
this.colorView.L = this.floor((1 - this.colorView.S * 0.5) - this.colorView.anchorTop / this.colorView.height /
(this.colorView.S + 1))
this.colorName = this.getColorString() // HLS RGB
},
pickHue(e) { //
if (e.touches[0].clientX >= this.hueView.left && e.touches[0].clientX <= this.hueView.right) {
this.hueView.anchorLeft = e.touches[0].clientX - this.hueView.left
this.hueView.H = this.hueView.anchorLeft / this.hueView.width * 360
this.colorName = this.getColorString() // HLS RGB
}
},
floor(num) {
return num < 0.09 ? 0 : num
},
confirm() {
this.$emit('confirm', {
color: this.colorName
})
},
cancel(){
this.$emit('cancel')
}
}
}
function hslToRgb(h, s, l) { // HSL RGB
var r, g, b;
if (s == 0) {
r = g = b = l; // achromatic
} else {
var hue2rgb = function hue2rgb(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
var p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
function rgbToHsl(r, g, b) {
r /= 255, g /= 255, b /= 255;
var max = Math.max(r, g, b),
min = Math.min(r, g, b);
var h, s, l = (max + min) / 2;
if (max == min) {
h = s = 0; // achromatic
} else {
var d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
var round = function(n, l) {
return Math.round(n * Math.pow(10, l)) / Math.pow(10, l)
}
return [round(h, 3), round(s, 3), round(l, 3)];
}
</script>
<style lang="scss" scoped>
.content {
height: 100%;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
background-color: #fff;
.head {
width: 100%;
}
.color-picker {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
.color-name {
margin: 23rpx;
font-size: 45rpx;
font-weight: bold;
letter-spacing: 8rpx;
}
.show-view {
height: 56rpx;
width: 567rpx;
}
.hue-view {
width: 567rpx;
height: 56rpx;
margin: 12rpx 0;
position: relative;
background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
.anchor {
width: 12rpx;
height: 100%;
position: absolute;
background: #FFFFFF;
transform: translate(-50%);
box-shadow: 0 0 2rpx rgba(0, 0, 0, 0.6);
}
}
.color-view {
width: 567rpx;
height: 345rpx;
position: relative;
margin-bottom: 12upx;
&::before,
&::after {
content: '';
top: 0;
left: 0;
width: 100%;
height: 100%;
position: absolute;
}
&::before {
background: linear-gradient(to right, white, transparent);
}
&::after {
background: linear-gradient(to top, black, transparent);
}
.anchor {
z-index: 1;
width: 24rpx;
height: 24rpx;
border-radius: 50%;
position: absolute;
border: 4rpx solid #FFFFFF;
background: rgba(0, 0, 0, .3);
transform: translate(-50%, -50%);
}
}
}
}
</style>

View File

@ -1,86 +1,19 @@
<template>
<view class="cu-editor">
<u-modal
:show="show"
showCancelButton
@confirm="confirm"
@cancel="close"
>
<view class="editor-wrapper">
<view class="btn-content">
<view v-for="(group, groupIndex) in btns" :key="groupIndex" class="btn-group">
<view v-for="item in group" :key="item.action" class="btn-item" @click="handleBtn(item)">
<text>{{ item.name }}</text>
</view>
</view>
<!-- <view class="btn-group">
<view class="btn-item">
<text>标题</text>
</view>
<view class="btn-item">
<text>列表</text>
</view>
<view class="btn-item">
<text>图片</text>
</view>
</view>
<view class="btn-group">
<view class="btn-item">
<text>字体颜色</text>
</view>
<view class="btn-item">
<text>背景色</text>
</view>
<view class="btn-item">
<text>字体大小</text>
</view>
<view class="btn-item">
<text>缩进</text>
</view>
</view>
<view class="btn-group">
<view class="btn-item">
<text>粗体</text>
</view>
<view class="btn-item">
<text>斜体</text>
</view>
<view class="btn-item">
<text>下划线</text>
</view>
<view class="btn-item">
<text>删除线</text>
</view>
</view>
<view class="btn-group">
<view class="btn-item">
<text>左对齐</text>
</view>
<view class="btn-item">
<text>居中</text>
</view>
<view class="btn-item">
<text>右对齐</text>
</view>
<view class="btn-item">
<text>两端对齐</text>
</view>
</view> -->
</view>
<view v-if="btn && options" class="btn-option">
<view class="title">{{ btn.name }}</view>
<view class="options">
<view v-for="item in options" :key="item.value" class="option" :style="item.style" @click="handleOption(item)">{{ item.name }}</view>
</view>
</view>
<editor id="editor" :placeholder="placeholder" @ready="onEditorReady" />
</view>
</u-modal>
</view>
<u-popup :show="show">
<robin-editor
v-model="value"
class="editor"
@cancel="close"
@save="confirm"
:imageUploader="uploadImage"
/>
</u-popup>
</template>
<script>
import RobinEditor from '@/components/robin-editor/editor'
export default {
components: { RobinEditor },
name: 'CuEditor',
props: {
placeholder: {
@ -90,84 +23,11 @@ export default {
},
data() {
return {
btns: [
[
{ name: '标题', type: 'format', action: 'header', options: [
{ name: 'H1', value: 'H1' },
{ name: 'H2', value: 'H2' },
{ name: 'H3', value: 'H3' },
]},
{ name: '列表', type: 'format', action: 'list', options: [
{ name: '有序列表', value: 'ordered' },
{ name: '无序列表', value: 'bullet ' },
]},
{ name: '图片', type: 'image', action: 'insert'},
],
// [
// { name: '', type: 'format', action: 'color', options: [
// { name: '', value: '#000000', style: { color: '#000000' } },
// { name: '', value: '#FF0000', style: { color: '#FF0000' } },
// { name: '绿', value: '#008000', style: { color: '#008000' } },
// { name: '', value: '#ffffff', style: { color: '#ffffff', background: 'grey' } },
// ]},
// { name: '', type: 'format', action: 'backgroundColor', options: [
// { name: '', value: '#000000', style: { background: '#000000', color: '#ffffff' } },
// { name: '', value: '#FF0000', style: { background: '#FF0000', color: '#ffffff' } },
// { name: '绿', value: '#008000', style: { background: '#008000', color: '#ffffff' } },
// { name: '', value: '#ffffff', style: { background: '#ffffff', color: '#ffffff' } },
// ]},
// { name: '', type: 'format', action: 'indent', options: [
// { name: '', value: '+1' },
// { name: '', value: '-1' },
// ]},
// ],
],
options: [],
btn: '',
option: '',
value: '',
show: true,
editor: ''
show: false,
}
},
methods: {
onEditorReady() {
uni.createSelectorQuery().in(this).select('#editor').context((res) => {
this.editor = res.context
if (this.value) {
this.editor.setContents({ html: this.value })
}
}).exec()
},
handleBtn(e) {
if (e.type == 'image') {
return uni.chooseImage({
success: (res) => {
res.tempFilePaths.forEach(item => this.editorAction('image', { width: '100%', height: '100%', alt: '无法显示图片' }, item))
// const files = res.tempFiles
}
})
}
if (!e.options) {
this.editorAction(e.type, e.action)
} else {
this.btn = e
this.options = e.options
}
},
handleOption(e) {
const value = e.value
this.editorAction(this.btn.type, this.btn.action, value)
},
editorAction(type, action, value) {
if (type == 'format') {
this.editor.format(action, value)
} else if (type == 'image') {
this.editor.insertImage({ src: value, action })
} else if (action == 'removeFormat') {
this.editor.removeFormat()
}
},
open(value) {
this.value = value
this.show = true
@ -175,71 +35,22 @@ export default {
close() {
this.show = false
},
confirm() {
if (this.editor) {
this.editor.getContents({
success: (res) => {
this.$emit('confirm', res)
this.close()
}
})
}
confirm(e) {
this.$emit('confirm', e)
this.close()
},
uploadImage(file, callback) {
this.$ajax.upload('/admin-api/upload_image', {
params: {'full-url': 1},
name: 'file',
filePath: file.path,
custom: { toast: false }
}).then(res => {
if (res.status == 0) {
callback(res.data.value)
}
})
}
}
}
</script>
<style scoped>
.cu-editor ::v-deep .u-modal__content {
padding-top: 0 !important;
}
.editor-wrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 600px;
overflow: scroll;
}
.btn-content {
margin-bottom: 10px;
}
.btn-group {
width: 100%;
display: flex;
margin-top: 5px;
justify-content: space-between;
}
.btn-item {
margin-right: 10px;
margin-top: 10px;
}
.btn-item:active {
background: grey;
color: white;
}
.btn-option {
width: 100%;
display: flex;
flex-direction: row;
}
.btn-option .title {
margin-right: 10px;
}
.btn-option .options {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.btn-option .option {
padding: 0 10px;
margin-bottom: 5px;
}
.btn-option .option:active {
background: grey;
color: white;
}
#editor {
height: 100%;
width: 100%;
margin-top: 20px;
}
</style>
</script>

View File

@ -0,0 +1,236 @@
@font-face {
font-family: "iconfont";
src: url('~./editor-icon.ttf') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-redo:before {
content: "\e627";
}
.icon-undo:before {
content: "\e633";
}
.icon-indent:before {
content: "\eb28";
}
.icon-outdent:before {
content: "\e6e8";
}
.icon-fontsize:before {
content: "\e6fd";
}
.icon-format-header-1:before {
content: "\e860";
}
.icon-format-header-4:before {
content: "\e863";
}
.icon-format-header-5:before {
content: "\e864";
}
.icon-format-header-6:before {
content: "\e865";
}
.icon-clearup:before {
content: "\e64d";
}
.icon-preview:before {
content: "\e631";
}
.icon-date:before {
content: "\e63e";
}
.icon-fontbgcolor:before {
content: "\e678";
}
.icon-clearedformat:before {
content: "\e67e";
}
.icon-font:before {
content: "\e684";
}
.icon-723bianjiqi_duanhouju:before {
content: "\e65f";
}
.icon-722bianjiqi_duanqianju:before {
content: "\e660";
}
.icon-text_color:before {
content: "\e72c";
}
.icon-format-header-2:before {
content: "\e75c";
}
.icon-format-header-3:before {
content: "\e75d";
}
.icon--checklist:before {
content: "\e664";
}
.icon-baocun:before {
content: "\ec09";
}
.icon-line-height:before {
content: "\e7f8";
}
.icon-quanping:before {
content: "\ec13";
}
.icon-direction-rtl:before {
content: "\e66e";
}
.icon-direction-ltr:before {
content: "\e66d";
}
.icon-selectall:before {
content: "\e62b";
}
.icon-fuzhi:before {
content: "\ec7a";
}
.icon-shanchu:before {
content: "\ec7b";
}
.icon-bianjisekuai:before {
content: "\ec7c";
}
.icon-fengexian:before {
content: "\ec7f";
}
.icon-dianzan:before {
content: "\ec80";
}
.icon-charulianjie:before {
content: "\ec81";
}
.icon-charutupian:before {
content: "\ec82";
}
.icon-wuxupailie:before {
content: "\ec83";
}
.icon-juzhongduiqi:before {
content: "\ec84";
}
.icon-yinyong:before {
content: "\ec85";
}
.icon-youxupailie:before {
content: "\ec86";
}
.icon-youduiqi:before {
content: "\ec87";
}
.icon-zitidaima:before {
content: "\ec88";
}
.icon-xiaolian:before {
content: "\ec89";
}
.icon-zitijiacu:before {
content: "\ec8a";
}
.icon-zitishanchuxian:before {
content: "\ec8b";
}
.icon-zitishangbiao:before {
content: "\ec8c";
}
.icon-zitibiaoti:before {
content: "\ec8d";
}
.icon-zitixiahuaxian:before {
content: "\ec8e";
}
.icon-zitixieti:before {
content: "\ec8f";
}
.icon-zitiyanse:before {
content: "\ec90";
}
.icon-zuoduiqi:before {
content: "\ec91";
}
.icon-zitiyulan:before {
content: "\ec92";
}
.icon-zitixiabiao:before {
content: "\ec93";
}
.icon-zuoyouduiqi:before {
content: "\ec94";
}
.icon-duigoux:before {
content: "\ec9e";
}
.icon-guanbi:before {
content: "\eca0";
}
.icon-shengyin_shiti:before {
content: "\eca5";
}
.icon-Character-Spacing:before {
content: "\e964";
}

Binary file not shown.

View File

@ -0,0 +1,488 @@
<template>
<view class="wrapper">
<topbar class="header" @cancel="cancel" @save="save" :labelConfirm="labelConfirm" :labelCancel="labelCancel"></topbar>
<view :style="'height:' + editorHeight + 'px;'" class="container" v-if="!previewMode" v-show="!showPreview">
<editor
v-if="!previewMode"
v-show="!showPreview"
id="editor"
class="ql-container"
placeholder="开始输入..."
showImgSize
showImgToolbar
showImgResize
@statuschange="onStatusChange"
:read-only="readOnly"
@ready="onEditorReady"
></editor>
</view>
<view class="toolbar" @tap="format" v-if="!showPreview" v-show="keyboardHeight || !autoHideToolbar" :style="'bottom:' + (isIOS ? keyboardHeight : 0) + 'px'">
<block v-for="(t, i) in tools" :key="i">
<view v-if="t == 'bold'" :class="formats.bold ? 'ql-active' : ''" class="iconfont icon-zitijiacu" data-name="bold" data-label="加粗"></view>
<view v-if="t == 'italic'" :class="formats.italic ? 'ql-active' : ''" class="iconfont icon-zitixieti" data-name="italic" data-label="斜体"></view>
<view v-if="t == 'underline'" :class="formats.underline ? 'ql-active' : ''" class="iconfont icon-zitixiahuaxian" data-name="underline" data-label="下滑线"></view>
<view v-if="t == 'strike'" :class="formats.strike ? 'ql-active' : ''" class="iconfont icon-zitishanchuxian" data-name="strike" data-label="删除线"></view>
<view
v-if="t == 'align-left'"
:class="formats.align === 'left' || !formats.align ? 'ql-active' : ''"
class="iconfont icon-zuoduiqi"
data-name="align"
data-value="left"
data-label="居左"
></view>
<view
v-if="t == 'align-center'"
:class="formats.align === 'center' ? 'ql-active' : ''"
class="iconfont icon-juzhongduiqi"
data-name="align"
data-value="center"
data-label="居中"
></view>
<view
v-if="t == 'align-right'"
:class="formats.align === 'right' ? 'ql-active' : ''"
class="iconfont icon-youduiqi"
data-name="align"
data-value="right"
data-label="居右"
></view>
<view
v-if="t == 'align-justify'"
:class="formats.align === 'justify' ? 'ql-active' : ''"
class="iconfont icon-zuoyouduiqi"
data-name="align"
data-value="justify"
data-label="平铺"
></view>
<!-- <view :class="formats.lineHeight ? 'ql-active' : ''" class="iconfont icon-line-height" data-name="lineHeight"
data-value="2"></view>
<view :class="formats.letterSpacing ? 'ql-active' : ''" class="iconfont icon-Character-Spacing" data-name="letterSpacing"
data-value="2em"></view>
<view :class="formats.marginTop ? 'ql-active' : ''" class="iconfont icon-722bianjiqi_duanqianju" data-name="marginTop"
data-value="20px"></view>
<view :class="formats.previewarginBottom ? 'ql-active' : ''" class="iconfont icon-723bianjiqi_duanhouju"
data-name="marginBottom" data-value="20px"></view> -->
<view v-if="t == 'remove'" class="iconfont icon-clearedformat" @tap.stop="removeFormat"></view>
<picker v-if="t == 'font'" class="iconfont" mode="selector" :range="fontSizeRange" @change="fontSize"><view class="icon-fontsize"></view></picker>
<view
v-if="t == 'color'"
:style="fontColor != '#FFFFFF' ? 'color:' + formats.color : ''"
class="iconfont icon-text_color"
data-name="color"
@tap.stop="openColor"
></view>
<view
v-if="t == 'backgroundColor'"
:style="bgColor ? 'color:' + formats.backgroundColor : ''"
class="iconfont icon-fontbgcolor"
data-name="backgroundColor"
@tap.stop="openColor"
></view>
<view v-if="t == 'image'" class="iconfont icon-charutupian" @tap.stop="insertImage"></view>
<view v-if="t == 'clear'" class="iconfont icon-shanchu" @tap.stop="clear"></view>
<view v-if="t == 'preview'" class="iconfont icon-preview" @tap.stop="preview"></view>
<view v-if="t == 'date'" class="iconfont icon-date" @tap="insertDate"></view>
<view v-if="t == 'list-check'" class="iconfont icon-checklist" data-name="list" data-value="check"></view>
<view
v-if="t == 'list-ordered'"
:class="formats.list === 'ordered' ? 'ql-active' : ''"
class="iconfont icon-youxupailie"
data-name="list"
data-value="ordered"
></view>
<view v-if="t == 'list-bullet'" :class="formats.list === 'bullet' ? 'ql-active' : ''" class="iconfont icon-wuxupailie" data-name="list" data-value="bullet"></view>
<view v-if="t == 'undo'" class="iconfont icon-undo" @tap="undo"></view>
<view v-if="t == 'redo'" class="iconfont icon-redo" @tap="redo"></view>
<view v-if="t == 'outdent'" class="iconfont icon-outdent" data-name="indent" data-value="-1"></view>
<view v-if="t == 'indent'" class="iconfont icon-indent" data-name="indent" data-value="+1"></view>
<view v-if="t == 'divider'" class="iconfont icon-fengexian" @tap="insertDivider"></view>
<view v-if="t == 'h1'" :class="formats.header === 1 ? 'ql-active' : ''" class="iconfont icon-format-header-1" data-name="header" :data-value="1"></view>
<view v-if="t == 'h2'" :class="formats.header === 2 ? 'ql-active' : ''" class="iconfont icon-format-header-2" data-name="header" :data-value="2"></view>
<view v-if="t == 'h3'" :class="formats.header === 3 ? 'ql-active' : ''" class="iconfont icon-format-header-3" data-name="header" :data-value="3"></view>
<view v-if="t == 'h4'" :class="formats.header === 4 ? 'ql-active' : ''" class="iconfont icon-format-header-4" data-name="header" :data-value="4"></view>
<view v-if="t == 'h5'" :class="formats.header === 5 ? 'ql-active' : ''" class="iconfont icon-format-header-5" data-name="header" :data-value="5"></view>
<view v-if="t == 'h6'" :class="formats.header === 6 ? 'ql-active' : ''" class="iconfont icon-format-header-6" data-name="header" :data-value="6"></view>
<view v-if="t == 'sub'" :class="formats.script === 'sub' ? 'ql-active' : ''" class="iconfont icon-zitixiabiao" data-name="script" data-value="sub"></view>
<view v-if="t == 'super'" :class="formats.script === 'super' ? 'ql-active' : ''" class="iconfont icon-zitishangbiao" data-name="script" data-value="super"></view>
<view
v-if="t == 'rtl'"
:class="formats.direction === 'rtl' ? 'ql-active' : ''"
class="iconfont icon-direction-rtl"
data-name="direction"
:data-value="formats.direction === 'rtl' ? '' : 'rtl'"
></view>
</block>
</view>
<uni-popup ref="popup" type="bottom" @transed="colorPop">
<colorPicker :color="color" :show="showColor" @confirm="colorChanged" @cancel="colorPopClose"></colorPicker>
</uni-popup>
<view class="preview" v-show="showPreview"><rich-text :nodes="htmlData" class="previewNodes"></rich-text></view>
</view>
</template>
<script>
import colorPicker from '@/components/colorPicker.vue';
import uniPopup from '@/components/uni-popup/uni-popup.vue';
import topbar from './header.vue';
export default {
components: {
colorPicker,
uniPopup,
topbar
},
props: {
value: {
type: String
},
imageUploader: {
type: Function
},
muiltImage: {
type: Boolean,
default: false
},
compressImage: {
type: Boolean,
default: false
},
previewMode: {
type: Boolean,
default: false
},
autoHideToolbar: {
type: Boolean,
default: false
},
tools: {
type: Array,
default: function() {
return [
'bold',
'italic',
'underline',
'strike',
'align-left',
'align-center',
'align-right',
'remove',
'font',
'color',
'backgroundColor',
'image',
'clear',
'preview'
];
}
}
},
data() {
return {
show: true,
readOnly: false,
formats: {},
fontColor: '#000000',
bgColor: '',
color: '',
colorPickerName: '',
showColor: false,
fontSizeRange: [10, 12, 14, 16, 18, 24, 32],
showPreview: false,
htmlData: '',
html: '',
keyboardHeight: 0,
editorHeight: 0,
isIOS: false
};
},
watch: {
value: function(newvar) {
this.html = newvar;
},
html: function(newvar) {
if (this.previewMode) {
this.previewData(this.html);
}
if (this.editorCtx) {
this.editorCtx.setContents({
html: this.html
});
}
}
},
created() {
this.html = this.value;
},
mounted: function() {
const platform = uni.getSystemInfoSync().platform;
this.isIOS = platform === 'ios';
if (this.previewMode) {
this.previewData(this.html);
}
let keyboardHeight = 0;
this.updatePosition(0);
uni.onKeyboardHeightChange(res => {
if (res.height === keyboardHeight) return;
const duration = res.height > 0 ? res.duration * 1000 : 0;
keyboardHeight = res.height;
setTimeout(() => {
uni.pageScrollTo({
scrollTop: 0,
success: () => {
this.updatePosition(keyboardHeight);
this.editorCtx && this.editorCtx.scrollIntoView();
}
});
}, duration);
});
},
computed: {
labelConfirm: function() {
return this.showPreview ? '关闭' : '保存';
},
labelCancel: function() {
return this.showPreview ? '' : '取消';
}
},
methods: {
updatePosition(keyboardHeight) {
const { windowHeight, windowWidth, platform } = uni.getSystemInfoSync();
const rpx = windowWidth / 750;
let topbarHeight = 85 * rpx;
//#ifdef H5
topbarHeight += 44;
//#endif
const toolbarHeight = (70 * Math.ceil(this.tools.length / 15) + 1) * rpx;
const bodyHeight = windowHeight - topbarHeight;
this.keyboardHeight = keyboardHeight;
this.editorHeight = keyboardHeight > 0 ? bodyHeight - keyboardHeight - toolbarHeight : this.autoHideToolbar ? bodyHeight : bodyHeight - toolbarHeight;
},
openColor(e) {
var name = e.currentTarget.dataset.name;
var color = this.formats[name];
this.colorPickerName = name;
if (name == 'backgroundColor' && !color) {
color = '#FFFFFF';
}
if (name == 'color' && !color) {
color = '#000000';
}
this.color = color;
this.$refs.popup.open({
type: 'bottom'
});
},
colorPop(e) {
this.showColor = e.show;
},
colorPopClose() {
this.$refs.popup.close();
},
colorChanged(e) {
let label = '';
switch (this.colorPickerName) {
case 'backgroundColor':
if (e.color == '#FFFFFF') {
e.color = '';
}
this.bgColor = e.color;
label = '背景色';
break;
case 'color':
this.fontColor = e.color;
label = '颜色';
break;
}
this.colorPopClose();
this._format(this.colorPickerName, e.color, label + e.color);
},
readOnlyChange() {
this.readOnly = !this.readOnly;
},
onEditorReady() {
uni.createSelectorQuery()
.in(this)
.select('#editor')
.context(res => {
this.editorCtx = res.context;
if (this.html) {
this.editorCtx.setContents({
html: this.html
});
}
})
.exec();
},
undo() {
this.editorCtx.undo();
this.toast('撤销');
},
redo() {
this.editorCtx.redo();
this.toast('重做');
},
format(e) {
let { name, value, label } = e.target.dataset;
if (!name) return;
this._format(name, value, label);
},
_format(name, value, label) {
this.editorCtx.format(name, value);
this.toast(label);
},
toast(label) {
uni.showToast({
duration: 600,
icon: 'none',
title: label
});
},
onStatusChange(e) {
const formats = e.detail;
this.formats = formats;
},
insertDivider() {
this.editorCtx.insertDivider({
success: function() {
this.toast('插入分割线');
}
});
},
clear() {
this.editorCtx.clear({
success: res => {
this.toast('清空');
}
});
},
removeFormat() {
this.editorCtx.removeFormat();
this.toast('清除格式');
},
insertDate() {
const date = new Date();
const formatDate = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
this.editorCtx.insertText({
text: formatDate
});
this.toast('插入日期');
},
insertImage() {
let params = {};
params.count = this.muiltImage ? 9 : 1;
params.sizeType = this.compressImage ? ['compressed'] : ['original'];
uni.chooseImage({
...params,
success: res => {
res.tempFiles.map(path => {
this.imageUploader(path, url => {
this.editorCtx.insertImage({
src: url,
alt: '图像'
});
});
});
}
});
},
fontSize(e) {
const index = e.detail.value;
const fz = this.fontSizeRange[index] + 'px';
this._format('fontSize', fz, '字体大小:' + fz);
},
cancel() {
this.$emit('cancel');
},
save() {
if (this.showPreview) {
if (this.previewMode) {
this.cancel();
} else {
this.showPreview = false;
}
} else {
this.editorCtx.getContents({
success: res => {
this.$emit('save', res);
this.$emit('input', res.html);
}
});
}
},
previewData: function(html) {
this.htmlData = html.replace(/\<img/gi, '<img style="max-width:100%;height:auto"');
this.showPreview = true;
},
preview: function() {
this.editorCtx.getContents({
success: res => {
this.previewData(res.html);
}
});
}
}
};
</script>
<style lang="scss" scoped>
@import './editor-icon.css';
.wrapper {
padding: 5px;
width: 100%;
.header {
}
.container {
width: 100%;
margin-top: 20rpx;
background: #fff;
.ql-container {
box-sizing: border-box;
width: 100%;
height: 100%;
font-size: 16px;
line-height: 1.5;
overflow: auto;
padding: 20rpx;
}
}
.toolbar {
position: fixed;
width: 100%;
left: 0;
bottom: 0;
box-sizing: border-box;
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
background-color: #fff;
border-top: 1px solid #eee;
line-height: 50rpx;
.iconfont {
display: inline-block;
padding: 10rpx 0;
width: 50rpx;
text-align: center;
font-size: 34rpx;
box-sizing: border-box;
}
}
}
.preview {
width: 100%;
margin-top: 90rpx;
.previewNodes {
width: 100%;
word-break: break-all;
}
}
.ql-active {
color: #06c;
}
</style>

View File

@ -0,0 +1,57 @@
<template>
<view class="head">
<view class="btn left" @tap="cancel" v-if="labelCancel">{{labelCancel}}</view>
<view class="btn right" @tap="save" v-if="labelConfirm">{{labelConfirm}}</view>
</view>
</template>
<script>
export default {
props:{
labelCancel:{
type:String,
default:"取消"
},
labelConfirm:{
type:String,
default:"确定"
}
},
methods:{
cancel:function(){
this.$emit('cancel')
},
save:function(){
this.$emit('save')
}
}
}
</script>
<style lang="scss" scoped>
.head {
display: flex;
justify-content: space-between;
width: 100%;
height: 100%;
border-bottom: 1px #eee solid;
// box-shadow: 1px 0 2px rgba(0, 0, 0, 0.1);
background: #fff;
.btn {
display: block;
width: 150upx;
height: 80upx;
line-height: 80upx;
font-size: 30upx;
color: #666;
padding-left: 20upx;
text-align: center;
&.left{
float: left;
}
&.right{
float: right;
}
}
}
</style>

View File

@ -0,0 +1,273 @@
<template>
<view v-if="showPopup" class="uni-popup" @touchmove.stop.prevent="clear">
<uni-transition :mode-class="['fade']" :styles="maskClass" :duration="duration" :show="showTrans" @click="onTap" />
<uni-transition :mode-class="ani" :styles="transClass" :duration="duration" :show="showTrans" @click="onTap">
<view class="uni-popup__wrapper-box" @click.stop="clear">
<slot />
</view>
</uni-transition>
</view>
</template>
<script>
import uniTransition from '../uni-transition/uni-transition.vue'
/**
* PopUp 弹出层
* @description 弹出层组件为了解决遮罩弹层的问题
* @tutorial https://ext.dcloud.net.cn/plugin?id=329
* @property {String} type = [top|center|bottom] 弹出方式
* @value top 顶部弹出
* @value center 中间弹出
* @value bottom 底部弹出
* @property {Boolean} animation = [ture|false] 是否开启动画
* @property {Boolean} maskClick = [ture|false] 蒙版点击是否关闭弹窗
* @event {Function} change 打开关闭弹窗触发e={show: false}
*/
export default {
name: 'UniPopup',
components: {
uniTransition
},
props: {
//
animation: {
type: Boolean,
default: true
},
// top: bottomcenter
type: {
type: String,
default: 'center'
},
// maskClick
maskClick: {
type: Boolean,
default: true
}
},
data() {
return {
duration: 300,
ani: [],
showPopup: false,
showTrans: false,
maskClass: {
'position': 'fixed',
'bottom': 0,
'top': 0,
'left': 0,
'right': 0,
'backgroundColor': 'rgba(0, 0, 0, 0.4)'
},
transClass: {
'position': 'fixed',
'left': 0,
'right': 0,
}
}
},
watch: {
type: {
handler: function(newVal) {
switch (this.type) {
case 'top':
this.ani = ['slide-top']
this.transClass = {
'position': 'fixed',
'left': 0,
'right': 0,
}
break
case 'bottom':
this.ani = ['slide-bottom']
this.transClass = {
'position': 'fixed',
'left': 0,
'right': 0,
'bottom': 0
}
break
case 'center':
this.ani = ['zoom-out', 'fade']
this.transClass = {
'position': 'fixed',
/* #ifndef APP-NVUE */
'display': 'flex',
'flexDirection': 'column',
/* #endif */
'bottom': 0,
'left': 0,
'right': 0,
'top': 0,
'justifyContent': 'center',
'alignItems': 'center'
}
break
}
},
immediate: true
}
},
created() {
if (this.animation) {
this.duration = 300
} else {
this.duration = 0
}
},
methods: {
clear(e) {
// TODO nvue
e.stopPropagation()
},
open() {
this.showPopup = true
this.$nextTick(() => {
clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.showTrans = true
}, 50);
setTimeout(()=>{
this.$emit('transed', {
show: true
})
},this.duration)
})
this.$emit('change', {
show: true
})
},
close(type) {
this.showTrans = false
this.$nextTick(() => {
clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.$emit('change', {
show: false
})
this.showPopup = false
setTimeout(()=>{
this.$emit('transed', {
show: false
})
},this.duration)
}, 300)
})
},
onTap() {
if (!this.maskClick) return
this.close()
}
}
}
</script>
<style lang="scss" scoped>
.uni-popup {
position: fixed;
/* #ifdef H5 */
top: var(--window-top);
/* #endif */
/* #ifndef H5 */
top: 0;
/* #endif */
bottom: 0;
left: 0;
right: 0;
/* #ifndef APP-NVUE */
z-index: 99;
/* #endif */
}
.uni-popup__mask {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: $uni-bg-color-mask;
opacity: 0;
}
.mask-ani {
transition-property: opacity;
transition-duration: 0.2s;
}
.uni-top-mask {
opacity: 1;
}
.uni-bottom-mask {
opacity: 1;
}
.uni-center-mask {
opacity: 1;
}
.uni-popup__wrapper {
/* #ifndef APP-NVUE */
display: block;
/* #endif */
position: absolute;
}
.top {
top: 0;
left: 0;
right: 0;
transform: translateY(-500px);
}
.bottom {
bottom: 0;
left: 0;
right: 0;
transform: translateY(500px);
}
.center {
/* #ifndef APP-NVUE */
display: flex;
flex-direction: column;
/* #endif */
bottom: 0;
left: 0;
right: 0;
top: 0;
justify-content: center;
align-items: center;
transform: scale(1.2);
opacity: 0;
}
.uni-popup__wrapper-box {
/* #ifndef APP-NVUE */
display: block;
/* #endif */
position: relative;
}
.content-ani {
// transition: transform 0.3s;
transition-property: transform, opacity;
transition-duration: 0.2s;
}
.uni-top-content {
transform: translateY(0);
}
.uni-bottom-content {
transform: translateY(0);
}
.uni-center-content {
transform: scale(1);
opacity: 1;
}
</style>

View File

@ -0,0 +1,279 @@
<template>
<view v-if="isShow" ref="ani" class="uni-transition" :class="[ani.in]" :style="'transform:' +transform+';'+stylesObject"
@click="change">
<slot></slot>
</view>
</template>
<script>
// #ifdef APP-NVUE
const animation = uni.requireNativePlugin('animation');
// #endif
/**
* Transition 过渡动画
* @description 简单过渡动画组件
* @tutorial https://ext.dcloud.net.cn/plugin?id=985
* @property {Boolean} show = [false|true] 控制组件显示或隐藏
* @property {Array} modeClass = [fade|slide-top|slide-right|slide-bottom|slide-left|zoom-in|zoom-out] 过渡动画类型
* @value fade 渐隐渐出过渡
* @value slide-top 由上至下过渡
* @value slide-right 由右至左过渡
* @value slide-bottom 由下至上过渡
* @value slide-left 由左至右过渡
* @value zoom-in 由小到大过渡
* @value zoom-out 由大到小过渡
* @property {Number} duration 过渡动画持续时间
* @property {Object} styles 组件样式 css 样式注意带-连接符的属性需要使用小驼峰写法如`backgroundColor:red`
*/
export default {
name: 'uniTransition',
props: {
show: {
type: Boolean,
default: false
},
modeClass: {
type: Array,
default () {
return []
}
},
duration: {
type: Number,
default: 300
},
styles: {
type: Object,
default () {
return {}
}
}
},
data() {
return {
isShow: false,
transform: '',
ani: { in: '',
active: ''
}
};
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.open()
} else {
this.close()
}
},
immediate: true
}
},
computed: {
stylesObject() {
let styles = {
...this.styles,
'transition-duration': this.duration / 1000 + 's'
}
let transfrom = ''
for (let i in styles) {
let line = this.toLine(i)
transfrom += line + ':' + styles[i] + ';'
}
return transfrom
}
},
created() {
// this.timer = null
// this.nextTick = (time = 50) => new Promise(resolve => {
// clearTimeout(this.timer)
// this.timer = setTimeout(resolve, time)
// return this.timer
// });
},
methods: {
change() {
this.$emit('click', {
detail: this.isShow
})
},
open() {
clearTimeout(this.timer)
this.isShow = true
this.transform = ''
this.ani.in = ''
for (let i in this.getTranfrom(false)) {
if (i === 'opacity') {
this.ani.in = 'fade-in'
} else {
this.transform += `${this.getTranfrom(false)[i]} `
}
}
this.$nextTick(() => {
setTimeout(() => {
this._animation(true)
}, 50)
})
},
close(type) {
clearTimeout(this.timer)
this._animation(false)
},
_animation(type) {
let styles = this.getTranfrom(type)
// #ifdef APP-NVUE
if(!this.$refs['ani']) return
animation.transition(this.$refs['ani'].ref, {
styles,
duration: this.duration, //ms
timingFunction: 'ease',
needLayout: false,
delay: 0 //ms
}, () => {
if (!type) {
this.isShow = false
}
this.$emit('change', {
detail: this.isShow
})
})
// #endif
// #ifndef APP-NVUE
this.transform = ''
for (let i in styles) {
if (i === 'opacity') {
this.ani.in = `fade-${type?'out':'in'}`
} else {
this.transform += `${styles[i]} `
}
}
this.timer = setTimeout(() => {
if (!type) {
this.isShow = false
}
this.$emit('change', {
detail: this.isShow
})
}, this.duration)
// #endif
},
getTranfrom(type) {
let styles = {
transform: ''
}
this.modeClass.forEach((mode) => {
switch (mode) {
case 'fade':
styles.opacity = type ? 1 : 0
break;
case 'slide-top':
styles.transform += `translateY(${type?'0':'-100%'}) `
break;
case 'slide-right':
styles.transform += `translateX(${type?'0':'100%'}) `
break;
case 'slide-bottom':
styles.transform += `translateY(${type?'0':'100%'}) `
break;
case 'slide-left':
styles.transform += `translateX(${type?'0':'-100%'}) `
break;
case 'zoom-in':
styles.transform += `scale(${type?1:0.8}) `
break;
case 'zoom-out':
styles.transform += `scale(${type?1:1.2}) `
break;
}
})
return styles
},
_modeClassArr(type) {
let mode = this.modeClass
if (typeof(mode) !== "string") {
let modestr = ''
mode.forEach((item) => {
modestr += (item + '-' + type + ',')
})
return modestr.substr(0, modestr.length - 1)
} else {
return mode + '-' + type
}
},
// getEl(el) {
// console.log(el || el.ref || null);
// return el || el.ref || null
// },
toLine(name) {
return name.replace(/([A-Z])/g, "-$1").toLowerCase();
}
}
}
</script>
<style>
.uni-transition {
transition-timing-function: ease;
transition-duration: 0.3s;
transition-property: transform, opacity;
}
.fade-in {
opacity: 0;
}
.fade-active {
opacity: 1;
}
.slide-top-in {
/* transition-property: transform, opacity; */
transform: translateY(-100%);
}
.slide-top-active {
transform: translateY(0);
/* opacity: 1; */
}
.slide-right-in {
transform: translateX(100%);
}
.slide-right-active {
transform: translateX(0);
}
.slide-bottom-in {
transform: translateY(100%);
}
.slide-bottom-active {
transform: translateY(0);
}
.slide-left-in {
transform: translateX(-100%);
}
.slide-left-active {
transform: translateX(0);
opacity: 1;
}
.zoom-in-in {
transform: scale(0.8);
}
.zoom-out-active {
transform: scale(1);
}
.zoom-out-in {
transform: scale(1.2);
}
</style>

11
src/package.json 100644
View File

@ -0,0 +1,11 @@
{
"id": "robin-editor",
"name": "微信小程序富文本编辑器",
"version": "1.1.6",
"description": "基于原生editor组件的富文本编辑器组件,支持颜色选择,插入图片",
"keywords": [
"编辑器",
"富文本",
"小程序"
]
}

View File

@ -12,6 +12,10 @@ export default {
uni.reLaunch({
url: '/pages/index/index'
})
} else if (e.reLaunch) {
uni.reLaunch({
url: e.reLaunch
})
} else {
uni.reLaunch({
url: '/pages/login/login'

View File

@ -232,10 +232,6 @@ export default {
.page {
padding: 0 20px;
background: white;
position: absolute;
height: 100%;
width: 100%;
box-sizing: border-box;
}
.input-text {
color: #303133;
@ -251,6 +247,6 @@ export default {
}
.button {
width: 100%;
margin-top: 20px;
margin: 20px 0;
}
</style>

View File

@ -4,6 +4,9 @@ module.exports = () => {
// 全局配置
uni.$u.http.setConfig((config) => {
config.baseURL = process.env.VUE_APP_BASE_API
config.custom = {
toast: true
}
return config
})