在uni-app中开发富文本输入功能,并使其兼容微信小程序,需要注意一些特定的限制和解决方案。由于微信小程序本身对HTML的支持有限,直接在小程序中实现像Web那样完整的富文本编辑功能(如使用CKEditor、Quill等)是不可能的。但你可以通过一些方法来实现基本的富文本输入或近似功能。
富文本编辑器,可以对图片、文字格式进行编辑和混排。
在web开发时,可以使用contenteditable
来实现内容编辑。但这是一个dom API,在非H5平台无法使用。于是微信小程序和uni-app的App-vue提供了editor
组件来实现这个功能,并且在uni-app的H5平台也提供了兼容。从技术本质来讲,这个组件仍然运行在视图层webview中,利用的也是浏览器的contenteditable
功能。
编辑器导出内容支持带标签的 html
和纯文本的 text
,编辑器内部采用 delta
格式进行存储。
通过setContents
接口设置内容时,解析插入的 html
可能会由于一些非法标签导致解析错误,建议开发者在应用内使用时通过 delta 进行插入。
组件扩展
<template><view class="diygw-col-24"><view :style="{height:height}" class='flex flex-direction-column wrapper'><view class='toolbar' @tap="format"><view v-if="tools.indexOf('undo')>-1" class="iconfont icon-undo" @tap="undo"></view><view v-if="tools.indexOf('redo')>-1" class="iconfont icon-redo" @tap="redo"></view><view v-if="tools.indexOf('bold')>-1" :class="formats.bold ? 'ql-active' : ''" class="iconfont icon-zitijiacu" data-name="bold"></view><view v-if="tools.indexOf('italic')>-1" :class="formats.italic ? 'ql-active' : ''" class="iconfont icon-zitixieti" data-name="italic"></view><view v-if="tools.indexOf('underline')>-1" :class="formats.underline ? 'ql-active' : ''" class="iconfont icon-zitixiahuaxian" data-name="underline"></view><view v-if="tools.indexOf('strike')>-1" :class="formats.strike ? 'ql-active' : ''" class="iconfont icon-zitishanchuxian" data-name="strike"></view><view v-if="tools.indexOf('align-left')>-1" :class="formats.align === 'left' ? 'ql-active' : ''" class="iconfont icon-zuoduiqi" data-name="align" data-value="left"></view><view v-if="tools.indexOf('align-center')>-1" :class="formats.align === 'center' ? 'ql-active' : ''" class="iconfont icon-juzhongduiqi" data-name="align" data-value="center"></view><view v-if="tools.indexOf('align-right')>-1" :class="formats.align === 'right' ? 'ql-active' : ''" class="iconfont icon-youduiqi" data-name="align" data-value="right"></view><view v-if="tools.indexOf('align-justify')>-1" :class="formats.align === 'justify' ? 'ql-active' : ''" class="iconfont icon-zuoyouduiqi" data-name="align" data-value="justify"></view><view v-if="tools.indexOf('lineHeight')>-1" :class="formats.lineHeight ? 'ql-active' : ''" class="iconfont icon-line-height" data-name="lineHeight" data-value="2"></view><view v-if="tools.indexOf('letterSpacing')>-1" :class="formats.letterSpacing ? 'ql-active' : ''" class="iconfont icon-Character-Spacing" data-name="letterSpacing" data-value="2em"></view><view v-if="tools.indexOf('marginTop')>-1" :class="formats.marginTop ? 'ql-active' : ''" class="iconfont icon-722bianjiqi_duanqianju" data-name="marginTop" data-value="20px"></view><view v-if="tools.indexOf('previewarginBottom')>-1" :class="formats.previewarginBottom ? 'ql-active' : ''" class="iconfont icon-723bianjiqi_duanhouju" data-name="marginBottom" data-value="20px"></view><view v-if="tools.indexOf('removeFormat')>-1" class="iconfont icon-clearedformat" @tap="removeFormat"></view><view v-if="tools.indexOf('fontFamily')>-1" :class="formats.fontFamily ? 'ql-active' : ''" class="iconfont icon-font" data-name="fontFamily" data-value="仿宋, 仿宋_GB2312"></view><!-- <picker v-if="tools.indexOf('fontSize')>-1" :range="fontSizelist" @change="formatsChange" @tap.stop="formatsChange" data-name="size" class="iconfont icon-fontsize" :class="formats.size? ' ql-active' : ''"></picker> --><picker v-if="tools.indexOf('fontSize')>-1" range-key="name" :range="fontSizelist" @change="formatsChange" @tap.stop="formatsChange" data-name="fontSize" class="iconfont icon-fontsize" :class="formats.fontSize? ' ql-active' : ''"></picker><!-- <view v-if="tools.indexOf('fontSize')>-1" :class="formats.fontSize === '24px' ? 'ql-active' : ''" class="iconfont icon-fontsize" data-name="fontSize" data-value="24px"></view> --><view v-if="tools.indexOf('color')>-1" :style="(formats.color != '#FFFFFF'&&formats.color != '#fff'&&formats.color != '#ffffff')? 'color:' + formats.color : ''" class="iconfont icon-text_color" data-name="color" @tap.stop="openColor"></view><view v-if="tools.indexOf('backgroundColor')>-1" :style="(formats.backgroundColor != '#FFFFFF'&&formats.backgroundColor != '#fff'&&formats.backgroundColor != '#ffffff') ? 'color:' + formats.backgroundColor : ''" class="iconfont icon-fontbgcolor" data-name="backgroundColor" @tap.stop="openColor"></view><view v-if="tools.indexOf('insertDate')>-1" class="iconfont icon-date" @tap="insertDate"></view><view v-if="tools.indexOf('list')>-1" class="iconfont icon--checklist" data-name="list" data-value="check"></view><view v-if="tools.indexOf('ordered')>-1" :class="formats.list === 'ordered' ? 'ql-active' : ''" class="iconfont icon-youxupailie" data-name="list" data-value="ordered"></view><view v-if="tools.indexOf('bullet')>-1" :class="formats.list === 'bullet' ? 'ql-active' : ''" class="iconfont icon-wuxupailie" data-name="list" data-value="bullet"></view><view v-if="tools.indexOf('indent-reduce')>-1" class="iconfont icon-outdent" data-name="indent" data-value="-1"></view><view v-if="tools.indexOf('indent-add')>-1" class="iconfont icon-indent" data-name="indent" data-value="+1"></view><view v-if="tools.indexOf('insert-divider')>-1" class="iconfont icon-fengexian" @tap="insertDivider"></view><view v-if="tools.indexOf('insert-image')>-1" class="iconfont icon-charutupian" @tap="selectImage"></view><picker v-if="tools.indexOf('header')>-1" :range="headerlist" @change="formatsChange" @tap.stop="formatsChange" data-name="header" :class="'iconfont icon-format-header-'+(headerindex==0?1:headerindex)+(formats.header? ' ql-active' : '')"></picker><!-- <view v-if="tools.indexOf('header')>-1" :class="formats.header === 1 ? 'ql-active' : ''" class="iconfont icon-format-header-1" data-name="header" :data-value="3"></view> --><view v-if="tools.indexOf('script-sub')>-1" :class="formats.script === 'sub' ? 'ql-active' : ''" class="iconfont icon-zitixiabiao" data-name="script" data-value="sub"></view><view v-if="tools.indexOf('script-super')>-1" :class="formats.script === 'super' ? 'ql-active' : ''" class="iconfont icon-zitishangbiao" data-name="script" data-value="super"></view><view v-if="tools.indexOf('direction')>-1" :class="formats.direction === 'rtl' ? 'ql-active' : ''" class="iconfont icon-direction-rtl" data-name="direction" data-value="rtl"></view><view v-if="tools.indexOf('clear')>-1" class="iconfont icon-shanchu" @tap="clear"></view></view><view class="flex-sub editor-wrapper"><editor id="editor" class="ql-container" :placeholder="placeholder" showImgSize showImgToolbar showImgResize @statuschange="onStatusChange" :read-only="readOnly" @ready="onEditorReady" @input="editorChange"></editor></view></view><block v-if="modal.show"><view class="mask" /><view class="modal"><view class="modal_title">{{modal.title}}</view><input type="text" class="modal_input" v-model="modal.value" /><view class="modal_foot"><view class="modal_button" @tap="modalCancel">取消</view><view class="modal_button" style="color:#576b95;border-left:1px solid rgba(0,0,0,.1)" @tap="modalConfirm">确定</view></view></view></block><diy-color-picker v-model="showColorPicker" :hexcolor="hexcolor" @confirm="getColor"></diy-color-picker></view>
</template><script>import Emitter from "../../libs/util/emitter.js";export default {mixins: [Emitter],emits: ["update:modelValue", "change"],props: {value: {type: String},modelValue:{type: String},placeholder: {type: String,default: '开始输入...'},height:{type:String,default: '100vh'},tools: {type: Array,default: function() {return ['bold','italic','underline','strike','align-left','align-center','align-right','align-justify','lineHeight','letterSpacing','marginTop','previewarginBottom','removeFormat','fontFamily','fontSize','color','backgroundColor','insertDate','list','ordered','bullet','redo','undo','indent-reduce','indent-add','insert-divider','insert-image','header','script-sub','script-super','clear','direction'];}},//上传图片action:{type:String,default: '/sys/storage/upload'}/*,uploadFile: {type: Function}*/},data() {return {modal: {show: false,title: '',value: ''},showColorPicker:false,html: '',fontSizelist: [{code: "",name: "默认"}, {code: "x-small",name: "超小"}, {code: "small",name: "小"}, {code: "medium",name: "中等"}, {code: "large",name: "大"}, {code: "x-large",name: "超大"}, {code: "xx-large",name: "超级大"}],headerlist: ['默认', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6'],headerindex: 0,colorPickerName: '',hexcolor: "#0000ff",readOnly: false,formats: {},update: 0,uForm:{inputAlign: "",clearable: ""}}},watch: {value: function(newval) {this.html = newval},modelValue: function(newval) {this.html = newval},html: function(newvar) {if (this.editorCtx) {if (this.update == 0) {this.editorCtx.setContents({html: this.html});} else {this.update = 0}}}},created() {this.html = this.value;},mounted() {let parent = this.$u.$parent.call(this, 'u-form');if (parent) {Object.keys(this.uForm).map(key => {this.uForm[key] = parent[key];});}},methods: {openColor(e) {let dataset = e.target.datasetthis.colorPickerName = dataset.name;this.hexcolor = dataset.value;this.showColorPicker = true// this.$refs.colorPicker.open();},getColor(e) {let msg = '';switch (this.colorPickerName) {case 'backgroundColor':if (e.hex.toUpperCase() == '#FFFFFF') {e.hex = '';}msg = '背景色';break;case 'color':msg = '颜色';break;}this.setformat(this.colorPickerName, e.hex, msg + e.hex);},modalConfirm() {let src = this.modal.value || '';if (src) {this.insertImage(src, null, null)}this.modal.show = false;},modalCancel() {this.modal.show = false;},formatsChange(e) {if (e.type == 'click') { //不让上层触发点击事件return false;}let value = e.detail.value;let name = e.target.dataset.nameif (name == 'header') {this.headerindex = value;if (value == 0) {value = null;}} else if (name == 'fontSize') {value = this.fontSizelist[value].code;} else if (name == 'size') {value = value > 0 ? value : 1;}let msg = name + '设置成功';console.log(value);this.setformat(name, value, msg)return false;},editorChange(e) {this.update = 1this.$emit('input', e.detail.html);this.$emit("update:modelValue", e.detail.html);// vue 原生的方法 return 出去this.$emit("change", e.detail.html);// 将当前的值发送到 u-form-item 进行校验this.dispatch("u-form-item", "onFieldBlur", e.detail.html);},readOnlyChange() {this.readOnly = !this.readOnly},onEditorReady() {const query = uni.createSelectorQuery().in(this);query.select('#editor').context((res) => {this.editorCtx = res.contextif (this.html) {this.editorCtx.setContents({html: this.html});}}).exec()},undo() {this.editorCtx.undo()},redo() {this.editorCtx.redo()},format(e) {let {name,value} = e.target.datasetif (!name) return// console.log('format', name, value)this.editorCtx.format(name, value)},setformat(name, value, msg) {this.editorCtx.format(name, value);// this.toast(msg);},toast(msg) {uni.showToast({duration: 600,icon: 'none',title: msg});},onStatusChange(e) {const formats = e.detailthis.formats = formats},insertDivider() {this.editorCtx.insertDivider({success: function() {console.log('insert divider success')}})},clear() {uni.showModal({content: "确定清空编辑器内容?",complete: (rs) => {if (rs.confirm) {this.editorCtx.clear({success: function(res) {console.log("clear success")}})}}})},removeFormat() {this.editorCtx.removeFormat()},insertDate() {const date = new Date()let month = date.getMonth() + 1if(month<10){month = "0" +month}let day = date.getDate()if(day<10){day = "0" + day}const formatDate = `${date.getFullYear()}-${month}-${day}`this.editorCtx.insertText({text: formatDate})},selectImage() {let thiz = this// 本地选取 自已处理上传方法,包括选择文件uni.chooseImage({count: 9,sizeType: ['original', 'compressed'], // 可以指定是原图还是压缩图,默认二者都有sourceType: ['album', 'camera'], // 可以指定来源是相册还是相机,默认二者都有javascript:;success: function (res) {// 返回选定照片的本地文件路径列表,tempFilePath可以作为img标签的src属性显示图片let tempFilePaths = res.tempFilePaths;for (let i = 0; i < tempFilePaths.length; i++) {let header = {}if(getApp().globalData.currentPage && getApp().globalData.currentPage.$session){header.Authorization = getApp().globalData.currentPage.$session.getToken()||''}uni.uploadFile({url: getApp().globalData.currentPage && getApp().globalData.currentPage.$http?getApp().globalData.currentPage.$http.setUrl(thiz.action,{}):thiz.action,filePath: tempFilePaths[i],name: 'file',header:header,success(res) {let data = getApp().globalData.currentPage.$tools.fromJson(res.data);let url = ''if(data.url){url = getApp().globalData.currentPage.$tools.renderImage(data.url);}if(data.data &&getApp().globalData.currentPage.$tools.isObject(data.data) && data.data.url){url = getApp().globalData.currentPage.$tools.renderImage(data.data.url);}if(url){thiz.insertImage(url,null,null);}}});}},});// uni.showActionSheet({// itemList: ['本地选取', '远程链接'],// success: res => {// if (res.tapIndex === 0) {// } else {// thiz.modal = {// show: true,// title: '图片链接',// value: ''// }// }// }// })},insertImage(src, data, alt) {debuggerlet inserdata = {src: src}if (data) {inserdata.data = data}if (alt) {inserdata.alt = alt}this.editorCtx.insertImage({...inserdata,success: function() {console.log('insert image success')}})}}}
</script><style>@import "./editor-icon.css";.container {width: 100%;}.wrapper {width: 100%;}.editor-wrapper {width: 100%;background: #fff;}.iconfont {display: inline-block;padding: 8px 8px;cursor: pointer;font-size: 25px;}.toolbar {box-sizing: border-box;border-bottom: 0;font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;}.ql-container {box-sizing: border-box;padding: 10px;width: 100%;min-height: 30vh;height: 100%;font-size: 16px;line-height: 1.5;}.ql-active {color: #06c;}/* 模态框 */.modal {position: fixed;z-index: 999999;top: 50%;left: 16px;right: 16px;background-color: #fff;border-radius: 12px;transform: translateY(-50%);}.modal_title {padding: 32px 24px 16px;font-size: 17px;font-weight: 700;text-align: center;}.modal_input {display: block;padding: 5px;line-height: 2.5em;height: 2.5em;margin: 0 24px 32px 24px;font-size: 14px;border: 1px solid #dfe2e5;}.modal_foot {display: flex;line-height: 56px;font-weight: 700;border-top: 1px solid rgba(0, 0, 0, .1);}.modal_button {flex: 1;text-align: center;}/* 遮罩版 */.mask {position: fixed;z-index: 99999;top: 0;right: 0;bottom: 0;left: 0;background-color: black;opacity: 0.5;}
</style>
组件调用
<template><view class="container container329152"><u-form-item :borderBottom="false" class="diygw-col-24" labelPosition="top" prop="editor"><diy-editor height="500px" v-model="editor"></diy-editor></u-form-item><view class="clearfix"></view></view>
</template><script>export default {data() {return {//用户全局信息userInfo: {},//页面传参globalOption: {},//自定义全局变量globalData: {},editor: ''};},onShow() {this.setCurrentPage(this);},onLoad(option) {this.setCurrentPage(this);if (option) {this.setData({globalOption: this.getOption(option)});}this.init();},methods: {async init() {},// 新增方法 自定义方法async testFunction(param) {let thiz = this;console.log(this.checkbox);}}};
</script><style lang="scss" scoped>.container329152 {}
</style>